diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d31e21..aefb905 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,6 +27,24 @@ android { ) } } + + // Apache POI bringt viele META-INF-Dateien mit – hier aus dem APK ausschließen + packaging { + resources { + excludes += listOf( + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt", + "META-INF/NOTICE.md", + "META-INF/LICENSE.md", + "META-INF/*.kotlin_module", + "META-INF/versions/**" + ) + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -44,22 +62,31 @@ dependencies { implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + implementation("com.google.code.gson:gson:2.10.1") + implementation("androidx.room:room-runtime:$room_version") kapt("androidx.room:room-compiler:$room_version") implementation("androidx.room:room-ktx:$room_version") - implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") - implementation ("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") // SQLCipher - implementation ("net.zetetic:android-database-sqlcipher:4.5.3@aar") - implementation ("androidx.sqlite:sqlite:2.1.0") - implementation ("androidx.sqlite:sqlite-framework:2.1.0") + implementation("net.zetetic:android-database-sqlcipher:4.5.3@aar") + implementation("androidx.sqlite:sqlite:2.1.0") + implementation("androidx.sqlite:sqlite-framework:2.1.0") // Server Upload implementation("com.squareup.okhttp3:okhttp:4.12.0") -} \ No newline at end of file + // ---- 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 +} diff --git a/app/src/main/java/com/dano/test1/DatabaseButtonHandler.kt b/app/src/main/java/com/dano/test1/DatabaseButtonHandler.kt index 8868b2c..9c46954 100644 --- a/app/src/main/java/com/dano/test1/DatabaseButtonHandler.kt +++ b/app/src/main/java/com/dano/test1/DatabaseButtonHandler.kt @@ -1,5 +1,6 @@ package com.dano.test1 +import android.os.Environment import android.util.Log import android.view.View import android.widget.* @@ -8,13 +9,19 @@ import com.dano.test1.data.Question import com.dano.test1.data.Questionnaire import kotlinx.coroutines.* import org.json.JSONArray +import java.io.File import java.nio.charset.Charset import kotlin.math.roundToInt +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.xssf.usermodel.XSSFWorkbook + class DatabaseButtonHandler( private val activity: MainActivity, private val databaseButton: Button, - private val onClose: () -> Unit + private val onClose: () -> Unit, + // Liefert die aktuelle Sprache; Default: "GERMAN" + private val languageIDProvider: () -> String = { "GERMAN" } ) { private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val tag = "DatabaseButtonHandler" @@ -37,8 +44,10 @@ class DatabaseButtonHandler( 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") backButton.setOnClickListener { onClose() } + btnDownloadHeader.setOnClickListener { onDownloadHeadersClicked(progress) } progress.visibility = View.VISIBLE emptyView.visibility = View.GONE @@ -68,6 +77,108 @@ class DatabaseButtonHandler( } } + // --------------------------- + // Export: Header aller Clients als Excel + // --------------------------- + private fun onDownloadHeadersClicked(progress: ProgressBar) { + uiScope.launch { + try { + progress.visibility = View.VISIBLE + + val exportResult = exportHeadersForAllClients() + + progress.visibility = View.GONE + + if (exportResult != null) { + Toast.makeText( + activity, + "Export erfolgreich: ${exportResult.absolutePath}", + Toast.LENGTH_LONG + ).show() + } else { + Toast.makeText(activity, "Export fehlgeschlagen.", Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + progress.visibility = View.GONE + Log.e(tag, "Download Header Fehler: ${e.message}", e) + Toast.makeText(activity, "Fehler: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + + /** + * Erzeugt eine Excel-Datei (ClientHeaders.xlsx) mit: + * - Spalten = IDs aus header_order.json (in genau der Reihenfolge) + * - Zeilen = je ein Client; Werte: + * - "client_code" => code + * - Fragebogen-IDs (questionnaire_*) => "Done" / "Not Done" + * - alle anderen IDs => Antwort oder "None" + * + * Rückgabewert: Ausgabedatei oder null bei Fehler. + */ + private suspend fun exportHeadersForAllClients(): File? { + val orderedIds = 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) + + var col = 0 + val header = sheet.createRow(0) + header.createCell(col++).setCellValue("#") + orderedIds.forEach { id -> header.createCell(col++).setCellValue(id) } + + clients.forEachIndexed { rowIdx, client -> + val row = sheet.createRow(rowIdx + 1) + 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 -> + // Rohwert wie bisher bestimmen + 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" + } + // NEU: für Excel auf Englisch lokalisieren (Done/Not Done/None bleiben) + val out = localizeForExportEn(id, raw) + row.createCell(c++).setCellValue(out) + } + } + + val bytes = java.io.ByteArrayOutputStream().use { bos -> + wb.write(bos) + bos.toByteArray() + } + wb.close() + + val extDir = activity.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS)?.apply { mkdirs() } + val extFile = extDir?.let { File(it, "ClientHeaders.xlsx") }?.apply { writeBytes(bytes) } + + val intDir = File(activity.filesDir, "exports").apply { mkdirs() } + val intFile = File(intDir, "ClientHeaders.xlsx").apply { writeBytes(bytes) } + + Toast.makeText( + activity, + "Export:\n• intern: ${intFile.absolutePath}\n• extern: ${extFile?.absolutePath ?: "—"}", + Toast.LENGTH_LONG + ).show() + + return extFile ?: intFile + } + // --------------------------- // SCREEN 2: Fragebogen-Übersicht + "header"-Liste für einen Client // --------------------------- @@ -119,72 +230,68 @@ class DatabaseButtonHandler( val questionnaireIdSet = allQuestionnaires.map { it.id }.toSet() val answerMap = allAnswersForClient.associate { it.questionId to it.answerValue } - // *** Sortierung der FRAGEBÖGEN nach questionnaire_1..., _2..., _3..., _4..., _5..., _6... *** - val sortedQuestionnaires = allQuestionnaires.sortedWith( - compareBy( - { extractQuestionnaireNumber(it.id) ?: Int.MAX_VALUE }, - { it.id } - ) - ) + // Tabelle 1: Fragebögen (nur ✓ klickbar) – hier NUR Textfarbe (kein Hintergrund) + allQuestionnaires + .sortedWith(compareBy({ extractQuestionnaireNumber(it.id) ?: Int.MAX_VALUE }, { it.id })) + .forEachIndexed { idx, q -> + val isDone = statusMap[q.id] ?: false + val statusText = if (isDone) "✓" else "✗" + val statusTextColor = if (isDone) 0xFF4CAF50.toInt() else 0xFFF44336.toInt() - // Tabelle 1: Fragebögen (nur ✓ klickbar) – HIER KEINE Hintergrundfarben, nur Textfarbe - sortedQuestionnaires.forEachIndexed { idx, q -> - val isDone = statusMap[q.id] ?: 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(), q.id, statusText), - onClick = { openQuestionnaireDetailScreen(clientCode, q.id) }, - colorOverrides = mapOf(2 to statusTextColor), // nur Text einfärben - cellBgOverrides = emptyMap() - ) - } else { - addDisabledRow( - table = tableQ, - cells = listOf((idx + 1).toString(), q.id, statusText), - colorOverrides = mapOf(2 to statusTextColor), // nur Text einfärben - cellBgOverrides = emptyMap() - ) + if (isDone) { + addClickableRow( + table = tableQ, + cells = listOf((idx + 1).toString(), q.id, statusText), + onClick = { openQuestionnaireDetailScreen(clientCode, q.id) }, + colorOverrides = mapOf(2 to statusTextColor), + cellBgOverrides = emptyMap() + ) + } else { + addDisabledRow( + table = tableQ, + cells = listOf((idx + 1).toString(), q.id, statusText), + colorOverrides = mapOf(2 to statusTextColor), + cellBgOverrides = emptyMap() + ) + } } - } - // Tabelle 2: "header"-Liste in der Reihenfolge aus assets/header_order.json - // HIER: GRÜN/ROT als HINTERGRUND für Fragebögen + GELB für "None"-Zeilen + // Tabelle 2: "header"-Liste aus header_order.json val orderedIds = loadOrderedIds() orderedIds.forEachIndexed { idx, id -> var rowBgColor: Int? = null + val darkGray = 0xFFBDBDBD.toInt() - val (value, bgColorForCells) = when { - id == "client_code" -> clientCode to null - id in questionnaireIdSet -> { - if (statusMap[id] == true) { - "Done" to 0xFF4CAF50.toInt() // GRÜN als Hintergrund - } else { - "Not Done" to 0xFFF44336.toInt() // ROT als Hintergrund - } - } - else -> { - val v = answerMap[id] - val out = if (!v.isNullOrBlank()) v else "None" - if (out == "None") { - rowBgColor = 0xFFFFF59D.toInt() // GELB ganze Zeile bei None - } - out to null - } + // 1) Rohwert ermitteln (für Logik & Farben) + val raw: String + val bgColorForCells: Int? + + if (id == "client_code") { + raw = clientCode + bgColorForCells = null + } else if (id in questionnaireIdSet) { + raw = if (statusMap[id] == true) "Done" else "Not Done" + bgColorForCells = if (raw == "Done") 0xFF4CAF50.toInt() else 0xFFF44336.toInt() + } else { + raw = answerMap[id]?.takeIf { it.isNotBlank() } ?: "None" + bgColorForCells = null + if (raw == "None") rowBgColor = darkGray } - // Für Fragebögen im Header: ID (Spalte 2) und Wert (Spalte 3) farbig HINTERLEGEN - val cellBg = if (bgColorForCells != null) mapOf(1 to bgColorForCells, 2 to bgColorForCells) else emptyMap() + // 2) Anzeige-Wert über LanguageManager lokalisieren (nur Header/Wert-Spalte) + val display = localizeHeaderValue(id, raw) + + // Für Fragebögen im Header: "#"(0), ID(1) und Wert(2) farbig HINTERLEGEN + val cellBg = if (bgColorForCells != null) + mapOf(0 to bgColorForCells, 1 to bgColorForCells, 2 to bgColorForCells) + else emptyMap() addRow( table = tableOrdered, - cells = listOf((idx + 1).toString(), id, value), + cells = listOf((idx + 1).toString(), id, display), colorOverrides = emptyMap(), // keine Textfarben im Header - rowBgColor = rowBgColor, // GELB für "None" - cellBgOverrides = cellBg // GRÜN/ROT für Done/Not Done + rowBgColor = rowBgColor, // dunkelgrau für "None" + cellBgOverrides = cellBg // GRÜN/ROT – inkl. Spalte "#" ) } } @@ -260,11 +367,54 @@ class DatabaseButtonHandler( // Hilfsfunktionen // --------------------------- private fun extractQuestionnaireNumber(id: String): Int? { - // Erwartet Präfix "questionnaire_" val m = Regex("^questionnaire_(\\d+)").find(id.lowercase()) return m?.groupValues?.get(1)?.toIntOrNull() } + // Lokalisiert den im Header angezeigten Wert (Wert-Spalte) über LanguageManager. + // Probiert mehrere Schlüssel-Varianten, fällt ansonsten auf den Rohwert zurück. + // Ersetzt die alte Version 1:1 + private fun localizeHeaderValue(id: String, raw: String): String { + // client_code nie übersetzen + if (id == "client_code") return raw + + val lang = try { languageIDProvider() } catch (_: Exception) { "GERMAN" } + + fun stripBrackets(s: String): String { + val m = Regex("^\\[(.*)]$").matchEntire(s.trim()) + return m?.groupValues?.get(1) ?: s + } + + fun tryKey(key: String): String? { + val t = try { LanguageManager.getText(lang, key) } catch (_: Exception) { key } + val stripped = stripBrackets(t) + // Wenn Ergebnis leer, gleich dem Key (nach evtl. Klammern) oder nur ein Platzhalter war: keine gültige Übersetzung + if (stripped.isBlank() || stripped.equals(key, ignoreCase = true)) return null + return stripped + } + + // Kandidaten-Schlüssel in sinnvoller Reihenfolge + val norm = raw.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) + if (norm.isNotBlank() && norm != raw) add(norm) + if (norm.isNotBlank()) { + add("${id}_$norm") + add("${id}-$norm") + } + } + + for (key in candidates) { + tryKey(key)?.let { return it } // nur echte Übersetzungen übernehmen + } + return raw // Fallback ohne eckige Klammern + } + private fun addHeaderRow(table: TableLayout, labels: List) { val row = TableRow(activity) labels.forEach { label -> row.addView(makeHeaderCell(label)) } @@ -377,4 +527,33 @@ class DatabaseButtonHandler( } return v } + + private fun localizeForExportEn(id: String, raw: String): String { + if (id == "client_code") return raw + if (raw == "Done" || raw == "Not Done" || raw == "None") return raw + + fun stripBrackets(s: String): String { + val m = Regex("^\\[(.*)]$").matchEntire(s.trim()) + return m?.groupValues?.get(1) ?: s + } + fun tryKey(key: String): String? { + val t = try { LanguageManager.getText("ENGLISH", key) } catch (_: Exception) { key } + val stripped = stripBrackets(t) + if (stripped.isBlank() || stripped.equals(key, ignoreCase = true)) return null + return stripped + } + + 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) tryKey(key)?.let { return it } + return raw + } + } diff --git a/app/src/main/java/com/dano/test1/HandlerOpeningScreen.kt b/app/src/main/java/com/dano/test1/HandlerOpeningScreen.kt index 66e5de5..d428eba 100644 --- a/app/src/main/java/com/dano/test1/HandlerOpeningScreen.kt +++ b/app/src/main/java/com/dano/test1/HandlerOpeningScreen.kt @@ -345,13 +345,16 @@ class HandlerOpeningScreen(private val activity: MainActivity) { private fun setupDatabaseButtonHandler() { DatabaseButtonHandler( activity = activity, - databaseButton = databaseButton - ) { - // zurück zum Opening-Screen - init() - }.setup() + databaseButton = databaseButton, + onClose = { + // zurück zum Opening-Screen + init() + }, + languageIDProvider = { languageID } // aktuelle Sprache an DatabaseButtonHandler weitergeben + ).setup() } + private fun updateMainButtonsState(isDatabaseAvailable: Boolean) { val buttons = listOf(buttonLoad, saveButton, editButton, databaseButton) // <-- NEU dabei buttons.forEach { button -> diff --git a/app/src/main/res/layout/database_screen.xml b/app/src/main/res/layout/database_screen.xml index e3c3b24..392a905 100644 --- a/app/src/main/res/layout/database_screen.xml +++ b/app/src/main/res/layout/database_screen.xml @@ -53,6 +53,13 @@ android:paddingTop="8dp" android:paddingBottom="8dp" /> + +