From 9d72dddbce3fea15ece6dc4ae4cfaca4e231568a Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Tue, 8 Sep 2026 17:36:40 +0200 Subject: [PATCH] feat(android): Android App Screens, SpeechManager und ApiClient hinzugefuegt --- .../java/de/jannik/mindomat/data/ApiClient.kt | 212 ++++++++++ .../jannik/mindomat/speech/SpeechManager.kt | 127 ++++++ .../mindomat/ui/screens/CaptureScreen.kt | 389 ++++++++++++++++++ .../mindomat/ui/screens/NoteDetailScreen.kt | 148 +++++++ .../jannik/mindomat/ui/screens/NotesScreen.kt | 311 ++++++++++++++ .../mindomat/ui/screens/SettingsScreen.kt | 274 ++++++++++++ 6 files changed, 1461 insertions(+) create mode 100644 android/app/src/main/java/de/jannik/mindomat/data/ApiClient.kt create mode 100644 android/app/src/main/java/de/jannik/mindomat/speech/SpeechManager.kt create mode 100644 android/app/src/main/java/de/jannik/mindomat/ui/screens/CaptureScreen.kt create mode 100644 android/app/src/main/java/de/jannik/mindomat/ui/screens/NoteDetailScreen.kt create mode 100644 android/app/src/main/java/de/jannik/mindomat/ui/screens/NotesScreen.kt create mode 100644 android/app/src/main/java/de/jannik/mindomat/ui/screens/SettingsScreen.kt diff --git a/android/app/src/main/java/de/jannik/mindomat/data/ApiClient.kt b/android/app/src/main/java/de/jannik/mindomat/data/ApiClient.kt new file mode 100644 index 0000000..7104027 --- /dev/null +++ b/android/app/src/main/java/de/jannik/mindomat/data/ApiClient.kt @@ -0,0 +1,212 @@ +package de.jannik.mindomat.data + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.net.URLEncoder +import java.util.concurrent.TimeUnit + +class ApiClient { + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(35, TimeUnit.SECONDS) + .writeTimeout(35, TimeUnit.SECONDS) + .build() + + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + + private fun buildRequest(url: String, apiToken: String): Request.Builder { + val builder = Request.Builder().url(url) + if (apiToken.isNotBlank()) { + builder.addHeader("Authorization", "Bearer $apiToken") + } + return builder + } + + suspend fun testConnection(serverUrl: String, apiToken: String): Result = + withContext(Dispatchers.IO) { + try { + val url = "${serverUrl.trimEnd('/')}/api/status" + val request = buildRequest(url, apiToken).get().build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}: ${response.message}")) + } + val body = response.body?.string() ?: "" + val json = JSONObject(body) + Result.success( + ServerStatus( + ok = json.optBoolean("ok", true), + name = json.optString("name", "Mind-o-Mat"), + version = json.optString("version", "1.0.0"), + authRequired = json.optBoolean("authRequired", false) + ) + ) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getNotes(serverUrl: String, apiToken: String): Result> = + withContext(Dispatchers.IO) { + try { + val url = "${serverUrl.trimEnd('/')}/api/notes" + val request = buildRequest(url, apiToken).get().build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}: ${response.message}")) + } + val body = response.body?.string() ?: "" + val json = JSONObject(body) + val result = mutableListOf() + + if (json.has("items")) { + val items = json.getJSONArray("items") + for (i in 0 until items.length()) { + val item = items.getJSONObject(i) + val tagsList = mutableListOf() + val tagsArr = item.optJSONArray("tags") + if (tagsArr != null) { + for (t in 0 until tagsArr.length()) { + tagsList.add(tagsArr.getString(t)) + } + } + result.add( + NoteSummary( + id = item.optString("id", ""), + title = item.optString("title", "Ohne Titel"), + folder = item.optString("folder", ""), + tags = tagsList, + snippet = item.optString("snippet", ""), + lastModified = item.optString("lastModified", "") + ) + ) + } + } else if (json.has("notes")) { + val notes = json.getJSONArray("notes") + for (i in 0 until notes.length()) { + val id = notes.getString(i) + val folder = id.substringBefore('/', "") + val title = id.substringAfterLast('/').removeSuffix(".md") + result.add( + NoteSummary( + id = id, + title = title, + folder = folder, + tags = emptyList(), + snippet = "", + lastModified = "" + ) + ) + } + } + Result.success(result) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getNoteDetail( + serverUrl: String, + apiToken: String, + noteId: String + ): Result = withContext(Dispatchers.IO) { + try { + val encoded = URLEncoder.encode(noteId, "UTF-8") + val url = "${serverUrl.trimEnd('/')}/api/notes/$encoded" + val request = buildRequest(url, apiToken).get().build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}: ${response.message}")) + } + val body = response.body?.string() ?: "" + val json = JSONObject(body) + val frontmatterMap = mutableMapOf() + val fm = json.optJSONObject("frontmatter") + if (fm != null) { + val keys = fm.keys() + while (keys.hasNext()) { + val key = keys.next() + frontmatterMap[key] = fm.opt(key) + } + } + Result.success( + NoteDetail( + id = json.optString("id", noteId), + content = json.optString("content", ""), + frontmatter = frontmatterMap, + lastModified = json.optString("lastModified", "") + ) + ) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun captureNote( + serverUrl: String, + apiToken: String, + req: CaptureRequest + ): Result = withContext(Dispatchers.IO) { + try { + val url = "${serverUrl.trimEnd('/')}/api/capture" + val json = JSONObject().apply { + put("text", req.text) + if (!req.title.isNullOrBlank()) put("title", req.title) + put("tags", JSONArray(req.tags)) + put("autoIngest", req.autoIngest) + put("autoSync", req.autoSync) + put("targetFolder", req.targetFolder) + } + val requestBody = json.toString().toRequestBody(jsonMediaType) + val request = buildRequest(url, apiToken).post(requestBody).build() + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: "" + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}: $body")) + } + val resJson = JSONObject(body) + Result.success( + CaptureResponse( + ok = resJson.optBoolean("ok", true), + noteId = resJson.optString("noteId", ""), + title = resJson.optString("title", ""), + ingested = resJson.optBoolean("ingested", false), + synced = resJson.optBoolean("synced", false), + message = resJson.optString("message", "Erfolgreich gespeichert") + ) + ) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun syncVault(serverUrl: String, apiToken: String): Result = + withContext(Dispatchers.IO) { + try { + val url = "${serverUrl.trimEnd('/')}/api/sync" + val request = buildRequest(url, apiToken) + .post("{}".toRequestBody(jsonMediaType)) + .build() + client.newCall(request).execute().use { response -> + val body = response.body?.string() ?: "" + if (!response.isSuccessful) { + return@withContext Result.failure(Exception("HTTP ${response.code}: $body")) + } + val json = JSONObject(body) + Result.success(json.optString("message", "Sync erfolgreich")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/android/app/src/main/java/de/jannik/mindomat/speech/SpeechManager.kt b/android/app/src/main/java/de/jannik/mindomat/speech/SpeechManager.kt new file mode 100644 index 0000000..1a4702e --- /dev/null +++ b/android/app/src/main/java/de/jannik/mindomat/speech/SpeechManager.kt @@ -0,0 +1,127 @@ +package de.jannik.mindomat.speech + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.Locale + +class SpeechManager(private val context: Context) { + private var recognizer: SpeechRecognizer? = null + + private val _isListening = MutableStateFlow(false) + val isListening: StateFlow = _isListening.asStateFlow() + + private val _spokenText = MutableStateFlow("") + val spokenText: StateFlow = _spokenText.asStateFlow() + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + fun isAvailable(): Boolean { + return SpeechRecognizer.isRecognitionAvailable(context) + } + + fun startListening() { + if (!isAvailable()) { + _errorMessage.value = "Spracherkennung auf diesem Gerät nicht verfügbar." + return + } + + stopListening() + + try { + recognizer = SpeechRecognizer.createSpeechRecognizer(context).apply { + setRecognitionListener(object : RecognitionListener { + override fun onReadyForSpeech(params: Bundle?) { + _isListening.value = true + _errorMessage.value = null + } + + override fun onBeginningOfSpeech() {} + override fun onRmsChanged(rmsdB: Float) {} + override fun onBufferReceived(buffer: ByteArray?) {} + override fun onEndOfSpeech() { + _isListening.value = false + } + + override fun onError(error: Int) { + _isListening.value = false + val msg = when (error) { + SpeechRecognizer.ERROR_AUDIO -> "Audio-Aufnahmefehler" + SpeechRecognizer.ERROR_CLIENT -> "Client-Fehler" + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Mikrofon-Berechtigung fehlt" + SpeechRecognizer.ERROR_NETWORK -> "Netzwerkfehler" + SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Netzwerk-Timeout" + SpeechRecognizer.ERROR_NO_MATCH -> "Keine Sprache erkannt" + SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Spracherkennung beschäftigt" + SpeechRecognizer.ERROR_SERVER -> "Server-Fehler" + SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Keine Spracheingabe" + else -> "Fehler bei Spracherkennung ($error)" + } + // Ignore harmless no-match / speech-timeout messages if text already captured + if (error != SpeechRecognizer.ERROR_NO_MATCH && error != SpeechRecognizer.ERROR_SPEECH_TIMEOUT) { + _errorMessage.value = msg + } + } + + override fun onResults(results: Bundle?) { + _isListening.value = false + val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + if (!matches.isNullOrEmpty()) { + val recognized = matches[0] + val current = _spokenText.value + _spokenText.value = if (current.isBlank()) recognized else "$current $recognized" + } + } + + override fun onPartialResults(partialResults: Bundle?) { + val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + if (!matches.isNullOrEmpty()) { + // Can show live partial preview if desired + } + } + + override fun onEvent(eventType: Int, params: Bundle?) {} + }) + } + + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.GERMAN.toLanguageTag()) + putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, "de-DE") + putExtra(RecognizerIntent.EXTRA_ONLY_RETURN_LANGUAGE_PREFERENCE, "de-DE") + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) + } + + recognizer?.startListening(intent) + _isListening.value = true + } catch (e: Exception) { + _isListening.value = false + _errorMessage.value = e.message + } + } + + fun stopListening() { + try { + recognizer?.stopListening() + recognizer?.destroy() + } catch (_: Exception) {} + recognizer = null + _isListening.value = false + } + + fun clearSpokenText() { + _spokenText.value = "" + } + + fun clearError() { + _errorMessage.value = null + } +} diff --git a/android/app/src/main/java/de/jannik/mindomat/ui/screens/CaptureScreen.kt b/android/app/src/main/java/de/jannik/mindomat/ui/screens/CaptureScreen.kt new file mode 100644 index 0000000..b6b5c6f --- /dev/null +++ b/android/app/src/main/java/de/jannik/mindomat/ui/screens/CaptureScreen.kt @@ -0,0 +1,389 @@ +package de.jannik.mindomat.ui.screens + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.jannik.mindomat.data.ApiClient +import de.jannik.mindomat.data.CaptureRequest +import de.jannik.mindomat.data.SettingsRepository +import de.jannik.mindomat.speech.SpeechManager +import de.jannik.mindomat.ui.theme.DarkHeader +import de.jannik.mindomat.ui.theme.MicRed +import de.jannik.mindomat.ui.theme.MintGreen +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CaptureScreen( + settingsRepo: SettingsRepository, + apiClient: ApiClient, + speechManager: SpeechManager, + initialText: String = "", + onRequestRecordPermission: () -> Unit, + hasRecordPermission: Boolean, + onNavigateToSettings: () -> Unit +) { + var title by remember { mutableStateOf("") } + var text by remember { mutableStateOf(initialText) } + var tags by remember { mutableStateOf("idee") } + var targetFolder by remember { mutableStateOf(settingsRepo.defaultFolder) } + var autoIngest by remember { mutableStateOf(settingsRepo.autoIngest) } + var autoSync by remember { mutableStateOf(settingsRepo.autoSync) } + + var isSubmitting by remember { mutableStateOf(false) } + var statusMessage by remember { mutableStateOf(null) } + var isSuccess by remember { mutableStateOf(false) } + + val coroutineScope = rememberCoroutineScope() + val isListening by speechManager.isListening.collectAsState() + val spokenText by speechManager.spokenText.collectAsState() + val speechError by speechManager.errorMessage.collectAsState() + + // Sync spoken text into text input + LaunchedEffect(spokenText) { + if (spokenText.isNotBlank()) { + text = spokenText + if (title.isBlank()) { + val words = spokenText.trim().split(" ").take(5).joinToString(" ") + title = words + } + } + } + + // Pulsing animation when listening + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val pulseScale by infiniteTransition.animateFloat( + initialValue = 1.0f, + targetValue = 1.25f, + animationSpec = infiniteRepeatable( + animation = tween(600), + repeatMode = RepeatMode.Reverse + ), + label = "pulseScale" + ) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Quick Capture", fontWeight = FontWeight.Bold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = DarkHeader, + titleContentColor = Color.White + ) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Status Banner + statusMessage?.let { msg -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSuccess) Color(0xFFE8F8F0) else Color(0xFFFFEBEE) + ), + shape = RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (isSuccess) Icons.Default.CheckCircle else Icons.Default.MicOff, + contentDescription = null, + tint = if (isSuccess) MintGreen else MicRed + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = msg, + color = if (isSuccess) Color(0xFF1B5E20) else Color(0xFFB71C1C), + fontSize = 13.sp + ) + } + } + } + + speechError?.let { err -> + Text( + text = err, + color = MicRed, + fontSize = 12.sp, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + // Big Microphone Button + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(vertical = 12.dp) + .size(90.dp) + ) { + if (isListening) { + Box( + modifier = Modifier + .size(90.dp) + .scale(pulseScale) + .background(MicRed.copy(alpha = 0.25f), shape = CircleShape) + ) + } + + FloatingActionButton( + onClick = { + if (!hasRecordPermission) { + onRequestRecordPermission() + } else { + if (isListening) { + speechManager.stopListening() + } else { + speechManager.clearError() + speechManager.startListening() + } + } + }, + containerColor = if (isListening) MicRed else MintGreen, + contentColor = Color.White, + shape = CircleShape, + modifier = Modifier.size(72.dp) + ) { + Icon( + imageVector = if (isListening) Icons.Default.Mic else Icons.Default.Mic, + contentDescription = "Diktieren", + modifier = Modifier.size(36.dp) + ) + } + } + + Text( + text = if (isListening) "🔴 Ich höre zu... (Auf Deutsch sprechen)" else "Tippe zum Diktieren", + fontSize = 14.sp, + fontWeight = if (isListening) FontWeight.Bold else FontWeight.Normal, + color = if (isListening) MicRed else Color.Gray, + modifier = Modifier.padding(bottom = 16.dp) + ) + + // Optional Title + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Titel (optional)") }, + placeholder = { Text("z. B. Projektbesprechung") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Note Content + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text("Notiztext (Markdown / Freitext)") }, + placeholder = { Text("Gedanke hier eintippen oder per Sprache diktieren...") }, + minLines = 6, + maxLines = 14, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Tags + OutlinedTextField( + value = tags, + onValueChange = { tags = it }, + label = { Text("Tags (kommasepariert)") }, + placeholder = { Text("idee, projekt, arbeit") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Folder Selection Chips + Text( + text = "Zielordner im Vault:", + fontSize = 12.sp, + color = Color.Gray, + modifier = Modifier.align(Alignment.Start) + ) + Spacer(modifier = Modifier.height(6.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf("00_Inbox", "01_Daily", "10_Wiki", "20_Projekte").forEach { folder -> + val isSelected = targetFolder == folder + FilterChip( + selected = isSelected, + onClick = { targetFolder = folder }, + label = { Text(folder, fontSize = 12.sp) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MintGreen, + selectedLabelColor = Color.White + ) + ) + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + // Switches for Auto-Ingest & Git-Push + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White), + shape = RoundedCornerShape(8.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("KI-Ingest ausführen", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) + Text("Arbeitet Notiz ins Wiki ein", fontSize = 12.sp, color = Color.Gray) + } + Switch( + checked = autoIngest, + onCheckedChange = { autoIngest = it }, + colors = SwitchDefaults.colors(checkedThumbColor = MintGreen) + ) + } + + Divider(modifier = Modifier.padding(vertical = 8.dp), color = Color(0xFFF0F0F0)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Direkt zu Git pushen", fontWeight = FontWeight.SemiBold, fontSize = 14.sp) + Text("Committet & pusht ins Gitea", fontSize = 12.sp, color = Color.Gray) + } + Switch( + checked = autoSync, + onCheckedChange = { autoSync = it }, + colors = SwitchDefaults.colors(checkedThumbColor = MintGreen) + ) + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // Submit Button + Button( + onClick = { + if (text.isBlank() && title.isBlank()) { + statusMessage = "Bitte Notiztext oder Titel eingeben" + isSuccess = false + return@Button + } + + if (settingsRepo.serverUrl.isBlank()) { + statusMessage = "Bitte zuerst Server-URL in den Einstellungen festlegen" + isSuccess = false + onNavigateToSettings() + return@Button + } + + isSubmitting = true + statusMessage = "Speichere & verarbeite..." + isSuccess = false + + val tagsList = tags.split(",").map { it.trim() }.filter { it.isNotBlank() } + + coroutineScope.launch { + val result = apiClient.captureNote( + serverUrl = settingsRepo.serverUrl, + apiToken = settingsRepo.apiToken, + req = CaptureRequest( + text = text.ifBlank { title }, + title = title.ifBlank { null }, + tags = tagsList, + autoIngest = autoIngest, + autoSync = autoSync, + targetFolder = targetFolder + ) + ) + + isSubmitting = false + result.fold( + onSuccess = { resp -> + isSuccess = true + val syncInfo = if (resp.synced) " + zu Git gepusht ✓" else "" + val ingestInfo = if (resp.ingested) "Wiki gepflegt" else "in Inbox abgelegt" + statusMessage = "✅ Notiz '${resp.title}' gespeichert ($ingestInfo$syncInfo)" + // Reset fields + text = "" + title = "" + speechManager.clearSpokenText() + }, + onFailure = { err -> + isSuccess = false + statusMessage = "Fehler: ${err.message}" + } + ) + } + }, + enabled = !isSubmitting, + colors = ButtonDefaults.buttonColors(containerColor = MintGreen), + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + shape = RoundedCornerShape(10.dp) + ) { + if (isSubmitting) { + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(10.dp)) + Text("Wird übertragen & verarbeitet...", color = Color.White) + } else { + Icon(Icons.Default.Send, contentDescription = null, tint = Color.White) + Spacer(modifier = Modifier.width(8.dp)) + Text("Notiz erfassen & Sync 🚀", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} diff --git a/android/app/src/main/java/de/jannik/mindomat/ui/screens/NoteDetailScreen.kt b/android/app/src/main/java/de/jannik/mindomat/ui/screens/NoteDetailScreen.kt new file mode 100644 index 0000000..52cc75b --- /dev/null +++ b/android/app/src/main/java/de/jannik/mindomat/ui/screens/NoteDetailScreen.kt @@ -0,0 +1,148 @@ +package de.jannik.mindomat.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.jannik.mindomat.data.ApiClient +import de.jannik.mindomat.data.NoteDetail +import de.jannik.mindomat.data.SettingsRepository +import de.jannik.mindomat.ui.theme.DarkHeader +import de.jannik.mindomat.ui.theme.MintGreen +import de.jannik.mindomat.ui.theme.TextPrimary +import de.jannik.mindomat.ui.theme.TextSecondary +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NoteDetailScreen( + noteId: String, + settingsRepo: SettingsRepository, + apiClient: ApiClient, + onBack: () -> Unit +) { + var noteDetail by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(noteId) { + coroutineScope.launch { + val res = apiClient.getNoteDetail(settingsRepo.serverUrl, settingsRepo.apiToken, noteId) + isLoading = false + res.fold( + onSuccess = { detail -> noteDetail = detail }, + onFailure = { err -> errorMessage = err.message } + ) + } + } + + val title = noteDetail?.frontmatter?.get("title")?.toString() + ?: noteId.substringAfterLast('/').removeSuffix(".md") + + Scaffold( + topBar = { + TopAppBar( + title = { Text(title, maxLines = 1, fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = Color.White) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = DarkHeader, + titleContentColor = Color.White + ) + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(Color.White) + ) { + if (isLoading) { + CircularProgressIndicator( + color = MintGreen, + modifier = Modifier.align(Alignment.Center) + ) + } else if (errorMessage != null) { + Text( + text = "Fehler: $errorMessage", + color = Color.Red, + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp) + ) + } else { + noteDetail?.let { detail -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // Metadata Card + Surface( + color = Color(0xFFF7F9FA), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Pfad: ${detail.id}", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = TextSecondary + ) + detail.frontmatter["created"]?.let { created -> + Text( + text = "Erstellt: $created", + fontSize = 12.sp, + color = TextSecondary + ) + } + detail.frontmatter["tags"]?.let { tags -> + Text( + text = "Tags: $tags", + fontSize = 12.sp, + color = TextSecondary + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Clean Content Display + val rawContent = detail.content + val cleanBody = rawContent.replace(Regex("^---[\\s\\S]*?---\\s*"), "") + + Text( + text = cleanBody, + fontSize = 15.sp, + lineHeight = 22.sp, + color = TextPrimary + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } + } + } + } +} diff --git a/android/app/src/main/java/de/jannik/mindomat/ui/screens/NotesScreen.kt b/android/app/src/main/java/de/jannik/mindomat/ui/screens/NotesScreen.kt new file mode 100644 index 0000000..b7726bc --- /dev/null +++ b/android/app/src/main/java/de/jannik/mindomat/ui/screens/NotesScreen.kt @@ -0,0 +1,311 @@ +package de.jannik.mindomat.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.jannik.mindomat.data.ApiClient +import de.jannik.mindomat.data.NoteSummary +import de.jannik.mindomat.data.SettingsRepository +import de.jannik.mindomat.ui.theme.* +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotesScreen( + settingsRepo: SettingsRepository, + apiClient: ApiClient, + onNoteClick: (String) -> Unit, + onNavigateToSettings: () -> Unit +) { + var notes by remember { mutableStateOf>(emptyList()) } + var searchQuery by remember { mutableStateOf("") } + var selectedFolder by remember { mutableStateOf("Alle") } + var isLoading by remember { mutableStateOf(false) } + var isSyncing by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var syncFeedback by remember { mutableStateOf(null) } + + val coroutineScope = rememberCoroutineScope() + + fun loadNotes() { + if (settingsRepo.serverUrl.isBlank()) { + errorMessage = "Server-URL fehlt. Bitte in Einstellungen konfigurieren." + return + } + isLoading = true + errorMessage = null + coroutineScope.launch { + val res = apiClient.getNotes(settingsRepo.serverUrl, settingsRepo.apiToken) + isLoading = false + res.fold( + onSuccess = { list -> + notes = list + }, + onFailure = { err -> + errorMessage = "Laden fehlgeschlagen: ${err.message}" + } + ) + } + } + + LaunchedEffect(settingsRepo.serverUrl, settingsRepo.apiToken) { + loadNotes() + } + + val filteredNotes = remember(notes, searchQuery, selectedFolder) { + notes.filter { note -> + val matchesFolder = when (selectedFolder) { + "Alle" -> true + "Inbox" -> note.folder.startsWith("00_Inbox") + "Wiki" -> note.folder.startsWith("10_Wiki") + "Daily" -> note.folder.startsWith("01_Daily") + "Projekte" -> note.folder.startsWith("20_Projekte") + else -> true + } + val matchesSearch = if (searchQuery.isBlank()) true else { + note.title.contains(searchQuery, ignoreCase = true) || + note.snippet.contains(searchQuery, ignoreCase = true) || + note.tags.any { it.contains(searchQuery, ignoreCase = true) } + } + matchesFolder && matchesSearch + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Second Brain", fontWeight = FontWeight.Bold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = DarkHeader, + titleContentColor = Color.White + ), + actions = { + IconButton( + onClick = { + isSyncing = true + coroutineScope.launch { + val syncRes = apiClient.syncVault(settingsRepo.serverUrl, settingsRepo.apiToken) + isSyncing = false + syncRes.fold( + onSuccess = { msg -> + syncFeedback = "✓ $msg" + loadNotes() + }, + onFailure = { err -> + syncFeedback = "Sync-Fehler: ${err.message}" + } + ) + } + } + ) { + if (isSyncing) { + CircularProgressIndicator( + color = MintGreen, + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } else { + Icon(Icons.Default.Sync, contentDescription = "Sync", tint = Color.White) + } + } + IconButton(onClick = { loadNotes() }) { + Icon(Icons.Default.Refresh, contentDescription = "Aktualisieren", tint = Color.White) + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(BackgroundLight) + ) { + // Search Field + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Notizen & Wiki durchsuchen...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White + ) + ) + + // Category Filter Chips + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf("Alle", "Inbox", "Wiki", "Daily", "Projekte").forEach { cat -> + val isSelected = selectedFolder == cat + FilterChip( + selected = isSelected, + onClick = { selectedFolder = cat }, + label = { Text(cat, fontSize = 12.sp) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MintGreen, + selectedLabelColor = Color.White + ) + ) + } + } + + // Sync Feedback Banner + syncFeedback?.let { fb -> + Text( + text = fb, + fontSize = 12.sp, + color = if (fb.startsWith("✓")) MintGreenDark else MicRed, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + + // Error Banner + errorMessage?.let { err -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE)) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(err, color = Color(0xFFB71C1C), fontSize = 13.sp) + if (err.contains("Server-URL")) { + Spacer(modifier = Modifier.height(6.dp)) + TextButton(onClick = onNavigateToSettings) { + Text("Einstellungen öffnen ➔") + } + } + } + } + } + + // Notes List + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = MintGreen) + } + } else if (filteredNotes.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Keine Notizen gefunden", color = Color.Gray, fontSize = 15.sp) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + items(filteredNotes, key = { it.id }) { note -> + NoteCard(note = note, onClick = { onNoteClick(note.id) }) + } + } + } + } + } +} + +@Composable +fun NoteCard(note: NoteSummary, onClick: () -> Unit) { + val folderColor = when { + note.folder.startsWith("00_Inbox") -> FolderInbox + note.folder.startsWith("10_Wiki") -> FolderWiki + note.folder.startsWith("01_Daily") -> FolderDaily + note.folder.startsWith("20_Projekte") -> FolderProjects + else -> FolderOther + } + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = note.title, + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Folder Badge + Surface( + shape = RoundedCornerShape(6.dp), + color = folderColor.copy(alpha = 0.2f) + ) { + Text( + text = note.folder.substringBefore('/'), + color = folderColor, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) + } + } + + if (note.snippet.isNotBlank()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = note.snippet, + fontSize = 13.sp, + color = TextSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + if (note.tags.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + note.tags.take(3).forEach { tag -> + Text( + text = "#$tag", + fontSize = 11.sp, + color = Color(0xFF555555), + modifier = Modifier + .background(Color(0xFFF0F0F0), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/de/jannik/mindomat/ui/screens/SettingsScreen.kt b/android/app/src/main/java/de/jannik/mindomat/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..1e9c5dc --- /dev/null +++ b/android/app/src/main/java/de/jannik/mindomat/ui/screens/SettingsScreen.kt @@ -0,0 +1,274 @@ +package de.jannik.mindomat.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.jannik.mindomat.data.ApiClient +import de.jannik.mindomat.data.SettingsRepository +import de.jannik.mindomat.ui.theme.BackgroundLight +import de.jannik.mindomat.ui.theme.DarkHeader +import de.jannik.mindomat.ui.theme.MicRed +import de.jannik.mindomat.ui.theme.MintGreen +import de.jannik.mindomat.ui.theme.MintGreenDark +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + settingsRepo: SettingsRepository, + apiClient: ApiClient +) { + var serverUrl by remember { mutableStateOf(settingsRepo.serverUrl) } + var apiToken by remember { mutableStateOf(settingsRepo.apiToken) } + var autoIngest by remember { mutableStateOf(settingsRepo.autoIngest) } + var autoSync by remember { mutableStateOf(settingsRepo.autoSync) } + var defaultFolder by remember { mutableStateOf(settingsRepo.defaultFolder) } + + var isTestingConnection by remember { mutableStateOf(false) } + var testResult by remember { mutableStateOf(null) } + var testOk by remember { mutableStateOf(false) } + var savedFeedback by remember { mutableStateOf(false) } + + val coroutineScope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Einstellungen", fontWeight = FontWeight.Bold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = DarkHeader, + titleContentColor = Color.White + ) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(BackgroundLight) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // Intro Card: Unabhängigkeit vom Vault + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White), + shape = RoundedCornerShape(10.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Mind-o-Mat Server Verknüpfung", + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Diese App ist unabhängig vom Vault. Trage hier deine Server-Adresse ein, um dein persönliches Second Brain zu verbinden.", + fontSize = 13.sp, + color = Color.Gray + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Server URL + OutlinedTextField( + value = serverUrl, + onValueChange = { + serverUrl = it + testResult = null + }, + label = { Text("Server-URL") }, + placeholder = { Text("z. B. https://mindomat.orfel.de") }, + supportingText = { Text("HTTPS-Domain oder lokale IP deines Mind-o-Mat Servers") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // API Token + OutlinedTextField( + value = apiToken, + onValueChange = { + apiToken = it + testResult = null + }, + label = { Text("API-Token (optional)") }, + placeholder = { Text("Bearer Auth-Token (falls im Server aktiviert)") }, + supportingText = { Text("Leer lassen, wenn keine Authentifizierung aktiv ist") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Test Connection Button + Button( + onClick = { + if (serverUrl.isBlank()) { + testResult = "Bitte zuerst Server-URL angeben" + testOk = false + return@Button + } + isTestingConnection = true + testResult = null + coroutineScope.launch { + val res = apiClient.testConnection(serverUrl, apiToken) + isTestingConnection = false + res.fold( + onSuccess = { status -> + testOk = true + testResult = "Verbindung erfolgreich! Server: ${status.name} (v${status.version})" + }, + onFailure = { err -> + testOk = false + testResult = "Verbindung fehlgeschlagen: ${err.message}" + } + ) + } + }, + enabled = !isTestingConnection, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF333333)), + modifier = Modifier.fillMaxWidth() + ) { + if (isTestingConnection) { + CircularProgressIndicator( + color = Color.White, + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Teste Verbindung...") + } else { + Text("Verbindung testen") + } + } + + testResult?.let { res -> + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (testOk) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (testOk) MintGreenDark else MicRed, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = res, + color = if (testOk) MintGreenDark else MicRed, + fontSize = 13.sp + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + // Defaults Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = Color.White), + shape = RoundedCornerShape(10.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Standard-Verhalten", fontWeight = FontWeight.Bold, fontSize = 15.sp) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Standardmäßig KI-Ingest", fontSize = 14.sp) + Text("Arbeitet Notizen direkt ins Wiki ein", fontSize = 12.sp, color = Color.Gray) + } + Switch( + checked = autoIngest, + onCheckedChange = { autoIngest = it }, + colors = SwitchDefaults.colors(checkedThumbColor = MintGreen) + ) + } + + Divider(modifier = Modifier.padding(vertical = 10.dp), color = Color(0xFFF0F0F0)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Standardmäßig Git-Push", fontSize = 14.sp) + Text("Pusht Notizen direkt zu Gitea", fontSize = 12.sp, color = Color.Gray) + } + Switch( + checked = autoSync, + onCheckedChange = { autoSync = it }, + colors = SwitchDefaults.colors(checkedThumbColor = MintGreen) + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Save Settings Button + Button( + onClick = { + settingsRepo.serverUrl = serverUrl + settingsRepo.apiToken = apiToken + settingsRepo.autoIngest = autoIngest + settingsRepo.autoSync = autoSync + settingsRepo.defaultFolder = defaultFolder + savedFeedback = true + }, + colors = ButtonDefaults.buttonColors(containerColor = MintGreen), + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + shape = RoundedCornerShape(10.dp) + ) { + Icon(Icons.Default.Save, contentDescription = null, tint = Color.White) + Spacer(modifier = Modifier.width(8.dp)) + Text("Einstellungen speichern", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + + if (savedFeedback) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "✓ Einstellungen erfolgreich gespeichert", + color = MintGreenDark, + fontSize = 13.sp, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Version info + Text( + text = "Mind-o-Mat v1.0.0 (Release)\nPackage: de.jannik.mindomat\nGoogle Play ready", + color = Color.Gray, + fontSize = 11.sp, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + } +}