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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=HA218GZY" /> <DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\danie\.android\avd\Medium_Phone.avd" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

8
.idea/misc.xml generated
View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
@ -6,4 +7,11 @@
<component name="ProjectType"> <component name="ProjectType">
<option name="id" value="Android" /> <option name="id" value="Android" />
</component> </component>
<component name="VisualizationToolProject">
<option name="state">
<ProjectState>
<option name="scale" value="0.1221923828125" />
</ProjectState>
</option>
</component>
</project> </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 minSdk = 29
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
@ -44,22 +62,31 @@ dependencies {
implementation(libs.material) implementation(libs.material)
implementation(libs.androidx.activity) implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout) implementation(libs.androidx.constraintlayout)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
implementation("com.google.code.gson:gson:2.10.1") implementation("com.google.code.gson:gson:2.10.1")
implementation("androidx.room:room-runtime:$room_version") implementation("androidx.room:room-runtime:$room_version")
kapt("androidx.room:room-compiler:$room_version") kapt("androidx.room:room-compiler:$room_version")
implementation("androidx.room:room-ktx:$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 // SQLCipher
implementation ("net.zetetic:android-database-sqlcipher:4.5.3@aar") implementation("net.zetetic:android-database-sqlcipher:4.5.3@aar")
implementation ("androidx.sqlite:sqlite:2.1.0") implementation("androidx.sqlite:sqlite:2.1.0")
implementation ("androidx.sqlite:sqlite-framework:2.1.0") implementation("androidx.sqlite:sqlite-framework:2.1.0")
// Server Upload // Server Upload
implementation("com.squareup.okhttp3:okhttp:4.12.0") 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"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
<uses-permission android:name="android.permission.INTERNET" /> package="com.dano.test1">
<!-- Netzwerkberechtigungen -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application <application
android:name=".MyApp" android:name=".MyApp"
@ -15,6 +18,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Test1" android:theme="@style/Theme.Test1"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
tools:targetApi="31"> tools:targetApi="31">
<provider <provider
@ -29,7 +33,8 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

Binary file not shown.

View File

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

View File

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

View File

@ -28,7 +28,11 @@
"questionnaire_1_demographic_information", "questionnaire_1_demographic_information",
"questionnaire_2_rhs", "questionnaire_2_rhs",
"questionnaire_3_integration_index" "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", "file": "questionnaire_6_follow_up_survey.json",
"showPoints": false, "showPoints": true,
"condition": { "condition": {
"anyOf": [ "anyOf": [
{ {

View File

@ -1,56 +1,71 @@
package com.dano.test1
import java.io.File import java.io.File
import java.io.FileInputStream import java.security.SecureRandom
import java.io.FileOutputStream
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.CipherInputStream import javax.crypto.Mac
import javax.crypto.CipherOutputStream
import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
import kotlin.random.Random import kotlin.math.min
object AES256Helper { object AES256Helper {
private const val TRANSFORMATION = "AES/CBC/PKCS5Padding" private fun hkdfFromToken(tokenHex: String, info: String = "qdb-aes", len: Int = 32): ByteArray {
private const val ALGORITHM = "AES" val ikm = hexToBytes(tokenHex)
private const val IV_SIZE = 16 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! var previous = ByteArray(0)
private val keyBytes = "12345678901234567890123456789012".toByteArray(Charsets.UTF_8) val okm = ByteArray(len)
private val secretKey = SecretKeySpec(keyBytes, ALGORITHM) var generated = 0
var counter = 1
// Verschlüsseln: InputFile -> OutputFile (mit zufälligem IV vorne in der Datei) while (generated < len) {
fun encryptFile(inputFile: File, outputFile: File) { mac.init(SecretKeySpec(prk, "HmacSHA256"))
val iv = ByteArray(IV_SIZE) mac.update(previous)
Random.nextBytes(iv) mac.update(info.toByteArray(Charsets.UTF_8))
val ivSpec = IvParameterSpec(iv) mac.update(counter.toByte())
val cipher = Cipher.getInstance(TRANSFORMATION) val t = mac.doFinal()
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec) val toCopy = min(len - generated, t.size)
System.arraycopy(t, 0, okm, generated, toCopy)
FileOutputStream(outputFile).use { fileOut -> previous = t
// IV vorne reinschreiben generated += toCopy
fileOut.write(iv) counter++
CipherOutputStream(fileOut, cipher).use { cipherOut ->
FileInputStream(inputFile).use { fileIn ->
fileIn.copyTo(cipherOut)
}
}
} }
return okm
} }
// Entschlüsseln: InputFile (IV+Ciphertext) -> OutputFile (Klartext) private fun hexToBytes(hex: String): ByteArray {
fun decryptFile(inputFile: File, outputFile: File) { val clean = hex.trim()
FileInputStream(inputFile).use { fileIn -> val len = clean.length
val iv = ByteArray(IV_SIZE) val out = ByteArray(len / 2)
if (fileIn.read(iv) != IV_SIZE) throw IllegalArgumentException("Ungültige Datei oder IV fehlt") var i = 0
val ivSpec = IvParameterSpec(iv) while (i < len) {
val cipher = Cipher.getInstance(TRANSFORMATION) out[i / 2] = ((Character.digit(clean[i], 16) shl 4) + Character.digit(clean[i + 1], 16)).toByte()
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec) i += 2
}
return out
}
CipherInputStream(fileIn, cipher).use { cipherIn -> fun encryptFileWithToken(inFile: File, outFile: File, token: String) {
FileOutputStream(outputFile).use { fileOut -> val key = hkdfFromToken(token)
cipherIn.copyTo(fileOut) 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.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
/*
Zentrale Room-Datenbank der App. Diese Klasse beschreibt:
- welche Tabellen (entities) es gibt: Client, Questionnaire, Question, Answer, CompletedQuestionnaire
- die Datenbank-Version (version = 1) für Migrations/Schema-Updates
Über die abstrakten DAO-Getter (clientDao(), questionnaireDao(), …) erhält der Rest der App Typsichere Zugriffe auf die jeweiligen Tabellen.
Hinweis:
- Room erzeugt zur Build-Zeit die konkrete Implementierung dieser abstrakten Klasse.
- Eine Instanz der Datenbank wird typischerweise per Room.databaseBuilder(...) erstellt und als Singleton verwendet.
*/
@Database( @Database(
entities = [ entities = [
Client::class, Client::class,

View File

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

View File

@ -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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object DatabaseDownloader { object DatabaseDownloader {
private const val DB_NAME = "questionnaire_database" private const val DB_NAME = "questionnaire_database"
private const val API_TOKEN = "MEIN_SUPER_GEHEIMES_TOKEN_12345" private const val SERVER_DOWNLOAD_URL = "https://daniel-ocks.de/qdb/downloadFull.php"
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 val client = OkHttpClient() 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 { CoroutineScope(Dispatchers.IO).launch {
var ok = false
try { try {
Log.d("DOWNLOAD", "Download gestartet: $SERVER_DOWNLOAD_URL")
val request = Request.Builder() val request = Request.Builder()
.url(SERVER_DOWNLOAD_URL) .url(SERVER_DOWNLOAD_URL)
.header("Authorization", "Bearer $token")
.build() .build()
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
if (!response.isSuccessful) { 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 return@launch
} }
// Zwischenspeichern der verschlüsselten Datei val encFile = File(context.cacheDir, "downloaded_database.enc")
val downloadedFile = File(context.cacheDir, "downloaded_database.enc")
response.body?.byteStream()?.use { input -> response.body?.byteStream()?.use { input ->
FileOutputStream(downloadedFile).use { output -> FileOutputStream(encFile).use { output -> input.copyTo(output) }
input.copyTo(output)
} }
}
Log.d("DOWNLOAD", "Datei gespeichert: ${downloadedFile.absolutePath}")
// Entschlüsselung val decryptedBytes = AES256Helper.decryptFileWithToken(encFile, token)
val decryptedBytes = decryptFile(downloadedFile)
val dbFile = context.getDatabasePath(DB_NAME) val dbFile = context.getDatabasePath(DB_NAME)
if (dbFile.exists()) dbFile.delete() if (dbFile.exists()) dbFile.delete()
FileOutputStream(dbFile).use { fos -> FileOutputStream(dbFile).use { it.write(decryptedBytes) }
fos.write(decryptedBytes)
}
Log.d("DOWNLOAD", "Neue DB erfolgreich entschlüsselt und eingesetzt")
Log.d("DOWNLOAD", "DB erfolgreich ersetzt")
ok = true
} catch (e: Exception) { } 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 { // Abwärtskompatible alte Signatur
val fileBytes = file.readBytes() fun downloadAndReplaceDatabase(context: Context, token: String) {
if (fileBytes.size < 16) throw IllegalArgumentException("Datei zu kurz, kein IV vorhanden") downloadAndReplaceDatabase(context, token, null)
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)
} }
} }

View File

@ -20,95 +20,49 @@ import kotlin.system.exitProcess
object DatabaseUploader { object DatabaseUploader {
private const val DB_NAME = "questionnaire_database" private const val DB_NAME = "questionnaire_database"
// TODO entferne uploadDeltaTest2.php private const val SERVER_DELTA_URL = "https://daniel-ocks.de/qdb/uploadDeltaTest5.php"
private const val SERVER_DELTA_URL = "http://49.13.157.44/uploadDeltaTest3.php" private const val SERVER_CHECK_URL = "https://daniel-ocks.de/qdb/checkDatabaseExists.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 val client = OkHttpClient() private val client = OkHttpClient()
fun uploadDatabase(context: Context) { private fun uploadDatabase(context: Context, token: String) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val dbFile = context.getDatabasePath(DB_NAME) val dbFile = context.getDatabasePath(DB_NAME)
if (!dbFile.exists()) { if (!dbFile.exists()) {
Log.e("UPLOAD", "Datenbankdatei existiert nicht: ${dbFile.absolutePath}") Log.e("UPLOAD", "DB fehlt: ${dbFile.absolutePath}")
return@launch return@launch
} }
// WAL-Checkpoint // WAL sauber schließen (falls aktiv)
try { try {
val db = SQLiteDatabase.openDatabase( val db = SQLiteDatabase.openDatabase(dbFile.absolutePath, null, SQLiteDatabase.OPEN_READWRITE)
dbFile.absolutePath, db.rawQuery("PRAGMA wal_checkpoint(FULL);", null).use { /* noop */ }
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) {}
}
}
db.close() db.close()
Log.d("UPLOAD", "WAL-Checkpoint erfolgreich.") } catch (_: Exception) { }
} 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)
}
checkDatabaseExists()
uploadPseudoDelta(context, dbFile, token)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("UPLOAD", "Fehler beim Hochladen der DB", e) Log.e("UPLOAD", "Fehler", e)
} }
} }
} }
private fun checkDatabaseExists(): Boolean { private fun checkDatabaseExists(): Boolean {
return try { return try {
val request = Request.Builder() val req = Request.Builder().url(SERVER_CHECK_URL).get().build()
.url(SERVER_CHECK_URL) client.newCall(req).execute().use { resp ->
.get() if (!resp.isSuccessful) return false
.build() val body = resp.body?.string() ?: return false
try { JSONObject(body).optBoolean("exists", false) } catch (_: Exception) { false }
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)
}
}
} catch (e: Exception) {
Log.e("UPLOAD", "Fehler bei Server-Prüfung", e)
false
} }
} catch (e: Exception) { false }
} }
/** private fun uploadPseudoDelta(context: Context, file: File, token: String) {
* 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) {
try { try {
val db = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY) val db = SQLiteDatabase.openDatabase(file.absolutePath, null, SQLiteDatabase.OPEN_READONLY)
val data = JSONObject().apply { val data = JSONObject().apply {
put("clients", queryToJsonArray(db, "SELECT clientCode FROM clients")) put("clients", queryToJsonArray(db, "SELECT clientCode FROM clients"))
put("questionnaires", queryToJsonArray(db, "SELECT id FROM questionnaires")) 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("answers", queryToJsonArray(db, "SELECT clientCode, questionId, answerValue FROM answers"))
put( put(
"completed_questionnaires", "completed_questionnaires",
queryToJsonArray( queryToJsonArray(db, "SELECT clientCode, questionnaireId, timestamp, isDone, sumPoints FROM completed_questionnaires")
db,
"SELECT clientCode, questionnaireId, timestamp, isDone, sumPoints FROM completed_questionnaires"
)
) )
} }
db.close() db.close()
// Schreibe JSON in temporäre Datei // JSON -> verschlüsselte Payload
val tmpJson = File(context.cacheDir, "payload.json") val tmpJson = File(context.cacheDir, "payload.json").apply { writeText(data.toString()) }
tmpJson.writeText(data.toString())
// Verschlüssele JSON -> tmpEnc
val tmpEnc = File(context.cacheDir, "payload.enc") val tmpEnc = File(context.cacheDir, "payload.enc")
try { try {
AES256Helper.encryptFile(tmpJson, tmpEnc) AES256Helper.encryptFileWithToken(tmpJson, tmpEnc, token)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("UPLOAD", "Fehler bei der Verschlüsselung der JSON-Datei", e) Log.e("UPLOAD", "Verschlüsselung fehlgeschlagen", e)
// cleanup tmpJson.delete(); return
tmpJson.delete()
return
} }
val requestBody = MultipartBody.Builder() val body = MultipartBody.Builder()
.setType(MultipartBody.FORM) .setType(MultipartBody.FORM)
.addFormDataPart("token", API_TOKEN) .addFormDataPart("token", token) // bleibt für Kompatibilität enthalten
// Datei-Feld "file" mit verschlüsselter Payload .addFormDataPart("file", "payload.enc", tmpEnc.asRequestBody("application/octet-stream".toMediaType()))
.addFormDataPart(
"file",
"payload.enc",
tmpEnc.asRequestBody("application/octet-stream".toMediaType())
)
.build() .build()
// WICHTIG: Jetzt HTTPS + Konstanten-URL verwenden, plus Bearer-Header
val request = Request.Builder() val request = Request.Builder()
.url(SERVER_DELTA_URL) .url(SERVER_DELTA_URL)
.post(requestBody) .post(body)
.header("Authorization", "Bearer $token")
.build() .build()
client.newCall(request).enqueue(object : Callback { client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
Log.e("UPLOAD", "Delta-Upload fehlgeschlagen: ${e.message}") Log.e("UPLOAD", "Fehlgeschlagen: ${e.message}")
// cleanup tmpJson.delete(); tmpEnc.delete()
tmpJson.delete()
tmpEnc.delete()
} }
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
val body = try { val respBody = try { response.body?.string() ?: "" } catch (_: Exception) { "" }
response.body?.string() ?: "Keine Response"
} catch (e: Exception) {
"Fehler beim Lesen der Response: ${e.message}"
}
if (response.isSuccessful) { if (response.isSuccessful) {
Log.d("UPLOAD", "Delta-Upload erfolgreich: $body") Log.d("UPLOAD", "OK: $respBody")
// Lösche Hauptdatenbank
if (file.delete()) { // alte Logik: lokale DB + Neben­dateien löschen
Log.d("UPLOAD", "Lokale DB gelöscht.") 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)
}
} else { } else {
Log.e("UPLOAD", "Löschen der lokalen DB fehlgeschlagen.") Log.e("UPLOAD", "HTTP ${response.code}: $respBody")
}
// 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()
} }
tmpJson.delete(); tmpEnc.delete()
// unverändert beibehalten
try { exitProcess(0) } catch (_: Exception) {}
} }
}) })
} catch (e: 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 { private fun queryToJsonArray(db: SQLiteDatabase, query: String): JSONArray {
val cursor = db.rawQuery(query, null) val c = db.rawQuery(query, null)
val jsonArray = JSONArray() val arr = JSONArray()
cursor.use { c.use {
val columnNames = it.columnNames val cols = it.columnNames
while (it.moveToNext()) { while (it.moveToNext()) {
val obj = JSONObject() val obj = JSONObject()
for (col in columnNames) { for (col in cols) {
val idx = it.getColumnIndex(col) val idx = it.getColumnIndex(col)
if (idx >= 0) { if (idx >= 0) {
when (it.getType(idx)) { when (it.getType(idx)) {
@ -215,17 +146,20 @@ object DatabaseUploader {
Cursor.FIELD_TYPE_FLOAT -> obj.put(col, it.getDouble(idx)) Cursor.FIELD_TYPE_FLOAT -> obj.put(col, it.getDouble(idx))
Cursor.FIELD_TYPE_STRING -> obj.put(col, it.getString(idx)) Cursor.FIELD_TYPE_STRING -> obj.put(col, it.getString(idx))
Cursor.FIELD_TYPE_NULL -> obj.put(col, JSONObject.NULL) Cursor.FIELD_TYPE_NULL -> obj.put(col, JSONObject.NULL)
Cursor.FIELD_TYPE_BLOB -> { Cursor.FIELD_TYPE_BLOB -> obj.put(col, Base64.encodeToString(it.getBlob(idx), Base64.NO_WRAP))
val blob = it.getBlob(idx)
obj.put(col, Base64.encodeToString(blob, Base64.NO_WRAP))
}
else -> obj.put(col, it.getString(idx)) 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.* import androidx.room.*
/*
Room-Entities (Tabellen) der App.
- Definieren das Schema für Clients, Questionnaires, Questions, Answers und CompletedQuestionnaires.
- Beziehungen:
* Question -> Questionnaire (FK, CASCADE)
* Answer -> Client (FK, CASCADE)
* Answer -> Question (FK, CASCADE)
* CompletedQuestionnaire -> Client (FK, CASCADE)
* CompletedQuestionnaire -> Questionnaire (FK, CASCADE)
- Primärschlüssel:
* Client: clientCode
* Questionnaire: id
* Question: questionId
* Answer: (clientCode, questionId) eine Antwort je Client & Frage
* CompletedQuestionnaire: (clientCode, questionnaireId) ein Status je Client & Fragebogen
*/
/* Tabelle: clients Eindeutige Identifikation eines Clients per clientCode. */
@Entity(tableName = "clients") @Entity(tableName = "clients")
data class Client( data class Client(
@PrimaryKey val clientCode: String, @PrimaryKey val clientCode: String,
) )
/* Tabelle: questionnaires Eindeutige Fragebogen-IDs. */
@Entity(tableName = "questionnaires") @Entity(tableName = "questionnaires")
data class Questionnaire( data class Questionnaire(
@PrimaryKey val id: String, @PrimaryKey val id: String,
) )
/*
Tabelle: questions
- Jede Frage gehört zu genau einem Fragebogen (questionnaireId).
- Fremdschlüssel sorgt dafür, dass beim Löschen eines Fragebogens die zugehörigen Fragen mit gelöscht werden.
- Index auf questionnaireId beschleunigt Abfragen „alle Fragen eines Fragebogens“.
*/
@Entity( @Entity(
tableName = "questions", tableName = "questions",
foreignKeys = [ foreignKeys = [
@ -30,6 +55,12 @@ data class Question(
val question: String = "" val question: String = ""
) )
/*
Tabelle: answers
- Zusammengesetzter Primärschlüssel (clientCode, questionId):
* Pro Client und Frage existiert höchstens eine Antwort.
* Löscht man den Client oder die Frage, werden die zugehörigen Antworten mit entfernt.
*/
@Entity( @Entity(
tableName = "answers", tableName = "answers",
primaryKeys = ["clientCode", "questionId"], primaryKeys = ["clientCode", "questionId"],
@ -55,6 +86,17 @@ data class Answer(
val answerValue: String = "" val answerValue: String = ""
) )
/*
Tabelle: completed_questionnaires
- Zusammengesetzter Primärschlüssel (clientCode, questionnaireId):
* Hält den Abschluss-Status eines Fragebogens pro Client.
- FKs mit CASCADE:
* Beim Löschen eines Clients oder Fragebogens verschwindet der Status-Eintrag ebenfalls.
- Indizes auf clientCode und questionnaireId für schnelle Lookups.
- timestamp: Zeitpunkt der Statusänderung (Default: now).
- isDone: true/false abgeschlossen oder nicht.
- sumPoints: optionaler Score des Fragebogens.
*/
@Entity( @Entity(
tableName = "completed_questionnaires", tableName = "completed_questionnaires",
primaryKeys = ["clientCode", "questionnaireId"], primaryKeys = ["clientCode", "questionnaireId"],
@ -81,4 +123,3 @@ data class CompletedQuestionnaire(
val isDone: Boolean, val isDone: Boolean,
val sumPoints: Int? = null val sumPoints: Int? = null
) )

View File

@ -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.view.View
import android.widget.* import android.widget.*
import android.util.TypedValue
import androidx.core.widget.TextViewCompat
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/*
Zweck :
- Steuert die Eingabeseite für „Client Code“ und „Coach Code“ innerhalb des Fragebogen-Flows.
*/
class HandlerClientCoachCode( class HandlerClientCoachCode(
private val answers: MutableMap<String, Any>, private val answers: MutableMap<String, Any>,
private val languageID: String, private val languageID: String,
@ -24,42 +31,57 @@ class HandlerClientCoachCode(
this.layout = layout this.layout = layout
this.question = question this.question = question
// Bind UI components
val clientCodeField = layout.findViewById<EditText>(R.id.client_code) 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 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 setTextSizePercentOfScreenHeight(titleTextView, 0.03f)
val lastClientCode = GlobalValues.LAST_CLIENT_CODE setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
if (!lastClientCode.isNullOrBlank()) { setTextSizePercentOfScreenHeight(clientCodeField, 0.025f)
clientCodeField.setText(lastClientCode) 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 clientCodeField.isEnabled = false
} else { } else {
clientCodeField.setText(answers["client_code"] as? String ?: "") clientCodeField.setText("")
clientCodeField.isEnabled = true clientCodeField.isEnabled = true
} }
// Load saved coach code // 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.setText(answers["coach_code"] as? String ?: "")
coachCodeField.isEnabled = true
}
// Set click listener for Next button
layout.findViewById<Button>(R.id.Qnext).setOnClickListener { layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
onNextClicked(clientCodeField, coachCodeField) onNextClicked(clientCodeField, coachCodeField)
} }
// Set click listener for Previous button
layout.findViewById<Button>(R.id.Qprev).setOnClickListener { layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
onPreviousClicked(clientCodeField, coachCodeField) 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) { private fun onNextClicked(clientCodeField: EditText, coachCodeField: EditText) {
val loadedClientCode = GlobalValues.LOADED_CLIENT_CODE
if (!validate()) { if (!validate()) {
val message = LanguageManager.getText(languageID, "fill_both_fields") val message = LanguageManager.getText(languageID, "fill_both_fields")
showToast(message) showToast(message)
@ -67,62 +89,73 @@ class HandlerClientCoachCode(
} }
val clientCode = clientCodeField.text.toString() 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 // Prüfen, ob die DB-Datei vor dem Zugriff existiert
val dbFile = layout.context.getDatabasePath("questionnaire_database") val dbPath = layout.context.getDatabasePath("questionnaire_database")
val dbJournalFile = layout.context.getDatabasePath("questionnaire_database-journal") val dbExistedBefore = dbPath.exists()
val dbExisted = dbFile.exists() || dbJournalFile.exists()
// Check if client code already exists asynchronously
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val existingClient = MyApp.database.clientDao().getClientByCode(clientCode) val existingClient = MyApp.database.clientDao().getClientByCode(clientCode)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (existingClient != null && clientCodeField.isEnabled) { if (existingClient != null && clientCodeField.isEnabled) {
// Client code already exists and field was editable
val message = LanguageManager.getText(languageID, "client_code_exists") val message = LanguageManager.getText(languageID, "client_code_exists")
showToast(message) showToast(message)
} else { } else {
// Either no existing client or re-using previous code
saveAnswers(clientCode, coachCode) saveAnswers(clientCode, coachCode)
// Datenbank-Dateien löschen, wenn sie vorher NICHT existierten
if (!dbExisted) {
dbFile.delete()
dbJournalFile.delete()
}
goToNextQuestion() 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) { private fun onPreviousClicked(clientCodeField: EditText, coachCodeField: EditText) {
val clientCode = clientCodeField.text.toString() val clientCode = clientCodeField.text.toString()
val coachCode = coachCodeField.text.toString() val coachCode = TokenStore.getUsername(layout.context) ?: coachCodeField.text.toString()
saveAnswers(clientCode, coachCode) saveAnswers(clientCode, coachCode)
goToPreviousQuestion() goToPreviousQuestion()
} }
// Validate that both fields are filled
override fun validate(): Boolean { override fun validate(): Boolean {
val clientCode = layout.findViewById<EditText>(R.id.client_code).text val clientCode = layout.findViewById<EditText>(R.id.client_code).text
val coachCode = layout.findViewById<EditText>(R.id.coach_code).text val coachText = layout.findViewById<EditText>(R.id.coach_code).text
return clientCode.isNotBlank() && coachCode.isNotBlank() return clientCode.isNotBlank() && coachText.isNotBlank()
} }
// Save answers to shared state and global value
private fun saveAnswers(clientCode: String, coachCode: String) { private fun saveAnswers(clientCode: String, coachCode: String) {
GlobalValues.LAST_CLIENT_CODE = clientCode GlobalValues.LAST_CLIENT_CODE = clientCode
answers["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() { override fun saveAnswer() {
// Not used // 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.view.View
import android.widget.* import android.widget.*
/*
Zweck:
- Steuert die Seite „Client hat nicht unterschrieben“ im Fragebogenfluss.
- Speichert den eingegebenen Coach-Code in das Answers-Map unter question.id (saveAnswer), damit der nachfolgende Prozess darauf zugreifen kann.
*/
class HandlerClientNotSigned( class HandlerClientNotSigned(
private val answers: MutableMap<String, Any>, private val answers: MutableMap<String, Any>,
private val languageID: String, private val languageID: String,
@ -14,7 +20,6 @@ class HandlerClientNotSigned(
private lateinit var layout: View private lateinit var layout: View
private lateinit var question: QuestionItem.ClientNotSigned private lateinit var question: QuestionItem.ClientNotSigned
// UI components
private lateinit var textView1: TextView private lateinit var textView1: TextView
private lateinit var textView2: TextView private lateinit var textView2: TextView
private lateinit var questionTextView: TextView private lateinit var questionTextView: TextView
@ -26,29 +31,24 @@ class HandlerClientNotSigned(
this.layout = layout this.layout = layout
this.question = question this.question = question
// Initialize UI components only once
initViews() initViews()
// Set localized text values from LanguageManager
textView1.text = question.textKey1?.let { LanguageManager.getText(languageID, it) } ?: "" textView1.text = question.textKey1?.let { LanguageManager.getText(languageID, it) } ?: ""
textView2.text = question.textKey2?.let { LanguageManager.getText(languageID, it) } ?: "" textView2.text = question.textKey2?.let { LanguageManager.getText(languageID, it) } ?: ""
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: "" questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
// Populate EditText with previous value if exists
coachCodeField.setText(answers[question.id] as? String ?: "") coachCodeField.setText(answers[question.id] as? String ?: "")
// Set click listener for Next button
layout.findViewById<Button>(R.id.Qnext).setOnClickListener { layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
onNextClicked() onNextClicked()
} }
// Set click listener for Previous button
layout.findViewById<Button>(R.id.Qprev).setOnClickListener { layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
goToPreviousQuestion() goToPreviousQuestion()
} }
} }
// Initialize all views once to avoid repeated findViewById calls
private fun initViews() { private fun initViews() {
textView1 = layout.findViewById(R.id.textView1) textView1 = layout.findViewById(R.id.textView1)
textView2 = layout.findViewById(R.id.textView2) textView2 = layout.findViewById(R.id.textView2)
@ -56,7 +56,6 @@ class HandlerClientNotSigned(
coachCodeField = layout.findViewById(R.id.coach_code) coachCodeField = layout.findViewById(R.id.coach_code)
} }
// Handle Next button click
private fun onNextClicked() { private fun onNextClicked() {
if (validate()) { if (validate()) {
saveAnswer() saveAnswer()
@ -67,13 +66,11 @@ class HandlerClientNotSigned(
} }
} }
// Validate that coach code field is not empty
override fun validate(): Boolean { override fun validate(): Boolean {
val coachCode = coachCodeField.text val coachCode = coachCodeField.text
return coachCode.isNotBlank() return coachCode.isNotBlank()
} }
// Save entered coach code to answers map
override fun saveAnswer() { override fun saveAnswer() {
answers[question.id] = coachCodeField.text.toString() answers[question.id] = coachCodeField.text.toString()
} }

View File

@ -2,9 +2,19 @@ package com.dano.test1
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.* import android.widget.*
import kotlinx.coroutines.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* 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( class HandlerDateSpinner(
private val context: Context, private val context: Context,
@ -12,7 +22,8 @@ class HandlerDateSpinner(
private val languageID: String, private val languageID: String,
private val goToNextQuestion: () -> Unit, private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit, private val goToPreviousQuestion: () -> Unit,
private val showToast: (String) -> Unit private val showToast: (String) -> Unit,
private val questionnaireMeta: String // neu für DB-Abfrage
) : QuestionHandler { ) : QuestionHandler {
private lateinit var question: QuestionItem.DateSpinnerQuestion private lateinit var question: QuestionItem.DateSpinnerQuestion
@ -33,9 +44,22 @@ class HandlerDateSpinner(
val questionTextView = layout.findViewById<TextView>(R.id.question) val questionTextView = layout.findViewById<TextView>(R.id.question)
val textView = layout.findViewById<TextView>(R.id.textView) 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) } ?: "" questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: "" textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
// Schriftgrößen pro Bildschirmhöhe
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 { val (savedYear, savedMonthIndex, savedDay) = question.question?.let {
parseSavedDate(answers[it] as? String) parseSavedDate(answers[it] as? String)
} ?: Triple(null, null, null) } ?: Triple(null, null, null)
@ -50,10 +74,54 @@ class HandlerDateSpinner(
?: months[today.get(Calendar.MONTH)] ?: months[today.get(Calendar.MONTH)]
val defaultYear = savedYear ?: today.get(Calendar.YEAR) val defaultYear = savedYear ?: today.get(Calendar.YEAR)
// Spinner responsiv aufsetzen (Schrift + Zeilenhöhe ohne Abschneiden)
setupSpinner(spinnerDay, days, defaultDay) setupSpinner(spinnerDay, days, defaultDay)
setupSpinner(spinnerMonth, months, defaultMonth) setupSpinner(spinnerMonth, months, defaultMonth)
setupSpinner(spinnerYear, years, defaultYear) 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 { layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) { if (validate()) {
saveAnswer() saveAnswer()
@ -139,16 +207,71 @@ class HandlerDateSpinner(
return sdf.parse(dateString) return sdf.parse(dateString)
} }
// Textgröße prozentual zur Bildschirmhöhe (in sp)
private fun setTextSizePercentOfScreenHeight(view: TextView, percentOfHeight: Float) {
val dm = (view.context ?: layout.context).resources.displayMetrics
val sp = (dm.heightPixels * percentOfHeight) / dm.scaledDensity
TextViewCompat.setAutoSizeTextTypeWithDefaults(view, TextViewCompat.AUTO_SIZE_TEXT_TYPE_NONE)
view.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
}
// Spinner-Adapter: Schrift & Zeilenhöhe dynamisch, kein Abschneiden
private fun <T> setupSpinner(spinner: Spinner, items: List<T>, defaultSelection: T?) { 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) adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter spinner.adapter = adapter
spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx)
spinner.minimumHeight = rowHeight
spinner.requestLayout()
defaultSelection?.let { defaultSelection?.let {
val index = items.indexOf(it) val index = items.indexOf(it)
if (index >= 0) { if (index >= 0) spinner.setSelection(index)
spinner.setSelection(index)
}
} }
} }
} }

View File

@ -1,9 +1,20 @@
package com.dano.test1 package com.dano.test1
import android.content.Context import android.content.Context
import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.widget.* import android.widget.*
import androidx.core.widget.TextViewCompat
import kotlinx.coroutines.*
/*
Zweck:
- Stellt eine „Glas-Skala“-Frage dar, bei der pro Symptom (Zeile) genau eine von fünf Antwortstufen gewählt wird: never / little / moderate / much / extreme.
- Die Stufen werden sowohl als RadioButtons als auch über eine feste Icon-Leiste visualisiert.
*/
class HandlerGlassScaleQuestion( class HandlerGlassScaleQuestion(
private val context: Context, private val context: Context,
@ -12,14 +23,19 @@ class HandlerGlassScaleQuestion(
private val languageID: String, private val languageID: String,
private val goToNextQuestion: () -> Unit, private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit, private val goToPreviousQuestion: () -> Unit,
private val showToast: (String) -> Unit private val showToast: (String) -> Unit,
private val questionnaireMeta: String
) : QuestionHandler { ) : QuestionHandler {
private lateinit var layout: View private lateinit var layout: View
private lateinit var question: QuestionItem.GlassScaleQuestion private lateinit var question: QuestionItem.GlassScaleQuestion
private val scaleLabels = listOf( 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( private val pointsMap = mapOf(
@ -30,60 +46,98 @@ class HandlerGlassScaleQuestion(
"extreme_glass" to 4 "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) { override fun bind(layout: View, question: QuestionItem) {
if (question !is QuestionItem.GlassScaleQuestion) return if (question !is QuestionItem.GlassScaleQuestion) return
this.layout = layout this.layout = layout
this.question = question this.question = question
layout.findViewById<TextView>(R.id.textView).text = val titleTv = layout.findViewById<TextView>(R.id.textView)
question.textKey?.let { LanguageManager.getText(languageID, it) } ?: "" val questionTv = layout.findViewById<TextView>(R.id.question)
layout.findViewById<TextView>(R.id.question).text = titleTv.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
question.question?.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) val tableLayout = layout.findViewById<TableLayout>(R.id.glass_table)
tableLayout.removeAllViews() 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) 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 { layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) { if (validate()) {
saveAnswer() saveAnswer()
goToNextQuestion() goToNextQuestion()
} else { } else {
val message = LanguageManager.getText(languageID, "select_one_answer_per_row") showToast(LanguageManager.getText(languageID, "select_one_answer_per_row"))
showToast(message)
} }
} }
layout.findViewById<Button>(R.id.Qprev).setOnClickListener { goToPreviousQuestion() }
layout.findViewById<Button>(R.id.Qprev).setOnClickListener {
goToPreviousQuestion()
}
} }
private fun addSymptomRows(table: TableLayout) { private fun addSymptomRows(table: TableLayout) {
@ -101,67 +155,91 @@ class HandlerGlassScaleQuestion(
text = LanguageManager.getText(languageID, symptomKey) text = LanguageManager.getText(languageID, symptomKey)
layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 4f) layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 4f)
setPadding(4, 16, 4, 16) setPadding(4, 16, 4, 16)
setTextSizePercentOfScreenHeight(this, 0.022f)
} }
row.addView(symptomText) row.addView(symptomText)
val radioGroup = RadioGroup(context).apply { val radioGroup = RadioGroup(context).apply {
orientation = RadioGroup.HORIZONTAL orientation = RadioGroup.HORIZONTAL
layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 5f) layoutParams = TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 5f)
setPadding(0, 0, 0, 0)
} }
scaleLabels.forEach { labelKey -> 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 tag = labelKey
id = View.generateViewId() id = View.generateViewId()
isChecked = savedLabel == labelKey isChecked = savedLabel == labelKey
layoutParams = setPadding(0, 0, 0, 0)
RadioGroup.LayoutParams(0, RadioGroup.LayoutParams.WRAP_CONTENT, 1f)
gravity = Gravity.CENTER
} }
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) row.addView(radioGroup)
table.addView(row) table.addView(row)
} }
} }
override fun validate(): Boolean { override fun validate(): Boolean {
val table = layout.findViewById<TableLayout>(R.id.glass_table) val table = layout.findViewById<TableLayout>(R.id.glass_table)
for (i in 1 until table.childCount) { for (i in 0 until table.childCount) {
val row = table.getChildAt(i) as TableRow val row = table.getChildAt(i) as TableRow
val radioGroup = row.getChildAt(1) as RadioGroup val radioGroup = row.getChildAt(1) as RadioGroup
if (radioGroup.checkedRadioButtonId == -1) { var anyChecked = false
return 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 return true
} }
override fun saveAnswer() { override fun saveAnswer() {
// Vorherige Punkte dieser Frage entfernen // alte Punkte entfernen
question.symptoms.forEach { question.symptoms.forEach { key ->
val previousLabel = answers[it] as? String val prev = answers[key] as? String
val previousPoint = pointsMap[previousLabel] prev?.let { pointsMap[it] }?.let { points.remove(it) }
if (previousPoint != null) {
points.remove(previousPoint)
}
} }
val table = layout.findViewById<TableLayout>(R.id.glass_table) 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 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 radioGroup = row.getChildAt(1) as RadioGroup
val checkedId = radioGroup.checkedRadioButtonId for (j in 0 until radioGroup.childCount) {
if (checkedId != -1) { val rb = getRadioFromChild(radioGroup.getChildAt(j)) ?: continue
val radioButton = radioGroup.findViewById<RadioButton>(checkedId) if (rb.isChecked) {
val selectedLabel = radioButton.tag as String val selected = rb.tag as String
answers[symptomKey] = selectedLabel answers[symptomKey] = selected
points.add(pointsMap[selected] ?: 0)
break
}
}
}
}
val point = pointsMap[selectedLabel] ?: 0 private fun getRadioFromChild(child: View): RadioButton? =
points.add(point) 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.widget.*
import android.text.Html import android.text.Html
import kotlinx.coroutines.* 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( class HandlerLastPage(
private val answers: Map<String, Any>, private val answers: Map<String, Any>,
@ -15,72 +34,113 @@ class HandlerLastPage(
private lateinit var currentQuestion: QuestionItem.LastPage private lateinit var currentQuestion: QuestionItem.LastPage
private lateinit var layout: View private lateinit var layout: View
private val minLoadingTimeMs = 2000L // Minimum loading time in milliseconds (2 seconds) private val minLoadingTimeMs = 2000L
override fun bind(layout: View, question: QuestionItem) { override fun bind(layout: View, question: QuestionItem) {
this.layout = layout this.layout = layout
currentQuestion = question as QuestionItem.LastPage currentQuestion = question as QuestionItem.LastPage
// Set localized text for the last page val titleTv = layout.findViewById<TextView>(R.id.textView)
layout.findViewById<TextView>(R.id.textView).text = val questionTv = layout.findViewById<TextView>(R.id.question)
LanguageManager.getText(languageID, currentQuestion.textKey) val prevBtn = layout.findViewById<MaterialButton>(R.id.Qprev)
val finishBtn = layout.findViewById<MaterialButton>(R.id.Qfinish)
// Set question text with HTML formatting // Texte setzen
layout.findViewById<TextView>(R.id.question).text = titleTv.text = LanguageManager.getText(languageID, currentQuestion.textKey)
Html.fromHtml( questionTv.text = Html.fromHtml(
LanguageManager.getText(languageID, currentQuestion.question), LanguageManager.getText(languageID, currentQuestion.question),
Html.FROM_HTML_MODE_LEGACY Html.FROM_HTML_MODE_LEGACY
) )
// Setup previous button // Finish-Button: Text + responsive Schrift
layout.findViewById<Button>(R.id.Qprev).setOnClickListener { finishBtn.text = LanguageManager.getText(languageID, "save")
goToPreviousQuestion() finishBtn.isAllCaps = false
} applyResponsiveTextSizing(finishBtn)
// Setup finish button // Überschriften responsiv skalieren (wie zuvor)
layout.findViewById<Button>(R.id.Qfinish).setOnClickListener { setTextSizePercentOfScreenHeight(titleTv, 0.03f)
showLoading(true) // Show loading indicator setTextSizePercentOfScreenHeight(questionTv, 0.03f)
// Buttons
prevBtn.setOnClickListener { goToPreviousQuestion() }
finishBtn.setOnClickListener {
showLoading(true)
// Save answers on a background thread
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
// Save answers to database (suspend function) // Antworten speichern
saveAnswersToDatabase(answers) saveAnswersToDatabase(answers)
// Calculate total points and update global value // Punkte summieren
GlobalValues.INTEGRATION_INDEX = sumPoints() 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 val clientCode = answers["client_code"] as? String
if (clientCode != null) GlobalValues.LAST_CLIENT_CODE = clientCode if (clientCode != null) {
GlobalValues.LAST_CLIENT_CODE = clientCode
// Ensure loading animation runs at least 2 seconds GlobalValues.LOADED_CLIENT_CODE = clientCode // <— zusätzlich setzen
val elapsedTime = System.currentTimeMillis() - startTime
if (elapsedTime < minLoadingTimeMs) {
delay(minLoadingTimeMs - elapsedTime)
} }
// 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) { withContext(Dispatchers.Main) {
showLoading(false) // Hide loading indicator showLoading(false)
val activity = layout.context as? MainActivity val activity = layout.context as? MainActivity
// Zurück zum Opening Screen der lädt dann automatisch (siehe Änderung 2)
activity?.finishQuestionnaire() ?: goToNextQuestion() activity?.finishQuestionnaire() ?: goToNextQuestion()
} }
} }
} }
} }
override fun validate(): Boolean = true // No validation needed on last page override fun validate(): Boolean = true
override fun saveAnswer() {} // No answers to save here 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 = private fun sumPoints(): Int =
answers.filterKeys { it.endsWith("_points") } answers.filterKeys { it.endsWith("_points") }
.values.mapNotNull { it as? Int } .values.mapNotNull { it as? Int }
.sum() .sum()
// Show or hide a ProgressBar (loading spinner)
private fun showLoading(show: Boolean) { private fun showLoading(show: Boolean) {
val progressBar = layout.findViewById<ProgressBar>(R.id.progressBar) val progressBar = layout.findViewById<ProgressBar>(R.id.progressBar)
val finishButton = layout.findViewById<Button>(R.id.Qfinish) val finishButton = layout.findViewById<Button>(R.id.Qfinish)

View File

@ -3,6 +3,14 @@ package com.dano.test1
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import android.widget.* import android.widget.*
import kotlinx.coroutines.*
import android.util.TypedValue
import androidx.core.widget.TextViewCompat
/*
Zweck:
- Steuert eine Frage mit mehreren auswählbaren Antwortoptionen (Checkboxen).
*/
class HandlerMultiCheckboxQuestion( class HandlerMultiCheckboxQuestion(
private val context: Context, private val context: Context,
@ -11,7 +19,8 @@ class HandlerMultiCheckboxQuestion(
private val languageID: String, private val languageID: String,
private val goToNextQuestion: () -> Unit, private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit, private val goToPreviousQuestion: () -> Unit,
private val showToast: (String) -> Unit private val showToast: (String) -> Unit,
private val questionnaireMeta: String //
) : QuestionHandler { ) : QuestionHandler {
private lateinit var layout: View private lateinit var layout: View
@ -25,21 +34,40 @@ class HandlerMultiCheckboxQuestion(
val questionTitle = layout.findViewById<TextView>(R.id.question) val questionTitle = layout.findViewById<TextView>(R.id.question)
val questionTextView = layout.findViewById<TextView>(R.id.textView) val questionTextView = layout.findViewById<TextView>(R.id.textView)
// Hier jetzt identisch zur RadioQuestion:
questionTextView.text = this.question.textKey?.let { LanguageManager.getText(languageID, it) } ?: "" questionTextView.text = this.question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
questionTitle.text = this.question.question?.let { LanguageManager.getText(languageID, it) } ?: "" questionTitle.text = this.question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
// Textgrößen pro Bildschirmhöhe (wie bei deinen anderen Handlern)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f) // Überschrift
setTextSizePercentOfScreenHeight(questionTitle, 0.03f) // Frage
container.removeAllViews() container.removeAllViews()
// bestehende Auswahl aus answers (falls vorhanden) als Set
val selectedKeys = this.question.question?.let { val selectedKeys = this.question.question?.let {
(answers[it] as? List<*>)?.map { it.toString() }?.toSet() (answers[it] as? List<*>)?.map { it.toString() }?.toSet()
} ?: emptySet() } ?: emptySet()
// Checkbox-Schrift & Zeilenhöhe dynamisch ableiten (kein Abschneiden)
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 -> this.question.options.forEach { option ->
val checkBox = CheckBox(context).apply { val checkBox = CheckBox(context).apply {
text = LanguageManager.getText(languageID, option.key) text = LanguageManager.getText(languageID, option.key)
tag = option.key tag = option.key
isChecked = selectedKeys.contains(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( layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
@ -52,6 +80,44 @@ class HandlerMultiCheckboxQuestion(
container.addView(checkBox) 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 { layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) { if (validate()) {
saveAnswer() saveAnswer()
@ -97,8 +163,51 @@ class HandlerMultiCheckboxQuestion(
} }
question.question?.let { questionKey -> 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 answers[questionKey] = selectedKeys
points.add(totalPoints) 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.view.View
import android.text.Html import android.text.Html
import android.widget.* 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( class HandlerRadioQuestion(
private val context: Context, private val context: Context,
@ -13,7 +21,8 @@ class HandlerRadioQuestion(
private val goToNextQuestion: () -> Unit, private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit, private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit, private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit private val showToast: (String) -> Unit,
private val questionnaireMeta: String
) : QuestionHandler { ) : QuestionHandler {
private lateinit var layout: View private lateinit var layout: View
@ -32,6 +41,11 @@ class HandlerRadioQuestion(
Html.fromHtml(LanguageManager.getText(languageID, it), Html.FROM_HTML_MODE_LEGACY) 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() radioGroup.removeAllViews()
@ -39,6 +53,10 @@ class HandlerRadioQuestion(
val radioButton = RadioButton(context).apply { val radioButton = RadioButton(context).apply {
text = LanguageManager.getText(languageID, option.key) text = LanguageManager.getText(languageID, option.key)
tag = option.key tag = option.key
// RadioButton-Text analog zu EditTexts: 2.5% der Bildschirmhöhe
setTextSizePercentOfScreenHeight(this, 0.025f)
layoutParams = RadioGroup.LayoutParams( layoutParams = RadioGroup.LayoutParams(
RadioGroup.LayoutParams.MATCH_PARENT, RadioGroup.LayoutParams.MATCH_PARENT,
RadioGroup.LayoutParams.WRAP_CONTENT RadioGroup.LayoutParams.WRAP_CONTENT
@ -47,12 +65,53 @@ class HandlerRadioQuestion(
val margin = (16 * scale + 0.5f).toInt() val margin = (16 * scale + 0.5f).toInt()
setMargins(0, 0, 0, margin) setMargins(0, 0, 0, margin)
} }
val padding = (12 * resources.displayMetrics.density).toInt()
setPadding(padding, padding, padding, padding)
} }
radioGroup.addView(radioButton) radioGroup.addView(radioButton)
} }
restorePreviousAnswer(radioGroup) 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 { layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) { if (validate()) {
saveAnswer() 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) { private fun restorePreviousAnswer(radioGroup: RadioGroup) {
question.question?.let { questionKey -> question.question?.let { questionKey ->
val savedAnswer = answers[questionKey] as? String val savedAnswer = answers[questionKey] as? String
@ -104,10 +172,8 @@ class HandlerRadioQuestion(
val answerKey = selectedRadioButton.tag.toString() val answerKey = selectedRadioButton.tag.toString()
question.question?.let { questionKey -> question.question?.let { questionKey ->
val oldAnswerKey = answers[questionKey] as? String val oldAnswerKey = answers[questionKey] as? String
val oldPoint = oldAnswerKey?.let { question.pointsMap?.get(it) } ?: 0 val oldPoint = oldAnswerKey?.let { question.pointsMap?.get(it) } ?: 0
points.remove(oldPoint) points.remove(oldPoint)
answers[questionKey] = answerKey answers[questionKey] = answerKey
@ -116,6 +182,4 @@ class HandlerRadioQuestion(
points.add(newPoint) points.add(newPoint)
} }
} }
} }

View File

@ -2,7 +2,18 @@ package com.dano.test1
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.* import android.widget.*
import kotlinx.coroutines.*
import 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( class HandlerStringSpinner(
private val context: Context, private val context: Context,
@ -10,7 +21,8 @@ class HandlerStringSpinner(
private val languageID: String, private val languageID: String,
private val goToNextQuestion: () -> Unit, private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit, private val goToPreviousQuestion: () -> Unit,
private val showToast: (String) -> Unit private val showToast: (String) -> Unit,
private val questionnaireMeta: String
) : QuestionHandler { ) : QuestionHandler {
private lateinit var layout: View private lateinit var layout: View
@ -26,15 +38,47 @@ class HandlerStringSpinner(
val textView = layout.findViewById<TextView>(R.id.textView) val textView = layout.findViewById<TextView>(R.id.textView)
val spinner = layout.findViewById<Spinner>(R.id.string_spinner) val spinner = layout.findViewById<Spinner>(R.id.string_spinner)
// Texte setzen
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: "" questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: "" textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
// Textgrößen prozentual zur Bildschirmhöhe (wie im HandlerRadioQuestion)
setTextSizePercentOfScreenHeight(textView, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
val options = buildOptionsList() val options = buildOptionsList()
// vorhandene Auswahl (falls vorhanden)
val savedSelection = question.question?.let { answers[it] as? String } val savedSelection = question.question?.let { answers[it] as? String }
// Spinner aufsetzen
setupSpinner(spinner, options, savedSelection) 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 { layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) { if (validate()) {
saveAnswer() saveAnswer()
@ -63,10 +107,7 @@ class HandlerStringSpinner(
override fun saveAnswer() { override fun saveAnswer() {
val spinner = layout.findViewById<Spinner>(R.id.string_spinner) val spinner = layout.findViewById<Spinner>(R.id.string_spinner)
val selected = spinner.selectedItem as? String ?: return 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> { 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?) { 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) adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter spinner.adapter = adapter
// Spinner selbst ausreichend hoch machen
spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx)
spinner.minimumHeight = rowHeight
spinner.requestLayout()
selectedItem?.let { selectedItem?.let {
val index = items.indexOf(it) val index = items.indexOf(it)
if (index >= 0) { if (index >= 0) spinner.setSelection(index)
spinner.setSelection(index)
}
} }
} }
} }

View File

@ -2,7 +2,19 @@ package com.dano.test1
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.* import android.widget.*
import kotlinx.coroutines.*
import 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( class HandlerValueSpinner(
private val context: Context, private val context: Context,
@ -11,7 +23,8 @@ class HandlerValueSpinner(
private val goToNextQuestion: () -> Unit, private val goToNextQuestion: () -> Unit,
private val goToPreviousQuestion: () -> Unit, private val goToPreviousQuestion: () -> Unit,
private val goToQuestionById: (String) -> Unit, private val goToQuestionById: (String) -> Unit,
private val showToast: (String) -> Unit private val showToast: (String) -> Unit,
private val questionnaireMeta: String
) : QuestionHandler { ) : QuestionHandler {
private lateinit var layout: View private lateinit var layout: View
@ -30,6 +43,11 @@ class HandlerValueSpinner(
questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: "" questionTextView.text = question.question?.let { LanguageManager.getText(languageID, it) } ?: ""
textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: "" textView.text = question.textKey?.let { LanguageManager.getText(languageID, it) } ?: ""
// Schriftgrößen wie im HandlerRadioQuestion
// Titel/Frage: 3% der Bildschirmhöhe
setTextSizePercentOfScreenHeight(textView, 0.03f)
setTextSizePercentOfScreenHeight(questionTextView, 0.03f)
val prompt = LanguageManager.getText(languageID, "choose_answer") val prompt = LanguageManager.getText(languageID, "choose_answer")
val spinnerItems: List<String> = listOf(prompt) + if (question.range != null) { val spinnerItems: List<String> = listOf(prompt) + if (question.range != null) {
(question.range.min..question.range.max).map { it.toString() } (question.range.min..question.range.max).map { it.toString() }
@ -40,6 +58,31 @@ class HandlerValueSpinner(
val savedValue = question.question?.let { answers[it] as? String } val savedValue = question.question?.let { answers[it] as? String }
setupSpinner(spinner, spinnerItems, savedValue) setupSpinner(spinner, spinnerItems, savedValue)
//DB-Abfrage falls noch keine Antwort im Map existiert
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 { layout.findViewById<Button>(R.id.Qnext).setOnClickListener {
if (validate()) { if (validate()) {
saveAnswer() 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?) { 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) adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinner.adapter = adapter spinner.adapter = adapter
// Spinner selbst ausreichend hoch machen
spinner.setPadding(spinner.paddingLeft, vPadPx, spinner.paddingRight, vPadPx)
spinner.minimumHeight = rowHeight
spinner.requestLayout()
selectedItem?.let { selectedItem?.let {
val index = items.indexOf(it) val index = items.indexOf(it)
if (index >= 0) spinner.setSelection(index) 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 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.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 androidx.appcompat.app.AppCompatActivity
import java.io.File
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@ -10,18 +22,189 @@ class MainActivity : AppCompatActivity() {
var isInQuestionnaire: Boolean = false var isInQuestionnaire: Boolean = false
var isFirstQuestionnairePage: 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Initialize the opening screen handler and show the opening screen
// === 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 = HandlerOpeningScreen(this)
openingScreenHandler.init() 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) { fun startQuestionnaire(questionnaire: QuestionnaireBase<*>, languageID: String) {
isInQuestionnaire = true isInQuestionnaire = true
isFirstQuestionnairePage = true isFirstQuestionnairePage = true
@ -29,24 +212,17 @@ class MainActivity : AppCompatActivity() {
questionnaire.startQuestionnaire() 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() { override fun onBackPressed() {
if (!openingScreenHandler.onBackPressed()) { if (!::openingScreenHandler.isInitialized || !openingScreenHandler.onBackPressed()) {
super.onBackPressed() super.onBackPressed()
} }
} }
/**
* Finish the questionnaire and return to the opening screen.
*/
fun finishQuestionnaire() { fun finishQuestionnaire() {
// For example, switch back to the opening screen:
isInQuestionnaire = false isInQuestionnaire = false
isFirstQuestionnairePage = false isFirstQuestionnairePage = false
if (::openingScreenHandler.isInitialized) {
openingScreenHandler.init() openingScreenHandler.init()
} }
}
} }

View File

@ -6,6 +6,19 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import com.dano.test1.data.AppDatabase import com.dano.test1.data.AppDatabase
/*
MyApp (Application)
- Einstiegspunkt der App, der einmal pro Prozessstart initialisiert wird.
Besonderheiten der DB-Konfiguration:
- Name: "questionnaire_database"
- fallbackToDestructiveMigration():
* Falls sich das Schema ändert und keine Migration vorliegt,wird die DB zerstört und neu angelegt.
- setJournalMode(TRUNCATE):
* Verwendet TRUNCATE-Journal (keine separaten -wal/-shm Dateien), es existiert nur die Hauptdatei „questionnaire_database“.
- Callback onOpen():
* Loggt beim Öffnen der Datenbank einen Hinweis.
*/
class MyApp : Application() { class MyApp : Application() {
companion object { companion object {
@ -16,7 +29,7 @@ class MyApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Room Datenbank bauen: nur die Hauptdatei, ohne WAL und Journal // Room-Datenbank bauen: nur die Hauptdatei, ohne WAL und Journal
database = Room.databaseBuilder( database = Room.databaseBuilder(
applicationContext, applicationContext,
AppDatabase::class.java, AppDatabase::class.java,

View File

@ -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? { protected open fun createHandlerForQuestion(question: QuestionItem): QuestionHandler? {
return when (question) { 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.ClientCoachCodeQuestion -> HandlerClientCoachCode(answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast)
is QuestionItem.DateSpinnerQuestion -> HandlerDateSpinner(context, answers, 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) is QuestionItem.ValueSpinnerQuestion -> HandlerValueSpinner(context, answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::goToQuestionById, ::showToast, questionnaireMeta.id)
is QuestionItem.GlassScaleQuestion -> HandlerGlassScaleQuestion(context, answers, points, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast) is QuestionItem.GlassScaleQuestion -> HandlerGlassScaleQuestion(context, answers, points, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast, questionnaireMeta.id)
is QuestionItem.ClientNotSigned -> HandlerClientNotSigned(answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast) is QuestionItem.ClientNotSigned -> HandlerClientNotSigned(answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast)
is QuestionItem.StringSpinnerQuestion -> HandlerStringSpinner(context, answers, 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) is QuestionItem.MultiCheckboxQuestion -> HandlerMultiCheckboxQuestion(context, answers, points, languageID, ::goToNextQuestion, ::goToPreviousQuestion, ::showToast, questionnaireMeta.id)
is QuestionItem.LastPage -> HandlerLastPage( is QuestionItem.LastPage -> HandlerLastPage(
answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion answers, languageID, ::goToNextQuestion, ::goToPreviousQuestion
) { CoroutineScope(Dispatchers.IO).launch { saveAnswersToDatabase(answers, questionnaireMeta.id) } } ) { CoroutineScope(Dispatchers.IO).launch { saveAnswersToDatabase(answers, questionnaireMeta.id) } }
@ -177,11 +177,16 @@ abstract class QuestionnaireBase<T> {
val clientCode = answers["client_code"] as? String ?: return val clientCode = answers["client_code"] as? String ?: return
saveClientAndQuestionnaire(db, clientCode, questionnaireId) saveClientAndQuestionnaire(db, clientCode, questionnaireId)
// 🔥 Vor dem Speichern alte Antworten löschen
db.answerDao().deleteAnswersForClientAndQuestionnaire(clientCode, questionnaireId)
saveQuestions(db, answers, questionnaireId) saveQuestions(db, answers, questionnaireId)
saveAnswers(db, answers, questionnaireId, clientCode) saveAnswers(db, answers, questionnaireId, clientCode)
markQuestionnaireCompleted(db, questionnaireId, clientCode) markQuestionnaireCompleted(db, questionnaireId, clientCode)
} }
private suspend fun saveClientAndQuestionnaire(db: AppDatabase, clientCode: String, questionnaireId: String) { private suspend fun saveClientAndQuestionnaire(db: AppDatabase, clientCode: String, questionnaireId: String) {
db.clientDao().insertClient(Client(clientCode)) db.clientDao().insertClient(Client(clientCode))
db.questionnaireDao().insertQuestionnaire(Questionnaire(id = questionnaireId)) 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:id="@+id/main"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".MainActivity" tools:context=".MainActivity">
tools:layout_editor_absoluteX="11dp"
tools:layout_editor_absoluteY="107dp">
<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:id="@+id/Qprev"
android:tag="previous" android:layout_width="@dimen/nav_btn_size"
android:layout_width="130dp" android:layout_height="@dimen/nav_btn_size"
android:layout_height="42dp" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintHorizontal_bias="0.056"
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/Qnext" android:id="@+id/Qnext"
android:tag="next" android:layout_width="@dimen/nav_btn_size"
android:layout_width="130dp" android:layout_height="@dimen/nav_btn_size"
android:layout_height="42dp" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="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" />
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:layout_marginStart="25dp" android:textStyle="bold"
android:layout_marginEnd="25dp" android:paddingStart="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingEnd="16dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.104" /> app:layout_constraintWidth_percent="0.9" />
<TextView <TextView
android:id="@+id/question" android:id="@+id/question"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="25dp" android:paddingStart="16dp"
android:layout_marginEnd="25dp" android:paddingEnd="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingTop="8dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.188" /> 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> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -9,29 +9,42 @@
tools:layout_editor_absoluteX="11dp" tools:layout_editor_absoluteX="11dp"
tools:layout_editor_absoluteY="107dp"> tools:layout_editor_absoluteY="107dp">
<Button <com.google.android.material.button.MaterialButton
android:id="@+id/Qprev" android:id="@+id/Qprev"
android:tag="previous" android:layout_width="@dimen/nav_btn_size"
android:layout_width="130dp" android:layout_height="@dimen/nav_btn_size"
android:layout_height="42dp" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
<Button <!-- Weiter -->
<com.google.android.material.button.MaterialButton
android:id="@+id/Qnext" android:id="@+id/Qnext"
android:tag="next" android:layout_width="@dimen/nav_btn_size"
android:layout_width="130dp" android:layout_height="@dimen/nav_btn_size"
android:layout_height="42dp" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="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 <EditText
@ -42,6 +55,7 @@
android:ems="10" android:ems="10"
android:inputType="text" android:inputType="text"
android:tag="coach_code" android:tag="coach_code"
android:textSize="30sp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.495" app:layout_constraintHorizontal_bias="0.495"
@ -53,6 +67,7 @@
android:id="@+id/textView2" android:id="@+id/textView2"
android:layout_width="329dp" android:layout_width="329dp"
android:layout_height="55dp" android:layout_height="55dp"
android:textSize="40sp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -63,6 +78,7 @@
android:id="@+id/question" android:id="@+id/question"
android:layout_width="329dp" android:layout_width="329dp"
android:layout_height="55dp" android:layout_height="55dp"
android:textSize="40sp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -73,6 +89,7 @@
android:id="@+id/textView1" android:id="@+id/textView1"
android:layout_width="329dp" android:layout_width="329dp"
android:layout_height="55dp" android:layout_height="55dp"
android:textSize="40sp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="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"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:layout_marginStart="25dp" android:textStyle="bold"
android:layout_marginEnd="25dp" android:paddingStart="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingEnd="16dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.104" /> app:layout_constraintWidth_percent="0.9" />
<TextView <TextView
android:id="@+id/question" android:id="@+id/question"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="25dp" android:paddingStart="16dp"
android:layout_marginEnd="25dp" android:paddingEnd="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingTop="8dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent" 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_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.453" app:layout_constraintWidth_percent="0.9" />
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" />
<TextView <TextView
android:id="@+id/date_spinner_day" android:id="@+id/date_spinner_day"
android:layout_width="90dp" android:layout_width="0dp"
android:layout_height="22dp" android:layout_height="wrap_content"
android:tag="day" 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_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.099" app:layout_constraintHorizontal_bias="0.07"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintVertical_bias="0.282" /> app:layout_constraintWidth_percent="0.22" />
<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" />
<TextView <TextView
android:id="@+id/date_spinner_month" android:id="@+id/date_spinner_month"
android:layout_width="142dp" android:layout_width="0dp"
android:layout_height="22dp" android:layout_height="wrap_content"
android:tag="month" 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_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.453" app:layout_constraintHorizontal_bias="0.459"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintVertical_bias="0.282" /> 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 <Spinner
android:id="@+id/spinner_value_year" android:id="@+id/spinner_value_year"
android:layout_width="110dp" android:layout_width="0dp"
android:layout_height="35dp" android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent" android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.877" app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toBottomOf="@id/date_spinner_year"
app:layout_constraintVertical_bias="0.341" /> app:layout_constraintWidth_percent="0.32" />
<Button <com.google.android.material.button.MaterialButton
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
android:id="@+id/Qprev" android:id="@+id/Qprev"
android:layout_width="130dp" android:layout_width="@dimen/nav_btn_size"
android:layout_height="42dp" android:layout_height="@dimen/nav_btn_size"
android:tag="previous" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent" <com.google.android.material.button.MaterialButton
app:layout_constraintTop_toTopOf="parent" android:id="@+id/Qnext"
app:layout_constraintVertical_bias="0.976" /> 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> </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" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<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 <ScrollView
android:layout_width="match_parent" android:id="@+id/glassScroll"
android:layout_height="466dp" android:layout_width="0dp"
android:layout_height="0dp"
android:fillViewport="true" 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_constraintBottom_toTopOf="@+id/Qprev"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"> app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="1">
<TableLayout <TableLayout
android:id="@+id/glass_table" android:id="@+id/glass_table"
@ -23,49 +75,36 @@
android:textStyle="bold" /> android:textStyle="bold" />
</ScrollView> </ScrollView>
<com.google.android.material.button.MaterialButton
<Button
android:id="@+id/Qprev" android:id="@+id/Qprev"
android:layout_width="130dp" android:layout_width="@dimen/nav_btn_size"
android:layout_height="wrap_content" android:layout_height="@dimen/nav_btn_size"
android:tag="previous" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent" />
android:layout_margin="16dp" />
<Button <com.google.android.material.button.MaterialButton
android:id="@+id/Qnext" android:id="@+id/Qnext"
android:layout_width="130dp" android:layout_width="@dimen/nav_btn_size"
android:layout_height="wrap_content" android:layout_height="@dimen/nav_btn_size"
android:tag="next" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent" />
android:layout_margin="16dp" />
<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> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -5,57 +5,77 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<TextView <!-- Zurück (links unten) -->
android:id="@+id/question" <com.google.android.material.button.MaterialButton
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
android:id="@+id/Qprev" android:id="@+id/Qprev"
android:layout_width="130dp" android:layout_width="@dimen/nav_btn_size"
android:layout_height="42dp" android:layout_height="@dimen/nav_btn_size"
android:tag="previous" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.976" />
<Button <!-- Fertig/Speichern (rechts unten) gleiche Farbe wie Qprev -->
<com.google.android.material.button.MaterialButton
android:id="@+id/Qfinish" android:id="@+id/Qfinish"
android:layout_width="231dp" android:layout_width="@dimen/finish_btn_width"
android:layout_height="42dp" android:layout_height="@dimen/nav_btn_size"
android:tag="finish" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="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" />
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="389dp" android:layout_width="0dp"
android:layout_height="115dp" android:layout_height="0dp"
android:tag="finish_data_entry" android:gravity="start|center_vertical"
android:textAlignment="viewStart" android:textAlignment="viewStart"
android:textSize="24sp" android:paddingStart="8dp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.25"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="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 <ProgressBar
android:id="@+id/progressBar" android:id="@+id/progressBar"
style="?android:attr/progressBarStyleLarge" style="?android:attr/progressBarStyleLarge"

View File

@ -5,71 +5,91 @@
android:id="@+id/main" android:id="@+id/main"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".MainActivity" tools:context=".MainActivity">
tools:layout_editor_absoluteX="11dp"
tools:layout_editor_absoluteY="107dp">
<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:id="@+id/Qprev"
android:tag="previous" android:layout_width="@dimen/nav_btn_size"
android:layout_width="130dp" android:layout_height="@dimen/nav_btn_size"
android:layout_height="42dp" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintHorizontal_bias="0.056"
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/Qnext" android:id="@+id/Qnext"
android:tag="next" android:layout_width="@dimen/nav_btn_size"
android:layout_width="130dp" android:layout_height="@dimen/nav_btn_size"
android:layout_height="42dp" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="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" />
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:layout_marginStart="25dp" android:textStyle="bold"
android:layout_marginEnd="25dp" android:paddingStart="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingEnd="16dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.104" /> app:layout_constraintWidth_percent="0.9" />
<TextView <TextView
android:id="@+id/question" android:id="@+id/question"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="25dp" android:paddingStart="16dp"
android:layout_marginEnd="25dp" android:paddingEnd="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingTop="8dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.188" /> app:layout_constraintWidth_percent="0.9" />
<ScrollView <ScrollView
android:id="@+id/scrollView" android:id="@+id/scrollView"
android:layout_width="360dp" android:layout_width="0dp"
android:layout_height="417dp" android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent" android:fillViewport="true"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginTop="8dp"
app:layout_constraintHorizontal_bias="0.489" android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.658"> app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintBottom_toTopOf="@id/Qnext"
app:layout_constraintWidth_percent="0.9">
<LinearLayout <LinearLayout
android:id="@+id/CheckboxContainer" android:id="@+id/CheckboxContainer"
@ -78,4 +98,5 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp" /> android:padding="16dp" />
</ScrollView> </ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -3,100 +3,307 @@
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:padding="16dp">
<EditText <com.google.android.material.card.MaterialCardView
android:id="@+id/editText" android:id="@+id/headerCard"
android:layout_width="216dp" android:layout_width="0dp"
android:layout_height="53dp" android:layout_height="wrap_content"
android:background="@android:drawable/edit_text" android:layout_marginTop="32dp"
android:ems="10" android:paddingStart="24dp"
android:inputType="text" android:paddingEnd="24dp"
android:tag="client_code" android:paddingTop="20dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingBottom="20dp"
app:layout_constraintEnd_toEndOf="parent" app:cardBackgroundColor="@color/brand_surface"
app:layout_constraintHorizontal_bias="0.897" app:cardCornerRadius="16dp"
app:layout_constraintStart_toStartOf="parent" app:strokeColor="@color/brand_stroke"
app:strokeWidth="1dp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.091" /> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Language (eigene Zeile) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="6dp">
<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" />
<Spinner <Spinner
android:id="@+id/string_spinner1" android:id="@+id/string_spinner1"
android:layout_width="143dp" android:layout_width="match_parent"
android:layout_height="43dp" android:layout_height="48dp"
app:layout_constraintBottom_toBottomOf="parent" android:layout_marginStart="8dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="8dp"
app:layout_constraintHorizontal_bias="0.07" android:background="@drawable/bg_field_filled"
app:layout_constraintStart_toStartOf="parent" android:paddingStart="12dp"
app:layout_constraintTop_toTopOf="parent" android:paddingEnd="12dp" />
app:layout_constraintVertical_bias="0.091" /> </LinearLayout>
<!-- Container für dynamische Buttons --> <!-- 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 <LinearLayout
android:id="@+id/buttonContainer"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="28dp" android:layout_weight="1"
android:orientation="vertical" android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="8dp">
app:layout_constraintHorizontal_bias="0.0"
<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_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView" /> app:layout_constraintEnd_toEndOf="parent">
<Button <com.google.android.material.button.MaterialButton
android:id="@+id/loadButton" android:id="@+id/loadButton"
android:layout_width="72dp" android:layout_width="0dp"
android:layout_height="52dp" android:layout_height="@dimen/pill_height"
app:layout_constraintEnd_toEndOf="@id/editText" android:layout_weight="1"
app:layout_constraintHorizontal_bias="0.0" android:layout_marginEnd="12dp"
app:layout_constraintStart_toStartOf="@id/editText" android:textAllCaps="false"
app:layout_constraintTop_toBottomOf="@id/editText" /> 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"/>
<Button <com.google.android.material.button.MaterialButton
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" />
<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" />
<Button
android:id="@+id/editButton" android:id="@+id/editButton"
android:layout_width="72dp" android:layout_width="0dp"
android:layout_height="52dp" android:layout_height="@dimen/pill_height"
app:layout_constraintEnd_toEndOf="@id/editText" android:layout_weight="1"
app:layout_constraintStart_toStartOf="@id/editText" android:layout_marginEnd="12dp"
app:layout_constraintTop_toBottomOf="@id/editText" /> 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"/>
<Button <com.google.android.material.button.MaterialButton
android:id="@+id/saveButton" android:id="@+id/saveButton"
android:layout_width="72dp" android:layout_width="0dp"
android:layout_height="52dp" android:layout_height="@dimen/pill_height"
app:layout_constraintEnd_toEndOf="@id/editText" android:layout_weight="1"
app:layout_constraintHorizontal_bias="1.0" android:textAllCaps="false"
app:layout_constraintStart_toStartOf="@id/editText" android:textColor="@android:color/white"
app:layout_constraintTop_toBottomOf="@id/editText" /> 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 <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="348dp" android:layout_width="0dp"
android:layout_height="162dp" android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent" android:layout_marginTop="24dp"
app:layout_constraintEnd_toEndOf="parent" android:textStyle="bold"
app:layout_constraintHorizontal_bias="0.492" android:textSize="22sp"
android:textColor="@color/brand_text_dark"
app:layout_constraintTop_toBottomOf="@id/secondaryActionsColumn"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"/>
app:layout_constraintVertical_bias="0.307" />
<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> </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:id="@+id/main"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".MainActivity" tools:context=".MainActivity">
tools:layout_editor_absoluteX="11dp"
tools:layout_editor_absoluteY="107dp">
<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:id="@+id/Qprev"
android:tag="previous" android:layout_width="@dimen/nav_btn_size"
android:layout_width="130dp" android:layout_height="@dimen/nav_btn_size"
android:layout_height="42dp" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintHorizontal_bias="0.056"
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/Qnext" android:id="@+id/Qnext"
android:tag="next" android:layout_width="@dimen/nav_btn_size"
android:layout_width="130dp" android:layout_height="@dimen/nav_btn_size"
android:layout_height="42dp" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="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" />
<TextView <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:layout_marginStart="25dp" android:textStyle="bold"
android:layout_marginEnd="25dp" android:paddingStart="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingEnd="16dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.104" /> app:layout_constraintWidth_percent="0.9" />
<TextView <TextView
android:id="@+id/question" android:id="@+id/question"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="25dp" android:paddingStart="16dp"
android:layout_marginEnd="25dp" android:paddingEnd="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingTop="8dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.188" /> 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> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -5,68 +5,85 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<Spinner <Spinner
android:id="@+id/string_spinner" android:id="@+id/string_spinner"
android:layout_width="250dp" android:layout_width="0dp"
android:layout_height="35dp" android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent" android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintWidth_percent="0.70"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="parent" 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_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.943" app:layout_constraintTop_toBottomOf="@id/question" />
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:id="@+id/Qprev"
android:layout_width="130dp" android:layout_width="@dimen/nav_btn_size"
android:layout_height="42dp" android:layout_height="@dimen/nav_btn_size"
android:tag="previous" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent" <com.google.android.material.button.MaterialButton
app:layout_constraintTop_toTopOf="parent" android:id="@+id/Qnext"
app:layout_constraintVertical_bias="0.976" /> 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 <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:layout_marginStart="25dp" android:textStyle="bold"
android:layout_marginEnd="25dp" android:paddingStart="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingEnd="16dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.104" /> app:layout_constraintWidth_percent="0.9" />
<TextView <TextView
android:id="@+id/question" android:id="@+id/question"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="25dp" android:paddingStart="16dp"
android:layout_marginEnd="25dp" android:paddingEnd="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingTop="8dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.188" /> app:layout_constraintWidth_percent="0.9" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -5,67 +5,87 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/gTop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="32dp" />
<Spinner <Spinner
android:id="@+id/value_spinner" android:id="@+id/value_spinner"
android:layout_width="250dp" android:layout_width="0dp"
android:layout_height="35dp" android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent" android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.498" app:layout_constraintHorizontal_bias="0.495"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toBottomOf="@id/question"
app:layout_constraintVertical_bias="0.38" /> app:layout_constraintVertical_bias="0.027"
app:layout_constraintWidth_percent="0.70" />
<Button <com.google.android.material.button.MaterialButton
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
android:id="@+id/Qprev" android:id="@+id/Qprev"
android:layout_width="130dp" android:layout_width="@dimen/nav_btn_size"
android:layout_height="42dp" android:layout_height="@dimen/nav_btn_size"
android:tag="previous" 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_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintHorizontal_bias="0.056"
app:layout_constraintStart_toStartOf="parent" <com.google.android.material.button.MaterialButton
app:layout_constraintTop_toTopOf="parent" android:id="@+id/Qnext"
app:layout_constraintVertical_bias="0.976" /> 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 <TextView
android:id="@+id/textView" android:id="@+id/textView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:layout_marginStart="25dp" android:textStyle="bold"
android:layout_marginEnd="25dp" android:paddingStart="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingEnd="16dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/gTop"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.104" /> app:layout_constraintWidth_percent="0.9" />
<TextView <TextView
android:id="@+id/question" android:id="@+id/question"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAlignment="center" android:gravity="center"
android:textStyle="bold" android:textStyle="bold"
android:layout_marginStart="25dp" android:paddingStart="16dp"
android:layout_marginEnd="25dp" android:paddingEnd="16dp"
app:layout_constraintBottom_toBottomOf="parent" android:paddingTop="8dp"
app:layout_constraintEnd_toEndOf="parent" android:paddingBottom="8dp"
app:layout_constraintTop_toBottomOf="@id/textView"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="0.188" /> app:layout_constraintWidth_percent="0.9" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -2,4 +2,8 @@
<resources> <resources>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</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> </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>