commit 269cc9e4177200c888d18992ee99c1515174b078
Author: jonasgaudian <43753916+jonasgaudian@users.noreply.github.com>
Date: Fri Feb 13 00:15:36 2026 +0100
migrate to gitea
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa724b7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 0000000..4a53bee
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml
new file mode 100644
index 0000000..e6a8cc5
--- /dev/null
+++ b/.idea/androidTestResultsUserPreferences.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
new file mode 100644
index 0000000..baa829c
--- /dev/null
+++ b/.idea/appInsightsSettings.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..bb3a40c
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..7b3006b
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..911a4a5
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..6d0ee1c
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..11b06af
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/valkyrie_settings.xml b/.idea/valkyrie_settings.xml
new file mode 100644
index 0000000..c5f8696
--- /dev/null
+++ b/.idea/valkyrie_settings.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..317b16d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,19 @@
+# Translator/Vocabulary App
+
+A Translator/Vocabulary App which uses the Mistral API OpenAI chat completion to perform translations, look up words in a dictionary, and generate vocabulary items.
+
+## Features
+| Feature | Description |
+ |-----------------------|---------------------------------------------------------------------------------------------------------------------------------------|
+ | Language Selection | Selection of 50 pre-defined languages, with the ability to add custom languages and dialects |
+ | Translation | Translate between given and customizable languages, including a translation history and pronunciation (only some languages supported) |
+ | Dictionary Lookup | Look up terms in a dictionary |
+ | Vocabulary Generation | Generate vocabulary items with the help of AI or add them manually |
+ | Learning Exercises | Learn vocabulary cards by customizable exercises (e.g., spelling mode) |
+ | Vocabulary Management | Manage vocabulary cards by sorting them into stages (progress), languages, custom categories, etc. |
+ | Overview | Overview of learned vocabulary and vocabulary in different stages (new, ..., learned) |
+
+## UI/UX
+ | Aspect | Description |
+ |--------------|-------------------------------------------------------------------------|
+ | UI Framework | Uses the classic Android Fragment UI and Composable for certain dialogs |
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..1a47b46
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,177 @@
+@file:Suppress("HardCodedStringLiteral")
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.hilt.android)
+ id("kotlin-parcelize")
+ id("org.jetbrains.kotlin.plugin.serialization")
+ id("com.google.devtools.ksp")
+}
+
+android {
+ namespace = "eu.gaudian.translator"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "eu.gaudian.translator"
+ minSdk = 28
+ targetSdk = 36
+ versionCode = 21
+ versionName = "0.4.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ testOptions {
+ execution = "ANDROIDX_TEST_ORCHESTRATOR"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ val buildTime = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()).format(Date())
+ buildConfigField("String", "BUILD_TIME", "\"$buildTime\"")
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ debug {
+ val buildTime = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()).format(Date())
+ buildConfigField("String", "BUILD_TIME", "\"$buildTime\"")
+ }
+ }
+
+ tasks.withType().configureEach {
+ compilerOptions.freeCompilerArgs.addAll(
+ "-opt-in=kotlin.time.ExperimentalTime",
+
+ "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
+ "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi"
+ )
+ }
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+ }
+ buildFeatures {
+ compose = true
+ viewBinding = false
+ dataBinding = false
+ buildConfig = true
+ }
+
+ packaging {
+ // This keeps your original resource exclusions
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ excludes += "**/dump_syms.bin"
+ }
+ jniLibs {
+ useLegacyPackaging = true
+ }
+ }
+ configurations.all {
+ exclude(group = "com.intellij", module = "annotations")
+ }
+ buildToolsVersion = "36.0.0"
+}
+
+dependencies {
+ implementation(platform(libs.androidx.compose.bom))
+
+ // Core & UI
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.material)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.core.splashscreen)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
+ implementation(libs.timber)
+
+ // Compose
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.foundation)
+ implementation(libs.androidx.foundation.layout)
+ implementation(libs.androidx.animation)
+ implementation(libs.androidx.animation.core)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.material.icons.extended)
+ implementation(libs.reorderable)
+
+ // Navigation
+ implementation(libs.androidx.navigation.compose)
+
+ // Data
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.paging.runtime.ktx)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.kotlinx.datetime)
+ implementation(libs.kotlinx.serialization.json)
+
+ // Room Database
+ implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime
+ implementation(libs.androidx.room.ktx)
+ implementation(libs.core.ktx)
+ ksp(libs.room.compiler) // CHANGED: Use ksp instead of implementation
+
+ // Networking
+ implementation(libs.retrofit)
+ implementation(libs.converter.gson)
+ implementation(libs.logging.interceptor)
+
+ implementation(libs.androidx.annotation)
+ implementation(libs.kotlin.stdlib)
+
+ implementation(libs.jsoup)
+ implementation(libs.core)
+
+ // Hilt
+ implementation(libs.hilt.android)
+ implementation(libs.hilt.navigation.compose)
+ ksp(libs.hilt.android.compiler)
+
+ // Debug and Test
+ debugImplementation(libs.ui.tooling)
+ debugImplementation(libs.ui.test.manifest)
+ testImplementation(libs.junit)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.androidx.core.testing)
+ testImplementation(libs.hilt.android.testing)
+ testAnnotationProcessor(libs.hilt.android.compiler)
+ testImplementation(libs.mockito.core)
+ testImplementation(libs.mockito.kotlin)
+ testImplementation(libs.mockk)
+ testImplementation(libs.truth)
+ testImplementation(libs.androidx.runner)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ androidTestImplementation(libs.androidx.navigation.testing)
+
+ //noinspection UseTomlInstead
+ implementation("com.pierfrancescosoffritti.androidyoutubeplayer:core:13.0.0")
+
+ // Compression
+ testImplementation (libs.zstd.jni)
+ implementation(libs.zstd.jni.get().toString() + "@aar")
+
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/eu/gaudian/translator/ApiRequestIntegrationTest.kt b/app/src/androidTest/java/eu/gaudian/translator/ApiRequestIntegrationTest.kt
new file mode 100644
index 0000000..8471d22
--- /dev/null
+++ b/app/src/androidTest/java/eu/gaudian/translator/ApiRequestIntegrationTest.kt
@@ -0,0 +1,177 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator
+
+import android.content.Context
+import android.util.Log
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import eu.gaudian.translator.model.communication.ApiManager
+import eu.gaudian.translator.utils.ApiRequestHandler
+import eu.gaudian.translator.utils.DictionaryDefinitionRequest
+import eu.gaudian.translator.utils.TextCorrectionRequest
+import eu.gaudian.translator.utils.TextTranslationRequest
+import eu.gaudian.translator.utils.VocabularyGenerationRequest
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ApiRequestIntegrationTest {
+
+ private lateinit var context: Context
+ private lateinit var apiRequestHandler: ApiRequestHandler
+ private lateinit var apiManager: ApiManager
+
+ // TAG for filtering in Logcat
+ @Suppress("PrivatePropertyName")
+ private val TAG = "ApiTest"
+
+ @get:Rule
+ val watcher = object : TestWatcher() {
+ override fun starting(description: Description) {
+ Log.i(TAG, "ð¢ STARTING TEST: ${description.methodName}")
+ }
+
+ override fun finished(description: Description) {
+ Log.i(TAG, "ð FINISHED TEST: ${description.methodName}")
+ }
+
+ override fun failed(e: Throwable?, description: Description) {
+ Log.e(TAG, "â FAILED TEST: ${description.methodName}", e)
+ }
+ }
+
+ @Before
+ fun setup() {
+ try {
+ Log.d(TAG, "SETUP: Initializing Context...")
+ // Use targetContext to access the app's resources/files
+ context = InstrumentationRegistry.getInstrumentation().targetContext
+
+ Log.d(TAG, "SETUP: Injecting Test Config into SharedPreferences...")
+ // Ensure we use the exact preference file name used by your SettingsRepository
+ // Default is usually "package_name_preferences"
+ val prefsName = "${context.packageName}_preferences"
+ val prefs = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
+
+ prefs.edit().apply {
+ putString("api_key", TestConfig.API_KEY)
+ // If your ApiManager uses a specific settings key for provider, set it here:
+ putString("selected_ai_provider", TestConfig.PROVIDER_NAME)
+ putString("custom_server_url", TestConfig.BASE_URL)
+ commit()
+ }
+ Log.d(TAG, "SETUP: Config injected. Key present: ${prefs.contains("api_key")}")
+
+ Log.d(TAG, "SETUP: Initializing ApiManager...")
+ apiManager = ApiManager(context)
+
+ Log.d(TAG, "SETUP: Initializing ApiRequestHandler...")
+ apiRequestHandler = ApiRequestHandler(apiManager, context)
+
+ Log.d(TAG, "SETUP: Complete.")
+
+ } catch (e: Exception) {
+ // THIS is what you are missing in the current output
+ Log.e(TAG, "ð¥ CRITICAL SETUP FAILURE: ${e.message}", e)
+ throw e // Re-throw to fail the test, but now it's logged
+ }
+ }
+
+ @Test
+ fun testDictionaryDefinitionRequest() = runBlocking {
+ Log.d(TAG, "Testing Dictionary Definition...")
+ val template = DictionaryDefinitionRequest(
+ word = "Serendipity",
+ language = "English",
+ requestedParts = "Definition"
+ )
+
+ val result = apiRequestHandler.executeRequest(template)
+
+ handleResult(result) { data ->
+ assertNotNull("Word should not be null", data.word)
+ assertTrue("Parts should not be empty", data.parts.isNotEmpty())
+ Log.i(TAG, "â
Dictionary Success: Defined '${data.word}'")
+ }
+ }
+
+ @Test
+ fun testTextTranslationRequest() = runBlocking {
+ Log.d(TAG, "Testing Text Translation...")
+ val template = TextTranslationRequest(
+ text = "Hello, world!",
+ sourceLanguage = "English",
+ targetLanguage = "German"
+ )
+
+ val result = apiRequestHandler.executeRequest(template)
+
+ handleResult(result) { data ->
+ assertNotNull("Translation should not be null", data.translatedText)
+ assertTrue("Translation should not be empty", data.translatedText.isNotBlank())
+ Log.i(TAG, "â
Translation Success: '${data.translatedText}'")
+ }
+ }
+
+ @Test
+ fun testCorrectionRequest() = runBlocking {
+ Log.d(TAG, "Testing Text Correction...")
+ val template = TextCorrectionRequest(
+ textToCorrect = "I has went home.",
+ language = "English",
+ grammarOnly = true,
+ tone = null
+ )
+
+ val result = apiRequestHandler.executeRequest(template)
+
+ handleResult(result) { data ->
+ assertNotEquals("Corrected text should be different", "I has went home.", data.correctedText)
+ Log.i(TAG, "â
Correction Success: -> '${data.correctedText}'")
+ }
+ }
+
+ @Test
+ fun testVocabularyGenerationRequest() = runBlocking {
+ Log.d(TAG, "Testing Vocab Generation...")
+ val template = VocabularyGenerationRequest(
+ category = "Technology",
+ languageFirst = "English",
+ languageSecond = "Spanish",
+ amount = 2
+ )
+
+ val result = apiRequestHandler.executeRequest(template)
+
+ handleResult(result) { data ->
+ assertEquals("Should receive exactly 2 cards", 2, data.flashcards.size)
+ Log.i(TAG, "â
Vocab Gen Success: Got ${data.flashcards.size} cards")
+ }
+ }
+
+ /**
+ * Helper to log results consistently and fail with clear messages
+ */
+ private fun handleResult(result: Result, assertions: (T) -> Unit) {
+ if (result.isFailure) {
+ val error = result.exceptionOrNull()
+ Log.e(TAG, "â API REQUEST FAILED", error)
+ fail("API Request failed with exception: ${error?.message}")
+ } else {
+ val data = result.getOrNull()!!
+ Log.d(TAG, "Received Data: $data")
+ assertions(data)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/eu/gaudian/translator/ExampleInstrumentedTest.kt b/app/src/androidTest/java/eu/gaudian/translator/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..16a0f3c
--- /dev/null
+++ b/app/src/androidTest/java/eu/gaudian/translator/ExampleInstrumentedTest.kt
@@ -0,0 +1,21 @@
+package eu.gaudian.translator
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("eu.gaudian.translator", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/eu/gaudian/translator/TestConfig.kt b/app/src/androidTest/java/eu/gaudian/translator/TestConfig.kt
new file mode 100644
index 0000000..6dba602
--- /dev/null
+++ b/app/src/androidTest/java/eu/gaudian/translator/TestConfig.kt
@@ -0,0 +1,17 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator
+
+object TestConfig {
+ // REPLACE with your actual API Key for the test
+ const val API_KEY = "YOUR_REAL_API_KEY_HERE"
+
+ // Set to true if you want to see full log output in Logcat
+ const val ENABLE_LOGGING = true
+
+ // Optional: If your ApiManager requires a specific provider (e.g., "Mistral", "OpenAI")
+ const val PROVIDER_NAME = "Mistral"
+
+ // Optional: If you need to override the Base URL
+ const val BASE_URL = "https://api.mistral.ai/"
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..1254ca1
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/language_configs/de.json b/app/src/main/assets/language_configs/de.json
new file mode 100644
index 0000000..40fd4f5
--- /dev/null
+++ b/app/src/main/assets/language_configs/de.json
@@ -0,0 +1,159 @@
+{
+ "language_code": "de",
+ "articles": ["der", "die", "das"],
+ "categories": {
+ "noun": {
+ "display_key": "category_noun",
+ "formatter": "({gender})",
+ "fields": [
+ {
+ "key": "gender",
+ "display_key": "prop_gender",
+ "type": "enum",
+ "options": ["masculine", "feminine", "neuter", "plural"]
+ },
+ {
+ "key": "plural",
+ "display_key": "prop_plural",
+ "type": "text"
+ },
+ {
+ "key": "genitive_singular",
+ "display_key": "prop_genitive_singular",
+ "type": "text"
+ }
+ ],
+ "mappings": {
+ "gender": {
+ "masculine": "der",
+ "feminine": "die, singular",
+ "neuter": "das",
+ "plural": "die, plural"
+ }
+ },
+ "declension_display": {
+ "cases_order": ["nominative", "genitive", "dative", "accusative"],
+ "case_labels": {
+ "nominative": "Nom.",
+ "genitive": "Gen.",
+ "dative": "Dat.",
+ "accusative": "Akk."
+ },
+ "numbers_order": ["singular", "plural"],
+ "number_labels": {
+ "singular": "Sing.",
+ "plural": "Plur."
+ }
+ }
+ },
+ "verb": {
+ "display_key": "category_verb",
+ "formatter": "({verb_type})",
+ "fields": [
+ {
+ "key": "auxiliary_verb",
+ "display_key": "prop_auxiliary_verb",
+ "type": "enum",
+ "options": ["haben", "sein"]
+ },
+ {
+ "key": "participle_past",
+ "display_key": "prop_participle_past",
+ "type": "text"
+ },
+ {
+ "key": "verb_type",
+ "display_key": "prop_verb_type",
+ "type": "enum",
+ "options": ["strong", "weak", "mixed"]
+ }
+ ],
+ "mappings": {
+ "verb_type": {
+ "strong": "stark",
+ "weak": "schwach",
+ "mixed": "gemischt"
+ }
+ },
+ "conjugation_display": {
+ "pronouns": ["ich", "du", "er/sie/es", "wir", "ihr", "sie"],
+ "tense_labels": {
+ "present": "PrÀsens",
+ "past": "PrÀteritum",
+ "subjunctive_i": "Konjunktiv I",
+ "subjunctive_ii": "Konjunktiv II"
+ }
+ }
+ },
+ "adjective": {
+ "display_key": "category_adjective",
+ "formatter": "({comparative}, {superlative})",
+ "fields": [
+ {
+ "key": "comparative",
+ "display_key": "prop_comparative",
+ "type": "text"
+ },
+ {
+ "key": "superlative",
+ "display_key": "prop_superlative",
+ "type": "text"
+ }
+ ]
+ },
+ "preposition": {
+ "display_key": "category_preposition",
+ "formatter": "(+{governs_case})",
+ "fields": [
+ {
+ "key": "governs_case",
+ "display_key": "prop_governs_case",
+ "type": "enum",
+ "options": ["accusative", "dative", "genitive", "dative_or_accusative"]
+ }
+ ],
+ "mappings": {
+ "governs_case": {
+ "accusative": "Akk",
+ "dative": "Dat",
+ "genitive": "Gen",
+ "dative_or_accusative": "Akk/Dat"
+ }
+ }
+ },
+ "article": {
+ "display_key": "category_article",
+ "formatter": "({article_type})",
+ "fields": [
+ {
+ "key": "article_type",
+ "display_key": "prop_article_type",
+ "type": "enum",
+ "options": ["definite", "indefinite"]
+ }
+ ],
+ "mappings": {
+ "article_type": {
+ "definite": "best.",
+ "indefinite": "unbest."
+ }
+ }
+ },
+ "adverb": {
+ "display_key": "category_adverb",
+ "fields": []
+ },
+ "pronoun": {
+ "display_key": "category_pronoun",
+ "fields": []
+ },
+ "conjunction": {
+ "display_key": "category_conjunction",
+ "fields": []
+ },
+ "sentence": {
+ "display_key": "category_sentence",
+ "fields": []
+ }
+ }
+}
diff --git a/app/src/main/assets/language_configs/en.json b/app/src/main/assets/language_configs/en.json
new file mode 100644
index 0000000..9c19eb6
--- /dev/null
+++ b/app/src/main/assets/language_configs/en.json
@@ -0,0 +1,73 @@
+{
+ "language_code": "en",
+ "articles": ["the"],
+ "categories": {
+ "noun": {
+ "display_key": "category_noun",
+ "formatter": "(pl: {plural})",
+ "fields": [
+ {
+ "key": "plural",
+ "display_key": "prop_plural",
+ "type": "text"
+ }
+ ]
+ },
+ "verb": {
+ "display_key": "category_verb",
+ "formatter": "({past_tense}, {past_participle})",
+ "fields": [
+ {
+ "key": "past_tense",
+ "display_key": "prop_past_tense",
+ "type": "text"
+ },
+ {
+ "key": "past_participle",
+ "display_key": "prop_past_participle",
+ "type": "text"
+ }
+ ]
+ },
+ "adjective": {
+ "display_key": "category_adjective",
+ "formatter": "({comparative}, {superlative})",
+ "fields": [
+ {
+ "key": "comparative",
+ "display_key": "prop_comparative",
+ "type": "text"
+ },
+ {
+ "key": "superlative",
+ "display_key": "prop_superlative",
+ "type": "text"
+ }
+ ]
+ },
+ "article": {
+ "display_key": "category_article",
+ "fields": []
+ },
+ "preposition": {
+ "display_key": "category_preposition",
+ "fields": []
+ },
+ "adverb": {
+ "display_key": "category_adverb",
+ "fields": []
+ },
+ "pronoun": {
+ "display_key": "category_pronoun",
+ "fields": []
+ },
+ "conjunction": {
+ "display_key": "category_conjunction",
+ "fields": []
+ },
+ "sentence": {
+ "display_key": "category_sentence",
+ "fields": []
+ }
+ }
+}
diff --git a/app/src/main/assets/language_configs/es.json b/app/src/main/assets/language_configs/es.json
new file mode 100644
index 0000000..497f752
--- /dev/null
+++ b/app/src/main/assets/language_configs/es.json
@@ -0,0 +1,76 @@
+{
+ "language_code": "es",
+ "articles": ["el", "la", "los", "las"],
+ "categories": {
+ "noun": {
+ "display_key": "category_noun",
+ "formatter": "({gender})",
+ "fields": [
+ {
+ "key": "gender",
+ "display_key": "prop_gender",
+ "type": "enum",
+ "options": ["masculine", "feminine"]
+ },
+ {
+ "key": "plural",
+ "display_key": "prop_plural",
+ "type": "text"
+ }
+ ],
+ "mappings": {
+ "gender": {
+ "masculine": "el",
+ "feminine": "la"
+ }
+ }
+ },
+ "verb": {
+ "display_key": "category_verb",
+ "formatter": "(-{conjugation_group})",
+ "fields": [
+ {
+ "key": "conjugation_group",
+ "display_key": "prop_conjugation_group",
+ "type": "enum",
+ "options": ["ar", "er", "ir"]
+ }
+ ]
+ },
+ "adjective": {
+ "display_key": "category_adjective",
+ "formatter": "(f: {feminine_form})",
+ "fields": [
+ {
+ "key": "feminine_form",
+ "display_key": "prop_feminine_form",
+ "type": "text"
+ }
+ ]
+ },
+ "article": {
+ "display_key": "category_article",
+ "fields": []
+ },
+ "preposition": {
+ "display_key": "category_preposition",
+ "fields": []
+ },
+ "adverb": {
+ "display_key": "category_adverb",
+ "fields": []
+ },
+ "pronoun": {
+ "display_key": "category_pronoun",
+ "fields": []
+ },
+ "conjunction": {
+ "display_key": "category_conjunction",
+ "fields": []
+ },
+ "sentence": {
+ "display_key": "category_sentence",
+ "fields": []
+ }
+ }
+}
diff --git a/app/src/main/assets/language_configs/fr.json b/app/src/main/assets/language_configs/fr.json
new file mode 100644
index 0000000..dcedade
--- /dev/null
+++ b/app/src/main/assets/language_configs/fr.json
@@ -0,0 +1,130 @@
+{
+ "language_code": "fr",
+ "articles": ["le", "la", "les"],
+ "categories": {
+ "noun": {
+ "display_key": "category_noun",
+ "formatter": "({gender})",
+ "fields": [
+ {
+ "key": "gender",
+ "display_key": "prop_gender",
+ "type": "enum",
+ "options": ["masculine", "feminine"]
+ },
+ {
+ "key": "plural",
+ "display_key": "prop_plural",
+ "type": "text"
+ },
+ {
+ "key": "starts_with_vowel_h",
+ "display_key": "prop_starts_with_vowel_h",
+ "type": "boolean"
+ }
+ ],
+ "mappings": {
+ "gender": {
+ "masculine": "m.",
+ "feminine": "f."
+ }
+ }
+ },
+ "verb": {
+ "display_key": "category_verb",
+ "formatter": "(reg. -{verb_group})",
+ "fields": [
+ {
+ "key": "auxiliary_verb",
+ "display_key": "prop_auxiliary_verb",
+ "type": "enum",
+ "options": ["avoir", "être"]
+ },
+ {
+ "key": "participle_past",
+ "display_key": "prop_participle_past",
+ "type": "text"
+ },
+ {
+ "key": "verb_group",
+ "display_key": "prop_verb_group",
+ "type": "enum",
+ "options": ["1st_group (er)", "2nd_group (ir)", "3rd_group (re)"]
+ }
+ ],
+ "mappings": {
+ "verb_group": {
+ "1st_group (er)": "er",
+ "2nd_group (ir)": "ir",
+ "3rd_group (re)": "re"
+ }
+ }
+ },
+ "adjective": {
+ "display_key": "category_adjective",
+ "formatter": "(f: {feminine_form})",
+ "declension_display": {
+ "cases_order": ["masculine", "feminine"],
+ "numbers_order": ["singular", "plural"]
+ },
+ "fields": [
+ {
+ "key": "feminine_form",
+ "display_key": "prop_feminine_form",
+ "type": "text"
+ },
+ {
+ "key": "position",
+ "display_key": "prop_position",
+ "type": "enum",
+ "options": ["before_noun", "after_noun"]
+ }
+ ]
+ },
+ "preposition": {
+ "display_key": "category_preposition",
+ "fields": [
+ {
+ "key": "contractions",
+ "display_key": "prop_contractions",
+ "type": "text"
+ }
+ ]
+ },
+ "article": {
+ "display_key": "category_article",
+ "formatter": "({article_type})",
+ "fields": [
+ {
+ "key": "article_type",
+ "display_key": "prop_article_type",
+ "type": "enum",
+ "options": ["definite", "indefinite", "partitive"]
+ }
+ ],
+ "mappings": {
+ "article_type": {
+ "definite": "déf.",
+ "indefinite": "indéf.",
+ "partitive": "part."
+ }
+ }
+ },
+ "adverb": {
+ "display_key": "category_adverb",
+ "fields": []
+ },
+ "pronoun": {
+ "display_key": "category_pronoun",
+ "fields": []
+ },
+ "conjunction": {
+ "display_key": "category_conjunction",
+ "fields": []
+ },
+ "sentence": {
+ "display_key": "category_sentence",
+ "fields": []
+ }
+ }
+}
diff --git a/app/src/main/assets/language_configs/hr.json b/app/src/main/assets/language_configs/hr.json
new file mode 100644
index 0000000..c36319e
--- /dev/null
+++ b/app/src/main/assets/language_configs/hr.json
@@ -0,0 +1,111 @@
+{
+ "language_code": "hr",
+ "articles": [],
+ "categories": {
+ "noun": {
+ "display_key": "category_noun",
+ "formatter": "({gender})",
+ "fields": [
+ {
+ "key": "gender",
+ "display_key": "prop_gender",
+ "type": "enum",
+ "options": ["masculine", "feminine", "neuter"]
+ },
+ {
+ "key": "plural",
+ "display_key": "prop_plural",
+ "type": "text"
+ },
+ {
+ "key": "genitive_singular",
+ "display_key": "prop_genitive_singular",
+ "type": "text"
+ }
+ ],
+ "mappings": {
+ "gender": {
+ "masculine": "m.",
+ "feminine": "f.",
+ "neuter": "n."
+ }
+ }
+ },
+ "verb": {
+ "display_key": "category_verb",
+ "formatter": "({aspect})",
+ "fields": [
+ {
+ "key": "aspect",
+ "display_key": "prop_aspect",
+ "type": "enum",
+ "options": ["perfective", "imperfective"]
+ }
+ ],
+ "mappings": {
+ "aspect": {
+ "perfective": "svršeni",
+ "imperfective": "nesvršeni"
+ }
+ }
+ },
+ "adjective": {
+ "display_key": "category_adjective",
+ "formatter": "({comparative}, {superlative})",
+ "fields": [
+ {
+ "key": "comparative",
+ "display_key": "prop_comparative",
+ "type": "text"
+ },
+ {
+ "key": "superlative",
+ "display_key": "prop_superlative",
+ "type": "text"
+ }
+ ]
+ },
+ "preposition": {
+ "display_key": "category_preposition",
+ "formatter": "(+{governs_case})",
+ "fields": [
+ {
+ "key": "governs_case",
+ "display_key": "prop_governs_case",
+ "type": "enum",
+ "options": ["genitive", "dative", "accusative", "locative", "instrumental", "dative_locative_instrumental"]
+ }
+ ],
+ "mappings": {
+ "governs_case": {
+ "genitive": "Gen",
+ "dative": "Dat",
+ "accusative": "Akk",
+ "locative": "Lok",
+ "instrumental": "Inst",
+ "dative_locative_instrumental": "Dat/Lok/Inst"
+ }
+ }
+ },
+ "article": {
+ "display_key": "category_article",
+ "fields": []
+ },
+ "adverb": {
+ "display_key": "category_adverb",
+ "fields": []
+ },
+ "pronoun": {
+ "display_key": "category_pronoun",
+ "fields": []
+ },
+ "conjunction": {
+ "display_key": "category_conjunction",
+ "fields": []
+ },
+ "sentence": {
+ "display_key": "category_sentence",
+ "fields": []
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/assets/language_configs/it.json b/app/src/main/assets/language_configs/it.json
new file mode 100644
index 0000000..16f47f6
--- /dev/null
+++ b/app/src/main/assets/language_configs/it.json
@@ -0,0 +1,69 @@
+{
+ "language_code": "it",
+ "articles": ["il", "lo", "la", "i", "gli", "le"],
+ "categories": {
+ "noun": {
+ "display_key": "category_noun",
+ "formatter": "({gender})",
+ "fields": [
+ {
+ "key": "gender",
+ "display_key": "prop_gender",
+ "type": "enum",
+ "options": ["masculine", "feminine"]
+ },
+ {
+ "key": "plural",
+ "display_key": "prop_plural",
+ "type": "text"
+ }
+ ],
+ "mappings": {
+ "gender": {
+ "masculine": "il/lo",
+ "feminine": "la"
+ }
+ }
+ },
+ "verb": {
+ "display_key": "category_verb",
+ "formatter": "(-{conjugation_group})",
+ "fields": [
+ {
+ "key": "conjugation_group",
+ "display_key": "prop_conjugation_group",
+ "type": "enum",
+ "options": ["are", "ere", "ire"]
+ }
+ ]
+ },
+ "adjective": {
+ "display_key": "category_adjective",
+ "fields": []
+ },
+ "article": {
+ "display_key": "category_article",
+ "fields": []
+ },
+ "preposition": {
+ "display_key": "category_preposition",
+ "fields": []
+ },
+ "adverb": {
+ "display_key": "category_adverb",
+ "fields": []
+ },
+ "pronoun": {
+ "display_key": "category_pronoun",
+ "fields": []
+ },
+ "conjunction": {
+ "display_key": "category_conjunction",
+ "fields": []
+ },
+ "sentence": {
+ "display_key": "category_sentence",
+ "fields": []
+ }
+ }
+}
diff --git a/app/src/main/assets/language_configs/nl.json b/app/src/main/assets/language_configs/nl.json
new file mode 100644
index 0000000..4fed2b2
--- /dev/null
+++ b/app/src/main/assets/language_configs/nl.json
@@ -0,0 +1,69 @@
+{
+ "language_code": "nl",
+ "articles": ["de", "het", "een"],
+ "categories": {
+ "noun": {
+ "display_key": "category_noun",
+ "formatter": "({gender})",
+ "fields": [
+ {
+ "key": "gender",
+ "display_key": "prop_gender",
+ "type": "enum",
+ "options": ["masculine_feminine", "neuter"]
+ },
+ {
+ "key": "plural",
+ "display_key": "prop_plural",
+ "type": "text"
+ }
+ ],
+ "mappings": {
+ "gender": {
+ "masculine_feminine": "de",
+ "neuter": "het"
+ }
+ }
+ },
+ "verb": {
+ "display_key": "category_verb",
+ "formatter": "({verb_type})",
+ "fields": [
+ {
+ "key": "verb_type",
+ "display_key": "prop_verb_type",
+ "type": "enum",
+ "options": ["strong", "weak"]
+ }
+ ]
+ },
+ "adjective": {
+ "display_key": "category_adjective",
+ "fields": []
+ },
+ "article": {
+ "display_key": "category_article",
+ "fields": []
+ },
+ "preposition": {
+ "display_key": "category_preposition",
+ "fields": []
+ },
+ "adverb": {
+ "display_key": "category_adverb",
+ "fields": []
+ },
+ "pronoun": {
+ "display_key": "category_pronoun",
+ "fields": []
+ },
+ "conjunction": {
+ "display_key": "category_conjunction",
+ "fields": []
+ },
+ "sentence": {
+ "display_key": "category_sentence",
+ "fields": []
+ }
+ }
+}
diff --git a/app/src/main/assets/language_configs/pt.json b/app/src/main/assets/language_configs/pt.json
new file mode 100644
index 0000000..4ebdb56
--- /dev/null
+++ b/app/src/main/assets/language_configs/pt.json
@@ -0,0 +1,98 @@
+{
+ "language_code": "pt",
+ "articles": ["o", "a", "os", "as"],
+ "categories": {
+ "noun": {
+ "display_key": "category_noun",
+ "formatter": "({gender})",
+ "fields": [
+ {
+ "key": "gender",
+ "display_key": "prop_gender",
+ "type": "enum",
+ "options": ["masculine", "feminine", "plural masculine", "plural feminine"]
+ },
+ {
+ "key": "plural",
+ "display_key": "prop_plural",
+ "type": "text"
+ }
+ ],
+ "mappings": {
+ "gender": {
+ "masculine": "o",
+ "feminine": "a",
+ "plural masculine": "os",
+ "plural feminine": "as"
+ }
+ }
+ },
+ "verb": {
+ "display_key": "category_verb",
+ "formatter": "(-{conjugation_group})",
+ "fields": [
+ {
+ "key": "conjugation_group",
+ "display_key": "prop_conjugation_group",
+ "type": "enum",
+ "options": ["ar", "er", "ir", "irregular"]
+ }
+ ],
+ "mappings": {
+ "conjugation_group": {
+ "ar": "ar",
+ "er": "er",
+ "ir": "ir",
+ "irregular": "irregular"
+ }
+ }
+ },
+ "adjective": {
+ "display_key": "category_adjective",
+ "formatter": "(f: {feminine_form})",
+ "declension_display": {
+ "cases_order": ["masculine", "feminine"],
+ "numbers_order": ["singular", "plural"]
+ },
+ "fields": [
+ {
+ "key": "feminine_form",
+ "display_key": "prop_feminine_form",
+ "type": "text"
+ }
+ ]
+ },
+ "article": {
+ "display_key": "category_article",
+ "formatter": "({article_type})",
+ "fields": [
+ {
+ "key": "article_type",
+ "display_key": "prop_article_type",
+ "type": "enum",
+ "options": ["definite", "indefinite"]
+ }
+ ]
+ },
+ "preposition": {
+ "display_key": "category_preposition",
+ "fields": []
+ },
+ "adverb": {
+ "display_key": "category_adverb",
+ "fields": []
+ },
+ "pronoun": {
+ "display_key": "category_pronoun",
+ "fields": []
+ },
+ "conjunction": {
+ "display_key": "category_conjunction",
+ "fields": []
+ },
+ "sentence": {
+ "display_key": "category_sentence",
+ "fields": []
+ }
+ }
+}
diff --git a/app/src/main/assets/providers_config.json b/app/src/main/assets/providers_config.json
new file mode 100644
index 0000000..a6ecb37
--- /dev/null
+++ b/app/src/main/assets/providers_config.json
@@ -0,0 +1,249 @@
+{
+ "providers": [
+ {
+ "key": "together",
+ "displayName": "Together AI",
+ "baseUrl": "https://api.together.xyz/v1/",
+ "endpoint": "chat/completions",
+ "websiteUrl": "https://www.together.ai/",
+ "isCustom": false,
+ "models": [
+ {
+ "modelId": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
+ "displayName": "Llama 3.3 70B Turbo",
+ "provider": "together",
+ "description": "Fast, accurate, and cost-effective open-source model."
+ },
+ {
+ "modelId": "meta-llama/Llama-4-Maverick-17B-Instruct",
+ "displayName": "Llama 4 Maverick 17B",
+ "provider": "together",
+ "description": "Next-gen efficient architecture; outperforms older 70B models."
+ },
+ {
+ "modelId": "deepseek-ai/DeepSeek-V3",
+ "displayName": "DeepSeek V3",
+ "provider": "together",
+ "description": "Top-tier open-source model specializing in code and logic."
+ }
+ ]
+ },
+ {
+ "key": "mistral",
+ "displayName": "Mistral AI",
+ "baseUrl": "https://api.mistral.ai/",
+ "endpoint": "v1/chat/completions",
+ "websiteUrl": "https://mistral.ai",
+ "isCustom": false,
+ "models": [
+ {
+ "modelId": "ministral-8b-latest",
+ "displayName": "Ministral 8B",
+ "provider": "mistral",
+ "description": "Extremely efficient edge model for low-latency tasks."
+ },
+ {
+ "modelId": "mistral-large-latest",
+ "displayName": "Mistral Large 3",
+ "provider": "mistral",
+ "description": "Flagship model with top-tier reasoning and multilingual capabilities."
+ }
+ ]
+ },
+ {
+ "key": "openai",
+ "displayName": "OpenAI",
+ "baseUrl": "https://api.openai.com/",
+ "endpoint": "v1/chat/completions",
+ "websiteUrl": "https://platform.openai.com/",
+ "isCustom": false,
+ "models": [
+ {
+ "modelId": "gpt-5.1-instant",
+ "displayName": "GPT-5.1 Instant",
+ "provider": "openai",
+ "description": "The standard high-speed efficiency model replacing older 'Nano' tiers."
+ },
+ {
+ "modelId": "gpt-5-nano",
+ "displayName": "GPT-5 Nano",
+ "provider": "openai",
+ "description": "Fast and cheap model sufficient for most tasks."
+ }
+ ]
+ },
+ {
+ "key": "anthropic",
+ "displayName": "Anthropic",
+ "baseUrl": "https://api.anthropic.com/",
+ "endpoint": "v1/messages",
+ "websiteUrl": "https://www.anthropic.com/",
+ "isCustom": false,
+ "models": [
+ {
+ "modelId": "claude-sonnet-5-20260203",
+ "displayName": "Claude Sonnet 5",
+ "provider": "anthropic",
+ "description": "Latest stable workhorse (Feb 2026), balancing speed and top-tier reasoning."
+ },
+ {
+ "modelId": "claude-4.5-haiku",
+ "displayName": "Claude 4.5 Haiku",
+ "provider": "anthropic",
+ "description": "Fastest Claude model for pure speed and simple tasks."
+ }
+ ]
+ },
+ {
+ "key": "deepseek",
+ "displayName": "DeepSeek",
+ "baseUrl": "https://api.deepseek.com/",
+ "endpoint": "chat/completions",
+ "websiteUrl": "https://www.deepseek.com/",
+ "isCustom": false,
+ "models": [
+ {
+ "modelId": "deepseek-reasoner",
+ "displayName": "DeepSeek R1",
+ "provider": "deepseek",
+ "description": "Reasoning-focused model (Chain of Thought) for complex math/code."
+ },
+ {
+ "modelId": "deepseek-chat",
+ "displayName": "DeepSeek V3",
+ "provider": "deepseek",
+ "description": "General purpose chat model, specialized in code and reasoning."
+ }
+ ]
+ },
+ {
+ "key": "gemini",
+ "displayName": "Google Gemini",
+ "baseUrl": "https://generativelanguage.googleapis.com/",
+ "endpoint": "v1beta/models/gemini-3-flash-preview:generateContent",
+ "websiteUrl": "https://ai.google/",
+ "isCustom": false,
+ "models": [
+ {
+ "modelId": "gemini-3-flash-preview",
+ "displayName": "Gemini 3 Flash",
+ "provider": "gemini",
+ "description": "Current default: Massive context, grounded, and extremely fast."
+ },
+ {
+ "modelId": "gemini-3-pro-preview",
+ "displayName": "Gemini 3 Pro",
+ "provider": "gemini",
+ "description": "Top-tier reasoning model for complex agentic workflows."
+ }
+ ]
+ },
+ {
+ "key": "openrouter",
+ "displayName": "OpenRouter",
+ "baseUrl": "https://openrouter.ai/api/",
+ "endpoint": "v1/chat/completions",
+ "websiteUrl": "https://openrouter.ai",
+ "isCustom": false,
+ "models": []
+ },
+ {
+ "key": "groq",
+ "displayName": "Groq",
+ "baseUrl": "https://api.groq.com/openai/",
+ "endpoint": "v1/chat/completions",
+ "websiteUrl": "https://groq.com/",
+ "isCustom": false,
+ "models": [
+ {
+ "modelId": "llama-4-scout-17b",
+ "displayName": "Llama 4 Scout",
+ "provider": "groq",
+ "description": "Powerful Llama 4 model running at extreme speed."
+ },
+ {
+ "modelId": "llama-3.3-70b-versatile",
+ "displayName": "Llama 3.3 70B",
+ "provider": "groq",
+ "description": "Previous gen flagship, highly reliable and fast on Groq chips."
+ }
+ ]
+ },
+ {
+ "key": "xai",
+ "displayName": "xAI Grok",
+ "baseUrl": "https://api.x.ai/",
+ "endpoint": "v1/chat/completions",
+ "websiteUrl": "https://x.ai",
+ "isCustom": false,
+ "models": [
+ {
+ "modelId": "grok-4-1-fast-reasoning",
+ "displayName": "Grok 4.1 Fast",
+ "provider": "xai",
+ "description": "Fast, flexible, and capable of reasoning."
+ }
+ ]
+ },
+ {
+ "key": "nvidia",
+ "displayName": "NVIDIA NIM",
+ "baseUrl": "https://integrate.api.nvidia.com/",
+ "endpoint": "v1/chat/completions",
+ "websiteUrl": "https://build.nvidia.com/explore",
+ "isCustom": false,
+ "models": [
+ {
+ "modelId": "meta/llama-3.3-70b-instruct",
+ "displayName": "Llama 3.3 70B",
+ "provider": "nvidia",
+ "description": "Standard high-performance open model accelerated by NVIDIA."
+ }
+ ]
+ },
+ {
+ "key": "cerebras",
+ "displayName": "Cerebras",
+ "baseUrl": "https://api.cerebras.ai/",
+ "endpoint": "v1/chat/completions",
+ "websiteUrl": "https://inference.cerebras.ai/",
+ "isCustom": false,
+ "models": [
+ {
+ "modelId": "llama-3.3-70b",
+ "displayName": "Llama 3.3 70B (Instant)",
+ "provider": "cerebras",
+ "description": "World's fastest inference (2000+ tokens/sec) on Wafer-Scale Engines."
+ },
+ {
+ "modelId": "llama3.1-8b",
+ "displayName": "Llama 3.1 8B",
+ "provider": "cerebras",
+ "description": "Instant speed for simple tasks."
+ }
+ ]
+ },
+ {
+ "key": "huggingface",
+ "displayName": "Hugging Face",
+ "baseUrl": "https://router.huggingface.co/",
+ "endpoint": "v1/chat/completions",
+ "websiteUrl": "https://huggingface.co/settings/tokens",
+ "isCustom": false,
+ "models": [
+ {
+ "modelId": "meta-llama/Llama-3.3-70B-Instruct",
+ "displayName": "Llama 3.3 70B",
+ "provider": "huggingface",
+ "description": "Hosted via the Hugging Face serverless router (Free tier limits apply)."
+ },
+ {
+ "modelId": "microsoft/Phi-3.5-mini-instruct",
+ "displayName": "Phi 3.5 Mini",
+ "provider": "huggingface",
+ "description": "Highly capable small model from Microsoft."
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..a79ba64
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/eu/gaudian/translator/CorrectActivity.kt b/app/src/main/java/eu/gaudian/translator/CorrectActivity.kt
new file mode 100644
index 0000000..c730db2
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/CorrectActivity.kt
@@ -0,0 +1,45 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator
+
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.Text
+import androidx.compose.ui.res.stringResource
+import eu.gaudian.translator.utils.Log
+
+class CorrectActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val intent = intent
+ val action = intent.action
+ val type = intent.type
+
+ if (Intent.ACTION_SEND == action && type == "text/plain") {
+ val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
+ if (sharedText != null) {
+ Log.d("EditActivity", "Received text: $sharedText")
+ setContent {
+ Text(stringResource(R.string.editing_text, sharedText))
+
+ }
+ } else {
+ Log.e("EditActivity", getString(R.string.no_text_received))
+ setContent {
+ Text(stringResource(R.string.error_no_text_to_edit))
+ }
+ }
+ } else {
+ Log.d("EditActivity", "Not launched with ACTION_SEND")
+ setContent {
+ Text(stringResource(R.string.not_launched_with_text_to_edit))
+ }
+ }
+
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/MyApplication.kt b/app/src/main/java/eu/gaudian/translator/MyApplication.kt
new file mode 100644
index 0000000..b10d261
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/MyApplication.kt
@@ -0,0 +1,36 @@
+package eu.gaudian.translator
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+import eu.gaudian.translator.model.repository.ApiRepository
+import eu.gaudian.translator.model.repository.LanguageRepository
+import timber.log.Timber
+
+@HiltAndroidApp
+class MyApplication : Application() {
+
+ private var _languageRepository: LanguageRepository? = null
+ private var _apiRepository: ApiRepository? = null
+
+ val languageRepository: LanguageRepository
+ get() = _languageRepository ?: synchronized(this) {
+ _languageRepository ?: LanguageRepository(this).also {
+ _languageRepository = it
+ }
+ }
+
+ val apiRepository: ApiRepository
+ get() = _apiRepository ?: synchronized(this) {
+ _apiRepository ?: ApiRepository(this).also {
+ _apiRepository = it
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ if (BuildConfig.DEBUG) {
+ Timber.plant(Timber.DebugTree())
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/di/RepositoryModule.kt b/app/src/main/java/eu/gaudian/translator/di/RepositoryModule.kt
new file mode 100644
index 0000000..bd0a57c
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/di/RepositoryModule.kt
@@ -0,0 +1,90 @@
+package eu.gaudian.translator.di
+
+import android.app.Application
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import eu.gaudian.translator.model.communication.ApiManager
+import eu.gaudian.translator.model.repository.ApiLogRepository
+import eu.gaudian.translator.model.repository.ApiRepository
+import eu.gaudian.translator.model.repository.DictionaryFileRepository
+import eu.gaudian.translator.model.repository.DictionaryLookupRepository
+import eu.gaudian.translator.model.repository.DictionaryRepository
+import eu.gaudian.translator.model.repository.LanguageRepository
+import eu.gaudian.translator.model.repository.SettingsRepository
+import eu.gaudian.translator.model.repository.VocabularyRepository
+import eu.gaudian.translator.utils.StatusMessageService
+import eu.gaudian.translator.utils.dictionary.DictionaryService
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object RepositoryModule {
+
+ @Provides
+ @Singleton
+ fun provideApiRepository(application: Application): ApiRepository {
+ return ApiRepository(application)
+ }
+
+ @Provides
+ @Singleton
+ fun provideSettingsRepository(application: Application): SettingsRepository {
+ return SettingsRepository(application)
+ }
+
+ @Provides
+ @Singleton
+ fun provideApiManager(application: Application): ApiManager {
+ return ApiManager(application)
+ }
+
+ @Provides
+ @Singleton
+ fun provideApiLogRepository(application: Application): ApiLogRepository {
+ return ApiLogRepository(application)
+ }
+
+ @Provides
+ @Singleton
+ fun provideDictionaryRepository(application: Application): DictionaryRepository {
+ return DictionaryRepository(application)
+ }
+
+ @Provides
+ @Singleton
+ fun provideLanguageRepository(application: Application): LanguageRepository {
+ return LanguageRepository(application)
+ }
+
+ @Provides
+ @Singleton
+ fun provideDictionaryFileRepository(application: Application): DictionaryFileRepository {
+ return DictionaryFileRepository(application)
+ }
+
+ @Provides
+ @Singleton
+ fun provideDictionaryLookupRepository(application: Application): DictionaryLookupRepository {
+ return DictionaryLookupRepository(application)
+ }
+
+ @Provides
+ @Singleton
+ fun provideDictionaryService(application: Application): DictionaryService {
+ return DictionaryService(application)
+ }
+
+ @Provides
+ @Singleton
+ fun provideVocabularyRepository(application: Application): VocabularyRepository {
+ return VocabularyRepository.getInstance(application)
+ }
+
+ @Provides
+ @Singleton
+ fun provideStatusMessageService(): StatusMessageService {
+ return StatusMessageService
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/DicitonaryEntry.kt b/app/src/main/java/eu/gaudian/translator/model/DicitonaryEntry.kt
new file mode 100644
index 0000000..b33d113
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/DicitonaryEntry.kt
@@ -0,0 +1,28 @@
+package eu.gaudian.translator.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import kotlinx.parcelize.RawValue
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+
+@Serializable
+@Parcelize
+data class DictionaryEntry @OptIn(ExperimentalTime::class) constructor(
+ val id: Int,
+ val word: String,
+ val definition: List, //list of Word class, declination, origin, etc.
+ var languageCode: Int,
+ var languageName: String,
+ @Contextual val createdAt: kotlin.time.Instant? = Clock.System.now()) :
+ Parcelable
+
+@Serializable
+@Parcelize
+data class EntryPart(
+ val title: String, //e.g. Word class
+ val content: @RawValue JsonElement, //e.g. Noun, Verb, etc.
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/Etymology.kt b/app/src/main/java/eu/gaudian/translator/model/Etymology.kt
new file mode 100644
index 0000000..1b92332
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/Etymology.kt
@@ -0,0 +1,23 @@
+package eu.gaudian.translator.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class EtymologyStep(
+ val year: String,
+ val language: String,
+ val description: String
+)
+
+@Serializable
+data class RelatedWord(
+ val language: String,
+ val word: String
+)
+
+@Serializable
+data class EtymologyData(
+ val word: String,
+ val timeline: List,
+ val relatedWords: List
+)
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/Exercise.kt b/app/src/main/java/eu/gaudian/translator/model/Exercise.kt
new file mode 100644
index 0000000..9df441f
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/Exercise.kt
@@ -0,0 +1,122 @@
+package eu.gaudian.translator.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlin.reflect.KClass
+
+@Serializable
+@Parcelize
+data class Exercise(
+ val id: String,
+ val title: String,
+ val questions: List,
+ val associatedVocabularyIds: List = emptyList(),
+ val sourceLanguage: String? = null,
+ val targetLanguage: String? = null,
+ val contextTitle: String? = null,
+ val contextText: String? = null,
+ val youtubeUrl: String? = null
+) : Parcelable
+
+
+@Serializable
+sealed class Question : Parcelable {
+ abstract val id: Int
+ abstract val name: String
+
+ companion object {
+
+ val allTypes: List> = listOf(
+ TrueFalseQuestion::class,
+ MultipleChoiceQuestion::class,
+ FillInTheBlankQuestion::class,
+ WordOrderQuestion::class,
+ MatchingPairsQuestion::class,
+ ListeningComprehensionQuestion::class,
+ CategorizationQuestion::class,
+ VocabularyTestQuestion::class
+ )
+ }
+}
+
+@Parcelize
+@Serializable
+@SerialName("TrueFalseQuestion")
+data class TrueFalseQuestion(
+ override val id: Int,
+ override val name: String,
+ val correctAnswer: Boolean,
+ val explanation: String = ""
+) : Question()
+
+@Parcelize
+@Serializable
+@SerialName("MultipleChoiceQuestion")
+data class MultipleChoiceQuestion(
+ override val id: Int,
+ override val name: String,
+ val options: List,
+ val correctAnswerIndex: Int
+) : Question()
+
+@Parcelize
+@Serializable
+@SerialName("FillInTheBlankQuestion")
+data class FillInTheBlankQuestion(
+ override val id: Int,
+ override val name: String, // The sentence with a placeholder like "___"
+ val correctAnswer: String,
+ val hintBaseForm: String = "",
+ val hintOptions: List = emptyList()
+) : Question()
+
+@Parcelize
+@Serializable
+@SerialName("WordOrderQuestion")
+data class WordOrderQuestion(
+ override val id: Int,
+ override val name: String, // The instruction, e.g., "Form the sentence."
+ val words: List, // The scrambled words
+ val correctOrder: List
+) : Question()
+
+@Parcelize
+@Serializable
+@SerialName("MatchingPairsQuestion")
+data class MatchingPairsQuestion(
+ override val id: Int,
+ override val name: String, // e.g., "Match the English words to their German translation."
+ val pairs: Map // Key-value pairs to be matched
+) : Question()
+
+@Parcelize
+@Serializable
+@SerialName("ListeningComprehensionQuestion")
+data class ListeningComprehensionQuestion(
+ override val id: Int,
+ override val name: String, // The text to be spoken and transcribed
+ val languageCode: String // e.g., "en-US" for TTS
+) : Question()
+
+@Parcelize
+@Serializable
+@SerialName("CategorizationQuestion")
+data class CategorizationQuestion(
+ override val id: Int,
+ override val name: String, // e.g., "Sort these into 'Fruit' and 'Vegetable' categories."
+ val items: List,
+ val categories: List,
+ val correctMapping: Map // Maps each item to its correct category
+) : Question()
+
+@Parcelize
+@Serializable
+@SerialName("VocabularyTestQuestion")
+data class VocabularyTestQuestion(
+ override val id: Int,
+ override val name: String,
+ val correctAnswer: String,
+ val languageDirection: String
+) : Question()
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/Language.kt b/app/src/main/java/eu/gaudian/translator/model/Language.kt
new file mode 100644
index 0000000..dd85f82
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/Language.kt
@@ -0,0 +1,188 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model
+
+import android.annotation.SuppressLint
+import android.content.Context
+import eu.gaudian.translator.R
+import eu.gaudian.translator.utils.Log
+import kotlinx.serialization.Serializable
+import java.util.Locale
+import kotlin.random.Random
+
+@Serializable
+data class Language(
+ val code: String, //ISO 639-1 code
+ val region: String,
+ val nameResId: Int,
+ val name: String, //the name is context specific and can differ in various languages -> gets loaded when App starts
+ val englishName: String, //to be used internally for requests etc.
+ val nativeName: String = englishName, //the native name of the language (e.g., "Deutsch" for German), defaults to englishName for backward compatibility
+ val isCustom: Boolean? = false, // there is also an option to add custom languages
+ var isSelected: Boolean? = false
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Language
+
+ if (code != other.code) return false
+ if (region != other.region) return false
+ if (nameResId != other.nameResId) return false
+ if (name != other.name) return false
+ if (englishName != other.englishName) return false
+ if (nativeName != other.nativeName) return false
+ if (isCustom != other.isCustom) return false
+ if (isSelected != other.isSelected) return false
+
+ return true
+ }
+ @Suppress("unused")
+ fun isSameAs(other: Language?): Boolean {
+ if (other == null) return false
+
+ if (this.isCustom == true && other.isCustom == true) {
+ return this.name.equals(other.name, ignoreCase = true) &&
+ this.code.equals(other.code, ignoreCase = true)
+ }
+ if (this.isCustom != other.isCustom) {
+ return false
+ }
+ return this.nameResId == other.nameResId
+ }
+
+ override fun hashCode(): Int {
+ var result = code.hashCode()
+ result = 31 * result + region.hashCode()
+ result = 31 * result + nameResId
+ result = 31 * result + name.hashCode()
+ result = 31 * result + englishName.hashCode()
+ result = 31 * result + nativeName.hashCode()
+ result = 31 * result + (isCustom?.hashCode() ?: 0)
+ result = 31 * result + (isSelected?.hashCode() ?: 0)
+ return result
+ }
+
+}
+
+fun parseLanguagesFromResources(context: Context): List {
+ val languages = mutableListOf()
+ val languageCodes = context.resources.getStringArray(R.array.language_codes)
+
+ Log.d("LanguageParser", "Starting to parse languages from resources")
+
+ languageCodes.forEach { item ->
+ val parts = item.split(",")
+ if (parts.size == 3) {
+ val code = parts[0].lowercase(Locale.getDefault())
+ val region = parts[1]
+ val nameResId = parts[2].toIntOrNull() ?: 0
+ if (nameResId != 0) {
+ val localizedName = getCapitalizedName(context, nameResId)
+ val englishName = getEnglishName(context, nameResId)
+ Log.d("LanguageParser", "Parsed language: $code, $region, $nameResId")
+ val nativeName = getNativeName(context, nameResId)
+ languages.add(
+ Language(
+ code = code,
+ region = region,
+ nameResId = nameResId,
+ name = localizedName,
+ englishName = englishName,
+ nativeName = nativeName,
+ isCustom = false,
+ isSelected = true
+ )
+ )
+ } else {
+ Log.w("LanguageParser", "Invalid nameResId for language: $code, $region")
+ }
+ } else {
+ Log.e("LanguageParser", "Invalid language code format: $item")
+ }
+ }
+
+ Log.d("LanguageParser", "Finished parsing languages. Total languages: ${languages.size}")
+ return languages
+}
+
+@SuppressLint("DiscouragedApi")
+private fun getCapitalizedName(context: Context, nameResId: Int): String {
+ return try {
+ val name = context.getString(
+ context.resources.getIdentifier("language_$nameResId", "string", context.packageName)
+ )
+ name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
+ } catch (e: Exception) {
+ Log.e("Language", "Resource not found for nameResId: $nameResId", e)
+ context.getString(R.string.text_unknown_language)
+ }
+}
+
+@SuppressLint("AppBundleLocaleChanges", "DiscouragedApi")
+private fun getEnglishName(context: Context, nameResId: Int): String {
+ return try {
+ val resName = "language_$nameResId"
+ val id = context.resources.getIdentifier(resName, "string", context.packageName)
+ if (id == 0) return context.getString(R.string.text_unknown_language)
+
+ // Use the application context with English locale for resource lookup
+ val resources = context.applicationContext.resources
+ val configuration = android.content.res.Configuration(resources.configuration)
+ configuration.setLocale(Locale.ENGLISH)
+ val localizedContext = context.applicationContext.createConfigurationContext(configuration)
+ localizedContext.resources.getString(id)
+ } catch (e: Exception) {
+ Log.e("Language", "Failed to get English name for nameResId: $nameResId", e)
+ context.getString(R.string.text_unknown_language)
+ }
+}
+
+@SuppressLint("DiscouragedApi")
+fun getNativeName(context: Context, nameResId: Int): String {
+
+ return try {
+ val resName = "native_language_$nameResId"
+ val id = context.resources.getIdentifier(resName, "string", context.packageName)
+ Log.d("Language", "Native name resource ID for nameResId $nameResId: $id")
+ if (id == 0) {
+ Log.w("Language", "Native name resource not found for nameResId: $nameResId")
+ getEnglishName(context, nameResId)
+ } else {
+
+ context.getString(id)
+ }
+ } catch (e: Exception) {
+ Log.e("Language", "Failed to get native name for nameResId: $nameResId", e)
+ getEnglishName(context, nameResId)
+ }
+}
+
+//meant for creating dummies, testing, etc
+fun generateRandomLanguage(): Language {
+ return Language(
+ code = "random",
+ region = "",
+ nameResId = 0,
+ name = "Language "+ Random.nextInt(1000, 9999).toString(),
+ englishName = "German",
+ nativeName = "Deutsch",
+ isCustom = false,
+ isSelected = true
+ )
+}
+
+fun generateSimpleLanguage(name: String): Language {
+ return Language(
+ code = "random",
+ region = "",
+ nameResId = 0,
+ name = name,
+ englishName = "German",
+ nativeName = "Deutsch",
+ isCustom = false,
+ isSelected = true
+ )
+}
+
diff --git a/app/src/main/java/eu/gaudian/translator/model/LanguageModel.kt b/app/src/main/java/eu/gaudian/translator/model/LanguageModel.kt
new file mode 100644
index 0000000..bcf6e9c
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/LanguageModel.kt
@@ -0,0 +1,12 @@
+package eu.gaudian.translator.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class LanguageModel(
+ val modelId: String,
+ val displayName: String,
+ val providerKey: String,
+ val description: String,
+ val isCustom: Boolean = false
+)
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/MyAppLanguageLevel.kt b/app/src/main/java/eu/gaudian/translator/model/MyAppLanguageLevel.kt
new file mode 100644
index 0000000..59838f9
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/MyAppLanguageLevel.kt
@@ -0,0 +1,248 @@
+package eu.gaudian.translator.model
+
+import eu.gaudian.translator.R
+
+
+object LanguageLevels {
+ val all: List = listOf(
+ MyAppLanguageLevel.Newborn,
+ MyAppLanguageLevel.EchoingEcho,
+ MyAppLanguageLevel.GoldfishMemory,
+ MyAppLanguageLevel.CleverPigeon,
+ MyAppLanguageLevel.KoshikTheElephant,
+ MyAppLanguageLevel.GossipLovingCrow,
+ MyAppLanguageLevel.HoneybeeCartographer,
+ MyAppLanguageLevel.ChattyParrotlet,
+ MyAppLanguageLevel.CuriousToddler,
+ MyAppLanguageLevel.RicoTheDog,
+ MyAppLanguageLevel.AuctioneerInTraining,
+ MyAppLanguageLevel.AlexTheParrot,
+ MyAppLanguageLevel.PilitaTheSeaLion,
+ MyAppLanguageLevel.KanziTheBonobo,
+ MyAppLanguageLevel.KokoTheGorilla,
+ MyAppLanguageLevel.ShakespeareanInsultGenerator,
+ MyAppLanguageLevel.FirstGrader,
+ MyAppLanguageLevel.PuppyInTraining,
+ MyAppLanguageLevel.ChaserTheSuperdog,
+ MyAppLanguageLevel.Bookworm,
+ MyAppLanguageLevel.MiddleSchooler,
+ MyAppLanguageLevel.AvidDebater,
+ MyAppLanguageLevel.HighSchoolGrad,
+ MyAppLanguageLevel.TheJournalist,
+ MyAppLanguageLevel.TheProfessor,
+ MyAppLanguageLevel.TheNovelist,
+ MyAppLanguageLevel.MasterLinguist,
+ MyAppLanguageLevel.ThePolyglotOracle
+ )
+
+ fun getLevelForWords(wordsLearned: Int): MyAppLanguageLevel {
+ return all.lastOrNull { wordsLearned >= it.wordsKnown } ?: MyAppLanguageLevel.Newborn
+ }
+ fun getNextLevel(wordsLearned: Int): MyAppLanguageLevel? {
+ return all.firstOrNull { wordsLearned < it.wordsKnown }
+
+ }
+}
+
+sealed class MyAppLanguageLevel(
+ val nameResId: Int,
+ val descriptionResId: Int,
+ val wordsKnown: Int,
+ val iconResId: Int
+) {
+ object Newborn : MyAppLanguageLevel(
+ nameResId = R.string.level_newborn_name,
+ descriptionResId = R.string.level_newborn_description,
+ wordsKnown = 0,
+ iconResId = R.drawable.ic_level_newborn
+ )
+
+ object EchoingEcho : MyAppLanguageLevel( // New
+ nameResId = R.string.level_echo_name,
+ descriptionResId = R.string.level_echo_description,
+ wordsKnown = 3,
+ iconResId = R.drawable.ic_level_echo
+ )
+
+ object GoldfishMemory : MyAppLanguageLevel(
+ nameResId = R.string.level_goldfish_name,
+ descriptionResId = R.string.level_goldfish_description,
+ wordsKnown = 5,
+ iconResId = R.drawable.ic_level_goldfish
+ )
+
+ object CleverPigeon : MyAppLanguageLevel(
+ nameResId = R.string.level_pigeon_name,
+ descriptionResId = R.string.level_pigeon_description,
+ wordsKnown = 10,
+ iconResId = R.drawable.ic_level_pigeon
+ )
+
+ object KoshikTheElephant : MyAppLanguageLevel(
+ nameResId = R.string.level_elephant_name,
+ descriptionResId = R.string.level_elephant_description,
+ wordsKnown = 20,
+ iconResId = R.drawable.ic_level_elephant
+ )
+
+ object GossipLovingCrow : MyAppLanguageLevel(
+ nameResId = R.string.level_crow_name,
+ descriptionResId = R.string.level_crow_description,
+ wordsKnown = 25,
+ iconResId = R.drawable.ic_level_crow
+ )
+
+ object HoneybeeCartographer : MyAppLanguageLevel(
+ nameResId = R.string.level_honeybee_name,
+ descriptionResId = R.string.level_honeybee_description,
+ wordsKnown = 35,
+ iconResId = R.drawable.ic_level_bee
+ )
+
+ object ChattyParrotlet : MyAppLanguageLevel(
+ nameResId = R.string.level_parrotlet_name,
+ descriptionResId = R.string.level_parrotlet_description,
+ wordsKnown = 50,
+ iconResId = R.drawable.ic_level_parrotlet
+ )
+
+ object CuriousToddler : MyAppLanguageLevel(
+ nameResId = R.string.level_toddler_name,
+ descriptionResId = R.string.level_toddler_description,
+ wordsKnown = 75,
+ iconResId = R.drawable.ic_level_toddler
+ )
+
+ object RicoTheDog : MyAppLanguageLevel(
+ nameResId = R.string.level_rico_name,
+ descriptionResId = R.string.level_rico_description,
+ wordsKnown = 100,
+ iconResId = R.drawable.ic_level_rico
+ )
+
+ object AuctioneerInTraining : MyAppLanguageLevel( // New
+ nameResId = R.string.level_auctioneer_name,
+ descriptionResId = R.string.level_auctioneer_description,
+ wordsKnown = 125,
+ iconResId = R.drawable.ic_level_auctioneer
+ )
+
+ object AlexTheParrot : MyAppLanguageLevel(
+ nameResId = R.string.level_alex_name,
+ descriptionResId = R.string.level_alex_description,
+ wordsKnown = 150,
+ iconResId = R.drawable.ic_level_parrot
+ )
+
+ object PilitaTheSeaLion : MyAppLanguageLevel(
+ nameResId = R.string.level_pilita_name,
+ descriptionResId = R.string.level_pilita_description,
+ wordsKnown = 225,
+ iconResId = R.drawable.ic_level_sea_lion
+ )
+
+ object KanziTheBonobo : MyAppLanguageLevel(
+ nameResId = R.string.level_kanzi_name,
+ descriptionResId = R.string.level_kanzi_description,
+ wordsKnown = 350,
+ iconResId = R.drawable.ic_level_bonobo
+ )
+
+ object KokoTheGorilla : MyAppLanguageLevel(
+ nameResId = R.string.level_koko_name,
+ descriptionResId = R.string.level_koko_description,
+ wordsKnown = 500,
+ iconResId = R.drawable.ic_level_gorilla
+ )
+
+ object ShakespeareanInsultGenerator : MyAppLanguageLevel(
+ nameResId = R.string.level_shakespeare_name,
+ descriptionResId = R.string.level_shakespeare_description,
+ wordsKnown = 600,
+ iconResId = R.drawable.ic_level_shakespeare
+ )
+
+ object FirstGrader : MyAppLanguageLevel(
+ nameResId = R.string.level_first_grader_name,
+ descriptionResId = R.string.level_first_grader_description,
+ wordsKnown = 750,
+ iconResId = R.drawable.ic_level_first_grader
+ )
+
+ object PuppyInTraining : MyAppLanguageLevel(
+ nameResId = R.string.level_puppy_in_training_name,
+ descriptionResId = R.string.level_puppy_in_training_description,
+ wordsKnown = 900,
+ iconResId = R.drawable.ic_level_puppy
+ )
+
+ object ChaserTheSuperdog : MyAppLanguageLevel(
+ nameResId = R.string.level_chaser_name,
+ descriptionResId = R.string.level_chaser_description,
+ wordsKnown = 1100,
+ iconResId = R.drawable.ic_level_chaser
+ )
+
+ object Bookworm : MyAppLanguageLevel(
+ nameResId = R.string.level_bookworm_name,
+ descriptionResId = R.string.level_bookworm_description,
+ wordsKnown = 1600,
+ iconResId = R.drawable.ic_level_bookworm
+ )
+
+ object MiddleSchooler : MyAppLanguageLevel(
+ nameResId = R.string.level_middle_schooler_name,
+ descriptionResId = R.string.level_middle_schooler_description,
+ wordsKnown = 2300,
+ iconResId = R.drawable.ic_level_middle_schooler
+ )
+
+ object AvidDebater : MyAppLanguageLevel(
+ nameResId = R.string.level_avid_debater_name,
+ descriptionResId = R.string.level_avid_debater_description,
+ wordsKnown = 3500,
+ iconResId = R.drawable.ic_level_avid_debater
+ )
+
+ object HighSchoolGrad : MyAppLanguageLevel(
+ nameResId = R.string.level_high_school_grad_name,
+ descriptionResId = R.string.level_high_school_grad_description,
+ wordsKnown = 5000,
+ iconResId = R.drawable.ic_level_high_school_grad
+ )
+
+ object TheJournalist : MyAppLanguageLevel(
+ nameResId = R.string.level_journalist_name,
+ descriptionResId = R.string.level_journalist_description,
+ wordsKnown = 7500,
+ iconResId = R.drawable.ic_level_journalist
+ )
+
+ object TheProfessor : MyAppLanguageLevel(
+ nameResId = R.string.level_professor_name,
+ descriptionResId = R.string.level_professor_description,
+ wordsKnown = 12000,
+ iconResId = R.drawable.ic_level_professor
+ )
+
+ object TheNovelist : MyAppLanguageLevel(
+ nameResId = R.string.level_novelist_name,
+ descriptionResId = R.string.level_novelist_description,
+ wordsKnown = 18000,
+ iconResId = R.drawable.ic_level_novelist
+ )
+
+ object MasterLinguist : MyAppLanguageLevel(
+ nameResId = R.string.level_linguist_name,
+ descriptionResId = R.string.level_linguist_description,
+ wordsKnown = 25000,
+ iconResId = R.drawable.ic_level_master_linguist
+ )
+
+ object ThePolyglotOracle : MyAppLanguageLevel(
+ nameResId = R.string.level_oracle_name,
+ descriptionResId = R.string.level_oracle_description,
+ wordsKnown = 50000,
+ iconResId = R.drawable.ic_level_oracle
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/TranslationHistoryItem.kt b/app/src/main/java/eu/gaudian/translator/model/TranslationHistoryItem.kt
new file mode 100644
index 0000000..41055d4
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/TranslationHistoryItem.kt
@@ -0,0 +1,22 @@
+package eu.gaudian.translator.model
+
+import android.text.format.DateUtils
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class TranslationHistoryItem(
+ val id: Long = System.currentTimeMillis(), // Unique ID for animations/deletion
+ val text: String, // The result text
+ val sourceText: String, // The original input
+ val sourceLanguageCode: Int?, // nameResID
+ val targetLanguageCode: Int?, // nameResID
+ val playable: Boolean? = null,
+ val timestamp: Long = System.currentTimeMillis(),
+ val translationSource: String? = null,
+ val translationModel: String? = null
+) {
+ fun getRelativeTimeSpan(): String {
+ val now = System.currentTimeMillis()
+ return DateUtils.getRelativeTimeSpanString(timestamp, now, DateUtils.MINUTE_IN_MILLIS).toString()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/Vocabulary.kt b/app/src/main/java/eu/gaudian/translator/model/Vocabulary.kt
new file mode 100644
index 0000000..2a985e4
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/Vocabulary.kt
@@ -0,0 +1,147 @@
+@file:OptIn(ExperimentalTime::class)
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model
+
+import android.content.Context
+import android.os.Parcelable
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import eu.gaudian.translator.R
+import eu.gaudian.translator.model.grammar.VocabularyFeatures
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
+
+val jsonParser = Json { ignoreUnknownKeys = true }
+
+@Serializable
+@Parcelize
+@Entity(tableName = "vocabulary_items")
+data class VocabularyItem(
+ @PrimaryKey val id: Int,
+ val languageFirstId: Int?,
+ val languageSecondId: Int?,
+ val wordFirst: String,
+ val wordSecond: String,
+ @Contextual val createdAt: Instant? = Clock.System.now(),
+ val features: String? = null,
+ val zipfFrequencyFirst: Float? = null,
+ val zipfFrequencySecond: Float? = null
+) : Parcelable {
+
+ fun isDuplicate(other: VocabularyItem): Boolean {
+ val normalizedWords = setOf(wordFirst.lowercase(), wordSecond.lowercase())
+ val otherNormalizedWords = setOf(other.wordFirst.lowercase(), other.wordSecond.lowercase())
+ val normalizedIds = setOf(languageFirstId, languageSecondId)
+ val otherNormalizedIds = setOf(other.languageFirstId, other.languageSecondId)
+ return normalizedWords == otherNormalizedWords && normalizedIds == otherNormalizedIds
+ }
+
+ @Suppress("unused")
+ fun switchOrder(): VocabularyItem {
+ val currentFeatures = features?.let { jsonParser.decodeFromString(it) }
+ val switchedFeatures = currentFeatures?.copy(first = currentFeatures.second, second = currentFeatures.first)
+ val switchedFeaturesJson = switchedFeatures?.let { jsonParser.encodeToString(it) }
+
+ return this.copy(
+ languageFirstId = this.languageSecondId,
+ languageSecondId = this.languageFirstId,
+ wordFirst = this.wordSecond,
+ wordSecond = this.wordFirst,
+ features = switchedFeaturesJson
+ )
+ }
+}
+
+@Serializable
+@Parcelize
+data class WordDetails(
+ @SerialName("category")
+ val wordClass: String,
+ val properties: Map
+) : Parcelable
+
+/**
+ * A container holding the grammatical details for both words in a VocabularyItem.
+ */
+@Serializable
+@Parcelize
+data class VocabularyGrammarDetails(
+ val first: WordDetails? = null,
+ val second: WordDetails? = null
+) : Parcelable
+
+
+@Serializable
+sealed class VocabularyCategory {
+ abstract val id: Int
+ abstract val name: String
+}
+
+
+@Serializable
+@SerialName("FilterCategory")
+data class VocabularyFilter(
+ override val id: Int,
+ override val name: String,
+ val languages: List? = null,
+ @Contextual val languagePairs: Pair ? = null,
+ val stages: List? = null,
+) : VocabularyCategory()
+
+@Serializable
+@SerialName("TagCategory")
+data class TagCategory(
+ override val id: Int,
+ override val name: String,
+) : VocabularyCategory()
+
+
+@Serializable
+@Entity(tableName = "vocabulary_states")
+data class VocabularyItemState(
+ @PrimaryKey val vocabularyItemId: Int,
+ @Contextual var lastCorrectAnswer: Instant? = null,
+ @Contextual var lastIncorrectAnswer: Instant? = null,
+ var correctAnswerCount: Int = 0,
+ var incorrectAnswerCount: Int = 0
+)
+
+@Serializable
+enum class VocabularyStage {
+ NEW,
+ STAGE_1,
+ STAGE_2,
+ STAGE_3,
+ STAGE_4,
+ STAGE_5,
+ LEARNED;
+
+ fun toString(context: Context): String {
+ val res = context.resources
+ return when (this) {
+ NEW -> res.getString(R.string.stage_new)
+ STAGE_1 -> res.getString(R.string.stage_1)
+ STAGE_2 -> res.getString(R.string.stage_2)
+ STAGE_3 -> res.getString(R.string.stage_3)
+ STAGE_4 -> res.getString(R.string.stage_4)
+ STAGE_5 -> res.getString(R.string.stage_5)
+ LEARNED -> res.getString(R.string.stage_learned)
+ }
+ }
+}
+
+@Serializable
+@Parcelize
+data class CardSet(
+ val id: Int?=null,
+ val languageFirst: Int?=null,
+ val languageSecond: Int?=null,
+ val cards: List,
+): Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/WidgetType.kt b/app/src/main/java/eu/gaudian/translator/model/WidgetType.kt
new file mode 100644
index 0000000..154fc01
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/WidgetType.kt
@@ -0,0 +1,39 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model
+
+import androidx.annotation.StringRes
+import eu.gaudian.translator.R
+
+
+sealed class WidgetType(val id: String, @param:StringRes val titleRes: Int) {
+ data object Status : WidgetType("status", R.string.label_status)
+ data object Streak : WidgetType("streak", R.string.title_widget_streak)
+ data object StartButtons : WidgetType("start_buttons", R.string.label_start_exercise)
+ data object AllVocabulary : WidgetType("all_vocabulary", R.string.label_all_vocabulary)
+ data object DueToday : WidgetType("due_today", R.string.title_widget_due_today)
+ data object CategoryProgress : WidgetType("category_progress", R.string.title_widget_category_progress)
+ data object WeeklyActivityChart : WidgetType("weekly_activity_chart", R.string.text_widget_title_weekly_activity)
+ data object Levels : WidgetType("category_stats", R.string.levels)
+
+ companion object {
+ /**
+ * The default order of widgets when the app is first launched or if no order is saved.
+ */
+ val DEFAULT_ORDER = listOf(
+ Status,
+ Streak,
+ StartButtons,
+ AllVocabulary,
+ DueToday,
+ CategoryProgress ,
+ WeeklyActivityChart,
+ Levels,
+ )
+
+
+ fun fromId(id: String?): WidgetType? {
+ return DEFAULT_ORDER.find { it.id == id }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/YouTubeData.kt b/app/src/main/java/eu/gaudian/translator/model/YouTubeData.kt
new file mode 100644
index 0000000..abfb9ba
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/YouTubeData.kt
@@ -0,0 +1,18 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SubtitleLine(
+ @SerialName("start") val start: Float,
+ @SerialName("duration") val duration: Float,
+ @SerialName("text") val text: String,
+ // Optional translated text to be displayed alongside the original subtitle.
+ // Not provided by the backend; filled by the app after fetching.
+ @SerialName("translatedText") val translatedText: String? = null
+) {
+ val end: Float get() = start + duration
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ApiLog.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ApiLog.kt
new file mode 100644
index 0000000..85ef419
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/ApiLog.kt
@@ -0,0 +1,25 @@
+
+package eu.gaudian.translator.model.communication
+
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class ApiLogEntry(
+ val id: String,
+ val timestamp: Long,
+ val providerKey: String,
+ val endpoint: String,
+ val method: String = "POST",
+ val model: String? = null,
+ val requestJson: String? = null,
+ val responseCode: Int? = null,
+ val responseMessage: String? = null,
+ val responseJson: String? = null,
+ val errorMessage: String? = null,
+ val durationMs: Long? = null,
+ val exceptionType: String? = null,
+ val isTimeout: Boolean? = null,
+ val parseErrorMessage: String? = null,
+ val url: String? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ApiManager.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ApiManager.kt
new file mode 100644
index 0000000..581d3fc
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/ApiManager.kt
@@ -0,0 +1,620 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.communication
+
+import android.content.Context
+import eu.gaudian.translator.R
+import eu.gaudian.translator.model.LanguageModel
+import eu.gaudian.translator.model.repository.ApiRepository
+import eu.gaudian.translator.model.repository.SettingsRepository
+import eu.gaudian.translator.utils.ApiCallback
+import eu.gaudian.translator.utils.Log
+import eu.gaudian.translator.utils.StatusAction
+import eu.gaudian.translator.utils.StatusMessageService
+import eu.gaudian.translator.viewmodel.MessageAction
+import eu.gaudian.translator.viewmodel.MessageDisplayType
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.HttpException
+import retrofit2.Response
+
+class ApiManager(private val context: Context) {
+
+ /**
+ * Checks whether a base URL is reachable and the (optional) port is open.
+ * - If no scheme is provided, http is assumed.
+ * - If no port is provided, defaults to 80 for http and 443 for https.
+ * Tries a TCP socket connect first, then a lightweight HTTP GET to the root.
+ * Returns Pair.
+ */
+ suspend fun checkProviderAvailability(baseUrl: String): Pair = withContext(Dispatchers.IO) {
+ try {
+ val normalized = try {
+
+ var url = baseUrl.trim()
+ if (url.isEmpty()) url = "http://localhost/"
+ if (!url.startsWith("http://") && !url.startsWith("https://")) url = "http://$url"
+ if (!url.endsWith('/')) url += "/"
+ url
+ } catch (_: Exception) {
+ var url = baseUrl.trim()
+ if (url.isEmpty()) url = "http://localhost/"
+ if (!url.startsWith("http://") && !url.startsWith("https://")) url = "http://$url"
+ if (!url.endsWith('/')) url += "/"
+ url
+ }
+
+ val uri = java.net.URI(normalized)
+ val host = uri.host ?: return@withContext Pair(false, "Invalid host")
+ val scheme = (uri.scheme ?: "http").lowercase()
+ val port = if (uri.port != -1) uri.port else if (scheme == "https") 443 else 80
+
+ // 1) TCP connect test
+ try {
+ java.net.Socket().use { socket ->
+ socket.connect(java.net.InetSocketAddress(host, port), 1500)
+ }
+ } catch (e: Exception) {
+ return@withContext Pair(false, "Cannot connect to $host:$port (${e.message})")
+ }
+
+ // 2) HTTP GET test (non-fatal if it fails; we already know port is open)
+ return@withContext try {
+ val client = okhttp3.OkHttpClient.Builder()
+ .connectTimeout(2, java.util.concurrent.TimeUnit.SECONDS)
+ .readTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
+ .build()
+ val request = okhttp3.Request.Builder().url(normalized).get().build()
+ client.newCall(request).execute().use { resp ->
+ val code = resp.code
+ if (code in 200..499) {
+ Pair(true, "Reachable ($code)")
+ } else {
+ Pair(true, "Port open; HTTP $code")
+ }
+ }
+ } catch (e: Exception) {
+ Pair(true, "Port open; HTTP check failed: ${e.message}")
+ }
+ } catch (e: Exception) {
+ Log.e("ApiManager", "Availability check error: ${e.message}", e)
+ Pair(false, e.message ?: "Unknown error")
+ }
+ }
+
+ private val apiLogRepository = eu.gaudian.translator.model.repository.ApiLogRepository(context)
+ private val gson = com.google.gson.Gson()
+
+ private val apiRepository = ApiRepository(context)
+ private val settingsRepository = SettingsRepository(context)
+
+ /**
+ * Validates a given API key against a specific provider's endpoint.
+ */
+ suspend fun validateApiKey(apiKey: String, provider: ApiProvider): Pair {
+ val validationMessage = "Validating API key for ${provider.displayName}..."
+ Log.d("ApiManager", validationMessage)
+
+ val cleanApiKey = apiKey.trim()
+ if (cleanApiKey.isEmpty()) {
+ return Pair(false, "API key cannot be empty.")
+ }
+
+ val tempApiService = RetrofitClient.getApiClient(cleanApiKey, provider.baseUrl, provider)
+ .create(LlmApiService::class.java)
+
+ val endpointUrl = provider.endpoint
+ val validationPrompt = "Just respond with the word \"success\" if you received this message."
+
+ // For custom providers or local hosts, allow saving the key without requiring a model.
+ val base = provider.baseUrl.trim()
+ val lower = base.lowercase()
+ val isLocalHost = (
+ lower.contains("localhost") ||
+ lower.contains("127.0.0.1") ||
+ lower.startsWith("192.168.") ||
+ lower.startsWith("http://192.168.") ||
+ lower.startsWith("10.") ||
+ lower.startsWith("http://10.") ||
+ Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower) ||
+ Regex("^http://172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower)
+ )
+ if (provider.isCustom || isLocalHost) {
+ // We cannot reliably validate without a model; accept non-empty key for setup.
+ return Pair(true, "Key accepted for ${provider.displayName}.")
+ }
+
+ val defaultModel = provider.defaultModel ?: return Pair(true, "No default model found; key saved. Configure a model to fully validate.")
+
+ val result = withTimeoutOrNull(10000) { // 10-second timeout
+ try {
+ when (provider.key) {
+ "gemini" -> {
+ val testRequest = GeminiRequest(
+ contents = listOf(GeminiContent("user", listOf(GeminiPart(validationPrompt))))
+ )
+ val response: Response = tempApiService.sendGeminiRequest(endpointUrl, testRequest).execute()
+ Pair(response.isSuccessful, response.body()?.toString() ?: response.errorBody()?.string())
+ }
+ else -> {
+ val testRequest = Request(
+ model = defaultModel,
+ messages = listOf(Request.Message("user", validationPrompt))
+ )
+ val response: Response = tempApiService.sendRequest(endpointUrl, testRequest).execute()
+ Pair(response.isSuccessful, response.body()?.toString() ?: response.errorBody()?.string())
+ }
+ }
+ } catch (e: Exception) {
+ Log.e("ApiManager", "Error validating API key: ${e.message}", e)
+ Pair(false, e.message)
+ }
+ } ?: Pair(false, "Timeout validating API key.")
+
+ return result
+ }
+
+ /**
+ * Fetch available models from provider if supported (e.g., OpenAI/OpenRouter/Mistral-compatible GET v1/models).
+ * Returns Pair, String?> where second is an error message if any.
+ */
+ suspend fun fetchAvailableModels(apiKey: String?, provider: ApiProvider): Pair, String?> {
+ val base = provider.baseUrl.trim()
+ val lower = base.lowercase()
+ val isLocalHost = (
+ lower.contains("localhost") ||
+ lower.contains("127.0.0.1") ||
+ lower.startsWith("192.168.") ||
+ lower.startsWith("http://192.168.") ||
+ lower.startsWith("10.") ||
+ lower.startsWith("http://10.") ||
+ Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower) ||
+ Regex("^http://172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower)
+ )
+
+ if (provider.key == "gemini") {
+ // Gemini uses a different endpoint and response shape for model listing.
+ val key = apiKey?.trim().orEmpty()
+ if (key.isEmpty() && !provider.isCustom) {
+ return Pair(emptyList(), "API key required to list models for this provider.")
+ }
+ return withContext(Dispatchers.IO) {
+ try {
+ val service = RetrofitClient.getApiClient(key, provider.baseUrl, provider).create(LlmApiService::class.java)
+ // Gemini model list endpoint (v1beta). We keep it relative to baseUrl like other calls.
+ val url = "v1beta/models"
+ val response = service.listGeminiModels(url).execute()
+ if (!response.isSuccessful) {
+ val err = response.errorBody()?.string() ?: "HTTP ${response.code()}"
+ Pair(emptyList(), err)
+ } else {
+ val body = response.body()
+ val items = body?.models ?: emptyList()
+ val mapped = items.map { item ->
+ val niceName = item.displayName ?: item.name ?: "unknown"
+ val parts = mutableListOf()
+ item.description?.let { if (it.isNotBlank()) parts.add(it) }
+ item.inputTokenLimit?.let { if (it > 0) parts.add("in $it ctx") }
+ item.outputTokenLimit?.let { if (it > 0) parts.add("out $it ctx") }
+ item.supportedGenerationMethods?.takeIf { it.isNotEmpty() }?.let { parts.add(it.joinToString("/")) }
+ val desc = parts.joinToString(" ⢠")
+ val modelIdClean = (item.name ?: niceName).substringAfterLast('/')
+ LanguageModel(
+ modelId = modelIdClean,
+ displayName = niceName,
+ providerKey = provider.key,
+ description = desc,
+ isCustom = provider.isCustom
+ )
+ }
+ Pair(mapped, null)
+ }
+ } catch (e: Exception) {
+ Log.e("ApiManager", "Error fetching Gemini models: ${e.message}", e)
+ Pair(emptyList(), e.message)
+ }
+ }
+ }
+
+ val key = apiKey?.trim().orEmpty()
+ // For custom providers (and local endpoints), allow scanning without key by attempting unauthenticated GET v1/models.
+ val allowNoKey = provider.isCustom || isLocalHost
+ val effectiveKey = if (key.isNotEmpty()) key else if (allowNoKey) "" else key
+
+ // Perplexity does not support listing models via /v1/models; fail fast with a clear message
+ if (provider.key.equals("perplexity", ignoreCase = true)) {
+ return Pair(emptyList(), "Perplexity does not support fetching modeles.") //TODO this must be transalted!
+ }
+
+ return withContext(Dispatchers.IO) {
+ try {
+ val service = RetrofitClient.getApiClient(effectiveKey, provider.baseUrl, provider).create(LlmApiService::class.java)
+ val url = "v1/models"
+ val response = service.listModels(url).execute()
+ Log.d("ApiManager", "Listing models response success=${response}")
+ if (!response.isSuccessful) {
+ val err = response.errorBody()?.string() ?: "HTTP ${response.code()}"
+ Pair(emptyList(), err)
+ } else {
+ val body = response.body()
+ val items = body?.data ?: emptyList()
+ val mapped = items.map { item ->
+ val niceName = item.displayName ?: item.title ?: item.name ?: item.id
+ // Build a compact description with the most useful info
+ val parts = mutableListOf()
+ (item.description ?: item.ownedBy ?: item.organization)?.let { if (it.isNotBlank()) parts.add(it) }
+ val ctx = item.contextLength ?: item.maxContext ?: item.tokenLimit
+ if (ctx != null && ctx > 0) parts.add("$ctx ctx")
+ item.capabilities?.let { cap ->
+ val caps = mutableListOf()
+ if (cap.vision == true) caps.add("vision")
+ if (cap.audio == true) caps.add("audio")
+ if (cap.tools == true) caps.add("tools")
+ if (cap.json == true) caps.add("json")
+ if (cap.reasoning == true) caps.add("reasoning")
+ if (caps.isNotEmpty()) parts.add(caps.joinToString("/"))
+ }
+ item.pricing?.let { p ->
+ val inPrice = p.input ?: p.inputAlt
+ val outPrice = p.output ?: p.outputAlt
+ val currency = p.currency ?: "$"
+ val unit = p.unit ?: "1K tok"
+ if (inPrice != null || outPrice != null) {
+ val priceStr = listOfNotNull(
+ inPrice?.let { "in ${currency}${it}/${unit}" },
+ outPrice?.let { "out ${currency}${it}/${unit}" }
+ ).joinToString(" · ")
+ if (priceStr.isNotBlank()) parts.add(priceStr)
+ }
+ }
+ if (item.deprecated == true) parts.add("deprecated")
+ val desc = parts.joinToString(" ⢠")
+ LanguageModel(
+ modelId = item.id,
+ displayName = niceName,
+ providerKey = provider.key,
+ description = desc,
+ isCustom = provider.isCustom
+ )
+ }
+ Pair(mapped, null)
+ }
+ } catch (e: Exception) {
+ Log.e("ApiManager", "Error fetching models: ${e.message}", e)
+ Pair(emptyList(), e.message)
+ }
+ }
+ }
+
+ /**
+ * Validates a specific model for a given provider and API key.
+ */
+ suspend fun validateModel(apiKey: String, provider: ApiProvider, model: LanguageModel): Pair {
+ val validationMessage = "Validating model '${model.displayName}' for provider '${provider.displayName}'..."
+ Log.d("ApiManager", validationMessage)
+
+ val cleanApiKey = apiKey.trim()
+ if (cleanApiKey.isEmpty()) {
+ return Pair(false, "API key is missing for this provider.")
+ }
+
+ val tempApiService = RetrofitClient.getApiClient(cleanApiKey, provider.baseUrl, provider)
+ .create(LlmApiService::class.java)
+
+ val validationPrompt = "This is a test message to validate the model."
+
+ val result = withTimeoutOrNull(10_000) { // 10-second timeout
+ try {
+ Log.d("ApiManager", "Starting validation for provider=${provider.key}, model=${model.modelId}")
+
+ when (provider.key) {
+ "gemini" -> {
+ val modelSpecificEndpoint = "v1beta/models/${model.modelId}:generateContent"
+ val testRequest = GeminiRequest(
+ contents = listOf(GeminiContent("user", listOf(GeminiPart(validationPrompt))))
+ )
+
+ Log.d("ApiManager", "Sending Gemini request to $modelSpecificEndpoint with prompt=$validationPrompt")
+
+ val response: Response = withContext(Dispatchers.IO) {
+ tempApiService.sendGeminiRequest(modelSpecificEndpoint, testRequest).execute()
+ }
+
+ Log.d("ApiManager", "Gemini response success=${response.isSuccessful}")
+
+ val bodyOrError = try {
+ response.body()?.toString()
+ ?: response.errorBody()?.string()
+ ?: "Unknown error"
+ } catch (ioe: Exception) {
+ Log.e("ApiManager", "Error extracting Gemini response body: ${ioe.message}", ioe)
+ "Failed to parse error body: ${ioe.message}"
+ }
+
+ Pair(response.isSuccessful, bodyOrError)
+ }
+
+ else -> {
+ val testRequest = Request(
+ model = model.modelId,
+ messages = listOf(Request.Message("user", validationPrompt))
+ )
+
+ Log.d("ApiManager", "Sending generic request to ${provider.endpoint} with model=${model.modelId}")
+
+ val response: Response = withContext(Dispatchers.IO) {
+ tempApiService.sendRequest(provider.endpoint, testRequest).execute()
+ }
+
+ Log.d("ApiManager", "Generic response success=${response.isSuccessful}")
+
+ val bodyOrError = try {
+ response.body()?.toString()
+ ?: response.errorBody()?.string()
+ ?: "Unknown error"
+ } catch (ioe: Exception) {
+ Log.e("ApiManager", "Error extracting generic response body: ${ioe.message}", ioe)
+ "Failed to parse error body: ${ioe.message}"
+ }
+
+ Pair(response.isSuccessful, bodyOrError)
+ }
+ }
+ } catch (e: Exception) {
+ val errorMessage = buildString {
+ append("Exception type=${e::class.java.simpleName}")
+ e.message?.let { append(", message=$it") }
+ if (e is HttpException) {
+ append(", httpCode=${e.code()}")
+ try {
+ append(", errorBody=${e.response()?.errorBody()?.string()} ")
+ } catch (ioe: Exception) {
+ append(", failed to read errorBody: ${ioe.message}")
+ }
+ }
+ }
+ Log.e("ApiManager", "Exception while validating model=${model.modelId}: $errorMessage", e)
+ Pair(false, errorMessage)
+ }
+ } ?: Pair(false, "Timeout validating model.")
+
+ return result
+ }
+
+ /**
+ * The primary method for making API requests for various tasks within the app.
+ */
+ fun getCompletion(
+ prompt: String,
+ modelType: ModelType,
+ callback: ApiCallback
+ ) {
+ CoroutineScope(Dispatchers.IO).launch {
+ val languageModel: LanguageModel? = when (modelType) {
+ ModelType.TRANSLATION -> apiRepository.getTranslationModel().first()
+ ModelType.EXERCISE -> apiRepository.getExerciseModel().first()
+ ModelType.VOCABULARY -> apiRepository.getVocabularyModel().first()
+ ModelType.DICTIONARY -> apiRepository.getDictionaryModel().first()
+ }
+
+ if (languageModel == null) {
+ val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context))
+ StatusMessageService.trigger(StatusAction.ShowActionableMessage(
+ text = errorMsg,
+ type = MessageDisplayType.ACTIONABLE_ERROR,
+ action = MessageAction.NAVIGATE_TO_API_KEYS
+ ))
+ callback.onFailure(errorMsg)
+ return@launch
+ }
+
+ val allProviders = apiRepository.getProviders().first()
+ val provider = allProviders.find { it.key == languageModel.providerKey }
+
+ if (provider == null) {
+ val errorMsg = "Provider '${languageModel.providerKey}' not found for the selected model."
+ StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5))
+ callback.onFailure(errorMsg)
+ return@launch
+ }
+
+ val allApiKeys = settingsRepository.getAllApiKeys().first()
+ val apiKey = allApiKeys[provider.key] ?: ""
+
+ if (apiKey.isBlank() && !provider.isCustom) {
+ val errorMsg = "API key for ${provider.displayName} is missing."
+ StatusMessageService.trigger(StatusAction.ShowActionableMessage(
+ text = errorMsg,
+ type = MessageDisplayType.ACTIONABLE_ERROR,
+ action = MessageAction.NAVIGATE_TO_API_KEYS
+ ))
+ callback.onFailure(errorMsg)
+ return@launch
+ }
+
+ val apiService = RetrofitClient.getApiClient(apiKey, provider.baseUrl, provider)
+ .create(LlmApiService::class.java)
+
+ val endpointUrl = provider.endpoint
+ Log.d("ApiManager", "Sending request to ${provider.displayName} with model ${languageModel.modelId} for task $modelType and URL ${provider.baseUrl}$endpointUrl")
+
+ when(provider.key) {
+ "gemini" -> {
+ val request = GeminiRequest(contents = listOf(GeminiContent("user", listOf(GeminiPart(prompt)))))
+ val requestJson = try { gson.toJson(request) } catch (_: Exception) { null }
+ val logId = java.util.UUID.randomUUID().toString()
+ val logTimestamp = System.currentTimeMillis()
+ val startTime = System.nanoTime()
+ apiService.sendGeminiRequest(endpointUrl, request).enqueue(object: Callback {
+ override fun onResponse(call: Call, response: Response) {
+ val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0)
+ var parseError: String? = null
+ val responseJson = try {
+ gson.toJson(response.body())
+ } catch (e: Exception) {
+ parseError = "JSON parse error: ${e.message}"
+ try { response.errorBody()?.string() } catch (_: Exception) { null }
+ }
+ val entry = ApiLogEntry(
+ id = logId,
+ timestamp = logTimestamp,
+ providerKey = provider.key,
+ endpoint = endpointUrl,
+ method = "POST",
+ model = languageModel.modelId,
+ requestJson = requestJson,
+ responseCode = response.code(),
+ responseMessage = response.message(),
+ responseJson = responseJson,
+ errorMessage = if (response.isSuccessful) null else (parseError ?: response.message()),
+ durationMs = durationMs,
+ exceptionType = null,
+ isTimeout = null,
+ parseErrorMessage = parseError,
+ url = endpointUrl
+ )
+ CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) }
+
+ if (response.isSuccessful && parseError == null) {
+ try {
+ callback.onSuccess(response.body()?.getResponse())
+ } catch (e: Exception) {
+ val err = "Response processing failed: ${e.message}"
+ CoroutineScope(Main).launch {
+ StatusMessageService.trigger(StatusAction.ShowMessage(err, MessageDisplayType.ERROR, 5))
+ }
+ callback.onFailure(err)
+ }
+ } else {
+ val errorMsg = parseError ?: "Response error: ${response.code()} ${response.message()}"
+ CoroutineScope(Main).launch {
+ StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5))
+ }
+ callback.onFailure(errorMsg)
+ }
+ }
+
+ override fun onFailure(call: Call, t: Throwable) {
+ val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0)
+ val isTimeout = t is java.net.SocketTimeoutException || t is java.io.InterruptedIOException
+ val entry = ApiLogEntry(
+ id = logId,
+ timestamp = logTimestamp,
+ providerKey = provider.key,
+ endpoint = endpointUrl,
+ method = "POST",
+ model = languageModel.modelId,
+ requestJson = requestJson,
+ responseCode = null,
+ responseMessage = null,
+ responseJson = null,
+ errorMessage = t.message,
+ durationMs = durationMs,
+ exceptionType = t::class.java.simpleName,
+ isTimeout = isTimeout,
+ parseErrorMessage = null,
+ url = endpointUrl
+ )
+ CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) }
+
+ val errorPrefix = if (isTimeout) "Request timeout" else "Request failed"
+ val errorMsg = "$errorPrefix: ${t.message}"
+ CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5)) }
+ callback.onFailure(errorMsg)
+ }
+ })
+ }
+ else -> {
+ val request = Request(languageModel.modelId, listOf(Request.Message("user", prompt)))
+ val requestJson = try { gson.toJson(request) } catch (_: Exception) { null }
+ val logId = java.util.UUID.randomUUID().toString()
+ val logTimestamp = System.currentTimeMillis()
+ val startTime = System.nanoTime()
+ apiService.sendRequest(endpointUrl, request).enqueue(object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0)
+ var parseError: String? = null
+ val responseJson = try {
+ gson.toJson(response.body())
+ } catch (e: Exception) {
+ parseError = "JSON parse error: ${e.message}"
+ try { response.errorBody()?.string() } catch (_: Exception) { null }
+ }
+ val entry = ApiLogEntry(
+ id = logId,
+ timestamp = logTimestamp,
+ providerKey = provider.key,
+ endpoint = endpointUrl,
+ method = "POST",
+ model = languageModel.modelId,
+ requestJson = requestJson,
+ responseCode = response.code(),
+ responseMessage = response.message(),
+ responseJson = responseJson,
+ errorMessage = if (response.isSuccessful) null else (parseError ?: response.message()),
+ durationMs = durationMs,
+ exceptionType = null,
+ isTimeout = null,
+ parseErrorMessage = parseError,
+ url = endpointUrl
+ )
+ CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) }
+
+ if (response.isSuccessful && parseError == null) {
+ try {
+ callback.onSuccess(response.body()?.getResponse())
+ } catch (e: Exception) {
+ val err = "Response processing failed: ${e.message}"
+ CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage(err, MessageDisplayType.ERROR, 5)) }
+ callback.onFailure(err)
+ }
+ } else {
+ val errorMsg = parseError ?: "Response error: ${response.code()} ${response.message()}"
+ CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage(
+ errorMsg, MessageDisplayType.ERROR, 5)) }
+ callback.onFailure(errorMsg)
+ }
+ }
+
+ override fun onFailure(call: Call, t: Throwable) {
+ val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0)
+ val isTimeout = t is java.net.SocketTimeoutException || t is java.io.InterruptedIOException
+ val entry = ApiLogEntry(
+ id = logId,
+ timestamp = logTimestamp,
+ providerKey = provider.key,
+ endpoint = endpointUrl,
+ method = "POST",
+ model = languageModel.modelId,
+ requestJson = requestJson,
+ responseCode = null,
+ responseMessage = null,
+ responseJson = null,
+ errorMessage = t.message,
+ durationMs = durationMs,
+ exceptionType = t::class.java.simpleName,
+ isTimeout = isTimeout,
+ parseErrorMessage = null,
+ url = endpointUrl
+ )
+ CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) }
+
+ val errorPrefix = if (isTimeout) "Request timeout" else "Request failed"
+ val errorMsg = "$errorPrefix: ${t.message}"
+ CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5)) }
+ callback.onFailure(errorMsg)
+ }
+ })
+ }
+ }
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ApiProvider.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ApiProvider.kt
new file mode 100644
index 0000000..217d5cc
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/ApiProvider.kt
@@ -0,0 +1,142 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.communication
+
+import android.content.Context
+import eu.gaudian.translator.model.LanguageModel
+import eu.gaudian.translator.utils.ProviderConfigParser
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
+import java.nio.file.NoSuchFileException
+
+@Serializable
+data class ApiProvider(
+ val key: String,
+ val displayName: String,
+ val baseUrl: String,
+ val endpoint: String,
+ val websiteUrl: String,
+ val models: List,
+ val isCustom: Boolean = false
+) {
+ @Transient
+ var hasValidKey: Boolean = false
+ val defaultModel: String? get() = models.firstOrNull()?.modelId
+
+ companion object {
+
+ /**
+ * Loads providers from the JSON configuration file with fallback to hardcoded providers
+ * @param context Android context for accessing assets
+ * @return List of ApiProvider instances
+ */
+ fun loadProviders(context: Context): List {
+ val providersFromJson = ProviderConfigParser.loadProvidersFromAssets(context)
+ return providersFromJson.ifEmpty {
+ // Fallback to hardcoded providers if JSON loading fails
+ getHardcodedProviders()
+ throw NoSuchFileException("providers.json not found in assets")
+ }
+ }
+
+ /**
+ * Returns the hardcoded default providers list
+ * @return List of ApiProvider instances
+ */
+ private fun getHardcodedProviders(): List {
+ return listOf(
+ ApiProvider(
+ key = "mistral",
+ displayName = "Mistral AI",
+ baseUrl = "https://api.mistral.ai/",
+ endpoint = "v1/chat/completions",
+ websiteUrl = "https://mistral.ai",
+ models = listOf(
+ LanguageModel("mistral-small-latest", "Mistral Small Latest", "mistral", "Fast and efficient for simple tasks."),
+ LanguageModel("open-mistral-nemo", "Mistral Nemo", "mistral", "Advanced model with native function calling."),
+ )
+ ),
+ ApiProvider(
+ key = "openai",
+ displayName = "OpenAI",
+ baseUrl = "https://api.openai.com/",
+ endpoint = "v1/chat/completions",
+ websiteUrl = "https://platform.openai.com/",
+ models = listOf(
+ LanguageModel("gpt-5-nano", "GPT 5 Nano", "openai", "Fast and cheap model sufficient for most tasks."),
+ )
+ ),
+ ApiProvider(
+ key = "anthropic",
+ displayName = "Anthropic",
+ baseUrl = "https://api.anthropic.com/",
+ endpoint = "v1/messages",
+ websiteUrl = "https://www.anthropic.com/",
+ models = listOf(
+ )
+ ),
+ ApiProvider(
+ key = "deepseek",
+ displayName = "DeepSeek",
+ baseUrl = "https://api.deepseek.com/",
+ endpoint = "chat/completions",
+ websiteUrl = "https://www.deepseek.com/",
+ models = listOf(
+ LanguageModel("deepseek-chat", "DeepSeek Chat", "deepseek", "Specialized in code and reasoning.")
+ )
+ ),
+ ApiProvider(
+ key = "gemini",
+ displayName = "Google Gemini",
+ baseUrl = "https://generativelanguage.googleapis.com/",
+ endpoint = "v1beta/models/gemini-2.5-flash:generateContent",
+ websiteUrl = "https://ai.google/",
+ models = listOf(
+ LanguageModel("gemini-2.5-flash", "Gemini 2.5 Flash", "gemini", "Current default: Fast, grounded, strong at conversational and search tasks."),
+ LanguageModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite", "gemini", "Fastest and most cost-efficient Gemini model for high throughput and low-latency needs.")
+ )
+ ),
+ ApiProvider(
+ key = "openrouter",
+ displayName = "OpenRouter",
+ baseUrl = "https://openrouter.ai/api/",
+ endpoint = "v1/chat/completions",
+ websiteUrl = "https://openrouter.ai",
+ models = listOf(
+ )
+ ),
+ ApiProvider(
+ key = "groq",
+ displayName = "Groq",
+ baseUrl = "https://api.groq.com/openai/",
+ endpoint = "v1/chat/completions",
+ websiteUrl = "https://groq.com/",
+ models = listOf(
+ LanguageModel("llama-3.1-8b-instant", "Llama 3.1 8B", "groq", "Powerful Llama 3 model running at extreme speed."),
+ )
+ ),
+ ApiProvider(
+ key = "perplexity",
+ displayName = "Perplexity",
+ baseUrl = "https://api.perplexity.ai/",
+ endpoint = "chat/completions",
+ websiteUrl = "https://www.perplexity.ai/",
+ models = listOf(
+ LanguageModel("sonar", "Sonar Small Online", "perplexity", "A faster online model for quick, up-to-date answers."), // default
+ LanguageModel("sonar-pro", "Sonar Pro", "perplexity", "Advanced search-focused model for richer context and longer answers."),
+ )
+ ),
+ ApiProvider(
+ key = "xai",
+ displayName = "xAI Grok",
+ baseUrl = "https://api.x.ai/",
+ endpoint = "v1/chat/completions",
+ websiteUrl = "https://x.ai",
+ models = listOf(
+ LanguageModel("grok-4-fast", "Grok 4 Fast", "xai", "Fast and flexible model from xAI.")
+ )
+ )
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/FileDownloadManager.kt b/app/src/main/java/eu/gaudian/translator/model/communication/FileDownloadManager.kt
new file mode 100644
index 0000000..b0c1c35
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/FileDownloadManager.kt
@@ -0,0 +1,193 @@
+package eu.gaudian.translator.model.communication
+
+import android.content.Context
+import androidx.core.content.edit
+import eu.gaudian.translator.R
+import eu.gaudian.translator.utils.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import retrofit2.create
+import java.io.File
+import java.io.FileOutputStream
+import java.security.MessageDigest
+
+/**
+ * Manages downloading files from the server, verifying checksums, and checking versions.
+ */
+class FileDownloadManager(private val context: Context) {
+
+ private val baseUrl = "http://23.88.48.47/"
+
+ private val retrofit = Retrofit.Builder()
+ .baseUrl(baseUrl)
+ .addConverterFactory(GsonConverterFactory.create())
+ .client(OkHttpClient.Builder().build())
+ .build()
+
+ private val manifestApiService = retrofit.create()
+
+ @Suppress("HardCodedStringLiteral")
+ private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
+
+ /**
+ * Fetches the manifest from the server.
+ */
+ suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
+ try {
+ val response = manifestApiService.getManifest().execute()
+ if (response.isSuccessful) {
+ response.body()
+ } else {
+ @Suppress("HardCodedStringLiteral") val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
+
+ @Suppress("HardCodedStringLiteral")
+ Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
+ throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
+ }
+ } catch (e: Exception) {
+ @Suppress("HardCodedStringLiteral")
+ Log.e("FileDownloadManager", "Error fetching manifest", e)
+ throw e
+ }
+ }
+
+ /**
+ * Downloads all assets for a file and verifies their checksums.
+ */
+ suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
+ val totalAssets = fileInfo.assets.size
+
+ for ((completedAssets, asset) in fileInfo.assets.withIndex()) {
+ val success = downloadAsset(asset) { assetProgress ->
+ // Calculate overall progress
+ val assetContribution = assetProgress / totalAssets
+ val previousAssetsProgress = completedAssets.toFloat() / totalAssets
+ onProgress(previousAssetsProgress + assetContribution)
+ }
+ if (!success) {
+ return@withContext false
+ }
+ }
+
+ // Save version after all assets are downloaded successfully
+ sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
+ @Suppress("HardCodedStringLiteral")
+ Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
+ true
+ }
+
+ /**
+ * Downloads a specific asset and verifies its checksum.
+ */
+ private suspend fun downloadAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
+ val fileUrl = "${baseUrl}${asset.filename}"
+ val localFile = File(context.filesDir, asset.filename)
+
+ try {
+ val client = OkHttpClient()
+ val request = Request.Builder().url(fileUrl).build()
+ val response = client.newCall(request).execute()
+
+ if (!response.isSuccessful) {
+ val errorMessage = context.getString(
+ R.string.text_download_failed_http,
+ response.code,
+ response.message
+ )
+ Log.e("FileDownloadManager", errorMessage)
+ throw Exception(errorMessage)
+ }
+
+ val body = response.body
+
+ val contentLength = body.contentLength()
+ if (contentLength <= 0) {
+ throw Exception("Invalid file size: $contentLength")
+ }
+
+ FileOutputStream(localFile).use { output ->
+ body.byteStream().use { input ->
+ val buffer = ByteArray(8192)
+ var bytesRead: Int
+ var totalBytesRead: Long = 0
+ @Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256")
+
+ while (input.read(buffer).also { bytesRead = it } != -1) {
+ output.write(buffer, 0, bytesRead)
+ digest.update(buffer, 0, bytesRead)
+ totalBytesRead += bytesRead
+ onProgress((totalBytesRead.toFloat() / contentLength))
+ }
+
+ output.flush()
+
+ // Compute checksum
+ val computedChecksum = digest.digest().joinToString("") {
+ @Suppress("HardCodedStringLiteral")
+ "%02X".format(it)
+ }
+
+ if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
+ @Suppress("HardCodedStringLiteral")
+ Log.d("FileDownloadManager", "Download successful for ${asset.filename}")
+ true
+ } else {
+ Log.e("FileDownloadManager",
+ context.getString(
+ R.string.text_checksum_mismatch_for_expected_got,
+ asset.filename,
+ asset.checksumSha256,
+ computedChecksum
+ ))
+ localFile.delete() // Delete corrupted file
+ throw Exception("Checksum verification failed for ${asset.filename}")
+ }
+ }
+ }
+ } catch (e: Exception) {
+ @Suppress("HardCodedStringLiteral")
+ Log.e("FileDownloadManager", "Error downloading asset", e)
+ // Clean up partial download
+ if (localFile.exists()) {
+ localFile.delete()
+ }
+ throw e
+ }
+ }
+
+ /**
+ * Checks if a newer version is available for a file.
+ */
+ fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
+ val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0"
+ return compareVersions(fileInfo.version, localVersion) > 0
+ }
+
+ /**
+ * Compares two version strings (assuming semantic versioning).
+ */
+ private fun compareVersions(version1: String, version2: String): Int {
+ val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
+ val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
+
+ for (i in 0 until maxOf(parts1.size, parts2.size)) {
+ val part1 = parts1.getOrElse(i) { 0 }
+ val part2 = parts2.getOrElse(i) { 0 }
+ if (part1 != part2) {
+ return part1.compareTo(part2)
+ }
+ }
+ return 0
+ }
+
+ /**
+ * Gets the local version of a file.
+ */
+ fun getLocalVersion(fileId: String): String {
+ return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/GeminiModels.kt b/app/src/main/java/eu/gaudian/translator/model/communication/GeminiModels.kt
new file mode 100644
index 0000000..a9c8c30
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/GeminiModels.kt
@@ -0,0 +1,17 @@
+
+package eu.gaudian.translator.model.communication
+
+
+
+data class GeminiModelsListResponse(
+ val models: List?
+)
+
+data class GeminiModelItem(
+ val name: String?,
+ val displayName: String?,
+ val description: String?,
+ val inputTokenLimit: Int?,
+ val outputTokenLimit: Int?,
+ val supportedGenerationMethods: List? // e.g., ["generateContent"]
+)
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/LlmApiService.kt b/app/src/main/java/eu/gaudian/translator/model/communication/LlmApiService.kt
new file mode 100644
index 0000000..e3d297b
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/LlmApiService.kt
@@ -0,0 +1,28 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.communication
+
+import retrofit2.Call
+import retrofit2.http.Body
+import retrofit2.http.GET
+import retrofit2.http.Headers
+import retrofit2.http.POST
+import retrofit2.http.Url
+
+interface LlmApiService {
+ @Headers("Content-Type: application/json")
+ @POST
+ fun sendRequest(@Url url: String, @Body request: Request): Call
+
+ @Headers("Content-Type: application/json")
+ @POST
+ fun sendGeminiRequest(@Url url: String, @Body request: GeminiRequest): Call
+
+ // Generic models listing (e.g., OpenAI-compatible: GET v1/models)
+ @GET
+ fun listModels(@Url url: String): Call
+
+ // Gemini models listing (GET v1beta/models)
+ @GET
+ fun listGeminiModels(@Url url: String): Call
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ManifestApiService.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ManifestApiService.kt
new file mode 100644
index 0000000..e1b4a49
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/ManifestApiService.kt
@@ -0,0 +1,17 @@
+package eu.gaudian.translator.model.communication
+
+import retrofit2.Call
+import retrofit2.http.GET
+
+/**
+ * API service for fetching the manifest and downloading files.
+ */
+interface ManifestApiService {
+
+ /**
+ * Fetches the manifest from the server.
+ */
+ @GET("manifest.json")
+ fun getManifest(): Call
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ManifestModels.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ManifestModels.kt
new file mode 100644
index 0000000..5915d29
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/ManifestModels.kt
@@ -0,0 +1,41 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.communication
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * Data class representing the manifest response from the server.
+ */
+data class ManifestResponse(
+ @SerializedName("files")
+ val files: List
+)
+
+/**
+ * Data class representing information about a downloadable file.
+ */
+data class FileInfo(
+ @SerializedName("id")
+ val id: String,
+ @SerializedName("name")
+ val name: String,
+ @SerializedName("description")
+ val description: String,
+ @SerializedName("version")
+ val version: String,
+ @SerializedName("assets")
+ val assets: List
+)
+
+/**
+ * Data class representing an asset file within a downloadable file.
+ */
+data class Asset(
+ @SerializedName("filename")
+ val filename: String,
+ @SerializedName("size_bytes")
+ val sizeBytes: Long,
+ @SerializedName("checksum_sha256")
+ val checksumSha256: String
+)
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ModelType.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ModelType.kt
new file mode 100644
index 0000000..2b2ad73
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/ModelType.kt
@@ -0,0 +1,24 @@
+package eu.gaudian.translator.model.communication
+
+import android.content.Context
+import eu.gaudian.translator.R
+
+enum class ModelType(private val stringResId: Int) {
+ TRANSLATION(R.string.label_translation),
+ EXERCISE(R.string.label_exercise),
+ VOCABULARY(R.string.label_vocabulary),
+ DICTIONARY(R.string.label_dictionary);
+
+ /**
+ * Returns the localized name of the model type.
+ *
+ * This function should be used instead of the default `name` property
+ * when displaying the model type to the user, as it provides a
+ * localized string.
+ *
+ * @param context The context to use for string localization.
+ * @return The localized name of the model type.
+ */
+ fun getLocalizedName(context: Context): String =
+ context.getString(stringResId)
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ModelsListResponse.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ModelsListResponse.kt
new file mode 100644
index 0000000..1b3fbd0
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/ModelsListResponse.kt
@@ -0,0 +1,82 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.communication
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * Schema for OpenAI-compatible list models response, extended with optional fields
+ * that some providers (OpenRouter, Mistral, etc.) may include. All extra fields are
+ * nullable so unknown providers won't break deserialization.
+ */
+data class ModelsListResponse(
+ val data: List = emptyList()
+)
+
+/**
+ * A conservative union of known fields from various providers.
+ */
+data class ModelItem(
+ val id: String,
+ // Owner / organization
+ @SerializedName("owned_by") val ownedBy: String? = null,
+ @SerializedName("organization") val organization: String? = null,
+
+ // Object type (OpenAI: "model")
+ @SerializedName("object") val objectType: String? = null,
+
+ // Human-friendly name
+ @SerializedName("display_name") val displayName: String? = null,
+ @SerializedName("title") val title: String? = null,
+ @SerializedName("name") val name: String? = null,
+
+ // Description and tags
+ @SerializedName("description") val description: String? = null,
+ @SerializedName("tags") val tags: List? = null,
+
+ // Capabilities / features
+ @SerializedName("capabilities") val capabilities: Capabilities? = null,
+
+ // Context window (various naming across providers)
+ @SerializedName("context_length") val contextLength: Int? = null,
+ @SerializedName("max_context") val maxContext: Int? = null,
+ @SerializedName("token_limit") val tokenLimit: Int? = null,
+
+ // Pricing information (if provided)
+ @SerializedName("pricing") val pricing: Pricing? = null,
+
+ // Lifecycle
+ @SerializedName("created") val created: Long? = null,
+ @SerializedName("deprecated") val deprecated: Boolean? = null,
+ @SerializedName("deprecation_date") val deprecationDate: String? = null,
+
+ // Family / type
+ @SerializedName("family") val family: String? = null,
+ @SerializedName("type") val type: String? = null,
+
+ // Aliases that may point to canonical models
+ @SerializedName("aliases") val aliases: List? = null
+)
+
+// Nested shapes that some providers use
+
+data class Capabilities(
+ val vision: Boolean? = null,
+ val audio: Boolean? = null,
+ val tools: Boolean? = null,
+ val json: Boolean? = null,
+ val reasoning: Boolean? = null
+)
+
+data class Pricing(
+ // Prices may come in different units; commonly per 1K or 1M tokens. We store raw doubles.
+ @SerializedName("prompt") val input: Double? = null,
+ @SerializedName("completion") val output: Double? = null,
+ @SerializedName("image") val image: Double? = null,
+ @SerializedName("request") val request: Double? = null,
+ // Alternative naming sometimes used
+ @SerializedName("input") val inputAlt: Double? = null,
+ @SerializedName("output") val outputAlt: Double? = null,
+ @SerializedName("unit") val unit: String? = null,
+ @SerializedName("currency") val currency: String? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/Request.kt b/app/src/main/java/eu/gaudian/translator/model/communication/Request.kt
new file mode 100644
index 0000000..73778c2
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/Request.kt
@@ -0,0 +1,54 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.communication
+
+data class Request(
+ val model: String,
+ val messages: List
+) {
+ data class Message(
+ val role: String,
+ val content: String
+ )
+}
+
+data class ApiResponse(
+ val choices: List
+) {
+ data class Choice(
+ val message: Message
+ ) {
+ data class Message(
+ val content: String
+ )
+ }
+
+ fun getResponse(): String {
+ return choices.firstOrNull()?.message?.content ?: "No response available"
+ }
+}
+
+data class GeminiRequest(
+ val contents: List
+)
+
+data class GeminiResponse(
+ val candidates: List?
+) {
+ fun getResponse(): String {
+ return candidates?.firstOrNull()?.content?.parts?.firstOrNull()?.text ?: "No response available"
+ }
+}
+
+data class GeminiCandidate(
+ val content: GeminiContent
+)
+
+data class GeminiContent(
+ val role: String?,
+ val parts: List
+)
+
+data class GeminiPart(
+ val text: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/RetrofitClient.kt b/app/src/main/java/eu/gaudian/translator/model/communication/RetrofitClient.kt
new file mode 100644
index 0000000..6527f33
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/RetrofitClient.kt
@@ -0,0 +1,183 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.communication
+
+import eu.gaudian.translator.utils.Log
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.TimeUnit
+
+object RetrofitClient {
+
+ private const val W_BASE_URL = "https://en.wiktionary.org/"
+
+ private val okHttpClient = OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ val pkg = try { eu.gaudian.translator.BuildConfig.APPLICATION_ID } catch (_: Exception) { "eu.gaudian.translator" }
+ val ver = try { eu.gaudian.translator.BuildConfig.VERSION_NAME } catch (_: Exception) { "unknown" }
+ val ua = "GaudianTranslator/$ver ($pkg; contact: jonas@gaudian.eu)"
+ val request = chain.request().newBuilder()
+ .header("User-Agent", ua)
+ .build()
+ chain.proceed(request)
+ }
+ .build()
+
+ val api: WiktionaryApiService by lazy {
+ Log.d("RetrofitClient", "Creating Wiktionary API with baseUrl=$W_BASE_URL")
+ // TODO: Ensure User-Agent complies with Wiktionary API policy; consider including app version.
+ val gson = com.google.gson.GsonBuilder()
+ .registerTypeAdapter(TextField::class.java, TextFieldDeserializer())
+ .create()
+ Retrofit.Builder()
+ .baseUrl(W_BASE_URL)
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .build()
+ .create(WiktionaryApiService::class.java)
+ }
+
+ private fun normalizeBaseUrl(input: String): String {
+ var url = input.trim()
+ if (url.isEmpty()) return "http://localhost/"
+ // If scheme missing, prepend http://
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
+ url = "http://$url"
+ }
+ // If it's just host:port or IP without trailing slash, ensure trailing slash
+ if (!url.endsWith('/')) {
+ url += "/"
+ }
+ return url
+ }
+
+ private val logCollector = InMemoryLogCollector()
+
+ /**
+ * Creates a Retrofit instance for a specific API provider.
+ * @param apiKey The API key for authorization.
+ * @param baseUrl The base URL of the API provider (e.g., "https://api.openai.com/").
+ * @param provider The ApiProvider object, used to determine which headers to add.
+ * @return A configured Retrofit instance.
+ */
+ fun getApiClient(apiKey: String, baseUrl: String, provider: ApiProvider): Retrofit {
+ Log.d("RetrofitClient", "Creating API client with baseUrl=$baseUrl")
+
+ val loggingInterceptor = UserVisibleLoggingInterceptor(logCollector)
+
+ val client = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(120, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .addInterceptor(loggingInterceptor)
+ .addInterceptor { chain ->
+ val requestBuilder = chain.request().newBuilder()
+ Log.d("RetrofitClient", "Adding headers to request: $requestBuilder")
+
+ val trimmedKey = apiKey.trim()
+ when (provider.key) {
+ "gemini" -> {
+ if (trimmedKey.isNotEmpty()) {
+ // Google Generative Language API typically accepts API key as query param `key`.
+ // We also send the x-goog-api-key header for compatibility.
+ requestBuilder.addHeader("x-goog-api-key", trimmedKey)
+
+ try {
+ val originalUrl = chain.request().url
+ val hasKeyParam = (0 until originalUrl.querySize).any { i ->
+ originalUrl.queryParameterName(i).equals("key", ignoreCase = true)
+ }
+ val newUrl = if (!hasKeyParam) {
+ originalUrl.newBuilder().addQueryParameter("key", trimmedKey).build()
+ } else {
+ originalUrl
+ }
+ requestBuilder.url(newUrl)
+ } catch (_: Exception) {
+ // Fail-safe: if URL manipulation fails, rely on header only.
+ }
+ }
+ }
+ "openrouter" -> {
+ if (trimmedKey.isNotEmpty()) {
+ // Use Bearer token for OpenRouter and add custom headers.
+ requestBuilder.addHeader("Authorization", "Bearer $trimmedKey")
+ requestBuilder.addHeader("HTTP-Referer", "https://gaudian.eu/translator")
+ requestBuilder.addHeader("X-Title", "Gaudian Translator")
+ }
+ }
+ else -> {
+ // Default to the standard Bearer token for most other APIs, but only if key is present.
+ if (trimmedKey.isNotEmpty()) {
+ requestBuilder.addHeader("Authorization", "Bearer $trimmedKey")
+ }
+ }
+ }
+ chain.proceed(requestBuilder.build())
+ }
+ .build()
+
+ val normalizedBaseUrl = normalizeBaseUrl(baseUrl)
+ Log.d("RetrofitClient", "Normalized baseUrl: $normalizedBaseUrl")
+
+ return Retrofit.Builder()
+ .baseUrl(normalizedBaseUrl)
+ .addConverterFactory(GsonConverterFactory.create())
+ .client(client)
+ .build()
+ }
+}
+
+class UserVisibleLoggingInterceptor(private val logCollector: LogCollector) : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+
+ val requestLog = StringBuilder().apply {
+ append("Request: ${request.method} ${request.url}\n")
+ request.headers.forEach { header ->
+ append("${header.first}: ${header.second}\n")
+ }
+ }.toString()
+ logCollector.addLog(requestLog)
+
+ val response = chain.proceed(request)
+
+ val responseLog = StringBuilder().apply {
+ append("Response: ${response.code} ${response.message}\n")
+ response.headers.forEach { header ->
+ append("${header.first}: ${header.second}\n")
+ }
+ }.toString()
+ logCollector.addLog(responseLog)
+
+ return response
+ }
+}
+
+interface LogCollector {
+ fun addLog(message: String)
+ fun getLogs(): List
+}
+
+class InMemoryLogCollector : LogCollector {
+ private val logs = mutableListOf()
+ private val maxLogs = 100 // Keep last 100 messages
+
+ override fun addLog(message: String) {
+ synchronized(logs) {
+ logs.add(message)
+ if (logs.size > maxLogs) {
+ logs.removeAt(0)
+ }
+ }
+ }
+
+ override fun getLogs(): List {
+ return synchronized(logs) {
+ logs.toList()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryApiService.kt b/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryApiService.kt
new file mode 100644
index 0000000..fe58b40
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryApiService.kt
@@ -0,0 +1,18 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.communication
+
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface WiktionaryApiService {
+ @GET("w/api.php")
+ suspend fun getPageHtml(
+ @Query("action") action: String = "parse",
+ @Query("page") page: String,
+ @Query("prop") prop: String = "text",
+ @Query("format") format: String = "json",
+ @Query("redirects") redirects: Int = 1,
+ @Query("formatversion") formatVersion: Int = 2
+ ): WiktionaryResponse
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryModels.kt b/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryModels.kt
new file mode 100644
index 0000000..c969348
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryModels.kt
@@ -0,0 +1,45 @@
+package eu.gaudian.translator.model.communication
+
+import com.google.gson.JsonDeserializationContext
+import com.google.gson.JsonDeserializer
+import com.google.gson.JsonElement
+import java.lang.reflect.Type
+
+/**
+ * Robust models for Wiktionary parse API that accept either an object {"*": ""}
+ * or a raw string for the `text` field.
+ */
+ data class WiktionaryResponse(
+ val parse: ParseData?
+ )
+
+ data class ParseData(
+ val title: String?,
+ val text: TextField?
+ )
+
+ /** Wrapper for the flexible `text` payload. */
+ data class TextField(
+ val html: String?
+ )
+
+ /** Gson deserializer that accepts either object-with-star or raw string. */
+ class TextFieldDeserializer : JsonDeserializer {
+ override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): TextField {
+ if (json == null || json.isJsonNull) return TextField(null)
+ return try {
+ when {
+ json.isJsonPrimitive && json.asJsonPrimitive.isString -> TextField(json.asString)
+ json.isJsonObject -> {
+ val obj = json.asJsonObject
+ val star = obj.get("*")
+ val html = if (star != null && !star.isJsonNull) star.asString else null
+ TextField(html)
+ }
+ else -> TextField(null)
+ }
+ } catch (_: Exception) {
+ TextField(null)
+ }
+ }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/db/AppDatabase.kt b/app/src/main/java/eu/gaudian/translator/model/db/AppDatabase.kt
new file mode 100644
index 0000000..a6a43bd
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/db/AppDatabase.kt
@@ -0,0 +1,90 @@
+@file:OptIn(ExperimentalTime::class)
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.db
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import androidx.sqlite.db.SupportSQLiteDatabase
+import eu.gaudian.translator.model.VocabularyItem
+import eu.gaudian.translator.model.VocabularyItemState
+import kotlin.time.ExperimentalTime
+
+@OptIn(ExperimentalTime::class)
+@Database(
+ entities = [
+ VocabularyItem::class,
+ VocabularyItemState::class,
+ VocabularyCategoryEntity::class,
+ CategoryMappingEntity::class,
+ StageMappingEntity::class,
+ DailyStatEntity::class
+ ],
+ version = 3
+)
+
+@TypeConverters(Converters::class)
+abstract class AppDatabase : RoomDatabase() {
+
+ abstract fun vocabularyItemDao(): VocabularyItemDao
+ abstract fun vocabularyStateDao(): VocabularyStateDao
+ abstract fun categoryDao(): CategoryDao
+ abstract fun mappingDao(): MappingDao
+ abstract fun dailyStatDao(): DailyStatDao
+
+ companion object {
+ @Volatile
+ private var INSTANCE: AppDatabase? = null
+
+ private val MIGRATION_1_2 = object : androidx.room.migration.Migration(1, 2) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE vocabulary_items ADD COLUMN zipfFrequency REAL")
+ }
+ }
+
+ private val MIGRATION_2_3 = object : androidx.room.migration.Migration(2, 3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // Create new table with the updated schema
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS `vocabulary_items_new` (" +
+ "`id` INTEGER NOT NULL, " +
+ "`languageFirstId` INTEGER, " +
+ "`languageSecondId` INTEGER, " +
+ "`wordFirst` TEXT NOT NULL, " +
+ "`wordSecond` TEXT NOT NULL, " +
+ "`createdAt` INTEGER, " +
+ "`features` TEXT, " +
+ "`zipfFrequencyFirst` REAL, " +
+ "`zipfFrequencySecond` REAL, " +
+ "PRIMARY KEY(`id`)"
+ + ")"
+ )
+ // Copy data from old table mapping zipfFrequency to zipfFrequencyFirst
+ db.execSQL(
+ "INSERT INTO `vocabulary_items_new` (" +
+ "id, languageFirstId, languageSecondId, wordFirst, wordSecond, createdAt, features, zipfFrequencyFirst, zipfFrequencySecond" +
+ ") SELECT id, languageFirstId, languageSecondId, wordFirst, wordSecond, createdAt, features, zipfFrequency, NULL FROM `vocabulary_items`"
+ )
+ // Drop old table
+ db.execSQL("DROP TABLE `vocabulary_items`")
+ // Rename new table to old name
+ db.execSQL("ALTER TABLE `vocabulary_items_new` RENAME TO `vocabulary_items`")
+ }
+ }
+
+ fun getDatabase(context: Context): AppDatabase {
+ return INSTANCE ?: synchronized(this) {
+ val instance = Room.databaseBuilder(
+ context.applicationContext,
+ AppDatabase::class.java,
+ "vocabulary_database"
+ ).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build()
+ INSTANCE = instance
+ instance
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/db/Converters.kt b/app/src/main/java/eu/gaudian/translator/model/db/Converters.kt
new file mode 100644
index 0000000..b1d3679
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/db/Converters.kt
@@ -0,0 +1,54 @@
+@file:OptIn(ExperimentalTime::class)
+
+package eu.gaudian.translator.model.db
+
+import androidx.room.TypeConverter
+import eu.gaudian.translator.model.Language
+import eu.gaudian.translator.model.VocabularyStage
+import kotlinx.datetime.LocalDate
+import kotlinx.serialization.json.Json
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
+
+@Suppress("HardCodedStringLiteral", "unused")
+class Converters {
+ @TypeConverter
+ fun fromTimestamp(value: Long?): Instant? {
+ return value?.let { Instant.fromEpochMilliseconds(it) }
+ }
+
+ @TypeConverter
+ fun dateToTimestamp(date: Instant?): Long? {
+ return date?.toEpochMilliseconds()
+ }
+
+ @TypeConverter
+ fun fromLocalDate(value: String?): LocalDate? {
+ return value?.let { LocalDate.parse(it) }
+ }
+
+ @TypeConverter
+ fun toLocalDate(date: LocalDate?): String? {
+ return date?.toString()
+ }
+
+ @TypeConverter
+ fun fromLanguageList(languages: List?): String? {
+ return languages?.let { Json.encodeToString(it) }
+ }
+
+ @TypeConverter
+ fun toLanguageList(json: String?): List? {
+ return json?.let { Json.decodeFromString>(it) }
+ }
+
+ @TypeConverter
+ fun fromStageList(stages: List?): String? {
+ return stages?.let { Json.encodeToString(it) }
+ }
+
+ @TypeConverter
+ fun toStageList(json: String?): List? {
+ return json?.let { Json.decodeFromString>(it) }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/db/Daos.kt b/app/src/main/java/eu/gaudian/translator/model/db/Daos.kt
new file mode 100644
index 0000000..18a10e7
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/db/Daos.kt
@@ -0,0 +1,216 @@
+@file:OptIn(ExperimentalTime::class)
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.db
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Upsert
+import eu.gaudian.translator.model.VocabularyItem
+import eu.gaudian.translator.model.VocabularyItemState
+import kotlinx.coroutines.flow.Flow
+import kotlinx.datetime.LocalDate
+import kotlin.time.ExperimentalTime
+
+@OptIn(ExperimentalTime::class)
+
+@Dao
+interface VocabularyItemDao {
+ @Query("DELETE FROM vocabulary_items")
+ suspend fun clearAllItems()
+ @Query("SELECT * FROM vocabulary_items")
+ fun getAllItemsFlow(): Flow>
+
+ @Query("SELECT * FROM vocabulary_items")
+ suspend fun getAllItems(): List
+
+ @Query("SELECT * FROM vocabulary_items WHERE id = :id")
+ suspend fun getItemById(id: Int): VocabularyItem?
+
+ @Query("SELECT EXISTS(SELECT 1 FROM vocabulary_items WHERE (LOWER(wordFirst) = LOWER(:word) AND languageFirstId IS :languageId) OR (LOWER(wordSecond) = LOWER(:word) AND languageSecondId IS :languageId))")
+ suspend fun itemExists(word: String, languageId: Int?): Boolean
+
+ @Upsert
+ suspend fun upsertItem(item: VocabularyItem)
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertAll(items: List)
+
+ @Query("DELETE FROM vocabulary_items WHERE id = :itemId")
+ suspend fun deleteItemById(itemId: Int)
+
+ @Query("DELETE FROM vocabulary_items WHERE id IN (:itemIds)")
+ suspend fun deleteItemsByIds(itemIds: List)
+
+ @Query("SELECT * FROM vocabulary_items WHERE id IN (:ids)")
+ suspend fun getItemsByIds(ids: List): List
+
+ @Query("SELECT MAX(id) FROM vocabulary_items")
+ suspend fun getMaxItemId(): Int?
+
+ @Query("""
+ SELECT i.* FROM vocabulary_items i
+ INNER JOIN category_mappings cm ON i.id = cm.vocabularyItemId
+ WHERE cm.categoryId = :categoryId
+"""
+ )
+ suspend fun getItemsByCategoryId(categoryId: Int): List
+
+ @Query("""
+ SELECT i.* FROM vocabulary_items AS i
+ LEFT JOIN stage_mappings AS sm ON i.id = sm.vocabularyItemId
+ LEFT JOIN vocabulary_states AS vs ON i.id = vs.vocabularyItemId
+ WHERE
+ -- Condition 1: Item is NEW
+ sm.stage IS NULL OR sm.stage = 'NEW' OR
+ -- Condition 2: Item has a due date that is in the past
+ (
+ -- Use last correct or incorrect answer as the base time
+ (COALESCE(vs.lastCorrectAnswer, vs.lastIncorrectAnswer) / 1000) +
+ (
+ CASE IFNULL(sm.stage, 'NEW')
+ WHEN 'STAGE_1' THEN :intervalStage1 * 86400
+ WHEN 'STAGE_2' THEN :intervalStage2 * 86400
+ WHEN 'STAGE_3' THEN :intervalStage3 * 86400
+ WHEN 'STAGE_4' THEN :intervalStage4 * 86400
+ WHEN 'STAGE_5' THEN :intervalStage5 * 86400
+ WHEN 'LEARNED' THEN :intervalLearned * 86400
+ ELSE 0
+ END
+ ) <= :now
+ )
+"""
+ )
+ fun getDueTodayItemsFlow(
+ now: Long,
+ intervalStage1: Int,
+ intervalStage2: Int,
+ intervalStage3: Int,
+ intervalStage4: Int,
+ intervalStage5: Int,
+ intervalLearned: Int
+ ): Flow>
+
+ @Query("""
+ SELECT * FROM vocabulary_items
+ WHERE id != :excludeId AND (
+ ((languageFirstId = :lang1 AND languageSecondId = :lang2) OR (languageFirstId = :lang2 AND languageSecondId = :lang1))
+ AND (wordFirst = :wordFirst OR wordSecond = :wordSecond)
+ )
+ """)
+ suspend fun getSynonyms(excludeId: Int, lang1: Int, lang2: Int, wordFirst: String, wordSecond: String): List
+}
+
+data class DailyCount(val date: LocalDate, val count: Int)
+
+@Dao
+interface VocabularyStateDao {
+
+ @Query("DELETE FROM vocabulary_states")
+ suspend fun clearAllStates()
+
+ @Query("""
+ SELECT DATE(lastCorrectAnswer / 1000, 'unixepoch') as date, COUNT(vocabularyItemId) as count
+ FROM vocabulary_states
+ WHERE lastCorrectAnswer IS NOT NULL AND date BETWEEN :startDate AND :endDate
+ GROUP BY date
+""")
+ suspend fun getCorrectAnswerCountsByDate(startDate: LocalDate, endDate: LocalDate): List
+ @Query("SELECT * FROM vocabulary_states")
+ fun getAllStatesFlow(): Flow>
+
+ @Query("SELECT * FROM vocabulary_states")
+ suspend fun getAllStates(): List
+
+ @Query("SELECT * FROM vocabulary_states WHERE vocabularyItemId = :itemId")
+ suspend fun getStateById(itemId: Int): VocabularyItemState?
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertAll(states: List)
+
+ @Upsert
+ suspend fun upsertState(state: VocabularyItemState)
+}
+
+@Dao
+interface CategoryDao {
+ @Query("DELETE FROM categories")
+ suspend fun clearAllCategories()
+ @Query("SELECT * FROM categories")
+ fun getAllCategoriesFlow(): Flow>
+
+ @Query("SELECT * FROM categories")
+ suspend fun getAllCategories(): List
+
+ @Query("SELECT * FROM categories WHERE id = :id")
+ suspend fun getCategoryById(id: Int): VocabularyCategoryEntity?
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertAll(categories: List)
+
+ @Upsert
+ suspend fun upsertCategory(category: VocabularyCategoryEntity)
+
+ @Query("DELETE FROM categories WHERE id = :categoryId")
+ suspend fun deleteCategoryById(categoryId: Int)
+}
+
+@Dao
+interface MappingDao {
+ @Query("DELETE FROM stage_mappings")
+ suspend fun clearStageMappings()
+ @Query("DELETE FROM stage_mappings WHERE vocabularyItemId NOT IN (:itemIds)")
+ suspend fun deleteStageMappingsNotIn(itemIds: List)
+ @Query("SELECT * FROM category_mappings")
+ fun getCategoryMappingsFlow(): Flow>
+
+ @Query("SELECT * FROM category_mappings")
+ suspend fun getCategoryMappings(): List
+
+
+ @Query("DELETE FROM category_mappings")
+ suspend fun clearCategoryMappings()
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertCategoryMappings(mappings: List)
+
+ @Transaction
+ suspend fun setAllCategoryMappings(mappings: List) {
+ clearCategoryMappings()
+ if (mappings.isNotEmpty()) {
+ insertCategoryMappings(mappings)
+ }
+ }
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun addCategoryMapping(mapping: CategoryMappingEntity)
+
+ @Query("DELETE FROM category_mappings WHERE vocabularyItemId = :itemId AND categoryId = :listId")
+ suspend fun removeCategoryMapping(itemId: Int, listId: Int)
+
+ @Query("SELECT * FROM stage_mappings")
+ fun getStageMappingsFlow(): Flow>
+
+ @Query("SELECT * FROM stage_mappings")
+ suspend fun getStageMappings(): List
+
+ @Upsert
+ suspend fun upsertStageMapping(mapping: StageMappingEntity)
+
+ @Upsert
+ suspend fun upsertStageMappings(mappings: List)
+}
+
+@Dao
+interface DailyStatDao {
+ @Query("DELETE FROM daily_stats")
+ suspend fun clearAll()
+ @Query("SELECT * FROM daily_stats WHERE date = :date")
+ suspend fun getStatForDate(date: LocalDate): DailyStatEntity?
+
+ @Upsert
+ suspend fun upsertStat(stat: DailyStatEntity)
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/db/DatabaseEntities.kt b/app/src/main/java/eu/gaudian/translator/model/db/DatabaseEntities.kt
new file mode 100644
index 0000000..c54d1ee
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/db/DatabaseEntities.kt
@@ -0,0 +1,51 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.db
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import eu.gaudian.translator.model.VocabularyStage
+import kotlinx.datetime.LocalDate
+
+/**
+ * An entity to store the different types of VocabularyCategory in a single table.
+ */
+@Entity(tableName = "categories")
+data class VocabularyCategoryEntity(
+ @PrimaryKey val id: Int,
+ val name: String,
+ val type: String,
+
+ val filterLanguages: String?,
+ val filterStages: String?,
+
+ val dictLangFirst: Int?,
+ val dictLangSecond: Int?
+)
+
+/**
+ * Entity to store the mapping between vocabulary items and categories.
+ */
+@Entity(tableName = "category_mappings", primaryKeys = ["vocabularyItemId", "categoryId"])
+data class CategoryMappingEntity(
+ val vocabularyItemId: Int,
+ val categoryId: Int
+)
+
+/**
+ * Entity to store the learning stage for each vocabulary item.
+ */
+@Entity(tableName = "stage_mappings")
+data class StageMappingEntity(
+ @PrimaryKey val vocabularyItemId: Int,
+ val stage: VocabularyStage
+)
+
+/**
+ * Entity to store daily learning statistics, replacing dynamic DataStore keys.
+ */
+@Entity(tableName = "daily_stats")
+data class DailyStatEntity(
+ @PrimaryKey val date: LocalDate,
+ val correctCount: Int
+)
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/AdjectiveVariationsParser.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/AdjectiveVariationsParser.kt
new file mode 100644
index 0000000..3e1ef3d
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/AdjectiveVariationsParser.kt
@@ -0,0 +1,195 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.grammar
+
+/**
+ * Parser for extracting adjective variations from raw forms data.
+ *
+ * This class is responsible for parsing FormData into structured AdjectiveVariation
+ * objects that can be easily displayed in the UI. It separates the parsing logic
+ * from the UI components for better testability and maintainability.
+ */
+object AdjectiveVariationsParser {
+
+ /**
+ * Supported languages for adjective variations.
+ */
+ private val SUPPORTED_LANGUAGES = setOf("fr", "pt", "de")
+
+ /**
+ * Gender tags that we recognize.
+ */
+ private val GENDER_TAGS = setOf("masculine", "feminine", "neuter")
+
+ /**
+ * Number tags that we recognize.
+ */
+ private val NUMBER_TAGS = setOf("singular", "plural")
+
+ /**
+ * Standard gender-number combinations to display in the table.
+ */
+ private val STANDARD_COMBINATIONS = listOf(
+ GenderNumberCombination("masculine", "singular"),
+ GenderNumberCombination("masculine", "plural"),
+ GenderNumberCombination("feminine", "singular"),
+ GenderNumberCombination("feminine", "plural")
+ )
+
+ /**
+ * Data class representing a gender-number combination.
+ */
+ data class GenderNumberCombination(
+ val gender: String,
+ val number: String
+ )
+
+ /**
+ * Parsed adjective variation ready for UI display.
+ */
+ data class ParsedAdjectiveVariation(
+ val form: String,
+ val gender: String,
+ val number: String,
+ val tags: List,
+ val ipas: List
+ )
+
+ /**
+ * Result of parsing adjective variations.
+ */
+ data class AdjectiveVariationsResult(
+ val variations: List,
+ val hasCompleteData: Boolean // Whether all 4 standard combinations are present
+ )
+
+ /**
+ * Check if the given language and forms data represents an adjective with variations.
+ *
+ * @param langCode Language code (e.g., "fr", "pt", "de")
+ * @param pos Part of speech (optional)
+ * @param forms List of FormData from the dictionary entry
+ * @return true if this appears to be an adjective with variations
+ */
+ fun isAdjectiveWithVariations(
+ langCode: String,
+ pos: String?,
+ forms: List
+ ): Boolean {
+ if (langCode !in SUPPORTED_LANGUAGES || forms.isEmpty()) {
+ return false
+ }
+
+ // Check if POS explicitly indicates adjective
+ val isAdjectiveByPos = pos?.equals("adjective", ignoreCase = true) == true
+ if (isAdjectiveByPos) {
+ return true
+ }
+
+ // Check if forms contain gender and number tags (indicating adjective variations)
+ return hasGenderNumberTags(forms)
+ }
+
+ /**
+ * Parse adjective variations from forms data.
+ *
+ * @param forms List of FormData from the dictionary entry
+ * @param lemma The base form of the adjective (used as fallback for missing combinations)
+ * @return AdjectiveVariationsResult containing parsed variations
+ */
+ fun parseVariations(
+ forms: List,
+ lemma: String? = null
+ ): AdjectiveVariationsResult {
+ val variations = mutableListOf()
+
+ // Parse each standard combination
+ STANDARD_COMBINATIONS.forEach { combination ->
+ val form = findFormForCombination(forms, combination)
+ if (form != null) {
+ variations.add(ParsedAdjectiveVariation(
+ form = form.form,
+ gender = combination.gender,
+ number = combination.number,
+ tags = form.tags,
+ ipas = form.ipas
+ ))
+ } else if (lemma != null && combination.gender == "masculine" && combination.number == "singular") {
+ // Use lemma as fallback for masculine singular
+ variations.add(ParsedAdjectiveVariation(
+ form = lemma,
+ gender = combination.gender,
+ number = combination.number,
+ tags = listOf("masculine", "singular"),
+ ipas = emptyList()
+ ))
+ }
+ }
+
+ val hasCompleteData = variations.size == STANDARD_COMBINATIONS.size
+
+ return AdjectiveVariationsResult(
+ variations = variations,
+ hasCompleteData = hasCompleteData
+ )
+ }
+
+ /**
+ * Get display labels for the supported languages.
+ */
+ fun getLanguageDisplayLabels(langCode: String): Map {
+ return when (langCode) {
+ "fr" -> mapOf(
+ "masculine" to "Masculin",
+ "feminine" to "Féminin",
+ "singular" to "Singulier",
+ "plural" to "Pluriel"
+ )
+ "pt" -> mapOf(
+ "masculine" to "Masculino",
+ "feminine" to "Feminino",
+ "singular" to "Singular",
+ "plural" to "Plural"
+ )
+ "de" -> mapOf(
+ "masculine" to "Maskulin",
+ "feminine" to "Feminin",
+ "neuter" to "Neutrum",
+ "singular" to "Singular",
+ "plural" to "Plural"
+ )
+ else -> mapOf(
+ "masculine" to "Masculine",
+ "feminine" to "Feminine",
+ "neuter" to "Neuter",
+ "singular" to "Singular",
+ "plural" to "Plural"
+ )
+ }
+ }
+
+ /**
+ * Check if forms contain gender and number tags.
+ */
+ private fun hasGenderNumberTags(forms: List): Boolean {
+ return forms.any { formData ->
+ val tags = formData.tags.map { it.lowercase() }
+ val hasGender = tags.any { it in GENDER_TAGS }
+ val hasNumber = tags.any { it in NUMBER_TAGS }
+ hasGender && hasNumber
+ }
+ }
+
+ /**
+ * Find the form that matches a specific gender-number combination.
+ */
+ private fun findFormForCombination(
+ forms: List,
+ combination: GenderNumberCombination
+ ): FormData? {
+ return forms.find { formData ->
+ val tags = formData.tags.map { it.lowercase() }
+ tags.contains(combination.gender) && tags.contains(combination.number)
+ }
+ }
+}
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/DictionaryJsonParser.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/DictionaryJsonParser.kt
new file mode 100644
index 0000000..b0ddf96
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/DictionaryJsonParser.kt
@@ -0,0 +1,439 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.grammar
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+/**
+ * Comprehensive JSON parser for local dictionary entries.
+ * * Enhanced to support detailed phonetics, homophones, form variations,
+ * and raw tags for granular linguistic data.
+ * * NOW SUPPORTS: Structured Verb Objects (e.g. German "forms": { "present": [...] })
+ */
+object DictionaryJsonParser {
+
+ private val json = Json { ignoreUnknownKeys = true }
+
+ /**
+ * Parse raw JSON string into a structured DictionaryEntryData object.
+ */
+ fun parseJson(jsonString: String): DictionaryEntryData? {
+ val root: JsonElement = try {
+ json.parseToJsonElement(jsonString)
+ } catch (_: Exception) {
+ return null
+ }
+
+ val obj = root as? JsonObject ?: return null
+
+ return DictionaryEntryData(
+ translations = parseTranslations(obj),
+ relations = parseRelations(obj),
+ phonetics = parsePhonetics(obj),
+ hyphenation = parseHyphenation(obj),
+ etymology = parseEtymology(obj),
+ senses = parseSenses(obj),
+ grammaticalFeatures = parseGrammaticalFeatures(obj),
+ grammaticalProperties = parseGrammaticalProperties(obj),
+ pronunciation = parsePronunciation(obj),
+ inflections = parseInflections(obj),
+ forms = parseForms(obj) // Updated to handle Object structures
+ )
+ }
+
+ private fun parseTranslations(obj: JsonObject): List {
+ val translationsElement = obj["translations"]
+ val array = translationsElement as? JsonArray ?: return emptyList()
+
+ return array.mapNotNull { element ->
+ val o = element.jsonObject
+ val langCode = o["lang_code"]?.jsonPrimitive?.contentOrNull
+ ?: o["language_code"]?.jsonPrimitive?.contentOrNull
+ val word = o["word"]?.jsonPrimitive?.contentOrNull
+
+ if (langCode.isNullOrBlank() || word.isNullOrBlank()) {
+ return@mapNotNull null
+ }
+
+ val sense = o["sense"]?.jsonPrimitive?.contentOrNull
+ ?: o["sense_index"]?.jsonPrimitive?.contentOrNull
+ val tags = (o["tags"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+ ?: emptyList()
+
+ TranslationData(
+ languageCode = langCode,
+ word = word,
+ sense = sense,
+ tags = tags
+ )
+ }
+ }
+
+ private fun parseRelations(obj: JsonObject): Map> {
+ val relationsElement = obj["relations"] as? JsonObject ?: return emptyMap()
+
+ val result = mutableMapOf>()
+ for ((relationType, value) in relationsElement) {
+ val array = value as? JsonArray ?: continue
+ val relations = array.mapNotNull { element ->
+ val o = element.jsonObject
+ val word = o["word"]?.jsonPrimitive?.contentOrNull
+ if (word.isNullOrBlank()) return@mapNotNull null
+
+ val senseIndex = o["sense_index"]?.jsonPrimitive?.contentOrNull
+
+ // Parse raw_tags
+ val rawTags = (o["raw_tags"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+ ?: emptyList()
+
+ RelationData(
+ word = word,
+ senseIndex = senseIndex,
+ rawTags = rawTags
+ )
+ }
+ result[relationType] = relations
+ }
+ return result
+ }
+
+ private fun parsePhonetics(obj: JsonObject): PhoneticsData? {
+ val phoneticsElement = obj["phonetics"] as? JsonObject ?: return null
+
+ // standard IPA list
+ val ipaList = (phoneticsElement["ipa"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull?.replace(Regex("[\\[\\]]"), "") }
+ ?: emptyList()
+
+ // homophones
+ val homophones = (phoneticsElement["homophones"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+ ?: emptyList()
+
+ // IPA variations (e.g. regional pronunciations)
+ val variationsArray = phoneticsElement["ipa_variations"] as? JsonArray
+ val variations = variationsArray?.mapNotNull { element ->
+ val vObj = element.jsonObject
+ val ipa = vObj["ipa"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
+ val rawTags = (vObj["raw_tags"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+ ?: emptyList()
+
+ IpaVariationData(ipa = ipa, rawTags = rawTags)
+ } ?: emptyList()
+
+ return PhoneticsData(
+ ipa = ipaList,
+ homophones = homophones,
+ variations = variations
+ )
+ }
+
+ private fun parseHyphenation(obj: JsonObject): List {
+ val hyphenationElement = obj["hyphenation"] as? JsonArray ?: return emptyList()
+ return hyphenationElement.mapNotNull { it.jsonPrimitive.contentOrNull }
+ }
+
+ private fun parseEtymology(obj: JsonObject): EtymologyData {
+ val element = obj["etymology"] ?: return EtymologyData(texts = emptyList())
+ val texts = when (element) {
+ is JsonArray -> element.mapNotNull { it.jsonPrimitive.contentOrNull }
+ is JsonObject -> {
+ val array = element["texts"] as? JsonArray
+ if (array != null) {
+ array.mapNotNull { it.jsonPrimitive.contentOrNull }
+ } else {
+ val singleText = element["text"]?.jsonPrimitive?.contentOrNull
+ if (singleText != null) listOf(singleText) else emptyList()
+ }
+ }
+ else -> emptyList()
+ }
+ return EtymologyData(texts = texts)
+ }
+
+ private fun parseSenses(obj: JsonObject): List {
+ val sensesElement = obj["senses"] as? JsonArray ?: return emptyList()
+
+ return sensesElement.mapNotNull { element ->
+ val senseObj = element.jsonObject
+
+ // Handle both "gloss" (string) and "glosses" (array) formats
+ val glosses = when {
+ senseObj["glosses"] is JsonArray -> {
+ (senseObj["glosses"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+ ?: emptyList()
+ }
+ !senseObj["gloss"]?.jsonPrimitive?.contentOrNull.isNullOrBlank() -> {
+ listOf(senseObj["gloss"]!!.jsonPrimitive.content)
+ }
+ else -> emptyList()
+ }
+
+ if (glosses.isEmpty()) return@mapNotNull null
+
+ val topics = (senseObj["topics"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+ ?: emptyList()
+
+ val examples = (senseObj["examples"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+ ?: emptyList()
+
+ val tags = (senseObj["tags"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+ ?: emptyList()
+
+ // Capture raw_tags
+ val rawTags = (senseObj["raw_tags"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+ ?: emptyList()
+
+ val categories = (senseObj["categories"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+ ?: emptyList()
+
+ SenseData(
+ glosses = glosses,
+ topics = topics,
+ examples = examples,
+ tags = tags,
+ rawTags = rawTags,
+ categories = categories
+ )
+ }
+ }
+
+ private fun parseGrammaticalFeatures(obj: JsonObject): GrammaticalFeaturesData? {
+ val featuresElement = obj["grammatical_features"] as? JsonObject ?: return null
+ val tags = (featuresElement["tags"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
+ val gender = featuresElement["gender"]?.jsonPrimitive?.contentOrNull
+ val number = featuresElement["number"]?.jsonPrimitive?.contentOrNull
+ return GrammaticalFeaturesData(tags = tags, gender = gender, number = number)
+ }
+
+ private fun parseGrammaticalProperties(obj: JsonObject): GrammaticalPropertiesData? {
+ val propsElement = obj["grammatical_properties"] as? JsonObject ?: return null
+ val otherTags = (propsElement["other_tags"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
+ return GrammaticalPropertiesData(otherTags = otherTags)
+ }
+
+ private fun parsePronunciation(obj: JsonObject): List {
+ val pronunciationElement = obj["pronunciation"] as? JsonArray ?: return emptyList()
+ return pronunciationElement.mapNotNull { element ->
+ val pronObj = element.jsonObject
+ val ipa = pronObj["ipa"]?.jsonPrimitive?.contentOrNull
+ val rhymes = pronObj["rhymes"]?.jsonPrimitive?.contentOrNull
+ if (ipa.isNullOrBlank()) return@mapNotNull null
+ PronunciationData(ipa = ipa, rhymes = rhymes)
+ }
+ }
+
+ private fun parseInflections(obj: JsonObject): List {
+ val inflectionElement = obj["inflections"] as? JsonArray ?: return emptyList()
+ return inflectionElement.mapNotNull { element ->
+ val infObj = element.jsonObject
+ val form = infObj["form"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
+ val grammaticalFeatures = (infObj["grammatical_features"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
+ InflectionData(form = form, grammaticalFeatures = grammaticalFeatures)
+ }
+ }
+
+ /**
+ * Parse forms array from JSON.
+ * Handles BOTH standard Arrays and structured Objects (common in German verbs).
+ */
+ private fun parseForms(obj: JsonObject): List {
+ val formsElement = obj["forms"] ?: return emptyList()
+
+ return when (formsElement) {
+ is JsonArray -> parseFormsArray(formsElement)
+ is JsonObject -> parseFormsObject(formsElement)
+ else -> emptyList()
+ }
+ }
+
+ /**
+ * Case 1: Standard Array of Form Objects
+ */
+ private fun parseFormsArray(array: JsonArray): List {
+ return array.mapNotNull { element ->
+ val formObj = element.jsonObject
+ val form = formObj["form"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
+
+ val tags = (formObj["tags"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+ ?: emptyList()
+
+ // Capture IPAs specific to this form (often escaped like "\\Êo\\")
+ val ipas = (formObj["ipas"] as? JsonArray)
+ ?.mapNotNull {
+ it.jsonPrimitive.contentOrNull?.replace("\\", "") // cleanup escapes
+ }
+ ?: emptyList()
+
+ FormData(
+ form = form,
+ tags = tags,
+ ipas = ipas
+ )
+ }
+ }
+
+ /**
+ * Case 2: Structured Object (German Verbs in "laufen" style)
+ * Flattens the object keys (present, past, etc.) into FormData objects tagged with that key.
+ */
+ private fun parseFormsObject(obj: JsonObject): List {
+ val result = mutableListOf()
+
+ // 1. Handle Array Tenses (e.g. "present": ["laufe", "lÀufst"...])
+ val tenseKeys = listOf(
+ "present", "past", "future",
+ "subjunctive_i", "subjunctive_ii",
+ "imperative", "conditional"
+ )
+
+ tenseKeys.forEach { key ->
+ val array = obj[key] as? JsonArray
+ array?.forEach { formElement ->
+ val form = formElement.jsonPrimitive.contentOrNull
+ if (!form.isNullOrBlank()) {
+ // We tag it with the key (e.g., "present") so the Parser can find it.
+ // The UnifiedMorphologyParser relies on list order for persons (1st->3rd),
+ // so we simply add them in order.
+ result.add(FormData(
+ form = form,
+ tags = listOf(key),
+ ipas = emptyList()
+ ))
+ }
+ }
+ }
+
+ // 2. Handle Single String Properties (e.g. "auxiliary": "sein")
+ val singleKeys = listOf("auxiliary", "infinitive", "participle_perfect", "participle_present")
+
+ singleKeys.forEach { key ->
+ val value = obj[key]?.jsonPrimitive?.contentOrNull
+ if (!value.isNullOrBlank()) {
+ result.add(FormData(
+ form = value,
+ tags = listOf(key),
+ ipas = emptyList()
+ ))
+ }
+ }
+
+ return result
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Data Classes
+// ---------------------------------------------------------------------------
+
+data class DictionaryEntryData(
+ val translations: List,
+ val relations: Map>,
+ val phonetics: PhoneticsData?,
+ val hyphenation: List,
+ val etymology: EtymologyData,
+ val senses: List,
+ val grammaticalFeatures: GrammaticalFeaturesData?,
+ val grammaticalProperties: GrammaticalPropertiesData?,
+ val pronunciation: List,
+ val inflections: List,
+ val forms: List
+) {
+ val synonyms: List
+ get() = relations["synonyms"] ?: emptyList()
+
+ val hyponyms: List
+ get() = relations["hyponyms"] ?: emptyList()
+
+ val allRelatedWords: List
+ get() = relations.values.flatten()
+
+ val allTags: List
+ get() = (grammaticalFeatures?.tags.orEmpty() + grammaticalProperties?.otherTags.orEmpty()).distinct()
+
+}
+
+data class TranslationData(
+ val languageCode: String,
+ val word: String,
+ val sense: String?,
+ val tags: List
+)
+
+data class RelationData(
+ val word: String,
+ val senseIndex: String?,
+ val rawTags: List = emptyList()
+)
+
+data class EtymologyData(
+ val texts: List
+)
+
+data class SenseData(
+ val glosses: List,
+ val topics: List = emptyList(),
+ val examples: List = emptyList(),
+ val tags: List = emptyList(),
+ val rawTags: List = emptyList(),
+ val categories: List = emptyList()
+) {
+ val gloss: String
+ get() = glosses.firstOrNull() ?: ""
+}
+
+data class GrammaticalPropertiesData(
+ val otherTags: List
+)
+
+data class GrammaticalFeaturesData(
+ val tags: List,
+ val gender: String? = null,
+ val number: String? = null
+)
+
+data class PronunciationData(
+ val ipa: String,
+ val rhymes: String?
+)
+
+data class PhoneticsData(
+ val ipa: List,
+ val homophones: List,
+ val variations: List
+)
+
+data class IpaVariationData(
+ val ipa: String,
+ val rawTags: List
+)
+
+data class InflectionData(
+ val form: String,
+ val grammaticalFeatures: List
+)
+
+data class FormData(
+ val form: String,
+ val tags: List,
+ val ipas: List
+)
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/GrammarConstants.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/GrammarConstants.kt
new file mode 100644
index 0000000..33743fc
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/GrammarConstants.kt
@@ -0,0 +1,53 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.grammar
+
+object GrammarConstants {
+ // POS Types
+ const val POS_NOUN = "noun"
+ const val POS_VERB = "verb"
+ const val POS_ADJ = "adjective"
+
+ // German Specific Types
+ const val TYPE_DE_VERB = "de_verb"
+ const val TYPE_DE_ADJ = "de_adj"
+
+ // Tense/Mood Keys
+ const val PRESENT = "present"
+ const val PAST = "past"
+ const val IMPERFECT = "imperfect"
+ const val FUTURE = "future"
+ const val SUBJUNCTIVE_I = "subjunctive_i"
+ const val SUBJUNCTIVE_II = "subjunctive_ii"
+ const val IMPERATIVE = "imperative"
+ const val CONDITIONAL = "conditional"
+ const val INFINITIVE = "infinitive"
+ const val PARTICIPLE_PAST = "participle_past"
+ const val PARTICIPLE_PRESENT = "participle_present"
+
+ // French Specific Keys
+ const val INDICATIVE_PRESENT = "indicative_present"
+ const val INDICATIVE_IMPERFECT = "indicative_imperfect"
+ const val INDICATIVE_SIMPLE_PAST = "indicative_simple_past"
+ const val INDICATIVE_FUTURE = "indicative_future"
+ const val SUBJUNCTIVE_PRESENT = "subjunctive_present"
+ const val CONDITIONAL_PRESENT = "conditional_present"
+
+ // Cases
+ const val NOMINATIVE = "nominative"
+ const val GENITIVE = "genitive"
+ const val DATIVE = "dative"
+ const val ACCUSATIVE = "accusative"
+
+ // Numbers
+ const val SINGULAR = "singular"
+ const val PLURAL = "plural"
+
+ // JSON Keys
+ const val KEY_FORMS = "forms"
+ const val KEY_INFLECTIONS = "inflections"
+ const val KEY_TYPE = "type"
+ const val KEY_DATA = "data"
+ const val KEY_TAGS = "tags"
+ const val KEY_FORM = "form"
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/GrammaticalFeature.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/GrammaticalFeature.kt
new file mode 100644
index 0000000..f78ae9e
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/GrammaticalFeature.kt
@@ -0,0 +1,31 @@
+package eu.gaudian.translator.model.grammar
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Serializable
+
+/**
+ * A container holding the grammatical features for BOTH words in a VocabularyItem.
+ * This entire object is serialized into the single `features` string.
+ */
+@Serializable
+@Parcelize
+data class VocabularyFeatures(
+ val first: GrammaticalFeature? = null,
+ val second: GrammaticalFeature? = null
+) : Parcelable
+
+/**
+ * A generic, serializable container for the grammatical information of a SINGLE word.
+ *
+ * @param category The type of word, e.g., "noun", "verb". This key corresponds
+ * to a category in the language configuration JSON.
+ * @param properties A flexible map to hold any language-specific properties,
+ * e.g., "gender" -> "masculine", "plural" -> "die MÀnner".
+ */
+@Serializable
+@Parcelize
+data class GrammaticalFeature(
+ val category: String,
+ val properties: Map
+) : Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/HowItWorks.txt b/app/src/main/java/eu/gaudian/translator/model/grammar/HowItWorks.txt
new file mode 100644
index 0000000..7fac2c8
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/HowItWorks.txt
@@ -0,0 +1,50 @@
+Unified Morphology Architecture & Data Standard1. System OverviewThe app uses a Declarative, Config-Driven Architecture to parse and display grammatical forms (morphology). Instead of writing language-specific code (e.g., GermanNounParser, FrenchVerbParser), the system uses a single generic engine driven by JSON configuration.The Engine (Kotlin): UnifiedMorphologyParser knows how to build data structures (Grids, Lists, Verb Paradigms) but not what to build.The Blueprint (JSON Config): Language files (e.g., fr.json, de.json) define the dimensions (Rows/Columns for grids, Tenses for verbs).The Data (Dictionary JSON): The raw word forms tagged with grammatical features.2. Configuration Standard (LanguageConfig)The configuration file defines the "rules" for the parser. It tells the engine which strategy to use for a given Part of Speech (POS).A. The Grid Strategy (Nouns & Adjectives)Used for any 2D data structure (Case à Number, Gender à Number).Requirement: The category object MUST contain declension_display.Example (fr.json - Adjectives):"adjective": {
+"declension_display": {
+"cases_order": ["masculine", "feminine"], // <--- Defined ROWS
+"numbers_order": ["singular", "plural"] // <--- Defined COLUMNS
+}
+}
+Note: The keys cases_order and numbers_order are generic. For adjectives, they conceptually represent "Genders" and "Numbers".B. The Verb Strategy (Conjugations)Used for verb paradigms.Requirement: The category object MUST contain conjugation_display.Example (de.json - Verbs):"verb": {
+"conjugation_display": {
+"pronouns": ["ich", "du", "er/sie/es", "wir", "ihr", "sie"], // Required for mapping ordered forms
+"tense_labels": {
+"present": "PrÀsens", // Internal Tag -> Display Label
+"past": "PrÀteritum",
+"subjunctive_i": "Konjunktiv I"
+}
+}
+}
+3. Data Input Standard (DictionaryEntryData)The dictionary entry provides the raw forms. The parser supports two input formats.Format A: The Standard List (Recommended)A flat list of objects. Each object contains the form string and a list of tags.Constraint: The tags values MUST match the keys defined in the Configuration (e.g., "masculine", "present")."forms": [
+ { "form": "beau", "tags": ["masculine", "singular"] },
+ { "form": "belle", "tags": ["feminine", "singular"] },
+ { "form": "beaux", "tags": ["masculine", "plural"] },
+ { "form": "belles", "tags": ["feminine", "plural"] }
+ ]
+ Format B: The Structured Object (Legacy/German Verbs)Some entries group forms by tense keys instead of tags.Parser Behavior: The DictionaryJsonParser detects this object and flattens it into Format A automatically.Mapping: Keys like present become tags. Arrays are preserved in order.// Input (Dictionary JSON)
+ "forms": {
+ "present": ["laufe", "lÀufst", "lÀuft", "laufen", "lauft", "laufen"],
+ "auxiliary": "sein"
+ }
+
+// Internal Representation after Flattening:
+// [
+// { "form": "laufe", "tags": ["present"] },
+// { "form": "lÀufst", "tags": ["present"] },
+// ...
+// { "form": "sein", "tags": ["auxiliary"] }
+// ]
+4. The Parsing AlgorithmThe UnifiedMorphologyParser follows this specific decision tree:Step 1: NormalizationInputs: Lemma (e.g., "beau"), POS (e.g., "adj"), Config.Normalize POS: Maps short tags to standard keys:adj, adjectif -> adjectivenom, substantive -> nounverbe -> verbAbort: If forms list is empty.Step 2: Rule SelectionCheck JSON Config: Does config.categories[pos] exist?Yes: Does it have declension_display? -> Build Grid Rule.Yes: Does it have conjugation_display? -> Build Verb Rule.Fallback: If JSON config is missing, check MorphologyRegistry.kt for a hardcoded default rule (e.g., generic fallback).Step 3: Execution (Grid Strategy)Applies to Nouns and Adjectives.Index Forms: Creates a fast lookup map: Set -> Form.Iterate: Loops through row_tags (from Config) Ã col_tags (from Config).Match: For cell [row, col], looks for a form containing BOTH tags.Example: Looking for form with tags ["masculine", "plural"].Fallback (The Lemma Rule): If a match is not found:Adjective Special Case: If the cell being built is masculine + singular, the parser defaults to using the Lemma (Headword). This handles dictionary data that omits the base form.Output: Returns UnifiedMorphology.Grid (Rendered as a table).Step 4: Execution (Verb Strategy)Applies to Verbs.Iterate: Loops through tense_labels keys (from Config).Filter: Finds all forms tagged with that tense key (e.g., "present").Truncate: Takes the first $N$ forms, where $N$ is the length of the pronouns list in Config.Auxiliary: Scans for a form tagged auxiliary.Output: Returns UnifiedMorphology.Verb (Rendered as conjugation lists).5. Validating Your JSONTo ensure your JSON renders correctly in the UI, check these constraints:ComponentConstraintWhy?Configcases_order must be non-null for Nouns/Adjectives.Without row definitions, the grid cannot be built.Configtense_labels keys must match Dictionary tags."present" in config must match "present" tag in data.DataTags must be lowercase in forms.The parser normalizes tags to lowercase, but consistency prevents bugs.DataVerb arrays must be ordered by person (1st->3rd).The parser maps forms to pronouns by index position (0=I, 1=You, etc.).Example: A Valid Adjective Setuppt.json (Config):"adjective": {
+ "declension_display": {
+ "cases_order": ["masculine", "feminine"],
+ "numbers_order": ["singular", "plural"]
+ }
+ }
+ Dictionary Entry (Data):"word": "lindo",
+ "forms": [
+ // Note: "lindo" (masc/sing) is MISSING here.
+ // The Parser will automatically insert the word "lindo" into the [masculine, singular] cell.
+ { "form": "linda", "tags": ["feminine", "singular"] },
+ { "form": "lindos", "tags": ["masculine", "plural"] },
+ { "form": "lindas", "tags": ["feminine", "plural"] }
+ ]
+ Resulting UI:| | Singular | Plural || :--- | :--- | :--- || Masculine | lindo (Lemma) | lindos || Feminine | linda | lindas |
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/LanguageConfigModels.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/LanguageConfigModels.kt
new file mode 100644
index 0000000..3811de7
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/LanguageConfigModels.kt
@@ -0,0 +1,60 @@
+@file:Suppress("PropertyName")
+
+package eu.gaudian.translator.model.grammar
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Represents the entire language configuration file (e.g., de.json).
+ *
+ * @param language_code The ISO code for the language (e.g., "de").
+ * @param articles A list of articles for the language (e.g., "der", "die"). This is optional.
+ * @param categories A map where the key is the category name (e.g., "noun")
+ * and the value contains the details for that category.
+ */
+@Serializable
+data class LanguageConfig(
+ val language_code: String,
+ val articles: List? = null,
+ val categories: Map
+)
+
+@Serializable
+data class CategoryConfig(
+ @Suppress("PropertyName") val display_key: String,
+ val fields: List,
+ val formatter: String? = null,
+ val mappings: Map>? = null,
+ /** Optional configuration for how verb conjugations should be displayed. */
+ val conjugation_display: VerbConjugationDisplayConfig? = null,
+ /** Optional configuration for how noun declension tables should be displayed. */
+ val declension_display: NounDeclensionDisplayConfig? = null
+)
+
+@Serializable
+data class VerbConjugationDisplayConfig(
+ /** Default pronoun order for this language's verb tables. */
+ val pronouns: List? = null,
+ /** Mapping from internal tense keys (e.g., "present") to display labels (e.g., "PrÀsens"). */
+ val tense_labels: Map? = null
+)
+
+@Serializable
+data class NounDeclensionDisplayConfig(
+ /** Ordered list of case keys to display as rows. */
+ val cases_order: List? = null,
+ /** Mapping from case keys to display labels (e.g., "nominative" -> "Nom."). */
+ val case_labels: Map? = null,
+ /** Ordered list of number keys to display as columns (e.g., ["singular", "plural"]). */
+ val numbers_order: List? = null,
+ /** Mapping from number keys to display labels (e.g., "singular" -> "Sing."). */
+ val number_labels: Map? = null
+)
+
+@Serializable
+data class FieldConfig(
+ val key: String,
+ val display_key: String,
+ val type: String,
+ val options: List? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/LocalDictionaryMorphologyMapper.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/LocalDictionaryMorphologyMapper.kt
new file mode 100644
index 0000000..67ef23e
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/LocalDictionaryMorphologyMapper.kt
@@ -0,0 +1,111 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.grammar
+
+import eu.gaudian.translator.utils.Log
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+
+/**
+ * Maps raw local dictionary JSON into grammar models used by the UI.
+ *
+ * This implementation uses the Strategy Pattern to delegate parsing logic
+ * based on the language code.
+ */
+object LocalDictionaryMorphologyMapper {
+
+ private val strategies = mapOf(
+ "de" to GermanMorphologyStrategy(),
+ "fr" to FrenchMorphologyStrategy()
+ )
+
+ private val genericStrategy = GenericMorphologyStrategy()
+
+ /**
+ * Parse morphology information (conjugation/declension/inflections) from a
+ * local dictionary entry.
+ *
+ * @param langCode ISO language code, e.g. "de".
+ * @param pos Part of speech as stored with the entry, if available.
+ * @param lemma The base form / headword, used as infinitive for verbs.
+ * @param data Root JSON object of the dictionary entry.
+ * @param languageConfig Optional language configuration, used for display
+ * metadata like pronoun order and tense/case labels when available.
+ */
+ fun parseMorphology(
+ langCode: String,
+ pos: String?,
+ lemma: String,
+ data: JsonObject,
+ languageConfig: LanguageConfig? = null
+ ): WordMorphology? {
+ Log.d("LocalDictionaryMorphologyMapper", "Parsing morphology for $langCode, word: $lemma")
+
+ // 1. Try specific language strategy
+ val specificStrategy = strategies[langCode]
+ if (specificStrategy != null) {
+ val result = specificStrategy.parse(lemma, pos, data, languageConfig)
+ if (result != null) return result
+ }
+
+ // 2. Fallback to generic strategy (valid for any language with 'inflections' array)
+ return genericStrategy.parse(lemma, pos, data, languageConfig)
+ }
+
+ /**
+ * Overload that uses parsed [DictionaryEntryData] instead of raw JSON.
+ */
+ fun parseMorphology(
+ langCode: String,
+ pos: String?,
+ lemma: String,
+ entryData: DictionaryEntryData,
+ languageConfig: LanguageConfig? = null
+ ): WordMorphology? {
+ Log.d("LocalDictionaryMorphologyMapper", "Parsing morphology (structured) for $langCode, word: $lemma")
+
+ // 1. Handle adjective forms for applicable languages (check this first)
+ Log.d("LocalDictionaryMorphologyMapper", "Checking adjective: langCode=$langCode, pos=$pos, formsCount=${entryData.forms.size}")
+
+ if (AdjectiveVariationsParser.isAdjectiveWithVariations(langCode, pos, entryData.forms)) {
+ Log.d("LocalDictionaryMorphologyMapper", "Creating adjective morphology for $lemma")
+ val comparison = AdjectiveComparison() // Empty comparison, forms will be accessed directly from entryData
+ return WordMorphology.Adjective(comparison)
+ }
+
+ // 2. Try specific language strategy if 'forms' data is available
+ val specificStrategy = strategies[langCode]
+ // Check isNotEmpty() because forms is now a List, not nullable JsonElement
+ if (specificStrategy != null && entryData.forms.isNotEmpty()) {
+
+ // Reconstruct the JsonArray for forms because the strategy expects raw JSON structures
+ val formsArray = JsonArray(entryData.forms.map { formData ->
+ JsonObject(
+ mapOf(
+ "form" to JsonPrimitive(formData.form),
+ "tags" to JsonArray(formData.tags.map { JsonPrimitive(it) }),
+ "ipas" to JsonArray(formData.ipas.map { JsonPrimitive(it) })
+ )
+ )
+ })
+
+ // Construct a minimal JsonObject for the strategy which expects "forms" key
+ val syntheticData = JsonObject(mapOf("forms" to formsArray))
+
+ val result = specificStrategy.parse(lemma, pos, syntheticData, languageConfig)
+ if (result != null) return result
+ }
+
+ // 3. Fallback to generic strategy using parsed inflections
+ if (entryData.inflections.isNotEmpty()) {
+ val inflections = entryData.inflections.map {
+ Inflection(it.form, it.grammaticalFeatures)
+ }
+ return WordMorphology.GenericInflections(inflections)
+ }
+
+ return null
+ }
+}
+
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyModels.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyModels.kt
new file mode 100644
index 0000000..5a1d485
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyModels.kt
@@ -0,0 +1,65 @@
+package eu.gaudian.translator.model.grammar
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Generic morphology models derived from local dictionary data.
+ *
+ * These models are UI-agnostic and can be rendered by different screens
+ * (dictionary results, vocabulary cards, etc.).
+ */
+
+/**
+ * Conjugation data for a single verb.
+ *
+ * @param infinitive The base form of the verb.
+ * @param conjugations Map from an internal or display tense key to the list of
+ * forms for each person (same order as [pronouns]).
+ * @param pronouns List of pronouns corresponding to the persons in [conjugations] values.
+ * @param auxiliary Optional auxiliary verb used by this verb (e.g., "haben/sein").
+ */
+@Serializable
+data class VerbConjugation(
+ val infinitive: String,
+ val conjugations: Map>, // tense -> list of forms
+ val pronouns: List = emptyList(),
+ val auxiliary: String? = null
+)
+
+/**
+ * Declension table for noun-like words.
+ *
+ * @param cases Ordered list of case keys (e.g., "nominative", "genitive", ...).
+ * @param numbers Ordered list of number keys (e.g., "singular", "plural").
+ * @param forms Mapping from (case, number) pair to the inflected form.
+ */
+@Serializable
+data class NounDeclensionTable(
+ val cases: List,
+ val numbers: List,
+ val forms: Map, String>
+)
+
+/**
+ * Comparison degrees for adjectives.
+ */
+@Serializable
+data class AdjectiveComparison(
+ val positive: String? = null,
+ val comparative: String? = null,
+ val superlative: String? = null
+)
+
+/**
+ * High-level morphology classification for a word.
+ */
+sealed class WordMorphology {
+ data class Verb(val conjugations: List) : WordMorphology()
+ data class Noun(val declension: NounDeclensionTable) : WordMorphology()
+ data class Adjective(val comparison: AdjectiveComparison) : WordMorphology()
+
+ /**
+ * Generic inflections as provided by some dictionaries (e.g. Portuguese).
+ */
+ data class GenericInflections(val inflections: List) : WordMorphology()
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRegistry.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRegistry.kt
new file mode 100644
index 0000000..49be4c2
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRegistry.kt
@@ -0,0 +1,90 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.grammar
+
+import eu.gaudian.translator.model.grammar.GrammarConstants.POS_ADJ
+import eu.gaudian.translator.model.grammar.GrammarConstants.POS_NOUN
+import eu.gaudian.translator.model.grammar.GrammarConstants.POS_VERB
+import eu.gaudian.translator.model.grammar.MorphologyRule.GenericRule
+import eu.gaudian.translator.model.grammar.MorphologyRule.GridRule
+import eu.gaudian.translator.model.grammar.MorphologyRule.VerbRule
+
+object MorphologyRegistry {
+
+ /**
+ * Retrieves the specific rule for a Language and POS.
+ * Defaults to GenericRule if no specific rule is defined.
+ */
+ fun getRule(langCode: String, pos: String?): MorphologyRule {
+ val normalizedPos = pos?.lowercase()?.trim() ?: return GenericRule
+
+ // 1. Look for specific match (e.g., "de" + "noun")
+ return rules["$langCode|$normalizedPos"]
+ // 2. Look for language default (if we had one) or fallback
+ ?: GenericRule
+ }
+
+ // =========================================================================
+ // CONFIGURATION AREA - DEFINE YOUR RULES HERE
+ // =========================================================================
+
+ private val rules = mapOf(
+ // --- GERMAN (DE) -----------------------------------------------------
+ "de|$POS_NOUN" to GridRule(
+ title = "Deklination",
+ rowTags = listOf("nominative", "genitive", "dative", "accusative"),
+ colTags = listOf("singular", "plural")
+ ),
+ "de|$POS_ADJ" to GridRule(
+ title = "Adjektivendungen",
+ rowTags = listOf("masculine", "feminine", "neuter"),
+ colTags = listOf("singular", "plural"),
+ fallbackStrategy = { row, col, lemma ->
+ // Fallback: Use lemma for Masc+Sing if missing (common in some dicts)
+ if (row == "masculine" && col == "singular") lemma else null
+ }
+ ),
+ "de|$POS_VERB" to VerbRule(
+ pronouns = listOf("ich", "du", "er/sie/es", "wir", "ihr", "sie"),
+ tenses = mapOf(
+ "present" to "PrÀsens",
+ "past" to "PrÀteritum",
+ "subjunctive_i" to "Konjunktiv I",
+ "subjunctive_ii" to "Konjunktiv II",
+ "imperative" to "Imperativ"
+ )
+ ),
+
+ // --- FRENCH (FR) -----------------------------------------------------
+ "fr|$POS_NOUN" to GridRule(
+ title = "Nombre",
+ rowTags = listOf("singular", "plural"),
+ colTags = listOf("") // Single column (just list rows)
+ ),
+ "fr|$POS_ADJ" to GridRule(
+ title = "Accord",
+ rowTags = listOf("masculine", "feminine"),
+ colTags = listOf("singular", "plural")
+ ),
+ "fr|$POS_VERB" to VerbRule(
+ pronouns = listOf("je", "tu", "il/elle", "nous", "vous", "ils/elles"),
+ tenses = mapOf(
+ "indicative_present" to "Présent",
+ "indicative_imperfect" to "Imparfait",
+ "indicative_future" to "Futur",
+ "subjunctive_present" to "Subjonctif",
+ "conditional_present" to "Conditionnel"
+ )
+ ),
+
+ // --- PORTUGUESE (PT) Example -----------------------------------------
+ "pt|$POS_VERB" to VerbRule(
+ pronouns = listOf("eu", "tu", "ele/ela", "nós", "vós", "eles/elas"),
+ tenses = mapOf(
+ "present" to "Presente",
+ "imperfect" to "Imperfeito",
+ "perfect" to "Perfeito"
+ )
+ )
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRules.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRules.kt
new file mode 100644
index 0000000..910221f
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRules.kt
@@ -0,0 +1,36 @@
+package eu.gaudian.translator.model.grammar
+
+/**
+ * Base class for all morphology rules.
+ */
+sealed class MorphologyRule {
+
+ /**
+ * RULE: Extract a 2D Grid (e.g., German Nouns).
+ * @param rowTags The tags defining the rows (e.g., Nominative, Genitive).
+ * @param colTags The tags defining the columns (e.g., Singular, Plural).
+ * @param title Display title for the table.
+ * @param fallbackStrategy Optional: If cell is missing, how to generate it (e.g., use Lemma).
+ */
+ data class GridRule(
+ val title: String,
+ val rowTags: List,
+ val colTags: List,
+ val fallbackStrategy: ((row: String, col: String, lemma: String) -> String?)? = null
+ ) : MorphologyRule()
+
+ /**
+ * RULE: Extract a Verb Paradigm.
+ * @param tenses Map of "Internal Tag" -> "Display Label".
+ * @param pronouns List of pronouns to display.
+ */
+ data class VerbRule(
+ val tenses: Map,
+ val pronouns: List
+ ) : MorphologyRule()
+
+ /**
+ * RULE: Just show everything generic.
+ */
+ object GenericRule : MorphologyRule()
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyStrategies.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyStrategies.kt
new file mode 100644
index 0000000..fb43b08
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyStrategies.kt
@@ -0,0 +1,269 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.grammar
+
+import eu.gaudian.translator.model.grammar.GrammarConstants.CONDITIONAL_PRESENT
+import eu.gaudian.translator.model.grammar.GrammarConstants.IMPERATIVE
+import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_FUTURE
+import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_IMPERFECT
+import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_PRESENT
+import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_SIMPLE_PAST
+import eu.gaudian.translator.model.grammar.GrammarConstants.INFINITIVE
+import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_DATA
+import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_FORMS
+import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_INFLECTIONS
+import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_TYPE
+import eu.gaudian.translator.model.grammar.GrammarConstants.PARTICIPLE_PAST
+import eu.gaudian.translator.model.grammar.GrammarConstants.PARTICIPLE_PRESENT
+import eu.gaudian.translator.model.grammar.GrammarConstants.PAST
+import eu.gaudian.translator.model.grammar.GrammarConstants.POS_NOUN
+import eu.gaudian.translator.model.grammar.GrammarConstants.POS_VERB
+import eu.gaudian.translator.model.grammar.GrammarConstants.PRESENT
+import eu.gaudian.translator.model.grammar.GrammarConstants.SUBJUNCTIVE_I
+import eu.gaudian.translator.model.grammar.GrammarConstants.SUBJUNCTIVE_II
+import eu.gaudian.translator.model.grammar.GrammarConstants.SUBJUNCTIVE_PRESENT
+import eu.gaudian.translator.model.grammar.GrammarConstants.TYPE_DE_ADJ
+import eu.gaudian.translator.model.grammar.GrammarConstants.TYPE_DE_VERB
+import eu.gaudian.translator.model.grammar.WordMorphology.Adjective
+import eu.gaudian.translator.model.grammar.WordMorphology.GenericInflections
+import eu.gaudian.translator.model.grammar.WordMorphology.Noun
+import eu.gaudian.translator.model.grammar.WordMorphology.Verb
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+/**
+ * Interface for language-specific morphology parsing.
+ */
+interface MorphologyStrategy {
+ fun parse(
+ lemma: String,
+ pos: String?,
+ rootData: JsonObject,
+ config: LanguageConfig?
+ ): WordMorphology?
+}
+
+/**
+ * Strategy for German (de).
+ */
+class GermanMorphologyStrategy : MorphologyStrategy {
+ override fun parse(
+ lemma: String,
+ pos: String?,
+ rootData: JsonObject,
+ config: LanguageConfig?
+ ): WordMorphology? {
+ val formsElement = rootData[KEY_FORMS] ?: return null
+ val categories = config?.categories
+ val normalizedPos = pos?.lowercase()?.trim()
+
+ return when (formsElement) {
+ is JsonObject -> {
+ val type = formsElement[KEY_TYPE]?.jsonPrimitive?.contentOrNull
+ val innerData = formsElement[KEY_DATA]?.jsonObject
+
+ when (type) {
+ TYPE_DE_VERB if innerData != null -> {
+ val verbConfig = categories?.get(POS_VERB)
+ val conjugation = mapJsonToVerbConjugation(lemma, innerData, verbConfig)
+ Verb(listOf(conjugation))
+ }
+ TYPE_DE_ADJ if innerData != null -> {
+ val comparison = parseAdjectiveComparison(innerData)
+ Adjective(comparison)
+ }
+ else -> {
+ null
+ }
+ }
+ }
+ is JsonArray -> {
+ if (formsElement.isEmpty()) return null
+
+ if (SharedMorphologyUtils.isNounLikeStructure(formsElement, normalizedPos)) {
+ val nounConfig = categories?.get(POS_NOUN)
+ val declension = SharedMorphologyUtils.parseNounDeclension(formsElement, nounConfig)
+ return Noun(declension)
+ }
+
+ // Handle array of verb blocks
+ val verbBlocks = formsElement.mapNotNull { element ->
+ val obj = element.jsonObject
+ val type = obj[KEY_TYPE]?.jsonPrimitive?.contentOrNull
+ val innerData = obj[KEY_DATA]?.jsonObject
+ if (innerData != null && (type == TYPE_DE_VERB || obj.containsKey(KEY_DATA))) innerData else null
+ }
+
+ if (verbBlocks.isNotEmpty()) {
+ val verbConfig = categories?.get(POS_VERB)
+ val conjugations = verbBlocks.map { innerData ->
+ mapJsonToVerbConjugation(lemma, innerData, verbConfig)
+ }
+ return Verb(conjugations)
+ }
+ null
+ }
+ else -> null
+ }
+ }
+
+ private fun mapJsonToVerbConjugation(
+ infinitive: String,
+ data: JsonObject,
+ categoryConfig: CategoryConfig?
+ ): VerbConjugation {
+ fun getForms(key: String): List =
+ data[key]?.jsonArray?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList()
+
+ val conjugationMap = mutableMapOf>()
+ val tenseLabels = categoryConfig?.conjugation_display?.tense_labels
+
+ fun addTense(key: String) {
+ val forms = getForms(key)
+ if (forms.isNotEmpty()) {
+ val label = tenseLabels?.get(key) ?: key.replaceFirstChar { it.titlecase() }
+ conjugationMap[label] = forms
+ }
+ }
+
+ addTense(PRESENT)
+ addTense(PAST)
+ addTense(SUBJUNCTIVE_I)
+ addTense(SUBJUNCTIVE_II)
+
+ val pronouns = categoryConfig?.conjugation_display?.pronouns
+ ?: listOf("ich", "du", "er/sie/es", "wir", "ihr", "sie")
+
+ val aux = data["aux"]?.jsonPrimitive?.contentOrNull
+
+ return VerbConjugation(infinitive, conjugationMap, pronouns, aux)
+ }
+
+ private fun parseAdjectiveComparison(data: JsonObject): AdjectiveComparison {
+ return AdjectiveComparison(
+ positive = data["positive_masculine"]?.jsonPrimitive?.contentOrNull
+ ?: data["positive_feminine"]?.jsonPrimitive?.contentOrNull,
+ comparative = data["comparative"]?.jsonPrimitive?.contentOrNull,
+ superlative = data["superlative"]?.jsonPrimitive?.contentOrNull
+ )
+ }
+}
+
+/**
+ * Strategy for French (fr).
+ */
+class FrenchMorphologyStrategy : MorphologyStrategy {
+ override fun parse(
+ lemma: String,
+ pos: String?,
+ rootData: JsonObject,
+ config: LanguageConfig?
+ ): WordMorphology? {
+ val formsElement = rootData[KEY_FORMS] ?: return null
+ val categories = config?.categories
+ val normalizedPos = pos?.lowercase()?.trim()
+
+ return when (formsElement) {
+ is JsonObject -> {
+ // Heuristic: Check for French verb keys
+ val hasVerbForms = formsElement.keys.any { key ->
+ key.startsWith("indicative_") || key.startsWith("subjunctive_") ||
+ key.startsWith(IMPERATIVE) || key.startsWith("conditional_") ||
+ key == INFINITIVE || key == PARTICIPLE_PRESENT || key == PARTICIPLE_PAST
+ }
+
+ if (hasVerbForms) {
+ val verbConfig = categories?.get(POS_VERB)
+ val conjugation = mapFrenchJsonToVerbConjugation(lemma, formsElement, verbConfig)
+ Verb(listOf(conjugation))
+ } else null
+ }
+ is JsonArray -> {
+ if (SharedMorphologyUtils.isNounLikeStructure(formsElement, normalizedPos)) {
+ val nounConfig = categories?.get(POS_NOUN)
+ val declension = SharedMorphologyUtils.parseNounDeclension(formsElement, nounConfig)
+ return Noun(declension)
+ }
+ null
+ }
+ else -> null
+ }
+ }
+
+ private fun mapFrenchJsonToVerbConjugation(
+ infinitive: String,
+ data: JsonObject,
+ categoryConfig: CategoryConfig?
+ ): VerbConjugation {
+ fun getForms(key: String): List =
+ data[key]?.jsonArray?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList()
+
+ val conjugationMap = mutableMapOf>()
+ val tenseLabels = categoryConfig?.conjugation_display?.tense_labels
+
+ fun addFrenchTense(frenchKey: String, displayKey: String) {
+ val forms = getForms(frenchKey)
+ if (forms.isNotEmpty()) {
+ val label = tenseLabels?.get(frenchKey) ?: displayKey
+ conjugationMap[label] = forms
+ }
+ }
+
+ addFrenchTense(INDICATIVE_PRESENT, "Indicative Present")
+ addFrenchTense(INDICATIVE_IMPERFECT, "Indicative Imperfect")
+ addFrenchTense(INDICATIVE_SIMPLE_PAST, "Indicative Simple Past")
+ addFrenchTense(INDICATIVE_FUTURE, "Indicative Future")
+ addFrenchTense(SUBJUNCTIVE_PRESENT, "Subjunctive Present")
+ addFrenchTense(CONDITIONAL_PRESENT, "Conditional Present")
+ addFrenchTense(IMPERATIVE, "Imperative")
+
+ val pronouns = categoryConfig?.conjugation_display?.pronouns
+ ?: listOf("je", "tu", "il/elle", "nous", "vous", "ils/elles")
+
+ // French data sometimes puts aux in an array
+ val aux = data["auxiliary"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.contentOrNull
+ ?: data["aux"]?.jsonPrimitive?.contentOrNull
+
+ return VerbConjugation(infinitive, conjugationMap, pronouns, aux)
+ }
+}
+
+/**
+ * Fallback Strategy (Portuguese, Generic, etc.).
+ * Handles flat list of inflections.
+ */
+class GenericMorphologyStrategy : MorphologyStrategy {
+ override fun parse(
+ lemma: String,
+ pos: String?,
+ rootData: JsonObject,
+ config: LanguageConfig?
+ ): WordMorphology? {
+ val inflectionsElement = rootData[KEY_INFLECTIONS] as? JsonArray
+
+ if (!inflectionsElement.isNullOrEmpty()) {
+ val inflections = inflectionsElement.mapNotNull { element ->
+ val obj = element.jsonObject
+ val form = obj["form"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
+
+ val features = (obj["grammatical_features"] as? JsonArray)
+ ?.mapNotNull { it.jsonPrimitive.contentOrNull }
+
+ // FIX: Use 'tags' instead of 'grammatical_features'
+ // FIX: Handle nullability with '?: emptyList()'
+ Inflection(form = form, tags = features ?: emptyList())
+ }
+
+ // Note: Ensure GenericInflections accepts List if you haven't updated it yet
+ if (inflections.isNotEmpty()) {
+ return GenericInflections(inflections)
+ }
+ }
+ return null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/SharedMorphologyUtils.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/SharedMorphologyUtils.kt
new file mode 100644
index 0000000..2cb9fc4
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/SharedMorphologyUtils.kt
@@ -0,0 +1,80 @@
+package eu.gaudian.translator.model.grammar
+
+import eu.gaudian.translator.model.grammar.GrammarConstants.ACCUSATIVE
+import eu.gaudian.translator.model.grammar.GrammarConstants.DATIVE
+import eu.gaudian.translator.model.grammar.GrammarConstants.GENITIVE
+import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_FORM
+import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_TAGS
+import eu.gaudian.translator.model.grammar.GrammarConstants.NOMINATIVE
+import eu.gaudian.translator.model.grammar.GrammarConstants.PLURAL
+import eu.gaudian.translator.model.grammar.GrammarConstants.SINGULAR
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+object SharedMorphologyUtils {
+
+ /**
+ * Parses a standard noun declension table from a list of forms.
+ * Used by both German and French strategies.
+ */
+ fun parseNounDeclension(
+ forms: JsonArray,
+ categoryConfig: CategoryConfig?
+ ): NounDeclensionTable {
+ val nestedMap = mutableMapOf>()
+
+ forms.forEach { item ->
+ val obj = item.jsonObject
+ val form = obj[KEY_FORM]?.jsonPrimitive?.contentOrNull ?: return@forEach
+ val tags = obj[KEY_TAGS]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
+
+ val case = tags.firstOrNull { it in listOf(NOMINATIVE, GENITIVE, DATIVE, ACCUSATIVE) }
+ val number = tags.firstOrNull { it in listOf(SINGULAR, PLURAL) }
+
+ if (case != null && number != null) {
+ nestedMap.getOrPut(case) { mutableMapOf() }[number] = form
+ }
+ }
+
+ val displayConfig = categoryConfig?.declension_display
+
+ val cases = displayConfig?.cases_order ?: listOf(NOMINATIVE, GENITIVE, DATIVE, ACCUSATIVE)
+ val numbers = displayConfig?.numbers_order ?: listOf(SINGULAR, PLURAL)
+
+ val flatMap = mutableMapOf, String>()
+ cases.forEach { caseKey ->
+ numbers.forEach { numberKey ->
+ val form = nestedMap[caseKey]?.get(numberKey)
+ if (form != null) {
+ flatMap[caseKey to numberKey] = form
+ }
+ }
+ }
+
+ return NounDeclensionTable(
+ cases = cases,
+ numbers = numbers,
+ forms = flatMap
+ )
+ }
+
+ /**
+ * Checks if a JSON array contains noun-like structures (Case + Number).
+ */
+ fun isNounLikeStructure(forms: JsonArray, normalizedPos: String?): Boolean {
+ if (forms.isEmpty()) return false
+
+ val hasCaseAndNumber = forms.any { formElement ->
+ val formObj = formElement.jsonObject
+ val tags = formObj[KEY_TAGS]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
+ val hasCase = tags.any { it in listOf(NOMINATIVE, GENITIVE, DATIVE, ACCUSATIVE) }
+ val hasNumber = tags.any { it in listOf(SINGULAR, PLURAL) }
+ hasCase && hasNumber
+ }
+
+ return hasCaseAndNumber && (normalizedPos == null || normalizedPos.startsWith(GrammarConstants.POS_NOUN))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedGrammarModels.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedGrammarModels.kt
new file mode 100644
index 0000000..e69de29
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphology.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphology.kt
new file mode 100644
index 0000000..ab6ac1f
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphology.kt
@@ -0,0 +1,43 @@
+package eu.gaudian.translator.model.grammar
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+sealed class UnifiedMorphology {
+
+ /**
+ * 2D Grid for Nouns (Case x Number) and Adjectives (Gender x Number).
+ */
+ data class Grid(
+ val title: String,
+ val rowLabels: List,
+ val colLabels: List,
+ val cells: Map
+ ) : UnifiedMorphology()
+
+ /**
+ * Verb Paradigm (Flattened - no intermediate 'paradigm' object).
+ */
+ data class Verb(
+ val infinitive: String,
+ val auxiliary: String?,
+ val tenses: Map>,
+ val pronouns: List
+ ) : UnifiedMorphology()
+
+ /**
+ * Generic List. Now holds 'Inflection' objects to keep tags visible.
+ */
+ data class ListForms(
+ val forms: List
+ ) : UnifiedMorphology()
+}
+
+/**
+ * Moved here to be shared. Represents a single form with its tags.
+ */
+@Serializable
+data class Inflection(
+ val form: String,
+ val tags: List
+)
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphologyParser.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphologyParser.kt
new file mode 100644
index 0000000..c3372e2
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphologyParser.kt
@@ -0,0 +1,210 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.grammar
+
+import android.content.Context
+import eu.gaudian.translator.R
+import eu.gaudian.translator.model.grammar.GrammarConstants.POS_ADJ
+import eu.gaudian.translator.model.grammar.GrammarConstants.POS_NOUN
+import eu.gaudian.translator.model.grammar.GrammarConstants.POS_VERB
+import eu.gaudian.translator.utils.Log
+
+object UnifiedMorphologyParser {
+
+ private const val TAG = "UnifiedMorphologyParser"
+
+ fun parse(
+ entry: DictionaryEntryData,
+ lemma: String,
+ pos: String?,
+ langCode: String,
+ config: LanguageConfig,
+ context: Context
+ ): UnifiedMorphology? {
+ // 1. Log Input
+ Log.d(TAG, "Request: lemma='$lemma', lang='$langCode', pos='$pos'")
+
+ if (entry.forms.isEmpty()) {
+ Log.d(TAG, "Aborting: No forms data available for '$lemma'")
+ return null
+ }
+
+ // FIX 1: Normalize POS tags (map "adj" -> "adjective", etc.)
+ val normalizedPos = normalizePos(pos)
+
+ if (normalizedPos == null) {
+ Log.w(TAG, "Aborting: POS could not be normalized for '$lemma' (raw: $pos)")
+ return null
+ }
+
+ // 2. Determine Rule Source
+ // First, check JSON Config
+ var rule = buildRuleFromConfig(normalizedPos, config, context)
+ val ruleSource = if (rule != null) "JSON Config" else "Registry Fallback"
+
+ // If not in JSON, check Registry
+ if (rule == null) {
+ rule = MorphologyRegistry.getRule(langCode, normalizedPos)
+ }
+
+ Log.d(TAG, "Strategy: Using $ruleSource for $langCode|$normalizedPos")
+
+ // 3. Execute Extraction
+ val result = try {
+ when (rule) {
+ is MorphologyRule.GridRule -> {
+ Log.d(TAG, "Extracting Grid: ${rule.title}")
+ parseGrid(entry.forms, rule, lemma)
+ }
+ is MorphologyRule.VerbRule -> {
+ Log.d(TAG, "Extracting Verb Paradigm")
+ parseVerb(entry.forms, rule, lemma)
+ }
+ is MorphologyRule.GenericRule -> {
+ Log.d(TAG, "Extracting Generic List")
+ parseGeneric(entry.forms)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error extracting morphology for '$lemma': ${e.message}")
+ return null
+ }
+
+ // 4. Log Outcome
+ if (result != null) {
+ Log.d(TAG, "Success: Generated ${result::class.java.simpleName} for '$lemma'")
+ } else {
+ Log.w(TAG, "Result was null for '$lemma'")
+ }
+
+ return result
+ }
+
+ /**
+ * Maps short/raw POS tags to the standard GrammarConstants keys.
+ */
+ private fun normalizePos(pos: String?): String? {
+ val raw = pos?.lowercase()?.trim() ?: return null
+ return when (raw) {
+ "adj", "adjective", "adjectif" -> POS_ADJ // "adjective"
+ "noun", "substantive", "nom" -> POS_NOUN // "noun"
+ "verb", "verbe" -> POS_VERB // "verb"
+ else -> raw // Return as-is if no map found (e.g. "adverb")
+ }
+ }
+
+ private fun buildRuleFromConfig(pos: String, config: LanguageConfig, context: Context): MorphologyRule? {
+ val category = config.categories[pos] ?: return null
+ return when (pos) {
+ // MERGED: Nouns AND Adjectives both use Grid Logic
+ POS_NOUN, POS_ADJ -> {
+ val display = category.declension_display ?: return null
+ if (display.cases_order != null && display.numbers_order != null) {
+
+ // FIX 2: Define a fallback strategy for Adjectives.
+ // If a cell is missing (e.g. Masc/Sing), use the lemma.
+ val fallback: ((String, String, String) -> String?)? = if (pos == POS_ADJ) {
+ { row, col, lemma ->
+ // If looking for Masculine Singular, assume it's the Lemma
+ if (row.startsWith("masc") && col.startsWith("sing")) lemma else null
+ }
+ } else null
+
+ MorphologyRule.GridRule(
+ title = if (pos == POS_ADJ) context.getString(R.string.label_variations) else context.getString(R.string.label_declension),
+ rowTags = display.cases_order,
+ colTags = display.numbers_order,
+ fallbackStrategy = fallback
+ )
+ } else null
+ }
+ POS_VERB -> {
+ val display = category.conjugation_display ?: return null
+ if (display.tense_labels != null && display.pronouns != null) {
+ MorphologyRule.VerbRule(
+ tenses = display.tense_labels,
+ pronouns = display.pronouns
+ )
+ } else null
+ }
+ else -> null
+ }
+ }
+
+ // --- EXECUTION LOGIC ---
+
+ private fun parseGrid(
+ forms: List,
+ rule: MorphologyRule.GridRule,
+ lemma: String
+ ): UnifiedMorphology.Grid {
+ val cells = mutableMapOf()
+
+ // Optimization: Lookup map
+ val formLookup = forms.associate { formData ->
+ formData.tags.map { it.lowercase() }.toSet() to formData.form
+ }
+
+ rule.rowTags.forEach { rowTag ->
+ rule.colTags.forEach { colTag ->
+ val requiredTags = listOf(rowTag, colTag)
+ .filter { it.isNotEmpty() }
+ .map { it.lowercase() }
+
+ val matchEntry = formLookup.entries.find { (tags, _) ->
+ tags.containsAll(requiredTags)
+ }
+
+ // Try to get value from match, OR use fallback (Lemma)
+ val cellValue = matchEntry?.value
+ ?: rule.fallbackStrategy?.invoke(rowTag, colTag, lemma)
+
+ if (cellValue != null) {
+ val key = if (colTag.isEmpty()) rowTag else "$rowTag|$colTag"
+ cells[key] = cellValue
+ }
+ }
+ }
+
+ return UnifiedMorphology.Grid(
+ title = rule.title,
+ rowLabels = rule.rowTags,
+ colLabels = rule.colTags.filter { it.isNotEmpty() },
+ cells = cells
+ )
+ }
+
+ private fun parseVerb(
+ forms: List,
+ rule: MorphologyRule.VerbRule,
+ lemma: String
+ ): UnifiedMorphology.Verb {
+ val tenseResults = mutableMapOf>()
+
+ rule.tenses.forEach { (tenseKey, displayLabel) ->
+ val relevantForms = forms.filter { form ->
+ form.tags.any { tag -> tag.equals(tenseKey, ignoreCase = true) }
+ }
+
+ if (relevantForms.isNotEmpty()) {
+ val truncated = relevantForms.take(rule.pronouns.size).map { it.form }
+ tenseResults[displayLabel] = truncated
+ }
+ }
+
+ val aux = forms.find { it.tags.contains("auxiliary") }?.form
+
+ return UnifiedMorphology.Verb(
+ infinitive = lemma,
+ auxiliary = aux,
+ tenses = tenseResults,
+ pronouns = rule.pronouns
+ )
+ }
+
+ private fun parseGeneric(forms: List): UnifiedMorphology.ListForms {
+ return UnifiedMorphology.ListForms(
+ forms.map { Inflection(it.form, it.tags) }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/formatGrammarDetails.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/formatGrammarDetails.kt
new file mode 100644
index 0000000..48ac3ed
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/grammar/formatGrammarDetails.kt
@@ -0,0 +1,38 @@
+package eu.gaudian.translator.model.grammar
+
+/**
+ * Formats the grammatical details of a word into a concise, user-friendly string
+ * based on the rules defined in a CategoryConfig.
+ *
+ * @param details The grammatical feature object containing the raw data.
+ * @param config The configuration for the specific category (e.g., "noun" in German).
+ * @return A formatted string like "(der)" or "(reg. -er)".
+ */
+fun formatGrammarDetails(
+ details: GrammaticalFeature,
+ config: CategoryConfig?
+): String {
+ val formatter = config?.formatter ?: return details.category.replaceFirstChar { it.uppercase() }
+
+ @Suppress("HardCodedStringLiteral") val placeholderRegex = Regex("\\{(\\w+)\\}")
+
+ val hasDataForFormatter = placeholderRegex.findAll(formatter).any { matchResult ->
+ val key = matchResult.groupValues[1]
+ details.properties.containsKey(key)
+ }
+
+ if (!hasDataForFormatter) {
+ return details.category.replaceFirstChar { it.uppercase() }
+ }
+
+
+ var result = formatter
+ result = placeholderRegex.replace(result) { matchResult ->
+ val key = matchResult.groupValues[1]
+ val rawValue = details.properties[key] ?: ""
+ val mappedValue = config.mappings?.get(key)?.get(rawValue)
+ mappedValue ?: rawValue
+ }
+
+ return result
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/ApiLogRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/ApiLogRepository.kt
new file mode 100644
index 0000000..8996dab
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/repository/ApiLogRepository.kt
@@ -0,0 +1,40 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.repository
+
+import android.content.Context
+import eu.gaudian.translator.model.communication.ApiLogEntry
+import eu.gaudian.translator.utils.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.withContext
+
+class ApiLogRepository(context: Context) {
+ private val dataStore = context.dataStore
+
+ fun getLogs(): Flow> = dataStore.loadObjectList(ApiStoreKeys.API_LOGS_KEY)
+
+ suspend fun addLog(entry: ApiLogEntry, maxKeep: Int = 200) {
+ withContext(Dispatchers.IO) {
+ try {
+ val current = getLogs().first()
+ val updated = (current + entry).takeLast(maxKeep)
+ dataStore.saveObjectList(ApiStoreKeys.API_LOGS_KEY, updated)
+ } catch (e: Exception) {
+ Log.e("ApiLogRepository", "Failed to add log", e)
+ }
+ }
+ }
+
+ suspend fun clear() {
+ withContext(Dispatchers.IO) {
+ dataStore.clear(ApiStoreKeys.API_LOGS_KEY)
+ }
+ }
+}
+
+// Separate object to avoid cluttering the global DataStoreKeys if needed
+object ApiStoreKeys {
+ val API_LOGS_KEY = DataStoreKeys.API_LOGS_KEY
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/ApiRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/ApiRepository.kt
new file mode 100644
index 0000000..52db647
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/repository/ApiRepository.kt
@@ -0,0 +1,356 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.repository
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import eu.gaudian.translator.model.LanguageModel
+import eu.gaudian.translator.model.communication.ApiProvider
+import eu.gaudian.translator.utils.Log
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+class ApiRepository(private val context: Context) {
+
+ private val settingsRepository = SettingsRepository(context)
+ private val dataStore: DataStore = context.dataStore
+
+ @Suppress("PrivatePropertyName")
+ private val TAG = "ApiRepository"
+
+ /**
+ * Checks and sets fallback models.
+ * Enforces consistency: If no key is present, default models are removed.
+ */
+ suspend fun initialInit() {
+ Log.i(TAG, "Starting initial model check...")
+
+ val jsonProviders = ApiProvider.loadProviders(context.applicationContext)
+ val jsonProvidersByKey = jsonProviders.associateBy { it.key }
+
+ // Load currently saved providers
+ var storedProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first()
+
+ // 1. First Run Handling
+ if (storedProviders.isEmpty()) {
+ Log.i(TAG, "DataStore empty. Initializing with JSON defaults.")
+ val initial = jsonProviders.map {
+ if (!it.isCustom) it.copy(models = emptyList()) else it
+ }
+ saveProviders(initial)
+ storedProviders = initial
+ }
+
+ val apiKeys = getAllApiKeys().first()
+ var needsSync = false
+
+ // --- NEW STEP: CLEANUP ---
+ // Filter out providers that are NOT custom AND are NO LONGER in the JSON
+ val validStoredProviders = storedProviders.filter { stored ->
+ val stillExistsInJson = jsonProvidersByKey.containsKey(stored.key)
+ val isCustom = stored.isCustom
+
+ // Keep it if it's custom OR if it still exists in the JSON source
+ if (isCustom || stillExistsInJson) {
+ true
+ } else {
+ Log.i(TAG, "Removing obsolete provider: ${stored.displayName} (Key: ${stored.key})")
+ needsSync = true
+ false // Drop this provider
+ }
+ }
+
+ // 2. Sync Existing Providers (Run logic on the CLEANED list)
+ val syncedStoredProviders = validStoredProviders.map { stored ->
+ if (!stored.isCustom) {
+ val latestDefault = jsonProvidersByKey[stored.key]
+ if (latestDefault != null) {
+ val hasKey = apiKeys[stored.key]?.isNotBlank() ?: false
+ val customModels = stored.models.filter { it.isCustom }
+
+ val targetModels = if (hasKey) {
+ val newDefaultModels = latestDefault.models.filter { !it.isCustom }
+ (customModels + newDefaultModels).distinctBy { it.modelId }
+ } else {
+ customModels
+ }
+
+ val needsUpdate = stored.displayName != latestDefault.displayName ||
+ stored.baseUrl != latestDefault.baseUrl ||
+ stored.endpoint != latestDefault.endpoint ||
+ stored.websiteUrl != latestDefault.websiteUrl ||
+ stored.models.size != targetModels.size ||
+ stored.models.map { it.modelId }.toSet() != targetModels.map { it.modelId }.toSet()
+
+ if (needsUpdate) {
+ needsSync = true
+ stored.copy(
+ displayName = latestDefault.displayName,
+ baseUrl = latestDefault.baseUrl,
+ endpoint = latestDefault.endpoint,
+ websiteUrl = latestDefault.websiteUrl,
+ models = targetModels,
+ isCustom = false
+ )
+ } else {
+ stored
+ }
+ } else {
+ stored
+ }
+ } else {
+ stored
+ }
+ }
+
+ // 3. Detect & Add NEW Providers
+ val existingKeys = syncedStoredProviders.map { it.key }.toSet()
+ val newProvidersFromJson = jsonProviders.filter { it.key !in existingKeys }
+
+ val newProvidersInitialized = newProvidersFromJson.map {
+ if (!it.isCustom) it.copy(models = emptyList()) else it
+ }
+
+ if (newProvidersInitialized.isNotEmpty()) {
+ Log.i(TAG, "Found ${newProvidersInitialized.size} new providers in JSON. Adding them.")
+ needsSync = true
+ }
+
+ // 4. Save and Apply
+ val finalProviderList = syncedStoredProviders + newProvidersInitialized
+
+ if (needsSync) {
+ Log.i(TAG, "Syncing providers...")
+ saveProviders(finalProviderList)
+ storedProviders = finalProviderList
+ }
+
+ // 5. Fallback Selection Logic
+ val currentTrans = getTranslationModel().first()
+ val currentExer = getExerciseModel().first()
+ val currentVocab = getVocabularyModel().first()
+ val currentDict = getDictionaryModel().first()
+
+ val validProviders = storedProviders.filter { provider ->
+ val hasKey = apiKeys[provider.key]?.isNotBlank() ?: false
+ val isLocalHost = provider.baseUrl.contains("localhost") || provider.baseUrl.contains("127.0.0.1")
+ hasKey || isLocalHost || provider.isCustom
+ }
+ val availableModels = validProviders.flatMap { it.models }
+
+ var configurationValid = true
+
+ // (Helper function to reduce repetition)
+ fun checkAndFallback(current: LanguageModel?, setter: suspend (LanguageModel) -> Unit) {
+ val isValid = current != null && availableModels.any { it.modelId == current.modelId && it.providerKey == current.providerKey }
+ if (!isValid) {
+ val fallback = findFallbackModel(availableModels)
+ if (fallback != null) {
+ // We must use a blocking call or scope here because we can't easily pass a suspend function to a lambda
+ // But since we are inside a suspend function, we can just call the setter directly if we unroll the loop.
+ // For simplicity, I'll keep the unrolled logic below.
+ }
+ }
+ }
+
+ // Fallback checks
+ if (currentTrans == null || !availableModels.any { it.modelId == currentTrans.modelId && it.providerKey == currentTrans.providerKey }) {
+ findFallbackModel(availableModels)?.let { setTranslationModel(it) } ?: run { configurationValid = false }
+ }
+
+ if (currentExer == null || !availableModels.any { it.modelId == currentExer.modelId && it.providerKey == currentExer.providerKey }) {
+ findFallbackModel(availableModels)?.let { setExerciseModel(it) } ?: run { configurationValid = false }
+ }
+
+ if (currentVocab == null || !availableModels.any { it.modelId == currentVocab.modelId && it.providerKey == currentVocab.providerKey }) {
+ findFallbackModel(availableModels)?.let { setVocabularyModel(it) } ?: run { configurationValid = false }
+ }
+
+ if (currentDict == null || !availableModels.any { it.modelId == currentDict.modelId && it.providerKey == currentDict.providerKey }) {
+ findFallbackModel(availableModels)?.let { setDictionaryModel(it) } ?: run { configurationValid = false }
+ }
+
+ settingsRepository.connectionConfigured.set(configurationValid)
+ }
+
+ /**
+ * Manually adds default models for a provider (Triggered on Key Activation).
+ */
+ suspend fun addDefaultModels(providerKey: String) {
+ val currentProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first().toMutableList()
+ val index = currentProviders.indexOfFirst { it.key == providerKey }
+ if (index == -1) return
+
+ val stored = currentProviders[index]
+ if (stored.isCustom) return // Don't touch custom providers
+
+ val jsonProviders = ApiProvider.loadProviders(context.applicationContext)
+ val default = jsonProviders.find { it.key == providerKey } ?: return
+
+ // Merge default models into existing (preserving customs)
+ val customModels = stored.models.filter { it.isCustom }
+ val defaultModels = default.models.filter { !it.isCustom }
+ val merged = (customModels + defaultModels).distinctBy { it.modelId }
+
+ currentProviders[index] = stored.copy(models = merged)
+ saveProviders(currentProviders)
+ Log.i(TAG, "Added default models for $providerKey. Count: ${defaultModels.size}")
+ }
+
+ /**
+ * Manually removes default models for a provider (Triggered on Key Deactivation).
+ */
+ suspend fun removeDefaultModels(providerKey: String) {
+ val currentProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first().toMutableList()
+ val index = currentProviders.indexOfFirst { it.key == providerKey }
+ if (index == -1) return
+
+ val stored = currentProviders[index]
+ if (stored.isCustom) return
+
+ // Remove all non-custom models
+ val customOnly = stored.models.filter { it.isCustom }
+
+ currentProviders[index] = stored.copy(models = customOnly)
+ saveProviders(currentProviders)
+ Log.i(TAG, "Removed default models for $providerKey.")
+ }
+
+ fun getProviders(): Flow> {
+ val providersFlow = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).map { list ->
+ list.ifEmpty {
+ ApiProvider.loadProviders(context.applicationContext).map {
+ // Initial state: No key = No models
+ if (!it.isCustom) it.copy(models = emptyList()) else it
+ }
+ }
+ }
+ val apiKeysFlow = getAllApiKeys()
+
+ return combine(providersFlow, apiKeysFlow) { providers, apiKeys ->
+ providers.map { provider ->
+ val hasKey = apiKeys[provider.key]?.isNotBlank() ?: false
+ val base = provider.baseUrl.trim().lowercase()
+ val isLocalHost = (base.contains("localhost") || base.contains("127.0.0.1") || base.startsWith("10."))
+
+ provider.copy().apply {
+ hasValidKey = hasKey || isLocalHost || isCustom
+ }
+ }
+ }
+ }
+
+ fun getAllApiKeys(): Flow> {
+ val apiKeySuffix = "_api_key"
+ return dataStore.data.map { preferences ->
+ preferences.asMap().mapNotNull { (key, value) ->
+ if (key.name.endsWith(apiKeySuffix) && value is String) {
+ val providerKey = key.name.removeSuffix(apiKeySuffix)
+ providerKey to value
+ } else {
+ null
+ }
+ }.toMap()
+ }
+ }
+
+ private fun findFallbackModel(allModels: List): LanguageModel? {
+ val preferredProviderOrder = listOf("mistral", "openai", "gemini", "deepseek", "openrouter")
+ for (providerKey in preferredProviderOrder) {
+ val model = allModels.firstOrNull { it.providerKey == providerKey }
+ if (model != null) return model
+ }
+ return allModels.firstOrNull()
+ }
+
+ suspend fun saveProviders(providers: List) {
+ try {
+ dataStore.saveObjectList(DataStoreKeys.PROVIDERS_KEY, providers)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error saving providers: ${e.message}", e)
+ }
+ }
+
+ suspend fun addProvider(provider: ApiProvider) {
+ val currentProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first().toMutableList()
+ if (currentProviders.none { it.key == provider.key }) {
+ currentProviders.add(provider)
+ saveProviders(currentProviders)
+ initialInit()
+ }
+ }
+
+ suspend fun updateProvider(updatedProvider: ApiProvider) {
+ val currentProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first().toMutableList()
+ val index = currentProviders.indexOfFirst { it.key == updatedProvider.key }
+ if (index != -1) {
+ currentProviders[index] = updatedProvider
+ saveProviders(currentProviders)
+ initialInit()
+ }
+ }
+
+ suspend fun deleteProvider(providerKey: String) {
+ val currentProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first().toMutableList()
+ val removed = currentProviders.removeAll { it.key == providerKey && it.isCustom }
+ if (removed) {
+ saveProviders(currentProviders)
+ initialInit()
+ }
+ }
+
+ /* --- Model Selection Setters --- */
+
+ suspend fun setTranslationModel(model: LanguageModel) {
+ dataStore.saveObject(DataStoreKeys.SELECTED_TRANSLATION_MODEL_KEY, model)
+ settingsRepository.connectionConfigured.set(true)
+ }
+ fun getTranslationModel(): Flow = dataStore.loadObject(DataStoreKeys.SELECTED_TRANSLATION_MODEL_KEY)
+
+ suspend fun setExerciseModel(model: LanguageModel) {
+ dataStore.saveObject(DataStoreKeys.SELECTED_EXERCISE_MODEL_KEY, model)
+ settingsRepository.connectionConfigured.set(true)
+ }
+ fun getExerciseModel(): Flow = dataStore.loadObject(DataStoreKeys.SELECTED_EXERCISE_MODEL_KEY)
+
+ suspend fun setVocabularyModel(model: LanguageModel) {
+ dataStore.saveObject(DataStoreKeys.SELECTED_VOCABULARY_MODEL_KEY, model)
+ settingsRepository.connectionConfigured.set(true)
+ }
+ fun getVocabularyModel(): Flow = dataStore.loadObject(DataStoreKeys.SELECTED_VOCABULARY_MODEL_KEY)
+
+ suspend fun setDictionaryModel(model: LanguageModel) {
+ dataStore.saveObject(DataStoreKeys.SELECTED_DICTIONARY_MODEL_KEY, model)
+ settingsRepository.connectionConfigured.set(true)
+ }
+ fun getDictionaryModel(): Flow = dataStore.loadObject(DataStoreKeys.SELECTED_DICTIONARY_MODEL_KEY)
+
+ suspend fun wipeAll() {
+ Log.w(TAG, "Executing wipeAll()")
+ settingsRepository.connectionConfigured.set(false)
+ try {
+ dataStore.edit { prefs ->
+ prefs.remove(DataStoreKeys.PROVIDERS_KEY)
+ prefs.remove(DataStoreKeys.SELECTED_TRANSLATION_MODEL_KEY)
+ prefs.remove(DataStoreKeys.SELECTED_EXERCISE_MODEL_KEY)
+ prefs.remove(DataStoreKeys.SELECTED_VOCABULARY_MODEL_KEY)
+ prefs.remove(DataStoreKeys.SELECTED_DICTIONARY_MODEL_KEY)
+
+ val apiKeySuffix = "_api_key"
+ val keysToRemove = prefs.asMap().keys.filter { it.name.endsWith(apiKeySuffix) }
+ for (k in keysToRemove) {
+ @Suppress("UNCHECKED_CAST")
+ val anyKey = k as Preferences.Key
+ prefs.remove(anyKey)
+ }
+ }
+ // initialInit will be called, see empty DataStore, and reload default providers (with EMPTY models)
+ initialInit()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error during wipeAll: ${e.message}", e)
+ }
+ }}
diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/DataStore.kt b/app/src/main/java/eu/gaudian/translator/model/repository/DataStore.kt
new file mode 100644
index 0000000..2446232
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/repository/DataStore.kt
@@ -0,0 +1,162 @@
+@file:Suppress("HardCodedStringLiteral", "unused")
+
+package eu.gaudian.translator.model.repository
+
+import android.annotation.SuppressLint
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import eu.gaudian.translator.utils.Log
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.map
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.Json
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
+
+object DataStoreKeys {
+ val PROVIDERS_KEY = stringPreferencesKey("providers")
+
+ val EXERCISES_KEY = stringPreferencesKey("exercises")
+
+
+
+ val QUESTIONS_KEY = stringPreferencesKey("questions")
+
+ // Language initialization metadata key
+ val LANGUAGE_INIT_METADATA_KEY = stringPreferencesKey("language_init_metadata")
+
+ // Language-related keys
+ val SELECTED_SOURCE_LANGUAGE_KEY = stringPreferencesKey("selected_source_language")
+ val SELECTED_TARGET_LANGUAGE_KEY = stringPreferencesKey("selected_target_language")
+
+ val DEFAULT_LANGUAGES_KEY = stringPreferencesKey("default_languages")
+ val CUSTOM_LANGUAGES_KEY = stringPreferencesKey("custom_language")
+ val ALL_LANGUAGES_KEY = stringPreferencesKey("all_languages")
+ val LANGUAGE_HISTORY_KEY = stringPreferencesKey("language_history")
+ val FAVORITE_LANGUAGES_KEY = stringPreferencesKey("favorite_languages")
+ val SELECTED_DICTIONARY_LANGUAGE_KEY = stringPreferencesKey("selected_dictionary_language")
+
+ val SELECTED_TRANSLATION_MODEL_KEY = stringPreferencesKey("selected_translation_model")
+ val SELECTED_EXERCISE_MODEL_KEY = stringPreferencesKey("selected_exercise_model")
+ val SELECTED_VOCABULARY_MODEL_KEY = stringPreferencesKey("selected_vocabulary_model")
+ val SELECTED_DICTIONARY_MODEL_KEY = stringPreferencesKey("selected_dictionary_model")
+
+ val TRANSLATION_HISTORY_KEY = stringPreferencesKey("translation_history")
+ val API_LOGS_KEY = stringPreferencesKey("api_logs")
+}
+
+val Context.dataStore: DataStore by preferencesDataStore(name = "app_data")
+
+suspend inline fun DataStore.saveObject(key: Preferences.Key, obj: T) {
+ edit { preferences ->
+ val jsonString = Json.encodeToString(obj)
+ preferences[key] = jsonString
+ }
+}
+
+suspend fun DataStore.saveStringSet(key: Preferences.Key>, set: Set) {
+ edit { preferences ->
+ preferences[key] = set
+ }
+}
+
+fun DataStore.loadStringSet(key: Preferences.Key>): Flow> {
+ return data.map { preferences ->
+ preferences[key] ?: emptySet()
+ }
+}
+
+inline fun DataStore.loadObject(key: Preferences.Key): Flow {
+ return data.map { preferences ->
+ val jsonString = preferences[key]
+ if (jsonString != null) {
+ Json.decodeFromString(jsonString)
+ } else {
+ null
+ }
+ }.catch { exception ->
+ if (exception is SerializationException) {
+ Log.w("DataStore: Failed to decode object for key '$key', clearing it.", exception)
+ this@loadObject.edit { it.remove(key) }
+ emit(null)
+ } else {
+ throw exception
+ }
+ }
+}
+
+@SuppressLint("SuspiciousIndentation")
+suspend inline fun DataStore.saveObjectList(key: Preferences.Key, list: List) {
+ try {
+ edit { preferences ->
+ try {
+ val jsonString = Json.encodeToString(list)
+ jsonString.also { preferences[key] = it }
+ } catch (e: Exception) {
+ Log.e("DataStore", "Failed to encode list to JSON", e)
+ throw e
+ }
+ }
+ } catch (e: Exception) {
+ Log.e("DataStore", "Failed to save list to DataStore", e)
+ throw e
+ }
+}
+
+
+inline fun DataStore.loadObjectList(key: Preferences.Key): Flow> {
+ return data.map { preferences ->
+ val jsonString = preferences[key]
+ if (jsonString != null) {
+ Json.decodeFromString>(jsonString)
+ } else {
+ emptyList()
+ }
+ }.catch { exception ->
+ if (exception is SerializationException) {
+ Log.w("DataStore", "Failed to decode list for key '$key', clearing it.", exception)
+ this@loadObjectList.edit { it.remove(key) } // Clears the bad data
+ emit(emptyList()) // Provides a default value to continue
+ } else {
+ throw exception
+ }
+ }
+}
+
+suspend fun DataStore.clear(key: Preferences.Key) {
+ edit { preferences ->
+ preferences.remove(key)
+ }
+}
+
+@kotlinx.serialization.Serializable
+data class LanguageInitializationMetadata(
+ val appVersion: String?,
+ val systemLocale: String,
+ val timestamp: Long
+)
+
+@OptIn(ExperimentalTime::class)
+object InstantSerializer : KSerializer {
+ override val descriptor: SerialDescriptor =
+ PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: Instant) {
+ encoder.encodeString(value.toString())
+ }
+
+ override fun deserialize(decoder: Decoder): Instant {
+ return Instant.parse(decoder.decodeString())
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryDatabaseRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryDatabaseRepository.kt
new file mode 100644
index 0000000..0056de5
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryDatabaseRepository.kt
@@ -0,0 +1,137 @@
+@file:Suppress("HardCodedStringLiteral", "unused")
+
+package eu.gaudian.translator.model.repository
+
+import android.content.Context
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import eu.gaudian.translator.model.communication.FileInfo
+import eu.gaudian.translator.utils.Log
+
+/**
+ * Repository for generic access and reading of downloaded dictionary .db files.
+ * All operations are read-only. The .db files are assumed to be SQLite databases.
+ */
+class DictionaryDatabaseRepository(private val context: Context) {
+
+ @Suppress("PrivatePropertyName")
+ private val TAG = "DictionaryDatabaseRepository"
+
+ /**
+ * Opens a read-only SQLite database for the given file info.
+ * Returns null if the file does not exist.
+ */
+ internal fun getDatabase(fileInfo: FileInfo): SQLiteDatabase? {
+ val dbAsset = fileInfo.assets.firstOrNull { it.filename.endsWith(".db") }
+ if (dbAsset == null) {
+ Log.e(TAG, "No database asset found for ${fileInfo.id}")
+ return null
+ }
+
+ val file = java.io.File(context.filesDir, dbAsset.filename)
+ return if (file.exists()) {
+ try {
+ // Use URI mode with immutable=1 to reduce locking noise
+ val path = "file:${file.absolutePath}?mode=ro&immutable=1"
+ SQLiteDatabase.openDatabase(
+ path,
+ null,
+ SQLiteDatabase.OPEN_READONLY
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Error opening database for ${dbAsset.filename}", e)
+ null
+ }
+ } else {
+ Log.w(TAG, "Database file does not exist: ${dbAsset.filename}")
+ null
+ }
+ }
+
+ /**
+ * Retrieves the list of table names in the database.
+ */
+ fun getTables(fileInfo: FileInfo): List {
+ val db = getDatabase(fileInfo) ?: return emptyList()
+ val tables = mutableListOf()
+ db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null).use { cursor ->
+ while (cursor.moveToNext()) {
+ tables.add(cursor.getString(0))
+ }
+ }
+ db.close()
+ return tables
+ }
+
+ /**
+ * Retrieves the list of column names for a specific table.
+ */
+ fun getColumns(fileInfo: FileInfo, table: String): List {
+ val db = getDatabase(fileInfo) ?: return emptyList()
+ val columns = mutableListOf()
+ db.rawQuery("PRAGMA table_info($table)", null).use { cursor ->
+ while (cursor.moveToNext()) {
+ columns.add(cursor.getString(1)) // name column index is 1
+ }
+ }
+ db.close()
+ return columns
+ }
+
+ /**
+ * Retrieves up to [limit] rows from a table as a list of maps (column name to value).
+ * Defaults to first 1000 rows if limit is not specified.
+ */
+ fun getTableData(fileInfo: FileInfo, table: String, limit: Int = 1000): List> {
+ val db = getDatabase(fileInfo) ?: return emptyList()
+ val data = mutableListOf>()
+ db.rawQuery("SELECT * FROM $table LIMIT $limit", null).use { cursor ->
+ val columnCount = cursor.columnCount
+ val columnNames = Array(columnCount) { i -> cursor.getColumnName(i) }
+ while (cursor.moveToNext()) {
+ val row = mutableMapOf()
+ for (i in 0 until columnCount) {
+ val name = columnNames[i]
+ val value = when (cursor.getType(i)) {
+ Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i)
+ Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(i)
+ Cursor.FIELD_TYPE_STRING -> cursor.getString(i)
+ Cursor.FIELD_TYPE_BLOB -> "BLOB (${cursor.getBlob(i)?.size ?: 0} bytes)"
+ else -> null
+ }
+ value?.let { row[name] = it }
+ }
+ data.add(row)
+ }
+ }
+ db.close()
+ return data
+ }
+
+ /**
+ * Checks if a table exists in the database.
+ */
+ fun tableExists(fileInfo: FileInfo, table: String): Boolean {
+ val db = getDatabase(fileInfo) ?: return false
+ val exists = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table' AND name='$table'", null).use { cursor ->
+ cursor.moveToFirst()
+ }
+ db.close()
+ return exists
+ }
+
+ /**
+ * Gets the row count for a specific table.
+ */
+ fun getRowCount(fileInfo: FileInfo, table: String): Int {
+ val db = getDatabase(fileInfo) ?: return 0
+ var count = 0
+ db.rawQuery("SELECT COUNT(*) FROM $table", null).use { cursor ->
+ if (cursor.moveToFirst()) {
+ count = cursor.getInt(0)
+ }
+ }
+ db.close()
+ return count
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryFileRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryFileRepository.kt
new file mode 100644
index 0000000..2e8cd88
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryFileRepository.kt
@@ -0,0 +1,270 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model.repository
+
+import android.content.Context
+import androidx.core.content.edit
+import eu.gaudian.translator.R
+import eu.gaudian.translator.model.communication.Asset
+import eu.gaudian.translator.model.communication.FileDownloadManager
+import eu.gaudian.translator.model.communication.FileInfo
+import eu.gaudian.translator.model.communication.ManifestResponse
+import eu.gaudian.translator.utils.Log
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.io.File
+
+/**
+ * Repository for managing downloaded dictionary files.
+ */
+class DictionaryFileRepository(private val context: Context) {
+
+ private val fileDownloadManager = FileDownloadManager(context)
+ private val _downloadedDictionaries = MutableStateFlow>(emptyList())
+ val downloadedDictionaries: Flow> = _downloadedDictionaries.asStateFlow()
+
+ private val _orphanedFiles = MutableStateFlow>(emptyList())
+ val orphanedFiles: Flow> = _orphanedFiles.asStateFlow()
+
+ private val _manifest = MutableStateFlow(null)
+ val manifest: Flow = _manifest.asStateFlow()
+
+ init {
+ loadDownloadedDictionaries()
+ }
+
+ /**
+ * Fetches the manifest and updates the state.
+ */
+ suspend fun fetchManifest() {
+ try {
+ val manifestResponse = fileDownloadManager.fetchManifest()
+ if (manifestResponse != null) {
+ _manifest.value = manifestResponse
+ loadDownloadedDictionaries() // Refresh both downloaded and orphaned lists
+ } else {
+ throw Exception("Manifest response is null")
+ }
+ } catch (e: Exception) {
+ Log.e("DictionaryFileRepository", "Error fetching manifest", e)
+ throw e
+ }
+ }
+
+ /**
+ * Downloads a dictionary file.
+ */
+ suspend fun downloadDictionary(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean {
+ try {
+ val success = fileDownloadManager.downloadFile(fileInfo, onProgress)
+ if (success) {
+ loadDownloadedDictionaries()
+ }
+ return success
+ } catch (e: Exception) {
+ Log.e("DictionaryFileRepository", "Error downloading dictionary", e)
+ throw e
+ }
+ }
+
+ /**
+ * Checks if a newer version is available for a dictionary.
+ */
+ fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
+ return fileDownloadManager.isNewerVersionAvailable(fileInfo)
+ }
+
+ /**
+ * Gets the local version of a dictionary.
+ */
+ fun getLocalVersion(fileId: String): String {
+ return fileDownloadManager.getLocalVersion(fileId)
+ }
+
+ /**
+ * Deletes all assets of a downloaded dictionary.
+ */
+ @Suppress("SameReturnValue", "SameReturnValue")
+ fun deleteDictionary(fileInfo: FileInfo): Boolean {
+ try {
+ var allDeleted = true
+ val failedFiles = mutableListOf()
+
+ for (asset in fileInfo.assets) {
+ val localFile = File(context.filesDir, asset.filename)
+ if (localFile.exists()) {
+ val deleted = localFile.delete()
+ if (!deleted) {
+ allDeleted = false
+ failedFiles.add(asset.filename)
+ Log.e("DictionaryFileRepository", "Failed to delete asset: ${asset.filename}")
+ } else {
+ Log.d("DictionaryFileRepository", "Deleted asset: ${asset.filename}")
+ }
+ } else {
+ Log.w("DictionaryFileRepository", "Asset file not found: ${asset.filename}")
+ }
+ }
+
+ if (allDeleted) {
+ // Remove version from SharedPreferences
+ val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
+ sharedPreferences.edit { remove(fileInfo.id) }
+ loadDownloadedDictionaries()
+ Log.d("DictionaryFileRepository", "Deleted all assets for dictionary: ${fileInfo.name}")
+ } else {
+ throw Exception("Failed to delete some assets: ${failedFiles.joinToString(", ")}")
+ }
+
+ return true
+ } catch (e: Exception) {
+ Log.e("DictionaryFileRepository", "Error deleting dictionary", e)
+ throw e
+ }
+ }
+
+ /**
+ * Deletes all downloaded dictionary files and their assets.
+ */
+ @Suppress("SameReturnValue")
+ fun deleteAllDictionaries(): Boolean {
+ try {
+ val filesDir = context.filesDir
+ // Find all dictionary-related files (.db and .zstdict)
+ val dictionaryFiles = filesDir.listFiles { file ->
+ file.isFile && (
+ file.name.endsWith(".db") ||
+ file.name.endsWith(".zstdict") ||
+ file.name.startsWith("dictionary")
+ ) && !file.name.endsWith(".db-wal") &&
+ !file.name.endsWith(".db-shm")
+ }
+
+ if (dictionaryFiles.isNullOrEmpty()) {
+ Log.d("DictionaryFileRepository", "No dictionary files found to delete")
+ return true
+ }
+
+ var allDeleted = true
+ val failedFiles = mutableListOf