commit 315dcbaea8d09b57239be4c10badccb4029cfc15 Author: orfelorfel23 Date: Tue Sep 8 18:21:44 2026 +0200 feat: initial release of Mind-o-Mat Android App (v23.01.1) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6832e0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.gradle +build/ +app/build/ +local.properties +.idea/ +*.iml +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7c8c0a9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Versioning & Build Rules + +Whenever a new build is triggered, the version MUST be updated in `app/build.gradle`. +- `version` format: `23.01.X` +- `versionCode`: `X` (just the last number) +Examples: +- version `23.01.1` -> versionCode `1` +- version `23.01.2` -> versionCode `2` +- version `23.01.123` -> versionCode `123` + +Apps must always be built as BOTH `.apk` and `.aab`. +The exact naming syntax for compiled files is: +- `Mindomat_v23.01.X.apk` +- `Mindomat_v23.01.X.aab` + +IMPORTANT: All compiled files must be placed in the `apks/` directory (e.g. `apks/Mindomat_v23.01.X.apk`). Do NOT place them in the root directory. + +# Architectural Decisions & Project Rules + +1. **Package Name** + - Must strictly remain `de.orfel.mindomat`. + +2. **Keystore & Signing** + - The app MUST ALWAYS be signed with `mindomat-upload-key.keystore`. + - Key-Alias: `mindomat-alias` + - The `mindomat-upload-key.keystore` file is checked into the Git repository. Do NOT delete it, ignore it in git, or change its password. + +3. **Vault Independence** + - The app does NOT hardcode any vault path or specific personal domain. + - The server URL and API token are configurable in the app settings. + +4. **Auto-Commit & Push Workflow** + - At the end of every interaction or when tasks are completed, ALL changes must be automatically committed and pushed to Git (`git add .`, `git commit`, `git push`). + - If a push to `git.orfel.de` fails, retry immediately. + - Execute commands individually (no command chaining). diff --git a/README.md b/README.md new file mode 100644 index 0000000..91026fb --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Mind-o-Mat Android App (Native) + +Vollwertige, vault-unabhängige Android-App für Mind-o-Mat mit nativer Spracherkennung, Second-Brain-Verwaltung und direktem Git-Push. + +## Features + +- **Quick Capture (Schnellerfassung)**: + - 🎤 Großer Mikrofon-Button mit Android-Spracherkennung (`SpeechRecognizer`) auf Deutsch. + - Markdown- und Freitext-Editor. + - Tags (`#idee`, `#meeting`, etc.). + - Zielordner wählbar (`00_Inbox`, `01_Daily`, `10_Wiki`, `20_Projekte`). + - Schalter für automatischen KI-Ingest und sofortigen Git-Push. + - **Quick Share Target**: Text und Links können aus jeder beliebigen Android-App (z. B. Chrome) über „Teilen mit Mind-o-Mat“ direkt erfasst werden. +- **Second Brain (Notizen-Explorer)**: + - Durchsuchen aller Notizen im Vault. + - Filter nach Kategorien (`Alle`, `Inbox`, `Wiki`, `Daily`, `Projekte`). + - Notizkarten mit Vorschau-Snippets, Tags und farbigen Ordner-Badges. + - Detailansicht mit formatiertem Markdown-Leser. + - 1-Klick Git-Sync Button in der Kopfleiste. +- **100% Vault-Unabhängig**: + - Jeder Nutzer kann in den Einstellungen seine eigene Server-URL (z. B. `https://mindomat.orfel.de` oder jede eigene Domain/IP) und seinen API-Token hinterlegen. + - Mit „Verbindung testen“-Funktion. + +## Play Store & Release Builds (Referenz: Schnappix) + +- **Package ID**: `de.orfel.mindomat` +- **Build-Outputs** (immer in `apks/` abgelegt): + - `apks/Mindomat_v23.01.X.aab` (Android App Bundle für Google Play Console) + - `apks/Mindomat_v23.01.X.apk` (Installierbare APK für Sideloading / Direktinstallation) + +### Keystore & Signierung + +- **Keystore**: `mindomat-upload-key.keystore` +- **Key-Alias**: `mindomat-alias` +- **SHA-256 Fingerprint**: + `55:7C:14:4A:E9:AC:A7:78:97:78:7A:09:3C:A9:27:48:F7:D8:E8:91:3B:36:29:06:41:20:80:45:A7:EF:5D:82` +- **Zertifikat**: `upload_certificate.pem` + +### Selbst neu bauen + +```bash +# APK bauen: +.\gradlew.bat assembleRelease + +# Google Play Store Bundle (.aab) bauen: +.\gradlew.bat bundleRelease +``` +Outputs liegen in `app/build/outputs/` und werden nach `apks/` kopiert. diff --git a/apks/Mindomat_v23.01.1.aab b/apks/Mindomat_v23.01.1.aab new file mode 100644 index 0000000..d66894f Binary files /dev/null and b/apks/Mindomat_v23.01.1.aab differ diff --git a/apks/Mindomat_v23.01.1.apk b/apks/Mindomat_v23.01.1.apk new file mode 100644 index 0000000..0e0ae2c Binary files /dev/null and b/apks/Mindomat_v23.01.1.apk differ diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..c305912 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,111 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.compose' +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +android { + namespace 'de.orfel.mindomat' + compileSdk 35 + + defaultConfig { + applicationId "de.orfel.mindomat" + minSdk 26 + targetSdk 35 + versionCode 1 + versionName "23.01.1" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary true + } + } + + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + release { + storeFile file('../mindomat-upload-key.keystore') + storePassword localProperties.getProperty('RELEASE_STORE_PASSWORD') ?: System.getenv('RELEASE_STORE_PASSWORD') ?: '' + keyAlias 'mindomat-alias' + keyPassword localProperties.getProperty('RELEASE_KEY_PASSWORD') ?: System.getenv('RELEASE_KEY_PASSWORD') ?: '' + } + } + + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + signingConfig signingConfigs.release + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + compose true + } + + packaging { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.6' + implementation 'androidx.activity:activity-compose:1.9.3' + + // Jetpack Compose + implementation platform('androidx.compose:compose-bom:2024.10.00') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material:material-icons-extended' + + // Navigation + implementation 'androidx.navigation:navigation-compose:2.8.3' + + // Networking + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' + + // JSON + implementation 'org.json:json:20240303' + + // ViewModel + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6' + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation platform('androidx.compose:compose-bom:2024.10.00') + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' +} diff --git a/app/debug.keystore b/app/debug.keystore new file mode 100644 index 0000000..364e105 Binary files /dev/null and b/app/debug.keystore differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2714f1c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/de/jannik/mindomat/MainActivity.kt b/app/src/main/java/de/jannik/mindomat/MainActivity.kt new file mode 100644 index 0000000..a3f5232 --- /dev/null +++ b/app/src/main/java/de/jannik/mindomat/MainActivity.kt @@ -0,0 +1,210 @@ +package de.jannik.mindomat + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.core.content.ContextCompat +import androidx.navigation.NavType +import androidx.navigation.compose.* +import androidx.navigation.navArgument +import de.jannik.mindomat.data.ApiClient +import de.jannik.mindomat.data.SettingsRepository +import de.jannik.mindomat.speech.SpeechManager +import de.jannik.mindomat.ui.screens.CaptureScreen +import de.jannik.mindomat.ui.screens.NoteDetailScreen +import de.jannik.mindomat.ui.screens.NotesScreen +import de.jannik.mindomat.ui.screens.SettingsScreen +import de.jannik.mindomat.ui.theme.DarkHeader +import de.jannik.mindomat.ui.theme.MindomatTheme +import de.jannik.mindomat.ui.theme.MintGreen +import java.net.URLDecoder +import java.net.URLEncoder + +class MainActivity : ComponentActivity() { + private lateinit var settingsRepo: SettingsRepository + private lateinit var apiClient: ApiClient + private lateinit var speechManager: SpeechManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + settingsRepo = SettingsRepository(this) + apiClient = ApiClient() + speechManager = SpeechManager(this) + + // Check if shared text was received via Intent.ACTION_SEND + val sharedText = if (intent?.action == Intent.ACTION_SEND && intent.type == "text/plain") { + intent.getStringExtra(Intent.EXTRA_TEXT) ?: "" + } else { + "" + } + + setContent { + MindomatTheme { + val navController = rememberNavController() + + var hasAudioPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + this@MainActivity, + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + ) + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + hasAudioPermission = isGranted + if (isGranted) { + speechManager.startListening() + } + } + + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = currentBackStackEntry?.destination?.route + + Scaffold( + bottomBar = { + // Only show bottom bar on main top-level screens + if (currentRoute == "capture" || currentRoute == "notes" || currentRoute == "settings") { + NavigationBar( + containerColor = DarkHeader, + contentColor = Color.White + ) { + NavigationBarItem( + icon = { Icon(Icons.Default.Mic, contentDescription = "Capture") }, + label = { Text("Capture") }, + selected = currentRoute == "capture", + onClick = { + navController.navigate("capture") { + popUpTo("capture") { inclusive = true } + } + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = DarkHeader, + selectedTextColor = MintGreen, + indicatorColor = MintGreen, + unselectedIconColor = Color.LightGray, + unselectedTextColor = Color.LightGray + ) + ) + + NavigationBarItem( + icon = { Icon(Icons.Default.Description, contentDescription = "Notizen") }, + label = { Text("Notizen") }, + selected = currentRoute == "notes", + onClick = { + navController.navigate("notes") { + popUpTo("capture") + } + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = DarkHeader, + selectedTextColor = MintGreen, + indicatorColor = MintGreen, + unselectedIconColor = Color.LightGray, + unselectedTextColor = Color.LightGray + ) + ) + + NavigationBarItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Einstellungen") }, + label = { Text("Einstellungen") }, + selected = currentRoute == "settings", + onClick = { + navController.navigate("settings") { + popUpTo("capture") + } + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = DarkHeader, + selectedTextColor = MintGreen, + indicatorColor = MintGreen, + unselectedIconColor = Color.LightGray, + unselectedTextColor = Color.LightGray + ) + ) + } + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = "capture", + modifier = Modifier.padding(innerPadding) + ) { + composable("capture") { + CaptureScreen( + settingsRepo = settingsRepo, + apiClient = apiClient, + speechManager = speechManager, + initialText = sharedText, + hasRecordPermission = hasAudioPermission, + onRequestRecordPermission = { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + }, + onNavigateToSettings = { + navController.navigate("settings") + } + ) + } + + composable("notes") { + NotesScreen( + settingsRepo = settingsRepo, + apiClient = apiClient, + onNoteClick = { noteId -> + val encoded = URLEncoder.encode(noteId, "UTF-8") + navController.navigate("note_detail/$encoded") + }, + onNavigateToSettings = { + navController.navigate("settings") + } + ) + } + + composable( + route = "note_detail/{noteId}", + arguments = listOf(navArgument("noteId") { type = NavType.StringType }) + ) { backStackEntry -> + val rawNoteId = backStackEntry.arguments?.getString("noteId") ?: "" + val decodedNoteId = URLDecoder.decode(rawNoteId, "UTF-8") + NoteDetailScreen( + noteId = decodedNoteId, + settingsRepo = settingsRepo, + apiClient = apiClient, + onBack = { navController.popBackStack() } + ) + } + + composable("settings") { + SettingsScreen( + settingsRepo = settingsRepo, + apiClient = apiClient + ) + } + } + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + speechManager.stopListening() + } +} diff --git a/app/src/main/java/de/jannik/mindomat/data/ApiClient.kt b/app/src/main/java/de/jannik/mindomat/data/ApiClient.kt new file mode 100644 index 0000000..7104027 --- /dev/null +++ b/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/app/src/main/java/de/jannik/mindomat/data/Models.kt b/app/src/main/java/de/jannik/mindomat/data/Models.kt new file mode 100644 index 0000000..7a0172b --- /dev/null +++ b/app/src/main/java/de/jannik/mindomat/data/Models.kt @@ -0,0 +1,42 @@ +package de.jannik.mindomat.data + +data class NoteSummary( + val id: String, + val title: String, + val folder: String, + val tags: List, + val snippet: String, + val lastModified: String +) + +data class NoteDetail( + val id: String, + val content: String, + val frontmatter: Map, + val lastModified: String +) + +data class CaptureRequest( + val text: String, + val title: String? = null, + val tags: List = emptyList(), + val autoIngest: Boolean = true, + val autoSync: Boolean = true, + val targetFolder: String = "00_Inbox" +) + +data class CaptureResponse( + val ok: Boolean, + val noteId: String, + val title: String, + val ingested: Boolean, + val synced: Boolean, + val message: String +) + +data class ServerStatus( + val ok: Boolean, + val name: String, + val version: String, + val authRequired: Boolean +) diff --git a/app/src/main/java/de/jannik/mindomat/data/SettingsRepository.kt b/app/src/main/java/de/jannik/mindomat/data/SettingsRepository.kt new file mode 100644 index 0000000..794d83e --- /dev/null +++ b/app/src/main/java/de/jannik/mindomat/data/SettingsRepository.kt @@ -0,0 +1,39 @@ +package de.jannik.mindomat.data + +import android.content.Context +import android.content.SharedPreferences + +class SettingsRepository(context: Context) { + private val prefs: SharedPreferences = + context.getSharedPreferences("mindomat_prefs", Context.MODE_PRIVATE) + + var serverUrl: String + get() = prefs.getString(KEY_SERVER_URL, DEFAULT_SERVER_URL) ?: DEFAULT_SERVER_URL + set(value) = prefs.edit().putString(KEY_SERVER_URL, value.trim().trimEnd('/')).apply() + + var apiToken: String + get() = prefs.getString(KEY_API_TOKEN, "") ?: "" + set(value) = prefs.edit().putString(KEY_API_TOKEN, value.trim()).apply() + + var autoIngest: Boolean + get() = prefs.getBoolean(KEY_AUTO_INGEST, true) + set(value) = prefs.edit().putBoolean(KEY_AUTO_INGEST, value).apply() + + var autoSync: Boolean + get() = prefs.getBoolean(KEY_AUTO_SYNC, true) + set(value) = prefs.edit().putBoolean(KEY_AUTO_SYNC, value).apply() + + var defaultFolder: String + get() = prefs.getString(KEY_DEFAULT_FOLDER, "00_Inbox") ?: "00_Inbox" + set(value) = prefs.edit().putString(KEY_DEFAULT_FOLDER, value).apply() + + companion object { + private const val KEY_SERVER_URL = "server_url" + private const val KEY_API_TOKEN = "api_token" + private const val KEY_AUTO_INGEST = "auto_ingest" + private const val KEY_AUTO_SYNC = "auto_sync" + private const val KEY_DEFAULT_FOLDER = "default_folder" + + const val DEFAULT_SERVER_URL = "https://mindomat.orfel.de" + } +} diff --git a/app/src/main/java/de/jannik/mindomat/speech/SpeechManager.kt b/app/src/main/java/de/jannik/mindomat/speech/SpeechManager.kt new file mode 100644 index 0000000..1a4702e --- /dev/null +++ b/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/app/src/main/java/de/jannik/mindomat/ui/screens/CaptureScreen.kt b/app/src/main/java/de/jannik/mindomat/ui/screens/CaptureScreen.kt new file mode 100644 index 0000000..b6b5c6f --- /dev/null +++ b/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/app/src/main/java/de/jannik/mindomat/ui/screens/NoteDetailScreen.kt b/app/src/main/java/de/jannik/mindomat/ui/screens/NoteDetailScreen.kt new file mode 100644 index 0000000..52cc75b --- /dev/null +++ b/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/app/src/main/java/de/jannik/mindomat/ui/screens/NotesScreen.kt b/app/src/main/java/de/jannik/mindomat/ui/screens/NotesScreen.kt new file mode 100644 index 0000000..b7726bc --- /dev/null +++ b/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/app/src/main/java/de/jannik/mindomat/ui/screens/SettingsScreen.kt b/app/src/main/java/de/jannik/mindomat/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..1e9c5dc --- /dev/null +++ b/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) + ) + } + } +} diff --git a/app/src/main/java/de/jannik/mindomat/ui/theme/Color.kt b/app/src/main/java/de/jannik/mindomat/ui/theme/Color.kt new file mode 100644 index 0000000..1b40975 --- /dev/null +++ b/app/src/main/java/de/jannik/mindomat/ui/theme/Color.kt @@ -0,0 +1,20 @@ +package de.jannik.mindomat.ui.theme + +import androidx.compose.ui.graphics.Color + +val DarkHeader = Color(0xFF222222) +val MintGreen = Color(0xFF5AD8A6) +val MintGreenDark = Color(0xFF389E73) +val BackgroundLight = Color(0xFFF8F9FA) +val SurfaceWhite = Color(0xFFFFFFFF) +val TextPrimary = Color(0xFF1E2022) +val TextSecondary = Color(0xFF686D76) +val BorderColor = Color(0xFFE0E0E0) +val MicRed = Color(0xFFE53935) + +// Folder Badge Colors +val FolderInbox = Color(0xFFF6C343) +val FolderWiki = Color(0xFF5AD8A6) +val FolderDaily = Color(0xFF5B8FF9) +val FolderProjects = Color(0xFF9270CA) +val FolderOther = Color(0xFF8C8C8C) diff --git a/app/src/main/java/de/jannik/mindomat/ui/theme/Theme.kt b/app/src/main/java/de/jannik/mindomat/ui/theme/Theme.kt new file mode 100644 index 0000000..6c67752 --- /dev/null +++ b/app/src/main/java/de/jannik/mindomat/ui/theme/Theme.kt @@ -0,0 +1,28 @@ +package de.jannik.mindomat.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +private val LightColorScheme = lightColorScheme( + primary = MintGreen, + onPrimary = SurfaceWhite, + primaryContainer = MintGreenDark, + secondary = DarkHeader, + onSecondary = SurfaceWhite, + background = BackgroundLight, + surface = SurfaceWhite, + onBackground = TextPrimary, + onSurface = TextPrimary, + outline = BorderColor +) + +@Composable +fun MindomatTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + colorScheme = LightColorScheme, + content = content + ) +} diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..6e265c1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..855b4e7 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..a7b6e7e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..2e8a1e1 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..f614635 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..14fa2a2 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..cd4efd1 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..1fbbd36 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1591350 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..7316e1a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..a048670 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8fe075b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..0336e8a Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..7d43d86 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..5477126 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ed40517 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #5AD8A6 + #222222 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..bfc1562 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Mind-o-Mat + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1d7963e --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..45845c3 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9e2c689 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..32793c3 --- /dev/null +++ b/build.gradle @@ -0,0 +1,10 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id 'com.android.application' version '8.5.2' apply false + id 'org.jetbrains.kotlin.android' version '2.0.21' apply false + id 'org.jetbrains.kotlin.plugin.compose' version '2.0.21' apply false +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..23b50a7 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m +android.useAndroidX=true +android.nonTransitiveRClass=true +android.suppressUnsupportedCompileSdk=35 +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..7f94d3d --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mindomat-upload-key.keystore b/mindomat-upload-key.keystore new file mode 100644 index 0000000..cca305f Binary files /dev/null and b/mindomat-upload-key.keystore differ diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..86d5bb9 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Mindomat" +include ':app' diff --git a/upload_certificate.pem b/upload_certificate.pem new file mode 100644 index 0000000..de86f6c --- /dev/null +++ b/upload_certificate.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDbTCCAlWgAwIBAgIIb3SpKoyI0WswDQYJKoZIhvcNAQELBQAwZDELMAkGA1UE +BhMCREUxDzANBgNVBAgTBkJlcmxpbjEPMA0GA1UEBxMGQmVybGluMQ4wDAYDVQQK +EwVPcmZlbDEOMAwGA1UECxMFT3JmZWwxEzARBgNVBAMTCk1pbmQtby1NYXQwIBcN +MjYwOTA4MTYwNTAxWhgPMjA1NDAxMjQxNjA1MDFaMGQxCzAJBgNVBAYTAkRFMQ8w +DQYDVQQIEwZCZXJsaW4xDzANBgNVBAcTBkJlcmxpbjEOMAwGA1UEChMFT3JmZWwx +DjAMBgNVBAsTBU9yZmVsMRMwEQYDVQQDEwpNaW5kLW8tTWF0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvdy+sFSV6VDtg6aqs/+notUkulFVo9lyRqAa +vVmcfZlIPAlUQg7OJgoFtdUvXTr8SSMIWzgBk8r8i22pqzRY99D9A/0FeTb40g16 +fVXTlkZZQCakCEvm7aZ7oD60OXgVVNVLK5GvXHqy3BlRWaO9YPF5jT+6ZfA92Csz +hcJs6VV07llMT6OcKq0QXKlcxGp8fQx5JTVQ40qrolMaBZBr1teMfvM6w7B+I7DU +3vQbDIg9kYuZhbRd605hnJu1WLTZGdDRl2/GoKmKDe8gDdGI/2X4d/4pCcffZsLT +aoQNVmcY7IZI8qVePMuudPMrRG7AxqzXCEWy2IF6TQgzhogPrQIDAQABoyEwHzAd +BgNVHQ4EFgQUxhj3M0yn8t7720YDqdf4Di6xqvIwDQYJKoZIhvcNAQELBQADggEB +AG7UaFEDiho+Tb2UE7zIhcylr1aYEBudCuJqjHj6sAR6zv6CPvumR6cTz2kNW8RN +JeFtylEP9P2BDA1J08btpSrH1vmKocbCxOMUHSXAC/2WbO4JiCNIQQxRMjvvDqCG +lKNNb0A9x/QzxOBaubct2AlIiwmOPm86ROZnZ2Qx1kkk6G5lZGeeyhkTUUllCvUP +GeVN+KsRDIUV7eFlz+aRA6RBUeyDeM/XBRZ5/jMqUdrp6BT6JQSWVVhIQxHJPY3T +KaQmj9xDdVTbDr9grxhIkL/tB2r2QyupHJoOhMijtkCtcJtT2FoNo0C+Nrhm3W3y +mb0AoMMB4iO/7Jm0XLAzndg= +-----END CERTIFICATE-----