migrate to gitea

This commit is contained in:
jonasgaudian
2026-02-13 00:15:36 +01:00
commit 269cc9e417
407 changed files with 66841 additions and 0 deletions

View File

@@ -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 <T> handleResult(result: Result<T>, 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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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/"
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />
<application
android:name=".MyApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
tools:targetApi="31"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Translator"
android:networkSecurityConfig="@xml/network_security_config">
<activity android:name=".view.MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true"
android:theme="@style/Theme.App.Starting"
tools:ignore="LockedOrientationActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".CorrectActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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": []
}
}
}

View File

@@ -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": []
}
}
}

View File

@@ -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": []
}
}
}

View File

@@ -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": []
}
}
}

View File

@@ -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": []
}
}
}

View File

@@ -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": []
}
}
}

View File

@@ -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": []
}
}
}

View File

@@ -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": []
}
}
}

View File

@@ -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."
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -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))
}
}
}
}

View File

@@ -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())
}
}
}

View File

@@ -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
}
}

View File

@@ -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<EntryPart>, //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

View File

@@ -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<EtymologyStep>,
val relatedWords: List<RelatedWord>
)

View File

@@ -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<Int>,
val associatedVocabularyIds: List<Int> = 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<KClass<out Question>> = 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<String>,
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<String> = 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<String>, // The scrambled words
val correctOrder: List<String>
) : 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<String, String> // 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<String>,
val categories: List<String>,
val correctMapping: Map<String, String> // 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()

View File

@@ -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<Language> {
val languages = mutableListOf<Language>()
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
)
}

View File

@@ -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
)

View File

@@ -0,0 +1,248 @@
package eu.gaudian.translator.model
import eu.gaudian.translator.R
object LanguageLevels {
val all: List<MyAppLanguageLevel> = 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
)
}

View File

@@ -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()
}
}

View File

@@ -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<VocabularyFeatures>(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<String, String>
) : 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<Int>? = null,
@Contextual val languagePairs: Pair<Int, Int> ? = null,
val stages: List<VocabularyStage>? = 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<VocabularyItem>,
): Parcelable

View File

@@ -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 }
}
}
}

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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<isAvailable, message>.
*/
suspend fun checkProviderAvailability(baseUrl: String): Pair<Boolean, String> = 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<Boolean, String?> {
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<GeminiResponse> = 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<ApiResponse> = 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<List<LanguageModel>, String?> where second is an error message if any.
*/
suspend fun fetchAvailableModels(apiKey: String?, provider: ApiProvider): Pair<List<LanguageModel>, 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<String>()
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<String>()
(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<String>()
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<Boolean, String?> {
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<GeminiResponse> = 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<ApiResponse> = 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<GeminiResponse> {
override fun onResponse(call: Call<GeminiResponse>, response: Response<GeminiResponse>) {
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<GeminiResponse>, 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<ApiResponse> {
override fun onResponse(call: Call<ApiResponse>, response: Response<ApiResponse>) {
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<ApiResponse>, 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)
}
})
}
}
}
}
}

View File

@@ -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<LanguageModel>,
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<ApiProvider> {
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<ApiProvider> {
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.")
)
)
)
}
}
}

View File

@@ -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<ManifestApiService>()
@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"
}
}

View File

@@ -0,0 +1,17 @@
package eu.gaudian.translator.model.communication
data class GeminiModelsListResponse(
val models: List<GeminiModelItem>?
)
data class GeminiModelItem(
val name: String?,
val displayName: String?,
val description: String?,
val inputTokenLimit: Int?,
val outputTokenLimit: Int?,
val supportedGenerationMethods: List<String>? // e.g., ["generateContent"]
)

View File

@@ -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<ApiResponse>
@Headers("Content-Type: application/json")
@POST
fun sendGeminiRequest(@Url url: String, @Body request: GeminiRequest): Call<GeminiResponse>
// Generic models listing (e.g., OpenAI-compatible: GET v1/models)
@GET
fun listModels(@Url url: String): Call<ModelsListResponse>
// Gemini models listing (GET v1beta/models)
@GET
fun listGeminiModels(@Url url: String): Call<GeminiModelsListResponse>
}

View File

@@ -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<ManifestResponse>
}

View File

@@ -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<FileInfo>
)
/**
* 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<Asset>
)
/**
* 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
)

View File

@@ -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)
}

View File

@@ -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<ModelItem> = 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<String>? = 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<String>? = 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
)

View File

@@ -0,0 +1,54 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.communication
data class Request(
val model: String,
val messages: List<Message>
) {
data class Message(
val role: String,
val content: String
)
}
data class ApiResponse(
val choices: List<Choice>
) {
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<GeminiContent>
)
data class GeminiResponse(
val candidates: List<GeminiCandidate>?
) {
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<GeminiPart>
)
data class GeminiPart(
val text: String
)

View File

@@ -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<String>
}
class InMemoryLogCollector : LogCollector {
private val logs = mutableListOf<String>()
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<String> {
return synchronized(logs) {
logs.toList()
}
}
}

View File

@@ -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
}

View File

@@ -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 {"*": "<html>"}
* 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<TextField> {
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)
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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<Language>?): String? {
return languages?.let { Json.encodeToString(it) }
}
@TypeConverter
fun toLanguageList(json: String?): List<Language>? {
return json?.let { Json.decodeFromString<List<Language>>(it) }
}
@TypeConverter
fun fromStageList(stages: List<VocabularyStage>?): String? {
return stages?.let { Json.encodeToString(it) }
}
@TypeConverter
fun toStageList(json: String?): List<VocabularyStage>? {
return json?.let { Json.decodeFromString<List<VocabularyStage>>(it) }
}
}

View File

@@ -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<List<VocabularyItem>>
@Query("SELECT * FROM vocabulary_items")
suspend fun getAllItems(): List<VocabularyItem>
@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<VocabularyItem>)
@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<Int>)
@Query("SELECT * FROM vocabulary_items WHERE id IN (:ids)")
suspend fun getItemsByIds(ids: List<Int>): List<VocabularyItem>
@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<VocabularyItem>
@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<List<VocabularyItem>>
@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<VocabularyItem>
}
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<DailyCount>
@Query("SELECT * FROM vocabulary_states")
fun getAllStatesFlow(): Flow<List<VocabularyItemState>>
@Query("SELECT * FROM vocabulary_states")
suspend fun getAllStates(): List<VocabularyItemState>
@Query("SELECT * FROM vocabulary_states WHERE vocabularyItemId = :itemId")
suspend fun getStateById(itemId: Int): VocabularyItemState?
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(states: List<VocabularyItemState>)
@Upsert
suspend fun upsertState(state: VocabularyItemState)
}
@Dao
interface CategoryDao {
@Query("DELETE FROM categories")
suspend fun clearAllCategories()
@Query("SELECT * FROM categories")
fun getAllCategoriesFlow(): Flow<List<VocabularyCategoryEntity>>
@Query("SELECT * FROM categories")
suspend fun getAllCategories(): List<VocabularyCategoryEntity>
@Query("SELECT * FROM categories WHERE id = :id")
suspend fun getCategoryById(id: Int): VocabularyCategoryEntity?
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertAll(categories: List<VocabularyCategoryEntity>)
@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<Int>)
@Query("SELECT * FROM category_mappings")
fun getCategoryMappingsFlow(): Flow<List<CategoryMappingEntity>>
@Query("SELECT * FROM category_mappings")
suspend fun getCategoryMappings(): List<CategoryMappingEntity>
@Query("DELETE FROM category_mappings")
suspend fun clearCategoryMappings()
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertCategoryMappings(mappings: List<CategoryMappingEntity>)
@Transaction
suspend fun setAllCategoryMappings(mappings: List<CategoryMappingEntity>) {
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<List<StageMappingEntity>>
@Query("SELECT * FROM stage_mappings")
suspend fun getStageMappings(): List<StageMappingEntity>
@Upsert
suspend fun upsertStageMapping(mapping: StageMappingEntity)
@Upsert
suspend fun upsertStageMappings(mappings: List<StageMappingEntity>)
}
@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)
}

View File

@@ -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
)

View File

@@ -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<String>,
val ipas: List<String>
)
/**
* Result of parsing adjective variations.
*/
data class AdjectiveVariationsResult(
val variations: List<ParsedAdjectiveVariation>,
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<FormData>
): 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<FormData>,
lemma: String? = null
): AdjectiveVariationsResult {
val variations = mutableListOf<ParsedAdjectiveVariation>()
// 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<String, String> {
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<FormData>): 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<FormData>,
combination: GenderNumberCombination
): FormData? {
return forms.find { formData ->
val tags = formData.tags.map { it.lowercase() }
tags.contains(combination.gender) && tags.contains(combination.number)
}
}
}

View File

@@ -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<TranslationData> {
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<String, List<RelationData>> {
val relationsElement = obj["relations"] as? JsonObject ?: return emptyMap()
val result = mutableMapOf<String, List<RelationData>>()
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<String> {
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<SenseData> {
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<PronunciationData> {
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<InflectionData> {
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<FormData> {
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<FormData> {
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<FormData> {
val result = mutableListOf<FormData>()
// 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<TranslationData>,
val relations: Map<String, List<RelationData>>,
val phonetics: PhoneticsData?,
val hyphenation: List<String>,
val etymology: EtymologyData,
val senses: List<SenseData>,
val grammaticalFeatures: GrammaticalFeaturesData?,
val grammaticalProperties: GrammaticalPropertiesData?,
val pronunciation: List<PronunciationData>,
val inflections: List<InflectionData>,
val forms: List<FormData>
) {
val synonyms: List<RelationData>
get() = relations["synonyms"] ?: emptyList()
val hyponyms: List<RelationData>
get() = relations["hyponyms"] ?: emptyList()
val allRelatedWords: List<RelationData>
get() = relations.values.flatten()
val allTags: List<String>
get() = (grammaticalFeatures?.tags.orEmpty() + grammaticalProperties?.otherTags.orEmpty()).distinct()
}
data class TranslationData(
val languageCode: String,
val word: String,
val sense: String?,
val tags: List<String>
)
data class RelationData(
val word: String,
val senseIndex: String?,
val rawTags: List<String> = emptyList()
)
data class EtymologyData(
val texts: List<String>
)
data class SenseData(
val glosses: List<String>,
val topics: List<String> = emptyList(),
val examples: List<String> = emptyList(),
val tags: List<String> = emptyList(),
val rawTags: List<String> = emptyList(),
val categories: List<String> = emptyList()
) {
val gloss: String
get() = glosses.firstOrNull() ?: ""
}
data class GrammaticalPropertiesData(
val otherTags: List<String>
)
data class GrammaticalFeaturesData(
val tags: List<String>,
val gender: String? = null,
val number: String? = null
)
data class PronunciationData(
val ipa: String,
val rhymes: String?
)
data class PhoneticsData(
val ipa: List<String>,
val homophones: List<String>,
val variations: List<IpaVariationData>
)
data class IpaVariationData(
val ipa: String,
val rawTags: List<String>
)
data class InflectionData(
val form: String,
val grammaticalFeatures: List<String>
)
data class FormData(
val form: String,
val tags: List<String>,
val ipas: List<String>
)

View File

@@ -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"
}

View File

@@ -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<String, String>
) : Parcelable

View File

@@ -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<Tags> -> 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 |

View File

@@ -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<String>? = null,
val categories: Map<String, CategoryConfig>
)
@Serializable
data class CategoryConfig(
@Suppress("PropertyName") val display_key: String,
val fields: List<FieldConfig>,
val formatter: String? = null,
val mappings: Map<String, Map<String, String>>? = 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<String>? = null,
/** Mapping from internal tense keys (e.g., "present") to display labels (e.g., "Präsens"). */
val tense_labels: Map<String, String>? = null
)
@Serializable
data class NounDeclensionDisplayConfig(
/** Ordered list of case keys to display as rows. */
val cases_order: List<String>? = null,
/** Mapping from case keys to display labels (e.g., "nominative" -> "Nom."). */
val case_labels: Map<String, String>? = null,
/** Ordered list of number keys to display as columns (e.g., ["singular", "plural"]). */
val numbers_order: List<String>? = null,
/** Mapping from number keys to display labels (e.g., "singular" -> "Sing."). */
val number_labels: Map<String, String>? = null
)
@Serializable
data class FieldConfig(
val key: String,
val display_key: String,
val type: String,
val options: List<String>? = null
)

View File

@@ -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<FormData>, 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
}
}

View File

@@ -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<String, List<String>>, // tense -> list of forms
val pronouns: List<String> = 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<String>,
val numbers: List<String>,
val forms: Map<Pair<String, String>, 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<VerbConjugation>) : 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<Inflection>) : WordMorphology()
}

View File

@@ -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"
)
)
)
}

View File

@@ -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<String>,
val colTags: List<String>,
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<String, String>,
val pronouns: List<String>
) : MorphologyRule()
/**
* RULE: Just show everything generic.
*/
object GenericRule : MorphologyRule()
}

View File

@@ -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<String> =
data[key]?.jsonArray?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList()
val conjugationMap = mutableMapOf<String, List<String>>()
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<String> =
data[key]?.jsonArray?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList()
val conjugationMap = mutableMapOf<String, List<String>>()
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<Inflection> if you haven't updated it yet
if (inflections.isNotEmpty()) {
return GenericInflections(inflections)
}
}
return null
}
}

View File

@@ -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<String, MutableMap<String, String>>()
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<Pair<String, String>, 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))
}
}

View File

@@ -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<String>,
val colLabels: List<String>,
val cells: Map<String, String>
) : UnifiedMorphology()
/**
* Verb Paradigm (Flattened - no intermediate 'paradigm' object).
*/
data class Verb(
val infinitive: String,
val auxiliary: String?,
val tenses: Map<String, List<String>>,
val pronouns: List<String>
) : UnifiedMorphology()
/**
* Generic List. Now holds 'Inflection' objects to keep tags visible.
*/
data class ListForms(
val forms: List<Inflection>
) : UnifiedMorphology()
}
/**
* Moved here to be shared. Represents a single form with its tags.
*/
@Serializable
data class Inflection(
val form: String,
val tags: List<String>
)

View File

@@ -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<FormData>,
rule: MorphologyRule.GridRule,
lemma: String
): UnifiedMorphology.Grid {
val cells = mutableMapOf<String, String>()
// 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<FormData>,
rule: MorphologyRule.VerbRule,
lemma: String
): UnifiedMorphology.Verb {
val tenseResults = mutableMapOf<String, List<String>>()
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<FormData>): UnifiedMorphology.ListForms {
return UnifiedMorphology.ListForms(
forms.map { Inflection(it.form, it.tags) }
)
}
}

View File

@@ -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
}

View File

@@ -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<List<ApiLogEntry>> = 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
}

View File

@@ -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<Preferences> = 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<ApiProvider>(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<ApiProvider>(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<ApiProvider>(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<List<ApiProvider>> {
val providersFlow = dataStore.loadObjectList<ApiProvider>(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<Map<String, String>> {
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>): 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<ApiProvider>) {
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<ApiProvider>(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<ApiProvider>(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<ApiProvider>(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<LanguageModel?> = 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<LanguageModel?> = 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<LanguageModel?> = 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<LanguageModel?> = 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<Any>
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)
}
}}

View File

@@ -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<Preferences> by preferencesDataStore(name = "app_data")
suspend inline fun <reified T> DataStore<Preferences>.saveObject(key: Preferences.Key<String>, obj: T) {
edit { preferences ->
val jsonString = Json.encodeToString(obj)
preferences[key] = jsonString
}
}
suspend fun DataStore<Preferences>.saveStringSet(key: Preferences.Key<Set<String>>, set: Set<String>) {
edit { preferences ->
preferences[key] = set
}
}
fun DataStore<Preferences>.loadStringSet(key: Preferences.Key<Set<String>>): Flow<Set<String>> {
return data.map { preferences ->
preferences[key] ?: emptySet()
}
}
inline fun <reified T> DataStore<Preferences>.loadObject(key: Preferences.Key<String>): Flow<T?> {
return data.map { preferences ->
val jsonString = preferences[key]
if (jsonString != null) {
Json.decodeFromString<T>(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 <reified T> DataStore<Preferences>.saveObjectList(key: Preferences.Key<String>, list: List<T>) {
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 <reified T> DataStore<Preferences>.loadObjectList(key: Preferences.Key<String>): Flow<List<T>> {
return data.map { preferences ->
val jsonString = preferences[key]
if (jsonString != null) {
Json.decodeFromString<List<T>>(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<Preferences>.clear(key: Preferences.Key<String>) {
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<Instant> {
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())
}
}

View File

@@ -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<String> {
val db = getDatabase(fileInfo) ?: return emptyList()
val tables = mutableListOf<String>()
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<String> {
val db = getDatabase(fileInfo) ?: return emptyList()
val columns = mutableListOf<String>()
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<Map<String, Any>> {
val db = getDatabase(fileInfo) ?: return emptyList()
val data = mutableListOf<Map<String, Any>>()
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<String, Any>()
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
}
}

View File

@@ -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<List<FileInfo>>(emptyList())
val downloadedDictionaries: Flow<List<FileInfo>> = _downloadedDictionaries.asStateFlow()
private val _orphanedFiles = MutableStateFlow<List<FileInfo>>(emptyList())
val orphanedFiles: Flow<List<FileInfo>> = _orphanedFiles.asStateFlow()
private val _manifest = MutableStateFlow<ManifestResponse?>(null)
val manifest: Flow<ManifestResponse?> = _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<String>()
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<String>()
dictionaryFiles.forEach { file ->
val deleted = file.delete()
if (!deleted) {
allDeleted = false
failedFiles.add(file.name)
Log.e("DictionaryFileRepository", "Failed to delete file: ${file.name}")
}
}
if (allDeleted) {
// Clear all versions from SharedPreferences
val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
sharedPreferences.edit { clear() }
loadDownloadedDictionaries()
Log.d("DictionaryFileRepository", "Deleted all dictionary files")
} else {
throw Exception("Failed to delete some files: ${failedFiles.joinToString(", ")}")
}
return true
} catch (e: Exception) {
Log.e("DictionaryFileRepository", "Error deleting all dictionaries", e)
throw e
}
}
/**
* Deletes an orphaned file.
*/
fun deleteOrphanedFile(fileInfo: FileInfo): Boolean {
try {
// For orphaned files, we only have one asset (the .db file)
val asset = fileInfo.assets.firstOrNull()
?: throw Exception("No asset found for orphaned file: ${fileInfo.id}")
val localFile = File(context.filesDir, asset.filename)
if (!localFile.exists()) {
throw Exception("Orphaned file not found: ${asset.filename}")
}
val deleted = localFile.delete()
if (deleted) {
loadDownloadedDictionaries()
Log.d("DictionaryFileRepository", "Deleted orphaned file: ${asset.filename}")
} else {
throw Exception("Failed to delete orphaned file: ${asset.filename}")
}
return true
} catch (e: Exception) {
Log.e("DictionaryFileRepository", "Error deleting orphaned file", e)
return false
}
}
/**
* Gets the total size of all assets for a downloaded dictionary.
*/
fun getDictionarySize(fileInfo: FileInfo): Long {
return fileInfo.assets.sumOf { asset ->
val localFile = File(context.filesDir, asset.filename)
if (localFile.exists()) localFile.length() else 0L
}
}
/**
* Loads the list of downloaded dictionaries and orphaned files based on local files.
*/
private fun loadDownloadedDictionaries() {
val filesDir = context.filesDir
val allDictionaryFiles = filesDir.listFiles { file ->
file.isFile && (
file.name.endsWith(".db") ||
file.name.endsWith(".zstdict") ||
file.name.endsWith(".db.corrupt") ||
file.name.startsWith("dictionary")
) &&
!file.name.endsWith(".db-wal") &&
!file.name.endsWith(".db-shm")
} ?: emptyArray()
val manifestFiles = _manifest.value?.files ?: emptyList()
// Find downloaded dictionaries (files where all assets exist locally)
val downloadedFiles = manifestFiles.filter { fileInfo ->
fileInfo.assets.all { asset ->
File(context.filesDir, asset.filename).exists()
}
}
// Collect all asset filenames from downloaded dictionaries
val downloadedAssetFilenames = downloadedFiles.flatMap { it.assets }.map { it.filename }.toSet()
// Find orphaned files (dictionary files that are not assets of any downloaded dictionary)
val orphanedFiles = allDictionaryFiles
.filter { file -> file.name !in downloadedAssetFilenames }
.map { file ->
// Create a FileInfo for orphaned files with minimal data
FileInfo(
id = "orphaned_${file.name}",
name = context.getString(R.string.label_unknown_dictionary_d, file.name),
description = context.getString(R.string.text_orphaned_file_description),
version = context.getString(R.string.label_unknown),
assets = listOf(
Asset(
filename = file.name,
sizeBytes = file.length(),
checksumSha256 = context.getString(R.string.label_unknown),
)
)
)
}
.toList()
_downloadedDictionaries.value = downloadedFiles
_orphanedFiles.value = orphanedFiles
}
}

View File

@@ -0,0 +1,188 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.repository
import eu.gaudian.translator.model.grammar.DictionaryEntryData
import eu.gaudian.translator.model.grammar.DictionaryJsonParser
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
/**
* Service layer for accessing parsed dictionary JSON data.
*
* This service provides a clean interface between the repository layer
* and the JSON parsing logic, making it easy to use structured dictionary
* data throughout the application.
*
* The service handles caching and error handling, ensuring that
* UI components and other parts of the app can safely access
* parsed dictionary data without worrying about JSON parsing details.
*/
class DictionaryJsonService @Inject constructor() {
private val parseCache = mutableMapOf<String, DictionaryEntryData?>()
@Suppress("PrivatePropertyName")
private val TAG = "DictionaryJsonService"
/**
* Parse a dictionary entry's JSON data into a structured format.
*
* @param entry The dictionary entry from the local database
* @return Structured dictionary data or null if parsing fails
*/
suspend fun parseEntry(entry: DictionaryWordEntry): DictionaryEntryData? {
return withContext(Dispatchers.IO) {
// Check cache first
parseCache[entry.json]?.let { return@withContext it }
try {
val parsed = DictionaryJsonParser.parseJson(entry.json)
parseCache[entry.json] = parsed
parsed
} catch (e: Exception) {
Log.e(TAG, "Failed to parse dictionary entry for '${entry.word}': ${e.message}", e)
parseCache[entry.json] = null
null
}
}
}
/**
* Parse multiple dictionary entries efficiently.
*
* @param entries List of dictionary entries to parse
* @return Map of entry word to parsed data (null for failed parses)
*/
suspend fun parseEntries(entries: List<DictionaryWordEntry>): Map<String, DictionaryEntryData?> {
return withContext(Dispatchers.IO) {
entries.associate { entry ->
entry.word to parseEntry(entry)
}
}
}
/**
* Get translations for a specific dictionary entry.
*
* @param entry The dictionary entry
* @return List of translations or empty list if parsing fails
*/
suspend fun getTranslations(entry: DictionaryWordEntry) =
parseEntry(entry)?.translations ?: emptyList()
/**
* Get synonyms for a specific dictionary entry.
*
* @param entry The dictionary entry
* @return List of synonyms or empty list if parsing fails
*/
suspend fun getSynonyms(entry: DictionaryWordEntry) =
parseEntry(entry)?.synonyms ?: emptyList()
/**
* Get hyponyms for a specific dictionary entry.
*
* @param entry The dictionary entry
* @return List of hyponyms or empty list if parsing fails
*/
suspend fun getHyponyms(entry: DictionaryWordEntry) =
parseEntry(entry)?.hyponyms ?: emptyList()
/**
* Get all related words for a specific dictionary entry.
*
* @param entry The dictionary entry
* @return List of all related words or empty list if parsing fails
*/
suspend fun getAllRelatedWords(entry: DictionaryWordEntry) =
parseEntry(entry)?.allRelatedWords ?: emptyList()
suspend fun getPhonetics(entry: DictionaryWordEntry): List<String> =
parseEntry(entry)?.phonetics?.ipa ?: emptyList()
/**
* Get hyphenation data for a specific dictionary entry.
*
* @param entry The dictionary entry
* @return List of hyphenation parts or empty list if parsing fails
*/
suspend fun getHyphenation(entry: DictionaryWordEntry) =
parseEntry(entry)?.hyphenation ?: emptyList()
/**
* Get etymology data for a specific dictionary entry.
*
* @param entry The dictionary entry
* @return Etymology data or empty data if parsing fails
*/
suspend fun getEtymology(entry: DictionaryWordEntry) =
parseEntry(entry)?.etymology ?: eu.gaudian.translator.model.grammar.EtymologyData(emptyList())
/**
* Get senses/definitions for a specific dictionary entry.
*
* @param entry The dictionary entry
* @return List of senses or empty list if parsing fails
*/
suspend fun getSenses(entry: DictionaryWordEntry) =
parseEntry(entry)?.senses ?: emptyList()
/**
* Get grammatical properties for a specific dictionary entry.
*
* @param entry The dictionary entry
* @return Grammatical properties or null if parsing fails
*/
suspend fun getGrammaticalProperties(entry: DictionaryWordEntry) =
parseEntry(entry)?.grammaticalProperties
/**
* Get pronunciation data for a specific dictionary entry.
*
* @param entry The dictionary entry
* @return List of pronunciation data or empty list if parsing fails
*/
suspend fun getPronunciation(entry: DictionaryWordEntry) =
parseEntry(entry)?.pronunciation ?: emptyList()
/**
* Get inflection data for a specific dictionary entry.
*
* @param entry The dictionary entry
* @return List of inflection data or empty list if parsing fails
*/
suspend fun getInflections(entry: DictionaryWordEntry) =
parseEntry(entry)?.inflections ?: emptyList()
/**
* Clear the internal cache. Useful for testing or when memory is constrained.
*/
fun clearCache() {
parseCache.clear()
}
/**
* Get cache statistics for debugging purposes.
*/
fun getCacheStats(): CacheStats {
return CacheStats(
totalEntries = parseCache.size,
successfulParses = parseCache.values.count { it != null },
failedParses = parseCache.values.count { it == null }
)
}
}
/**
* Cache statistics for debugging and monitoring.
*/
data class CacheStats(
val totalEntries: Int,
val successfulParses: Int,
val failedParses: Int
)

View File

@@ -0,0 +1,334 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.repository
import android.content.Context
import com.github.luben.zstd.Zstd
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import eu.gaudian.translator.model.communication.Asset
import eu.gaudian.translator.model.communication.FileInfo
import eu.gaudian.translator.utils.Log
import java.io.File
import java.io.IOException
import java.text.Normalizer
/**
* Data class representing a dictionary word entry with parsed fields where possible.
*/
data class DictionaryWordEntry(
val word: String,
val langCode: String,
val pos: String?,
val json: String
)
/**
* Repository for performing dictionary lookups using the DictionaryDatabaseRepository.
* Handles specific dictionary files named "dictionary_<langCode>.db".
*/
class DictionaryLookupRepository(private val context: Context) {
private val databaseRepository = DictionaryDatabaseRepository(context)
private val gson = Gson()
@Suppress("PrivatePropertyName")
private val TAG = "DictionaryLookupRepository"
private val dictionaryCache = mutableMapOf<String, ByteArray>()
/**
* Developer helper: returns a list of ALL words in the local dictionary for a language.
* This can be large and is intended for debugging / cycling through entries only.
*/
@Suppress("HardCodedStringLiteral")
fun getAllWords(langCode: String, limit: Int = 10_000, offset: Int = 0): List<String> {
if (!hasDictionaryForLanguage(langCode)) {
Log.w(TAG, "No dictionary available for language: $langCode")
return emptyList()
}
val dbFilename = "dictionary_$langCode.db"
val dictFilename = "dictionary_$langCode.zstdict"
val fileInfo = FileInfo(
id = "dictionary_$langCode",
name = "Dictionary for $langCode",
description = "",
version = "",
assets = listOf(
Asset(filename = dbFilename, sizeBytes = 0L, checksumSha256 = ""),
Asset(filename = dictFilename, sizeBytes = 0L, checksumSha256 = "")
)
)
val db = databaseRepository.getDatabase(fileInfo) ?: return emptyList()
val sql = "SELECT word FROM dictionary_data ORDER BY word LIMIT ? OFFSET ?"
Log.d(TAG, "Developer getAllWords for '$langCode' with limit=$limit, offset=$offset")
return try {
db.rawQuery(sql, arrayOf(limit.toString(), offset.toString())).use { cursor ->
val words = mutableListOf<String>()
val wordIndex = cursor.getColumnIndex("word")
while (cursor.moveToNext()) {
val wordValue = cursor.getString(wordIndex)
if (!wordValue.isNullOrBlank()) {
words.add(wordValue)
}
}
Log.d(TAG, "Developer getAllWords fetched ${words.size} words")
words
}
} catch (e: Exception) {
Log.e(TAG, "Developer getAllWords failed for lang '$langCode': ${e.message}", e)
emptyList()
}.also { db.close() }
}
/**
* Checks if a dictionary file exists for the given language code.
* Language codes are the first part in languages.xml strings, e.g., "de" for German.
*/
fun hasDictionaryForLanguage(langCode: String): Boolean {
val filename = "dictionary_$langCode.db"
val exists = File(context.filesDir, filename).exists()
return exists
}
/**
* Checks if a specific word exists in the dictionary for the given language code.
* This performs an actual lookup to verify the word is available, not just that
* the dictionary file exists.
*/
fun hasWordInDictionary(word: String, langCode: String): Boolean {
if (!hasDictionaryForLanguage(langCode)) {
return false
}
val dbFilename = "dictionary_$langCode.db"
val dictFilename = "dictionary_$langCode.zstdict"
val fileInfo = FileInfo(
id = "dictionary_$langCode",
name = "Dictionary for $langCode",
description = "",
version = "",
assets = listOf(
Asset(filename = dbFilename, sizeBytes = 0L, checksumSha256 = ""),
Asset(filename = dictFilename, sizeBytes = 0L, checksumSha256 = "")
)
)
val db = databaseRepository.getDatabase(fileInfo) ?: return false
val sql = "SELECT 1 FROM dictionary_data WHERE word = ? COLLATE NOCASE LIMIT 1"
return try {
db.rawQuery(sql, arrayOf(word)).use { cursor ->
val found = cursor.moveToFirst()
found
}
} catch (e: Exception) {
Log.e(TAG, "Failed to check word '$word' in dictionary '$langCode': ${e.message}", e)
false
}.also { db.close() }
}
/**
* Searches for words in the dictionary for the specified language code.
* Returns a list of DictionaryWordEntry matching the exact word.
* Uses case-insensitive exact match on the word column.
*/
@Suppress("HardCodedStringLiteral")
fun searchWord(word: String, langCode: String): List<DictionaryWordEntry> {
if (!hasDictionaryForLanguage(langCode)) {
Log.w(TAG, "No dictionary available for language: $langCode")
return emptyList()
}
val dbFilename = "dictionary_$langCode.db"
val dictFilename = "dictionary_$langCode.zstdict"
val fileInfo = FileInfo(
id = "dictionary_$langCode",
name = "Dictionary for $langCode",
description = "",
version = "",
assets = listOf(
Asset(filename = dbFilename, sizeBytes = 0L, checksumSha256 = ""),
Asset(filename = dictFilename, sizeBytes = 0L, checksumSha256 = "")
)
)
val db = databaseRepository.getDatabase(fileInfo) ?: return emptyList()
val sql = "SELECT word, pos, data_blob, uncompressed_size FROM dictionary_data WHERE word = ? COLLATE NOCASE LIMIT 100"
//Log.d(TAG, "Attempting query for exact word match '$word': $sql")
return try {
db.rawQuery(sql, arrayOf(word)).use { cursor ->
val entries = mutableListOf<DictionaryWordEntry>()
val wordIndex = cursor.getColumnIndex("word")
val posIndex = cursor.getColumnIndex("pos")
val blobIndex = cursor.getColumnIndex("data_blob")
val sizeIndex = cursor.getColumnIndex("uncompressed_size")
if(cursor.count != 1){
Log.d(TAG, "Cursor has ${cursor.count} rows")}
while (cursor.moveToNext()) {
val wordValue = cursor.getString(wordIndex)
val posValue = if (posIndex >= 0) cursor.getString(posIndex) else null
val blob = cursor.getBlob(blobIndex)
val uncompressedSize = cursor.getInt(sizeIndex)
// Decompress the blob to get the JSON data
val dict = getDecompressionDict(langCode)
val jsonData = if (dict != null && blob != null) {
try {
String(Zstd.decompress(blob, dict, uncompressedSize))
} catch (e: Exception) {
Log.e(TAG, "Failed to decompress data for word '$word'", e)
""
}
} else {
""
}
val entry = DictionaryWordEntry(
word = wordValue,
langCode = langCode,
pos = posValue,
json = jsonData
)
entries.add(entry)
}
if(entries.size != 1){
Log.d(TAG, "Processed ${entries.size} results")}
entries
}
} catch (e: Exception) {
Log.e(TAG, "Query failed for word '$word' in dictionary '$langCode': ${e.message}", e)
Log.d(TAG, "Attemped query for exact word match '$word': $sql")
emptyList()
}.also { db.close() }
}
/**
* Parses JSON string into a list of strings, or null if parsing fails.
*/
@Suppress("unused")
private fun parseJsonList(json: String?): List<String>? {
return json?.let {
try {
gson.fromJson(it, object : TypeToken<List<String>>() {}.type)
} catch (e: Exception) {
@Suppress("HardCodedStringLiteral")
Log.w(TAG, "Failed to parse JSON list: $json", e)
null
}
}
}
/**
* Retrieves word suggestions based on prefix match.
* The match is accent-insensitive, so e.g. "haufig" will still suggest "häufig".
*/
fun getSuggestions(prefix: String, langCode: String, limit: Int): List<String> {
if (!hasDictionaryForLanguage(langCode)) {
return emptyList()
}
// Normalize the prefix: lowercase and strip diacritics (accents)
val normalizedPrefix = prefix.trim().lowercase().removeDiacritics()
if (normalizedPrefix.isEmpty()) {
return emptyList()
}
val dbFilename = "dictionary_$langCode.db"
val dictFilename = "dictionary_$langCode.zstdict"
@Suppress("HardCodedStringLiteral") val fileInfo = FileInfo(
id = "dictionary_$langCode",
name = "Dictionary for $langCode",
description = "",
version = "",
assets = listOf(
Asset(filename = dbFilename, sizeBytes = 0L, checksumSha256 = ""),
Asset(filename = dictFilename, sizeBytes = 0L, checksumSha256 = "")
)
)
val db = databaseRepository.getDatabase(fileInfo) ?: return emptyList()
// To support accent-insensitive matching, query a broader set of candidates
// (all words starting with the same first base letter), then filter in Kotlin.
val broadPrefix = normalizedPrefix[0].toString()
val sql = "SELECT word FROM dictionary_fts WHERE word MATCH ? LIMIT ?"
val matchQuery = "$broadPrefix*"
return try {
db.rawQuery(sql, arrayOf(matchQuery, (limit * 20).toString())).use { cursor ->
val suggestions = mutableListOf<String>()
val wordIndex = cursor.getColumnIndex("word")
while (cursor.moveToNext()) {
val word = cursor.getString(wordIndex)
if (!word.isNullOrBlank()) {
suggestions.add(word)
}
}
suggestions
.distinct() // Remove duplicates
.filter { candidate ->
val candidateNorm = candidate.lowercase().removeDiacritics()
candidateNorm.startsWith(normalizedPrefix)
}
.take(limit)
}
} catch (e: Exception) {
Log.e(TAG, "Suggestion query failed: ${e.message}", e)
emptyList()
}.also { db.close() }
}
/**
* Parses translations JSON into a map of language code to list of translations, or null if parsing fails.
*/
@Suppress("unused")
private fun parseTranslationsJson(json: String?): Map<String, List<String>>? {
return json?.let {
try {
gson.fromJson(it, object : TypeToken<Map<String, List<String>>>() {}.type)
} catch (e: Exception) {
@Suppress("HardCodedStringLiteral")
Log.w(TAG, "Failed to parse translations JSON: $json", e)
null
}
}
}
/**
* Gets a cached decompression dictionary for the given language, creating it if necessary.
*/
private fun getDecompressionDict(langCode: String): ByteArray? {
// Return the cached dictionary if it's already loaded
if (dictionaryCache.containsKey(langCode)) {
return dictionaryCache[langCode]
}
return try {
val dictFileName = "dictionary_${langCode}.zstdict"
Log.d(TAG, "Loading compression dictionary: $dictFileName")
// Read the dictionary file from filesDir (consistent with database location)
val dictFile = File(context.filesDir, dictFileName)
if (dictFile.exists()) {
val dictBytes = dictFile.readBytes()
// Cache the dictionary bytes for later use
dictionaryCache[langCode] = dictBytes
dictBytes
} else {
Log.e(TAG, "Zstd dictionary file does not exist: $dictFileName")
null
}
} catch (e: IOException) {
Log.e(TAG, "Failed to load Zstd dictionary file for lang '$langCode'", e)
null
}
}
}
private fun String.removeDiacritics(): String {
val normalized = Normalizer.normalize(this, Normalizer.Form.NFD)
return normalized.replace("\\p{Mn}+".toRegex(), "")
}

View File

@@ -0,0 +1,89 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.repository
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import eu.gaudian.translator.model.DictionaryEntry
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.SerializationException
class DictionaryRepository(private val context: Context) {
companion object {
private val DICTIONARY_ENTRY_KEY = stringPreferencesKey("dictionary_entry")
private val WORD_OF_THE_DAY_KEY = stringPreferencesKey("word_of_the_day")
}
init {
CoroutineScope(context = Dispatchers.IO).launch {
}
}
suspend fun saveDictionaryEntry(entry: DictionaryEntry) {
val history = loadDictionaryEntry().first().toMutableList()
history.removeAll { it.word.equals(entry.word, ignoreCase = true) && it.languageCode == entry.languageCode }
history.add(0, entry)
val updatedHistory = history.take(20)
context.dataStore.saveObjectList(DICTIONARY_ENTRY_KEY, updatedHistory)
}
fun loadDictionaryEntry(): Flow<List<DictionaryEntry>> {
return context.dataStore.loadObjectList<DictionaryEntry>(DICTIONARY_ENTRY_KEY)
.catch { exception ->
if (exception is SerializationException) {
Log.w("DictionaryRepo", "Could not parse old dictionary history. Clearing it.", exception)
clearHistory()
emit(emptyList())
} else {
throw exception
}
}
}
suspend fun updateDictionaryEntry(entry: DictionaryEntry) {
val history = loadDictionaryEntry().first().toMutableList()
val index = history.indexOfFirst { it.id == entry.id }
if (index != -1) {
history[index] = entry
context.dataStore.saveObjectList(DICTIONARY_ENTRY_KEY, history)
Log.d("DictionaryRepo", "Entry with id ${entry.id} updated.")
} else {
Log.w("DictionaryRepo", "Attempted to update non-existent entry with id ${entry.id}.")
}
}
suspend fun clearHistory() {
context.dataStore.saveObjectList(DICTIONARY_ENTRY_KEY, emptyList<DictionaryEntry>())
}
suspend fun saveWordOfTheDay(entry: DictionaryEntry) {
context.dataStore.saveObject(WORD_OF_THE_DAY_KEY, entry)
}
fun loadWordOfTheDay(): Flow<DictionaryEntry?> {
return context.dataStore.loadObject<DictionaryEntry>(WORD_OF_THE_DAY_KEY)
.catch { exception ->
if (exception is SerializationException) {
Log.w("DictionaryRepo", "Could not parse old Word of the Day. Clearing it.", exception)
context.dataStore.edit { preferences ->
preferences.remove(WORD_OF_THE_DAY_KEY)
}
emit(null)
} else {
throw exception
}
}
}
}

View File

@@ -0,0 +1,96 @@
@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 eu.gaudian.translator.model.Exercise
import eu.gaudian.translator.model.Question
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
private const val TAG = "ExerciseRepository"
class ExerciseRepository {
constructor(context: Context) {
this.dataStore = context.dataStore
}
private val dataStore: DataStore<Preferences>
fun getAllExercisesFlow(): Flow<List<Exercise>> {
return dataStore.loadObjectList(DataStoreKeys.EXERCISES_KEY)
}
private suspend fun getAllExercises(): List<Exercise> {
return getAllExercisesFlow().firstOrNull() ?: emptyList()
}
suspend fun saveExercise(exercise: Exercise) {
try {
val currentExercises = getAllExercises().toMutableList()
val existingIndex = currentExercises.indexOfFirst { it.id == exercise.id }
if (existingIndex != -1) {
currentExercises[existingIndex] = exercise
} else {
currentExercises.add(exercise)
}
dataStore.saveObjectList(DataStoreKeys.EXERCISES_KEY, currentExercises)
} catch (e: Exception) {
Log.e(TAG, "Failed to save exercise", e)
}
}
suspend fun deleteExercise(exerciseId: String) {
try {
val currentExercises = getAllExercises().toMutableList()
if (currentExercises.removeAll { it.id == exerciseId }) {
dataStore.saveObjectList(DataStoreKeys.EXERCISES_KEY, currentExercises)
Log.d(TAG, "Successfully deleted exercise with ID: $exerciseId")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to delete exercise with ID: $exerciseId", e)
}
}
fun getAllQuestionsFlow(): Flow<List<Question>> {
return dataStore.loadObjectList(DataStoreKeys.QUESTIONS_KEY)
}
private suspend fun getAllQuestions(): List<Question> {
return getAllQuestionsFlow().firstOrNull() ?: emptyList()
}
/**
* Saves a list of questions to the DataStore, replacing any existing ones with the same ID.
*/
private suspend fun saveQuestions(questions: List<Question>) {
try {
val currentQuestions = getAllQuestions().toMutableList()
questions.forEach { newQuestion ->
val index = currentQuestions.indexOfFirst { it.id == newQuestion.id }
if (index != -1) {
currentQuestions[index] = newQuestion
} else {
currentQuestions.add(newQuestion)
}
}
dataStore.saveObjectList(DataStoreKeys.QUESTIONS_KEY, currentQuestions)
} catch (e: Exception) {
Log.e(TAG, "Failed to save questions", e)
}
}
suspend fun saveNewExerciseWithQuestions(exercise: Exercise, questions: List<Question>) {
saveQuestions(questions)
saveExercise(exercise)
Log.d(TAG, "Successfully saved new exercise '${exercise.title}' with ${questions.size} questions.")
}
}

View File

@@ -0,0 +1,367 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.repository
import android.content.Context
import android.content.pm.PackageManager
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.parseLanguagesFromResources
import eu.gaudian.translator.model.repository.DataStoreKeys.LANGUAGE_INIT_METADATA_KEY
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
enum class LanguageListType {
DEFAULT,
CUSTOM,
ALL,
FAVORITE,
HISTORY
}
class LanguageRepository(private val context: Context) {
private val dataStore = context.dataStore
private fun getKeyForType(type: LanguageListType) = when (type) {
// Note: For ALL we will now store only the enabled language IDs (Int) instead of full Language objects
LanguageListType.ALL -> DataStoreKeys.ALL_LANGUAGES_KEY
LanguageListType.DEFAULT -> DataStoreKeys.DEFAULT_LANGUAGES_KEY
LanguageListType.CUSTOM -> DataStoreKeys.CUSTOM_LANGUAGES_KEY
LanguageListType.FAVORITE -> DataStoreKeys.FAVORITE_LANGUAGES_KEY
LanguageListType.HISTORY -> DataStoreKeys.LANGUAGE_HISTORY_KEY
}
// Returns a flow of the master catalog = DEFAULT + CUSTOM with conflict disambiguation applied
private fun masterLanguagesFlow(): Flow<List<Language>> {
return kotlinx.coroutines.flow.combine(
loadLanguages(LanguageListType.DEFAULT),
loadLanguages(LanguageListType.CUSTOM)
) { defaults, customs ->
val master = (defaults + customs)
disambiguateConflictingNames(master)
}
}
// Suffix region codes for languages with duplicate names within the provided list
private fun disambiguateConflictingNames(list: List<Language>): List<Language> {
if (list.isEmpty()) return list
// Define a regex that matches a trailing region suffix we add, e.g. " (DE)" or " (PT)"
val suffixRegex = " \\([A-Z]{2,}\\)$".toRegex()
// Compute base names by stripping any existing suffix first
val baseNames = list.associate { lang ->
lang.name to lang.name.replace(suffixRegex, "")
}
// Count occurrences by base name
val countsByBase = list
.map { baseNames[it.name] ?: it.name }
.groupingBy { it }
.eachCount()
// Map each language to a display name based on base name conflict
return list.map { lang ->
val base = baseNames[lang.name] ?: lang.name
val count = countsByBase[base] ?: 0
if (count > 1 && lang.region.isNotEmpty()) {
val suffix = lang.region.uppercase()
// Always construct from base to prevent duplicate/chained suffixes
lang.copy(name = "$base ($suffix)")
} else {
// No conflict: ensure we show the clean base
lang.copy(name = base)
}
}
}
suspend fun wipeHistoryAndFavorites() {
clearLanguages(LanguageListType.HISTORY)
clearLanguages(LanguageListType.FAVORITE)
saveSelectedSourceLanguage(null)
saveSelectedTargetLanguage(null)
}
suspend fun initializeDefaultLanguages() {
Log.d("LanguageRepository", "Initializing default languages")
try {
// Check if we already have default languages saved
val savedDefaultLanguages = loadLanguages(LanguageListType.DEFAULT).firstOrNull() ?: emptyList()
// Check if we need to re-parse languages (first run, version change, or language change)
val shouldReparse = shouldReparseLanguages(savedDefaultLanguages)
if (shouldReparse) {
Log.d("LanguageRepository", "Parsing languages from resources")
val parsedLanguages = parseLanguagesFromResources(context)
wipeHistoryAndFavorites()
saveLanguages(LanguageListType.DEFAULT, parsedLanguages)
// Save the current app version and locale to detect changes next time
saveLanguageInitializationMetadata()
} else {
Log.d("LanguageRepository", "Using cached default languages")
}
} catch (e: Exception) {
Log.e("LanguageRepository", "Error initializing default languages: ${e.message}", e)
}
}
private suspend fun shouldReparseLanguages(savedLanguages: List<Language>): Boolean {
// Always reparse if no languages are saved
if (savedLanguages.isEmpty()) {
return true
}
// Check if the number of languages matches expected count (51)
if (savedLanguages.size != 51) {
return true
}
// Check if app version has changed (indicating possible new languages)
val metadata = getLanguageInitializationMetadata()
val currentVersion = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
} catch (_: PackageManager.NameNotFoundException) {
// If we can't get the package info, assume version changed to trigger reparse
return true
}
if (metadata?.appVersion != currentVersion) {
return true
}
// Check if system locale has changed (affecting localized language names)
val currentLocale = context.resources.configuration.locales.get(0)?.toLanguageTag()
return metadata?.systemLocale != currentLocale
}
private suspend fun saveLanguageInitializationMetadata() {
val currentVersion = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
} catch (_: PackageManager.NameNotFoundException) {
// If we can't get the package info, use a default value
"unknown"
}
val currentLocale = context.resources.configuration.locales.get(0)?.toLanguageTag() ?: ""
val metadata = LanguageInitializationMetadata(
appVersion = currentVersion,
systemLocale = currentLocale,
timestamp = System.currentTimeMillis()
)
dataStore.saveObject(LANGUAGE_INIT_METADATA_KEY, metadata)
}
private suspend fun getLanguageInitializationMetadata(): LanguageInitializationMetadata? {
return try {
dataStore.loadObject<LanguageInitializationMetadata>(LANGUAGE_INIT_METADATA_KEY).firstOrNull()
} catch (_: Exception) {
null
}
}
suspend fun initializeAllLanguages() {
Log.d("LanguageRepository", "Initializing enabled languages (ALL as IDs)")
try {
val defaultLanguages = loadLanguages(LanguageListType.DEFAULT).first()
val customLanguages = loadLanguages(LanguageListType.CUSTOM).first()
val master = (defaultLanguages + customLanguages).distinctBy { it.nameResId }
// Sanitize existing enabled IDs and initialize if empty
val existingEnabled: List<Int> = try {
context.dataStore.loadObjectList<Int>(DataStoreKeys.ALL_LANGUAGES_KEY).firstOrNull() ?: emptyList()
} catch (_: Exception) {
emptyList()
}
val masterIds = master.map { it.nameResId }.toSet()
val sanitized = existingEnabled.filter { it in masterIds }
val finalIds = sanitized.ifEmpty { master.filter { it.isSelected == true }.map { it.nameResId } }
context.dataStore.saveObjectList(DataStoreKeys.ALL_LANGUAGES_KEY, finalIds)
val historyLanguages = loadLanguages(LanguageListType.HISTORY).firstOrNull() ?: emptyList()
if (historyLanguages.size > 5) {
saveLanguages(LanguageListType.HISTORY, historyLanguages.takeLast(5))
}
} catch (e: Exception) {
Log.e("LanguageRepository", "Error initializing enabled languages: ${e.message}", e)
}
}
suspend fun getLanguagesByResourceIds(ids: Set<Int>): List<Language> {
val master = masterLanguagesFlow().first()
return master.filter { it.nameResId in ids }
}
fun loadMasterLanguages(): Flow<List<Language>> = masterLanguagesFlow()
suspend fun setEnabledLanguagesByIds(ids: List<Int>) {
dataStore.saveObjectList(DataStoreKeys.ALL_LANGUAGES_KEY, ids)
}
suspend fun editCustomLanguage(languageId: Int, newName: String?, newCode: String, newRegion: String) {
// Update in DEFAULT or CUSTOM by id (nameResId)
val defaults = loadLanguages(LanguageListType.DEFAULT).first().toMutableList()
val customs = loadLanguages(LanguageListType.CUSTOM).first().toMutableList()
var updated = false
for (i in defaults.indices) {
if (defaults[i].nameResId == languageId) {
val l = defaults[i]
// Default languages: do not change name
defaults[i] = l.copy(code = newCode, region = newRegion)
updated = true
break
}
}
if (!updated) {
for (i in customs.indices) {
if (customs[i].nameResId == languageId) {
val l = customs[i]
// Custom languages: allow name editing if provided
val targetName = newName ?: l.name
// Prevent exact duplicates by name+region+code with other customs
val duplicate = customs.withIndex().any { (idx, other) ->
idx != i && other.name.equals(targetName, true) && other.region.equals(newRegion, true) && other.code.equals(newCode, true)
}
if (!duplicate) {
customs[i] = l.copy(name = targetName, code = newCode, region = newRegion)
updated = true
} else {
Log.w("LanguageRepository", "Skipping update to avoid duplicate custom language")
}
break
}
}
}
if (updated) {
saveLanguages(LanguageListType.DEFAULT, defaults)
saveLanguages(LanguageListType.CUSTOM, customs)
// Update selected languages if necessary
val src = loadSelectedSourceLanguage().first()
if (src?.nameResId == languageId) saveSelectedSourceLanguage(src.copy(name = newName ?: src.name, code = newCode, region = newRegion))
val tgt = loadSelectedTargetLanguage().first()
if (tgt?.nameResId == languageId) saveSelectedTargetLanguage(tgt.copy(name = newName ?: tgt.name, code = newCode, region = newRegion))
val dict = loadSelectedDictionaryLanguage().first()
if (dict?.nameResId == languageId) saveSelectedDictionaryLanguage(dict.copy(name = newName ?: dict.name, code = newCode, region = newRegion))
initializeAllLanguages()
}
}
fun loadLanguages(type: LanguageListType): Flow<List<Language>> {
return when (type) {
LanguageListType.ALL -> {
// Enabled languages (IDs) mapped to actual Language objects from master catalog
kotlinx.coroutines.flow.combine(
dataStore.loadObjectList<Int>(getKeyForType(type)),
masterLanguagesFlow()
) { ids, master ->
val idSet = ids.toSet()
disambiguateConflictingNames(master.filter { it.nameResId in idSet })
}
}
LanguageListType.FAVORITE, LanguageListType.HISTORY -> {
// Internally store only the language keys (nameResId) to avoid duplicate Language instances
kotlinx.coroutines.flow.combine(
dataStore.loadObjectList<Int>(getKeyForType(type)),
masterLanguagesFlow()
) { ids, master ->
val idSet = ids.toSet()
master.filter { it.nameResId in idSet }
}
}
else -> dataStore.loadObjectList(getKeyForType(type))
}
}
suspend fun saveLanguages(type: LanguageListType, languages: List<Language>) {
when (type) {
LanguageListType.ALL, LanguageListType.FAVORITE, LanguageListType.HISTORY -> {
val ids = languages.map { it.nameResId }
dataStore.saveObjectList(getKeyForType(type), ids)
}
else -> dataStore.saveObjectList(getKeyForType(type), languages)
}
}
suspend fun clearLanguages(type: LanguageListType) {
dataStore.clear(getKeyForType(type))
}
fun loadSelectedSourceLanguage(): Flow<Language?> {
return dataStore.loadObject(DataStoreKeys.SELECTED_SOURCE_LANGUAGE_KEY)
}
suspend fun saveSelectedSourceLanguage(language: Language?) {
dataStore.saveObject(DataStoreKeys.SELECTED_SOURCE_LANGUAGE_KEY, language)
}
fun loadSelectedTargetLanguage(): Flow<Language?> {
return dataStore.loadObject(DataStoreKeys.SELECTED_TARGET_LANGUAGE_KEY)
}
suspend fun saveSelectedTargetLanguage(language: Language?) {
dataStore.saveObject(DataStoreKeys.SELECTED_TARGET_LANGUAGE_KEY, language)
}
fun loadSelectedDictionaryLanguage(): Flow<Language?> {
return dataStore.loadObject(DataStoreKeys.SELECTED_DICTIONARY_LANGUAGE_KEY)
}
suspend fun saveSelectedDictionaryLanguage(language: Language?) {
dataStore.saveObject(DataStoreKeys.SELECTED_DICTIONARY_LANGUAGE_KEY, language)
}
suspend fun addCustomLanguage(language: Language) {
val customLanguages = loadLanguages(LanguageListType.CUSTOM).first().toMutableList()
val newId = -(System.currentTimeMillis().toInt())
val newLanguage = language.copy(nameResId = newId, isCustom = true)
// Allow same names with different regions; prevent exact duplicates by name+region+code
if (!customLanguages.any { it.name.equals(newLanguage.name, true) && it.region.equals(newLanguage.region, true) && it.code.equals(newLanguage.code, true) }) {
customLanguages.add(newLanguage)
saveLanguages(LanguageListType.CUSTOM, customLanguages)
initializeAllLanguages()
}
}
suspend fun deleteCustomLanguage(language: Language) {
// Read all lists needed first
val customLanguages = loadLanguages(LanguageListType.CUSTOM).first().toMutableList()
val historyLanguages = loadLanguages(LanguageListType.HISTORY).first().toMutableList()
val favoriteLanguages = loadLanguages(LanguageListType.FAVORITE).first().toMutableList()
val enabledIds = context.dataStore.loadObjectList<Int>(DataStoreKeys.ALL_LANGUAGES_KEY).firstOrNull()?.toMutableList() ?: mutableListOf()
// Perform removals
val wasCustomRemoved = customLanguages.removeIf { it.nameResId == language.nameResId }
val wasHistoryRemoved = historyLanguages.removeIf { it.nameResId == language.nameResId }
val wasFavoriteRemoved = favoriteLanguages.removeIf { it.nameResId == language.nameResId }
val wasEnabledRemoved = enabledIds.removeIf { it == language.nameResId }
// Write back to DataStore only if changes were made
if (wasCustomRemoved) {
saveLanguages(LanguageListType.CUSTOM, customLanguages)
// Re-run initialization to update the enabled list
initializeAllLanguages()
}
if (wasHistoryRemoved) {
saveLanguages(LanguageListType.HISTORY, historyLanguages)
}
if (wasFavoriteRemoved) {
saveLanguages(LanguageListType.FAVORITE, favoriteLanguages)
}
if (wasEnabledRemoved) {
context.dataStore.saveObjectList(DataStoreKeys.ALL_LANGUAGES_KEY, enabledIds)
}
}
suspend fun getLanguageById(id: Int): Language? {
if (id == 0) return null
return try {
masterLanguagesFlow().first().find { it.nameResId == id }
} catch (_: Exception) {
null
}
}
}

View File

@@ -0,0 +1,41 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.repository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* A generic wrapper for a single setting in DataStore.
*
* @param T The type of the setting value.
* @param dataStore The DataStore instance.
* @param key The Preferences.Key for this setting.
* @param defaultValue The default value to return if none is set.
*/
class Setting<T>(
private val dataStore: DataStore<Preferences>,
private val key: Preferences.Key<T>,
private val defaultValue: T
) {
/**
* A Flow that emits the current value of the setting.
*/
val flow: Flow<T> = dataStore.data.map { preferences ->
preferences[key] ?: defaultValue
}
/**
* Sets or updates the value of the setting.
*/
suspend fun set(value: T) {
Log.d("Setting", "Setting value for key $key to $value")
dataStore.edit { settings ->
settings[key] = value
}
}
}

View File

@@ -0,0 +1,163 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.repository
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import eu.gaudian.translator.model.communication.ApiProvider
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class SettingsRepository(private val context: Context) {
private object PrefKeys {
const val API_KEY_SUFFIX = "_api_key"
val CUSTOM_PROMPT_TRANSLATION = stringPreferencesKey("custom_prompt_translation")
val CUSTOM_PROMPT_VOCABULARY = stringPreferencesKey("custom_prompt_vocabulary")
val CUSTOM_PROMPT_DICTIONARY = stringPreferencesKey("custom_prompt_dictionary")
val CUSTOM_PROMPT_EXERCISE = stringPreferencesKey("custom_prompt_exercise")
val SPEAKING_SPEED = intPreferencesKey("SPEAKING_SPEED")
val DEVELOPER_MODE = booleanPreferencesKey("developer_mode")
val DAILY_GOAL = intPreferencesKey("daily_goal")
val SELECTED_CATEGORIES = stringSetPreferencesKey("selectedCategories")
val DICTIONARY_SWITCHES = stringSetPreferencesKey("dictionary_switches")
val THEME = stringPreferencesKey("selected_theme")
val DARK_MODE_PREFERENCE = stringPreferencesKey("dark_mode_preference")
val FONT_PREFERENCE = stringPreferencesKey("font_preference")
val INTRO_COMPLETED = booleanPreferencesKey("intro_completed")
val INTERVAL_NEW = intPreferencesKey("interval_new")
val INTERVAL_STAGE_1 = intPreferencesKey("interval_stage_1")
val INTERVAL_STAGE_2 = intPreferencesKey("interval_stage_2")
val INTERVAL_STAGE_3 = intPreferencesKey("interval_stage_3")
val INTERVAL_STAGE_4 = intPreferencesKey("interval_stage_4")
val INTERVAL_STAGE_5 = intPreferencesKey("interval_stage_5")
val INTERVAL_LEARNED = intPreferencesKey("interval_learned")
val CRITERIA_CORRECT = intPreferencesKey("criteria_correct")
val CRITERIA_WRONG = intPreferencesKey("criteria_wrong")
val SHOW_HINTS = booleanPreferencesKey("show_hints")
val EXPERIMENTAL_FEATURES = booleanPreferencesKey("experimental_features")
val TRY_WIKTIONARY_FIRST = booleanPreferencesKey("try_wiktionary_first")
val SHOW_BOTTOM_NAV_LABELS = booleanPreferencesKey("show_bottom_nav_labels")
val CONNECTION_CONFIGURED = booleanPreferencesKey("connection_configured")
val USE_LIBRE_TRANSLATE = booleanPreferencesKey("use_libretranslate")
val LAST_SEEN_VERSION = stringPreferencesKey("last_seen_version")
fun getTtsVoiceKey(code: String, region: String): androidx.datastore.preferences.core.Preferences.Key<String> {
val c = code.lowercase()
val r = region.trim()
return stringPreferencesKey("tts_voice_" + if (r.isBlank()) c else "${c}_${r.uppercase()}")
}
fun getLegacyTtsVoiceKey(code: String) = stringPreferencesKey("tts_voice_" + code.lowercase())
fun getApiKeyPrefKey(providerKey: String) = stringPreferencesKey("${providerKey}$API_KEY_SUFFIX")
}
suspend fun setTtsVoiceForLanguage(code: String, region: String, voiceName: String?) {
context.dataStore.edit { prefs ->
val key = PrefKeys.getTtsVoiceKey(code, region)
if (voiceName.isNullOrBlank()) {
prefs.remove(key)
} else {
prefs[key] = voiceName
}
}
}
fun getTtsVoiceForLanguage(code: String, region: String): Flow<String?> {
val key = PrefKeys.getTtsVoiceKey(code, region)
val legacy = PrefKeys.getLegacyTtsVoiceKey(code)
return context.dataStore.data.map { prefs ->
prefs[key] ?: prefs[legacy]
}
}
@Deprecated("Use region-aware overload", ReplaceWith("getTtsVoiceForLanguage(code, region)"))
fun getTtsVoiceForLanguage(code: String): Flow<String?> = getTtsVoiceForLanguage(code, "")
@Deprecated("Use region-aware overload", ReplaceWith("setTtsVoiceForLanguage(code, region, voiceName)"))
suspend fun setTtsVoiceForLanguage(code: String, voiceName: String?) = setTtsVoiceForLanguage(code, "", voiceName)
val theme = Setting(context.dataStore, PrefKeys.THEME, "Default")
val darkModePreference = Setting(context.dataStore, PrefKeys.DARK_MODE_PREFERENCE, "System")
val fontPreference = Setting(context.dataStore, PrefKeys.FONT_PREFERENCE, "Default")
val introCompleted = Setting(context.dataStore, PrefKeys.INTRO_COMPLETED, false)
val developerMode = Setting(context.dataStore, PrefKeys.DEVELOPER_MODE, false)
val dailyGoal = Setting(context.dataStore, PrefKeys.DAILY_GOAL, 10)
val selectedCategories = Setting(context.dataStore, PrefKeys.SELECTED_CATEGORIES, emptySet())
val dictionarySwitches = Setting(context.dataStore, PrefKeys.DICTIONARY_SWITCHES, emptySet())
val customPromptTranslation = Setting(context.dataStore, PrefKeys.CUSTOM_PROMPT_TRANSLATION, "")
val customPromptVocabulary = Setting(context.dataStore, PrefKeys.CUSTOM_PROMPT_VOCABULARY, "")
val customPromptDictionary = Setting(context.dataStore, PrefKeys.CUSTOM_PROMPT_DICTIONARY, "")
val customPromptExercise = Setting(context.dataStore, PrefKeys.CUSTOM_PROMPT_EXERCISE, "")
val speakingSpeed = Setting(context.dataStore, PrefKeys.SPEAKING_SPEED, 100)
val intervalNew = Setting(context.dataStore, PrefKeys.INTERVAL_NEW, 1)
val intervalStage1 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_1, 3)
val intervalStage2 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_2, 7)
val intervalStage3 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_3, 14)
val intervalStage4 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_4, 30)
val intervalStage5 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_5, 60)
val intervalLearned = Setting(context.dataStore, PrefKeys.INTERVAL_LEARNED, 90)
val criteriaCorrect = Setting(context.dataStore, PrefKeys.CRITERIA_CORRECT, 3)
val criteriaWrong = Setting(context.dataStore, PrefKeys.CRITERIA_WRONG, 2)
val showHints = Setting(context.dataStore, PrefKeys.SHOW_HINTS, true)
val experimentalFeatures = Setting(context.dataStore, PrefKeys.EXPERIMENTAL_FEATURES, false)
val tryWiktionaryFirst = Setting(context.dataStore, PrefKeys.TRY_WIKTIONARY_FIRST, false)
val showBottomNavLabels = Setting(context.dataStore, PrefKeys.SHOW_BOTTOM_NAV_LABELS, true)
val connectionConfigured = Setting(context.dataStore, PrefKeys.CONNECTION_CONFIGURED, true)
val useLibreTranslate = Setting(context.dataStore, PrefKeys.USE_LIBRE_TRANSLATE, false)
val lastSeenVersion = Setting(context.dataStore, PrefKeys.LAST_SEEN_VERSION, "")
fun getAllApiKeys(): Flow<Map<String, String>> {
return context.dataStore.data.map { preferences ->
preferences.asMap().mapNotNull { (key, value) ->
if (key.name.endsWith(PrefKeys.API_KEY_SUFFIX) && value is String) {
val providerKey = key.name.removeSuffix(PrefKeys.API_KEY_SUFFIX)
providerKey to value
} else {
null
}
}.toMap()
}
}
/**
* Checks if the user has seen the "what's new" dialog for the current version
*/
suspend fun hasSeenCurrentVersion(currentVersion: String): Boolean {
val lastSeen = lastSeenVersion.flow.first()
return lastSeen == currentVersion
}
/**
* Marks the current version as seen by the user
*/
suspend fun markVersionAsSeen(version: String) {
lastSeenVersion.set(version)
}
/**
* Saves an API key for a given provider.
* The provider object is used to get the key, but the preference is stored dynamically.
*/
suspend fun saveApiKey(provider: ApiProvider, apiKey: String) {
context.dataStore.edit { settings ->
settings[PrefKeys.getApiKeyPrefKey(provider.key)] = apiKey
}
}
/**
* Deletes an API key for a given provider.
*/
suspend fun deleteApiKey(providerKey: String) {
context.dataStore.edit { settings ->
settings.remove(PrefKeys.getApiKeyPrefKey(providerKey))
}
}
}

View File

@@ -0,0 +1,100 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.repository
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class VocabularyFileSaver(private val context: Context, private val repository: VocabularyRepository) {
fun createSaveDocumentIntent(suggestedFilename: String): Intent {
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/json" // Adjust as needed
putExtra(Intent.EXTRA_TITLE, suggestedFilename)
}
}
suspend fun saveRepositoryToUri(uri: Uri) {
withContext(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
val vocabularyItems = repository.getAllVocabularyItems()
if (vocabularyItems.isNotEmpty()) {
val jsonString = Json.encodeToString(vocabularyItems)
outputStream.write(jsonString.toByteArray())
val filename = getFileNameFromUri(uri)
Log.d(TAG, "File saved: $filename")
} else {
Log.e(TAG, "No vocabulary items to save.")
}
}
} catch (e: IOException) {
Log.e(TAG, "Error saving: ${e.message}", e)
}
}
}
suspend fun saveCategoryToUri(uri: Uri, categoryId: Int) {
withContext(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
val vocabularyItems = repository.getVocabularyItemsByCategory(categoryId)
if (vocabularyItems.isNotEmpty()) {
val jsonString = Json.encodeToString(vocabularyItems)
outputStream.write(jsonString.toByteArray())
val filename = getFileNameFromUri(uri)
Log.d(TAG, "File saved for category $categoryId: $filename")
} else {
Log.e(TAG, "No vocabulary items to save for category $categoryId.")
}
}
} catch (e: IOException) {
Log.e(TAG, "Error saving for category $categoryId: ${e.message}", e)
}
}
}
fun generateFilename(): String {
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
return "vocabulary_$timeStamp.json"
}
fun generateFilenameForCategory(categoryId: Int): String {
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
return "vocabulary_category_$categoryId$timeStamp.json"
}
fun getFileNameFromUri(uri: Uri): String {
var fileName = "unknown_file"
if (uri.scheme == "content") {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val displayNameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)
if (displayNameIndex != -1) {
fileName = cursor.getString(displayNameIndex)
}
}
}
} else if (uri.scheme == "file") {
fileName = uri.lastPathSegment ?: fileName
}
return fileName
}
companion object {
private const val TAG = "VocabularyFileSaver"
}
}

View File

@@ -0,0 +1,792 @@
@file:OptIn(ExperimentalTime::class)
@file:Suppress("HardCodedStringLiteral", "unused")
package eu.gaudian.translator.model.repository
import android.content.Context
import androidx.room.withTransaction
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyFilter
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.model.VocabularyItemState
import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.model.db.AppDatabase
import eu.gaudian.translator.model.db.CategoryMappingEntity
import eu.gaudian.translator.model.db.DailyStatEntity
import eu.gaudian.translator.model.db.StageMappingEntity
import eu.gaudian.translator.model.db.VocabularyCategoryEntity
import eu.gaudian.translator.utils.Log
import eu.gaudian.translator.utils.VocabularyService
import eu.gaudian.translator.viewmodel.CategoryProgress
import eu.gaudian.translator.viewmodel.StageStats
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
private const val TAG = "VocabularyRepository"
@Serializable
data class CategoryMapping(
val vocabularyItemId: Int,
val categoryId: Int,
)
class VocabularyRepository private constructor(context: Context) {
companion object {
@Volatile private var INSTANCE: VocabularyRepository? = null
fun getInstance(context: Context): VocabularyRepository = INSTANCE ?: synchronized(this) {
INSTANCE ?: VocabularyRepository(context.applicationContext).also { INSTANCE = it }
}
}
val settingsRepository = SettingsRepository(context)
private val vocabularyItemService = VocabularyService(context)
private val updateMappingsMutex = Mutex()
// Coalescing scheduler for updateMappings to avoid heavy bursts
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val debounceMs = 1500L
@Volatile private var isRunning = false
@Volatile private var pendingRequest = false
private var debounceJob: Job? = null
// DAOs from Room are the new data source
private val db = AppDatabase.getDatabase(context)
private val itemDao = db.vocabularyItemDao()
private val stateDao = db.vocabularyStateDao()
private val categoryDao = db.categoryDao()
private val mappingDao = db.mappingDao()
private val dailyStatDao = db.dailyStatDao()
fun initializeRepository() {
Log.d(TAG, "Initializing repository...")
requestUpdateMappings()
}
suspend fun getDailyVocabularyStats(startDate: LocalDate, endDate: LocalDate): Map<LocalDate, Int> {
// The DAO query does all the hard work
val statsFromDb = stateDao.getCorrectAnswerCountsByDate(startDate, endDate)
val dailyStats = statsFromDb.associate { it.date to it.count }.toMutableMap()
// Ensure all dates in the range are present, even if their count is 0
var currentDate = startDate
while (currentDate <= endDate) {
dailyStats.putIfAbsent(currentDate, 0)
currentDate = currentDate.plus(1, DateTimeUnit.DAY)
}
return dailyStats
}
fun getCategoryMappingsFlow(): Flow<List<CategoryMapping>> {
return mappingDao.getCategoryMappingsFlow().map { list ->
list.map { CategoryMapping(it.vocabularyItemId, it.categoryId) }
}
}
suspend fun getWordsLearnedByDate(startDate: LocalDate, endDate: LocalDate): Map<LocalDate, Int> {
val allStates = getAllVocabularyItemStates()
val dailyStats = mutableMapOf<LocalDate, Int>().withDefault { 0 }
allStates.forEach { state ->
if (state.correctAnswerCount >= settingsRepository.criteriaCorrect.flow.first()) {
state.lastCorrectAnswer?.let { learnedTime ->
val learnedDate = learnedTime.toLocalDateTime(TimeZone.currentSystemDefault()).date
if (learnedDate in startDate..endDate) {
dailyStats[learnedDate] = dailyStats.getValue(learnedDate) + 1
}
}
}
}
var currentDate = startDate
while (currentDate <= endDate) {
dailyStats.putIfAbsent(currentDate, 0)
currentDate = currentDate.plus(1, DateTimeUnit.DAY)
}
return dailyStats
}
private suspend fun runUpdateMappingsInternal() = updateMappingsMutex.withLock {
coroutineScope {
val stageUpdateJob = async { actualizeVocabularyStageMappings() }
stageUpdateJob.await()
val listMappings = async { calculateListMappings() }.await()
val filterMappings = async { calculateFilterMappings() }.await()
val allMappings = (listMappings + filterMappings).distinct()
// Atomically replace all mappings
mappingDao.setAllCategoryMappings(allMappings.map { CategoryMappingEntity(it.vocabularyItemId, it.categoryId) })
}
}
// Public scheduler entry
private fun requestUpdateMappings() {
// Mark that a request is pending
pendingRequest = true
// Restart debounce timer
debounceJob?.cancel()
debounceJob = repoScope.launch {
delay(debounceMs)
triggerIfNeeded()
}
}
private fun triggerIfNeeded() {
// Only one runner at a time; if already running, we'll run again afterwards
if (isRunning) return
if (!pendingRequest) return
pendingRequest = false
isRunning = true
repoScope.launch {
try {
runUpdateMappingsInternal()
} finally {
isRunning = false
// If more requests arrived while running, schedule next run after small debounce
if (pendingRequest) {
debounceJob?.cancel()
debounceJob = repoScope.launch {
delay(debounceMs)
triggerIfNeeded()
}
}
}
}
}
suspend fun editVocabularyItem(vocabularyItem: VocabularyItem) {
Log.d(TAG, "editVocabularyItem: Editing item id=${vocabularyItem.id}")
itemDao.upsertItem(vocabularyItem)
requestUpdateMappings()
}
suspend fun updateVocabularyItems(items: List<VocabularyItem>) {
Log.d(TAG, "updateVocabularyItems: Updating ${items.size} items.")
items.forEach { itemDao.upsertItem(it) }
requestUpdateMappings()
}
@OptIn(ExperimentalCoroutinesApi::class)
fun getDueTodayItemsFlow(): Flow<List<VocabularyItem>> {
return combine(
settingsRepository.intervalStage1.flow, settingsRepository.intervalStage2.flow,
settingsRepository.intervalStage3.flow, settingsRepository.intervalStage4.flow,
settingsRepository.intervalStage5.flow, settingsRepository.intervalLearned.flow
) { intervals ->
// This is a list of integers [stage1, stage2, ...]
intervals.toList()
}.flatMapLatest { intervals ->
// flatMapLatest ensures the DB query is re-triggered when intervals change
itemDao.getDueTodayItemsFlow(
now = Clock.System.now().epochSeconds,
intervalStage1 = intervals[0],
intervalStage2 = intervals[1],
intervalStage3 = intervals[2],
intervalStage4 = intervals[3],
intervalStage5 = intervals[4],
intervalLearned = intervals[5]
)
}
}
fun getAllVocabularyItemsFlow(): Flow<List<VocabularyItem>> = itemDao.getAllItemsFlow()
suspend fun getAllVocabularyItems(): List<VocabularyItem> = itemDao.getAllItems()
suspend fun getVocabularyItemById(vocabularyItemId: Int): VocabularyItem? = itemDao.getItemById(vocabularyItemId)
suspend fun deleteVocabularyItemById(vocabularyItemId: Int) {
Log.w(TAG, "deleteVocabularyItemById: Deleting item id=$vocabularyItemId")
itemDao.deleteItemById(vocabularyItemId)
requestUpdateMappings()
}
suspend fun deleteVocabularyItemsByIds(vocabularyItemIds: List<Int>) {
Log.w(TAG, "deleteVocabularyItemsByIds: Deleting ${vocabularyItemIds.size} items.")
itemDao.deleteItemsByIds(vocabularyItemIds)
requestUpdateMappings()
}
suspend fun generateVocabularyItems(
category: String, languageFirst: Language, languageSecond: Language, amount: Int
): Result<List<VocabularyItem>> {
return vocabularyItemService.generateVocabularyItems(category, languageFirst, languageSecond, amount)
}
suspend fun introduceVocabularyItems(newItems: List<VocabularyItem>, categoryIds: List<Int> = emptyList()) {
Log.i(TAG, "introduceVocabularyItems: Adding ${newItems.size} new items to categories: $categoryIds")
val maxId = itemDao.getMaxItemId() ?: 0
val updatedItems = newItems.mapIndexed { index, item -> item.copy(id = maxId + index + 1) }
itemDao.insertAll(updatedItems)
if (categoryIds.isNotEmpty()) {
val newMappings = updatedItems.flatMap { item ->
categoryIds.map { categoryId ->
CategoryMappingEntity(vocabularyItemId = item.id, categoryId = categoryId)
}
}
newMappings.forEach { mappingDao.addCategoryMapping(it) }
}
requestUpdateMappings()
}
@Suppress("unused")
suspend fun findDuplicates(): List<VocabularyItem> {
Log.d(TAG, "findDuplicates: Searching for duplicate items.")
return getAllVocabularyItems()
.groupBy { item -> item.wordFirst.lowercase() to item.wordSecond.lowercase() }
.values
.filter { it.size > 1 }
.flatten()
}
suspend fun cleanDuplicates() {
Log.i(TAG, "cleanDuplicates: Starting duplicate cleanup.")
val allItems = getAllVocabularyItems()
val uniqueItems = allItems.distinctBy { item -> item.wordFirst.lowercase() to item.wordSecond.lowercase() }
if (allItems.size != uniqueItems.size) {
val itemsToDelete = allItems.filterNot { uniqueItem -> uniqueItems.any { it.id == uniqueItem.id } }
Log.w(TAG, "cleanDuplicates: Found and deleting ${itemsToDelete.size} duplicates.")
itemDao.deleteItemsByIds(itemsToDelete.map { it.id })
requestUpdateMappings()
} else {
Log.d(TAG, "cleanDuplicates: No duplicates found.")
}
}
private fun mapEntityToCategory(entity: VocabularyCategoryEntity): VocabularyCategory {
return when(entity.type) {
"LIST", "TAG" -> TagCategory(entity.id, entity.name)
"FILTER" -> {
val langsJson = entity.filterLanguages
val langs: List<Int>? = langsJson?.let { Json.decodeFromString(it) }
val pair: Pair<Int, Int>? = if (entity.dictLangFirst != null && entity.dictLangSecond != null) {
Pair(entity.dictLangFirst, entity.dictLangSecond)
} else null
VocabularyFilter(
id = entity.id,
name = entity.name,
languages = langs,
languagePairs = pair,
stages = entity.filterStages?.let { Json.decodeFromString(it) }
)
}
"DICTIONARY" -> VocabularyFilter(
id = entity.id,
name = entity.name,
languages = null,
languagePairs = Pair(entity.dictLangFirst!!, entity.dictLangSecond!!),
stages = null
)
else -> TagCategory(entity.id, entity.name)
}
}
private suspend fun mapCategoryToEntity(category: VocabularyCategory): VocabularyCategoryEntity {
val id = if (category.id == 0) (categoryDao.getAllCategories().maxOfOrNull { it.id } ?: 0) + 1 else category.id
return when(category) {
is TagCategory -> VocabularyCategoryEntity(id, category.name, "TAG", null, null, null, null)
is VocabularyFilter -> {
Log.d(TAG, "mapCategoryToEntity: $category, id=$id")
val hasPair = category.languagePairs != null
VocabularyCategoryEntity(
id = id,
name = category.name,
type = "FILTER",
filterLanguages = if (!hasPair) category.languages?.let { Json.encodeToString(it) } else null,
filterStages = category.stages?.let { if (it.isEmpty()) null else Json.encodeToString(it) },
dictLangFirst = category.languagePairs?.first,
dictLangSecond = category.languagePairs?.second
)
}
}
}
suspend fun getAllCategories(): List<VocabularyCategory> {
return categoryDao.getAllCategories().map { mapEntityToCategory(it) }
}
fun getAllCategoriesFlow(): Flow<List<VocabularyCategory>> {
return categoryDao.getAllCategoriesFlow().map { list -> list.map { mapEntityToCategory(it) } }
}
suspend fun saveCategory(category: VocabularyCategory) {
val entity = mapCategoryToEntity(category)
Log.i(TAG, "saveCategory: Upserting category='${category.name}' -> entity=${entity}")
categoryDao.upsertCategory(entity)
// Recalculate mappings after saving the updated category to ensure filters (languages/stages/pair) take effect immediately
requestUpdateMappings()
}
suspend fun deleteCategoryById(categoryId: Int) {
Log.w(TAG, "deleteCategoryById: Deleting category id=$categoryId")
categoryDao.deleteCategoryById(categoryId)
requestUpdateMappings()
}
suspend fun getCategoryMappings(): List<CategoryMapping> {
return mappingDao.getCategoryMappings().map { CategoryMapping(it.vocabularyItemId, it.categoryId) }
}
suspend fun addVocabularyItemToList(vocabularyItemId: Int, listId: Int) {
Log.d(TAG, "addVocabularyItemToList: Adding item $vocabularyItemId to list $listId")
mappingDao.addCategoryMapping(CategoryMappingEntity(vocabularyItemId, listId))
}
suspend fun removeVocabularyItemFromList(vocabularyItemId: Int, listId: Int) {
Log.d(TAG, "removeVocabularyItemFromList: Removing item $vocabularyItemId from list $listId")
mappingDao.removeCategoryMapping(vocabularyItemId, listId)
}
suspend fun getVocabularyItemsByCategory(categoryId: Int): List<VocabularyItem> {
return itemDao.getItemsByCategoryId(categoryId)
}
@Suppress("unused")
fun getAllVocabularyItemStatesFlow(): Flow<List<VocabularyItemState>> = stateDao.getAllStatesFlow()
suspend fun getAllVocabularyItemStates(): List<VocabularyItemState> = stateDao.getAllStates()
suspend fun getVocabularyItemStateById(vocabularyItemId: Int): VocabularyItemState? = stateDao.getStateById(vocabularyItemId)
suspend fun saveVocabularyItemState(vocabularyItemState: VocabularyItemState) {
Log.d(TAG, "saveVocabularyItemState: Saving state for item ${vocabularyItemState.vocabularyItemId}")
stateDao.upsertState(vocabularyItemState)
}
@Suppress("unused")
suspend fun itemExists(word: String, languageID: Int?): Boolean {
// Avoid loading all items into memory; delegate existence check to the DB
return itemDao.itemExists(word, languageID)
}
suspend fun getLastCorrectAnswer(vocabularyItemId: Int): Instant? {
return getVocabularyItemStateById(vocabularyItemId)?.lastCorrectAnswer
}
suspend fun getLastIncorrectAnswer(vocabularyItemId: Int): Instant? {
return getVocabularyItemStateById(vocabularyItemId)?.lastIncorrectAnswer
}
suspend fun getCorrectAnswerCount(vocabularyItemId: Int): Int {
return getVocabularyItemStateById(vocabularyItemId)?.correctAnswerCount ?: 0
}
suspend fun getIncorrectAnswerCount(vocabularyItemId: Int): Int {
return getVocabularyItemStateById(vocabularyItemId)?.incorrectAnswerCount ?: 0
}
@Suppress("unused")
suspend fun getVocabularyItemsByStage(stage: VocabularyStage): List<VocabularyItem> {
val stageMapping = loadStageMapping().first()
val idsForStage = stageMapping.filterValues { it == stage }.keys
if (idsForStage.isEmpty()) return emptyList()
return itemDao.getItemsByIds(idsForStage.toList())
}
suspend fun changeVocabularyItemStage(item: VocabularyItem?, newStage: VocabularyStage) {
val itemId = item?.id ?: return
Log.d(TAG, "changeVocabularyItemStage: Changing item $itemId to stage $newStage")
mappingDao.upsertStageMapping(StageMappingEntity(itemId, newStage))
}
suspend fun changeVocabularyItemsStage(items: List<VocabularyItem>?, newStage: VocabularyStage) {
if (items.isNullOrEmpty()) return
Log.d(TAG, "changeVocabularyItemsStage: Changing ${items.size} items to stage $newStage")
val newMappings = items.map { StageMappingEntity(it.id, newStage) }
mappingDao.upsertStageMappings(newMappings)
}
fun getVocabularyItemStage(itemId: Int): Flow<VocabularyStage> {
return loadStageMapping().map { stageMap ->
stageMap[itemId] ?: VocabularyStage.NEW
}
}
fun loadStageMapping(): Flow<Map<Int, VocabularyStage>> {
return mappingDao.getStageMappingsFlow().map { list ->
list.associate { it.vocabularyItemId to it.stage }
}
}
private suspend fun saveStageMapping(mapping: Map<Int, VocabularyStage>) {
val entities = mapping.map { StageMappingEntity(it.key, it.value) }
mappingDao.upsertStageMappings(entities)
}
suspend fun updateFlashcardStage(item: VocabularyItem, isCorrect: Boolean) {
val vocabularyItemId = item.id
val currentStage = getVocabularyItemStage(item.id).first()
val vocabularyItemState = getVocabularyItemStateById(vocabularyItemId) ?: VocabularyItemState(vocabularyItemId)
val criteriaCorrect = settingsRepository.criteriaCorrect.flow.first()
val criteriaWrong = settingsRepository.criteriaWrong.flow.first()
val now = Clock.System.now()
Log.d(TAG, "updateFlashcardStage: Item=${item.id}, Correct=$isCorrect, CurrentStage=$currentStage")
if (isCorrect) {
vocabularyItemState.correctAnswerCount++
vocabularyItemState.lastCorrectAnswer = now
} else {
vocabularyItemState.incorrectAnswerCount++
vocabularyItemState.lastIncorrectAnswer = now
}
val nextStage = calculateNextStage(
currentStage, isCorrect,
vocabularyItemState.correctAnswerCount, vocabularyItemState.incorrectAnswerCount,
criteriaCorrect, criteriaWrong
)
if (nextStage != currentStage) {
Log.i(TAG, "updateFlashcardStage: Item ${item.id} moved from $currentStage to $nextStage")
vocabularyItemState.correctAnswerCount = 0
vocabularyItemState.incorrectAnswerCount = 0
changeVocabularyItemStage(item, nextStage)
}
saveVocabularyItemState(vocabularyItemState)
}
private fun calculateNextStage(
currentStage: VocabularyStage, isCorrect: Boolean,
correctCount: Int, incorrectCount: Int,
criteriaCorrect: Int, criteriaWrong: Int
): VocabularyStage {
val readyToAdvance = if (isCorrect) correctCount >= criteriaCorrect else incorrectCount >= criteriaWrong
if (!readyToAdvance) return currentStage
return when (currentStage) {
VocabularyStage.NEW -> VocabularyStage.STAGE_1
VocabularyStage.STAGE_1 -> VocabularyStage.STAGE_2
VocabularyStage.STAGE_2 -> VocabularyStage.STAGE_3
VocabularyStage.STAGE_3 -> VocabularyStage.STAGE_4
VocabularyStage.STAGE_4 -> VocabularyStage.STAGE_5
VocabularyStage.STAGE_5 -> VocabularyStage.LEARNED
VocabularyStage.LEARNED -> VocabularyStage.LEARNED
}
}
private fun isItemFitForCategory(
item: VocabularyItem,
filter: VocabularyFilter,
stageMapping: Map<Int, VocabularyStage>
): Boolean {
val stage = stageMapping[item.id] ?: VocabularyStage.NEW
val stageFilter = filter.stages
val stageMatches = stageFilter.isNullOrEmpty() || stageFilter.contains(stage)
// Language filter precedence: dictionary pair > languages list > no language filter
val firstId = item.languageFirstId
val secondId = item.languageSecondId
val languageMatches = when {
// Pair specified: both item language IDs must be non-null and match the pair (in any order)
filter.languagePairs != null -> {
val (a, b) = filter.languagePairs
(firstId != null && secondId != null) &&
((firstId == a && secondId == b) || (firstId == b && secondId == a))
}
// Languages list specified: any of the item language IDs must be in the list
!filter.languages.isNullOrEmpty() -> {
val ids = filter.languages
(firstId != null && ids.contains(firstId)) || (secondId != null && ids.contains(secondId))
}
else -> true
}
val matches = stageMatches && languageMatches
return matches
}
suspend fun calcAvailableDictionaries(): Set<Pair<Int, Int>> {
return getAllVocabularyItems().mapNotNull {
val lang1 = it.languageFirstId
val lang2 = it.languageSecondId
if (lang1 != null && lang2 != null) {
if (lang1 < lang2) lang1 to lang2 else lang2 to lang1
} else {
null
}
}.toSet()
}
suspend fun getCategoryById(categoryId: Int): VocabularyCategory? {
return categoryDao.getCategoryById(categoryId)?.let { mapEntityToCategory(it) }
}
private suspend fun calculateListMappings(): List<CategoryMapping> {
val allItemIds = getAllVocabularyItems().map { it.id }.toSet()
val listIds = getAllCategories().filterIsInstance<TagCategory>().map { it.id }.toSet()
val listMappings = getCategoryMappings().filter { it.categoryId in listIds && it.vocabularyItemId in allItemIds }
Log.d(TAG, "calculateListMappings: Found ${listMappings.size} manual list mappings.")
return listMappings
}
private suspend fun calculateFilterMappings(): List<CategoryMapping> {
val vocabularyItems = getAllVocabularyItems()
val autoFilters = getAllCategories().filterIsInstance<VocabularyFilter>()
val stageMapping = loadStageMapping().first()
val newMappings = mutableListOf<CategoryMapping>()
for (item in vocabularyItems) {
for (filter in autoFilters) {
if (isItemFitForCategory(item, filter, stageMapping)) {
newMappings.add(CategoryMapping(item.id, filter.id))
}
}
}
Log.d(TAG, "calculateFilterMappings: Generated ${newMappings.size} filter mappings.")
return newMappings
}
suspend fun actualizeVocabularyStageMappings() {
val allItems = getAllVocabularyItems()
val currentStageMapping = loadStageMapping().first()
val newStageMappings = allItems.associate { item ->
item.id to (currentStageMapping[item.id] ?: VocabularyStage.NEW)
}
val allIds = allItems.map { it.id }
mappingDao.deleteStageMappingsNotIn(allIds)
saveStageMapping(newStageMappings)
calculateCategoryProgress()
}
suspend fun getDueTodayItems(): List<VocabularyItem> {
return getDueTodayItemsFlow().first()
}
suspend fun calculateStageStatistics(): List<StageStats> {
val stageMapping = loadStageMapping().first()
val counts = stageMapping.values.groupingBy { it }.eachCount()
return VocabularyStage.entries.map { stage ->
StageStats(stage, counts[stage] ?: 0)
}
}
suspend fun getDailyCorrectCount(date: LocalDate): Int {
return dailyStatDao.getStatForDate(date)?.correctCount ?: 0
}
suspend fun updateDailyCorrectCount(date: LocalDate, count: Int) {
dailyStatDao.upsertStat(DailyStatEntity(date, count))
}
suspend fun getCorrectCountsForLastDays(days: Int): Map<LocalDate, Int> {
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
val counts = mutableMapOf<LocalDate, Int>()
for (i in 0 until days) {
val date = today.minus(i, DateTimeUnit.DAY)
counts[date] = getDailyCorrectCount(date)
}
return counts
}
suspend fun isTargetMetForDate(date: LocalDate): Boolean {
val dailyCorrectCount = getDailyCorrectCount(date)
val target = 10 // TODO: Replace with settingsRepository.dailyGoal.flow.first()
return dailyCorrectCount >= target
}
suspend fun getAllLanguageIdsFromVocabulary(): Set<Int?> {
Log.d(TAG, "getAllLanguageIdsFromVocabulary: Fetching all language IDs.")
val languageIds = getAllVocabularyItems()
.asSequence()
.flatMap { sequenceOf(it.languageFirstId, it.languageSecondId) }
.toSet()
Log.d(TAG, "getAllLanguageIdsFromVocabulary: Found ${languageIds.size} unique language IDs: $languageIds")
return languageIds
}
suspend fun calculateCategoryProgress(): List<CategoryProgress> = coroutineScope {
val stageMappingDeferred = async { loadStageMapping().first() }
val itemsDeferred = async { getAllVocabularyItems() }
val mappingsDeferred = async { getCategoryMappings() }
val categories = getAllCategories()
val stageMapping = stageMappingDeferred.await()
val allItems = itemsDeferred.await()
val allMappings = mappingsDeferred.await()
// Create maps for efficient lookups
val itemsById = allItems.associateBy { it.id }
val itemIdsByCategoryId = allMappings.groupBy(
keySelector = { it.categoryId },
valueTransform = { it.vocabularyItemId }
)
categories.map { category ->
val itemIdsForCategory = itemIdsByCategoryId[category.id] ?: emptyList()
val itemsInCategory = itemIdsForCategory.mapNotNull { itemsById[it] }
val totalItems = itemsInCategory.size
val itemsCompleted = itemsInCategory.count { stageMapping[it.id] == VocabularyStage.LEARNED }
val itemsInStages = itemsInCategory.count {
val stage = stageMapping[it.id]
stage != VocabularyStage.NEW && stage != VocabularyStage.LEARNED
}
val newItems = itemsInCategory.count { stageMapping[it.id] == VocabularyStage.NEW || stageMapping[it.id] == null }
CategoryProgress(
vocabularyCategory = category,
totalItems = totalItems,
itemsCompleted = itemsCompleted,
itemsInStages = itemsInStages,
newItems = newItems,
)
}
}
suspend fun getSynonymsForItem(itemId: Int): List<VocabularyItem> {
val item = getVocabularyItemById(itemId) ?: return emptyList()
val lang1 = item.languageFirstId
val lang2 = item.languageSecondId
return if (lang1 != null && lang2 != null) {
itemDao.getSynonyms(
excludeId = item.id,
lang1 = lang1,
lang2 = lang2,
wordFirst = item.wordFirst,
wordSecond = item.wordSecond
)
} else {
// Fallback: if we don't have both language IDs, do a minimal in-memory filter
getAllVocabularyItems().asSequence()
.filter { it.id != item.id }
.filter { it.wordFirst == item.wordFirst || it.wordSecond == item.wordSecond }
.toList()
}
}
suspend fun getNewlyAddedCountForDate(date: LocalDate): Int {
return getAllVocabularyItems().count { item ->
item.createdAt?.toLocalDateTime(TimeZone.currentSystemDefault())?.date == date
}
}
suspend fun getCompletedCountForDate(date: LocalDate): Int {
val criteria = settingsRepository.criteriaCorrect.flow.first()
return getAllVocabularyItemStates().count { state ->
val completedDate = state.lastCorrectAnswer?.toLocalDateTime(TimeZone.currentSystemDefault())?.date
completedDate == date && state.correctAnswerCount >= criteria
}
}
suspend fun getCorrectAnswerCountForDate(date: LocalDate): Int {
return getDailyCorrectCount(date)
}
@Suppress("unused")
suspend fun backupRepositoryState(): String {
Log.i(TAG, "backupRepositoryState: Creating repository backup string.")
val backupData = RepositoryBackup(
items = getAllVocabularyItems(),
categories = getAllCategories(),
states = getAllVocabularyItemStates(),
categoryMappings = getCategoryMappings(),
stageMappings = loadStageMapping().first().toList()
)
return Json.encodeToString(RepositoryBackup.serializer(), backupData)
}
@Suppress("unused")
suspend fun restoreRepositoryState(backupJson: String) {
Log.w(TAG, "restoreRepositoryState: Restoring repository from backup. This will replace all existing data.")
val backupData = Json.decodeFromString(RepositoryBackup.serializer(), backupJson)
db.withTransaction {
itemDao.insertAll(backupData.items)
stateDao.insertAll(backupData.states)
val categoryEntities = backupData.categories.map { mapCategoryToEntity(it) }
categoryDao.insertAll(categoryEntities)
val categoryMappingEntities = backupData.categoryMappings.map { CategoryMappingEntity(it.vocabularyItemId, it.categoryId) }
mappingDao.setAllCategoryMappings(categoryMappingEntities)
val stageMappingEntities = backupData.stageMappings.map { StageMappingEntity(it.first, it.second) }
mappingDao.upsertStageMappings(stageMappingEntities)
}
requestUpdateMappings()
Log.i(TAG, "restoreRepositoryState: Restore complete.")
}
suspend fun wipeRepository() {
Log.e(TAG, "wipeRepository: Deleting all repository data!")
db.withTransaction {
mappingDao.clearCategoryMappings()
mappingDao.clearStageMappings()
categoryDao.clearAllCategories()
stateDao.clearAllStates()
itemDao.clearAllItems()
dailyStatDao.clearAll()
}
requestUpdateMappings()
Log.i(TAG, "wipeRepository: All data deleted.")
}
/**
* Prints a detailed summary of the current repository state to the debug log.
* This function is intended for debugging purposes.
*/
@Suppress("unused")
suspend fun printRepoState() {
val allItems = getAllVocabularyItems()
val allCategories = getAllCategories()
val allStates = getAllVocabularyItemStates()
val categoryMappings = getCategoryMappings()
val stageMapping = loadStageMapping().first()
val itemsPerCategory = categoryMappings.groupBy { it.categoryId }
.mapValues { it.value.size }
val itemsPerStage = stageMapping.values.groupingBy { it }.eachCount()
Log.d(TAG, "--- REPOSITORY STATE ---")
Log.d(TAG, "Total Items: ${allItems.size}")
Log.d(TAG, "Total Categories: ${allCategories.size} (${allCategories.filterIsInstance<TagCategory>().size} Tags, ${allCategories.filterIsInstance<VocabularyFilter>().size} Filters)")
Log.d(TAG, "Total Item States: ${allStates.size}")
Log.d(TAG, "Total Category Mappings: ${categoryMappings.size}")
Log.d(TAG, "Total Stage Mappings: ${stageMapping.size}")
Log.d(TAG, "--- Items per Category ---")
allCategories.forEach { category ->
Log.d(TAG, " - ${category.name} (ID: ${category.id}, Type: ${category::class.simpleName}): ${itemsPerCategory[category.id] ?: 0} items")
}
Log.d(TAG, "--- Items per Stage ---")
VocabularyStage.entries.forEach { stage ->
Log.d(TAG, " - ${stage.name}: ${itemsPerStage[stage] ?: 0} items")
}
Log.d(TAG, "--- END REPOSITORY STATE ---")
}
}
@Serializable
data class RepositoryBackup(
val items: List<VocabularyItem>,
val categories: List<VocabularyCategory>,
val states: List<VocabularyItemState>,
val categoryMappings: List<CategoryMapping>,
val stageMappings: List<Pair<Int, VocabularyStage>>
)

View File

@@ -0,0 +1,154 @@
package eu.gaudian.translator.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
@Immutable
data class SemanticColors(
val success: Color,
val onSuccess: Color,
val successContainer: Color,
val onSuccessContainer: Color,
// wrong (error) semantic colors to style destructive/negative actions consistently across themes
val wrong: Color,
val onWrong: Color,
val wrongContainer: Color,
val onWrongContainer: Color,
// streak (fire) color used to indicate learning streaks consistently across themes
val streak: Color,
val onStreak: Color,
// A 6-step gradient transitioning from theme primary to onPrimary
val stageGradient1: Color,
val stageGradient2: Color,
val stageGradient3: Color,
val stageGradient4: Color,
val stageGradient5: Color,
val stageGradient6: Color,
)
private val LocalSemanticColors = staticCompositionLocalOf {
// Reasonable fallbacks; will be overridden by ProvideSemanticColors
SemanticColors(
success = Color(0xFF2E7D32),
onSuccess = Color(0xFF64DD17),
successContainer = Color(0xFFC8E6C9),
onSuccessContainer = Color(0xFF1B5E20),
wrong = Color(0xFFB00020),
onWrong = Color(0xFFFFFFFF),
wrongContainer = Color(0xFFFCD8DF),
onWrongContainer = Color(0xFF370B0E),
streak = Color(0xFFFF6F00),
onStreak = Color(0xFF000000),
stageGradient1 = Color(0xFF2E7D32),
stageGradient2 = Color(0xFF3FA046),
stageGradient3 = Color(0xFF51C55B),
stageGradient4 = Color(0xFF7ADD84),
stageGradient5 = Color(0xFFA4F5AE),
stageGradient6 = Color(0xFFFFFFFF),
)
}
@Suppress("UnusedReceiverParameter")
val MaterialTheme.semanticColors: SemanticColors
@Composable get() = LocalSemanticColors.current
private fun lerpColor(a: Color, b: Color, t: Float): Color {
return Color(
red = a.red + (b.red - a.red) * t,
green = a.green + (b.green - a.green) * t,
blue = a.blue + (b.blue - a.blue) * t,
alpha = a.alpha + (b.alpha - a.alpha) * t
)
}
@Composable
private fun baseSemanticFromTheme(light: Boolean): SemanticColors {
val cs = MaterialTheme.colorScheme
val start = cs.primary
val mid = cs.secondary
val end = cs.tertiary
val steps = listOf(0f, 0.18f, 0.38f, 0.6f, 0.82f, 1f)
fun triLerpColor(a: Color, b: Color, c: Color, t: Float): Color {
val x = t.coerceIn(0f, 1f)
return if (x <= 0.5f) {
lerpColor(a, b, x / 0.5f)
} else {
lerpColor(b, c, (x - 0.5f) / 0.5f)
}
}
val g1 = triLerpColor(start, mid, end, steps[0])
val g2 = triLerpColor(start, mid, end, steps[1])
val g3 = triLerpColor(start, mid, end, steps[2])
val g4 = triLerpColor(start, mid, end, steps[3])
val g5 = triLerpColor(start, mid, end, steps[4])
val g6 = triLerpColor(start, mid, end, steps[5])
return if (light) {
SemanticColors(
success = Color(0xFF2F8C33),
onSuccess = Color(0xFFFFFFFF),
successContainer = Color(0xFFC8E6C9),
onSuccessContainer = Color(0xFF1B5E20),
wrong = Color(0xFFB00020),
onWrong = Color(0xFFFFFFFF),
wrongContainer = Color(0xFFFFDAD6),
onWrongContainer = Color(0xFF410002),
streak = Color(0xFFFF6F00),
onStreak = Color(0xFF000000),
stageGradient1 = g1,
stageGradient2 = g2,
stageGradient3 = g3,
stageGradient4 = g4,
stageGradient5 = g5,
stageGradient6 = g6,
)
} else {
SemanticColors(
success = Color(0xFF81C784),
onSuccess = Color(0xFF003314),
successContainer = Color(0xFF1B5E20),
onSuccessContainer = Color(0xFFC8E6C9),
wrong = Color(0xFFCF6679),
onWrong = Color(0xFF370B0E),
wrongContainer = Color(0xFF93000A),
onWrongContainer = Color(0xFFFFDAD6),
streak = Color(0xFFFFAB40),
onStreak = Color(0xFF1A0C00),
stageGradient1 = g1,
stageGradient2 = g2,
stageGradient3 = g3,
stageGradient4 = g4,
stageGradient5 = g5,
stageGradient6 = g6,
)
}
}
@Composable
fun ProvideSemanticColors(content: @Composable () -> Unit) {
// Provide a green-guaranteed semantic palette. We keep it independent from theme hues
// but adapt for light vs. dark to ensure accessibility and visual fit.
val isLight = MaterialTheme.colorScheme.background.luminance() > 0.5f
val derived = if (isLight) {
// Light mode greens/reds
baseSemanticFromTheme(light = true)
} else {
// Dark mode greens/reds
baseSemanticFromTheme(light = false)
}
CompositionLocalProvider(LocalSemanticColors provides derived) {
content()
}
}

View File

@@ -0,0 +1,206 @@
package eu.gaudian.translator.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.themes.AutumnSpiceTheme
import eu.gaudian.translator.ui.theme.themes.CitrusSplashTheme
import eu.gaudian.translator.ui.theme.themes.CoffeeTheme
import eu.gaudian.translator.ui.theme.themes.CrimsonTheme
import eu.gaudian.translator.ui.theme.themes.CyberpunkTheme
import eu.gaudian.translator.ui.theme.themes.DefaultTheme
import eu.gaudian.translator.ui.theme.themes.ForestTheme
import eu.gaudian.translator.ui.theme.themes.NordTheme
import eu.gaudian.translator.ui.theme.themes.OceanicCalmTheme
import eu.gaudian.translator.ui.theme.themes.PixelTheme
import eu.gaudian.translator.ui.theme.themes.SakuraTheme
import eu.gaudian.translator.ui.theme.themes.SlateAndStoneTheme
import eu.gaudian.translator.ui.theme.themes.SpaceTheme
import eu.gaudian.translator.ui.theme.themes.SynthwaveTheme
import eu.gaudian.translator.ui.theme.themes.TealTheme
import eu.gaudian.translator.ui.theme.themes.TwilightSerenityTheme
/**
* A data class to hold the core colors for a theme variation (light or dark).
* This makes defining new themes as simple as providing these color values.
*/
data class ThemeColorSet(
// Main colors
val primary: Color,
val secondary: Color,
val tertiary: Color,
// Container colors
val primaryContainer: Color,
val secondaryContainer: Color,
val tertiaryContainer: Color,
// On colors (text/icons on top of main colors)
val onPrimary: Color,
val onSecondary: Color,
val onTertiary: Color,
val onPrimaryContainer: Color,
val onSecondaryContainer: Color,
val onTertiaryContainer: Color,
// Error colors
val error: Color,
val onError: Color,
val errorContainer: Color,
val onErrorContainer: Color,
// Background/surface colors
val background: Color,
val onBackground: Color,
val surface: Color,
val onSurface: Color,
val surfaceVariant: Color,
val onSurfaceVariant: Color,
// Outline colors
val outline: Color,
val outlineVariant: Color,
// Scrim
val scrim: Color,
// Inverse colors
val inverseSurface: Color,
val inverseOnSurface: Color,
val inversePrimary: Color,
// Surface container colors
val surfaceDim: Color,
val surfaceBright: Color,
val surfaceContainerLowest: Color,
val surfaceContainerLow: Color,
val surfaceContainer: Color,
val surfaceContainerHigh: Color,
val surfaceContainerHighest: Color
)
/**
* Represents a complete, named theme in the app, containing both its light and dark color sets.
*/
data class AppTheme(
val name: String,
val lightColors: ThemeColorSet,
val darkColors: ThemeColorSet
)
/**
* Represents a font style, including its display name and the filename of the font file.
*/
val AllThemes = listOf(
DefaultTheme,
PixelTheme,
CrimsonTheme,
SakuraTheme,
AutumnSpiceTheme,
TealTheme,
ForestTheme,
CoffeeTheme,
CitrusSplashTheme,
OceanicCalmTheme,
SlateAndStoneTheme,
NordTheme,
TwilightSerenityTheme,
SpaceTheme,
CyberpunkTheme,
SynthwaveTheme,
)
/**
* A helper function that dynamically builds a Material [ColorScheme]
* from our generic [ThemeColorSet].
*
* @param colorSet The set of colors to use.
* @param isDark Whether to build a dark or light color scheme.
* @return A complete Material 3 [ColorScheme].
*/
fun buildColorScheme(colorSet: ThemeColorSet, isDark: Boolean): ColorScheme {
return if (isDark) {
darkColorScheme(
primary = colorSet.primary,
onPrimary = colorSet.onPrimary,
primaryContainer = colorSet.primaryContainer,
onPrimaryContainer = colorSet.onPrimaryContainer,
inversePrimary = colorSet.inversePrimary,
secondary = colorSet.secondary,
onSecondary = colorSet.onSecondary,
secondaryContainer = colorSet.secondaryContainer,
onSecondaryContainer = colorSet.onSecondaryContainer,
tertiary = colorSet.tertiary,
onTertiary = colorSet.onTertiary,
tertiaryContainer = colorSet.tertiaryContainer,
onTertiaryContainer = colorSet.onTertiaryContainer,
background = colorSet.background,
onBackground = colorSet.onBackground,
surface = colorSet.surface,
onSurface = colorSet.onSurface,
surfaceVariant = colorSet.surfaceVariant,
onSurfaceVariant = colorSet.onSurfaceVariant,
surfaceDim = colorSet.surfaceDim,
surfaceBright = colorSet.surfaceBright,
surfaceContainerLowest = colorSet.surfaceContainerLowest,
surfaceContainerLow = colorSet.surfaceContainerLow,
surfaceContainer = colorSet.surfaceContainer,
surfaceContainerHigh = colorSet.surfaceContainerHigh,
surfaceContainerHighest = colorSet.surfaceContainerHighest,
error = colorSet.error,
onError = colorSet.onError,
errorContainer = colorSet.errorContainer,
onErrorContainer = colorSet.onErrorContainer,
outline = colorSet.outline,
outlineVariant = colorSet.outlineVariant,
scrim = colorSet.scrim,
inverseSurface = colorSet.inverseSurface,
inverseOnSurface = colorSet.inverseOnSurface
)
} else {
lightColorScheme(
primary = colorSet.primary,
onPrimary = colorSet.onPrimary,
primaryContainer = colorSet.primaryContainer,
onPrimaryContainer = colorSet.onPrimaryContainer,
inversePrimary = colorSet.inversePrimary,
secondary = colorSet.secondary,
onSecondary = colorSet.onSecondary,
secondaryContainer = colorSet.secondaryContainer,
onSecondaryContainer = colorSet.onSecondaryContainer,
tertiary = colorSet.tertiary,
onTertiary = colorSet.onTertiary,
tertiaryContainer = colorSet.tertiaryContainer,
onTertiaryContainer = colorSet.onTertiaryContainer,
background = colorSet.background,
onBackground = colorSet.onBackground,
surface = colorSet.surface,
onSurface = colorSet.onSurface,
surfaceVariant = colorSet.surfaceVariant,
onSurfaceVariant = colorSet.onSurfaceVariant,
surfaceDim = colorSet.surfaceDim,
surfaceBright = colorSet.surfaceBright,
surfaceContainerLowest = colorSet.surfaceContainerLowest,
surfaceContainerLow = colorSet.surfaceContainerLow,
surfaceContainer = colorSet.surfaceContainer,
surfaceContainerHigh = colorSet.surfaceContainerHigh,
surfaceContainerHighest = colorSet.surfaceContainerHighest,
error = colorSet.error,
onError = colorSet.onError,
errorContainer = colorSet.errorContainer,
onErrorContainer = colorSet.onErrorContainer,
outline = colorSet.outline,
outlineVariant = colorSet.outlineVariant,
scrim = colorSet.scrim,
inverseSurface = colorSet.inverseSurface,
inverseOnSurface = colorSet.inverseOnSurface
)
}
}

View File

@@ -0,0 +1,23 @@
package eu.gaudian.translator.ui.theme
import android.annotation.SuppressLint
import android.content.res.Configuration
import androidx.compose.ui.tooling.preview.Preview
/**
* A multipreview annotation that shows a Composable in both light and dark themes.
* It also suppresses the "HardcodedText" lint warning, as previews are for development
* and do not need string resources.
*/
@Preview(
name = "Light Mode",
showBackground = true
)
@Preview(
name = "Dark Mode",
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
)
@SuppressLint("HardcodedText")@Suppress("HardCodedStringLiteral")
annotation class ThemePreviews

View File

@@ -0,0 +1,20 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme
data class FontStyle(
val name: String,
val fileName: String
)
val AllFonts = listOf(
FontStyle("Default", "default"),
FontStyle("Merriweather", "merriweather_regular"),
FontStyle("Lato", "lato_regular"),
FontStyle("Playfair Display", "playfairdisplay_regular"),
FontStyle("Roboto", "roboto_regular"),
FontStyle("Lora", "lora_regular"),
FontStyle("Open Sans", "opensans_regular"),
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val AutumnSpiceTheme = AppTheme(
name = "Autumn Spice",
lightColors = ThemeColorSet(
primary = Color(0xFFC55000),
secondary = Color(0xFFA03F3B),
tertiary = Color(0xFF795900),
primaryContainer = Color(0xFFFFDBC8),
secondaryContainer = Color(0xFFFFDAD7),
tertiaryContainer = Color(0xFFFFDF96),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF431400),
onSecondaryContainer = Color(0xFF400000),
onTertiaryContainer = Color(0xFF261A00),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFF8F6),
onBackground = Color(0xFF231917),
surface = Color(0xFFFFF8F6),
onSurface = Color(0xFF231917),
surfaceVariant = Color(0xFFF5DED7),
onSurfaceVariant = Color(0xFF53433F),
outline = Color(0xFF85736E),
outlineVariant = Color(0xFFD8C2BB),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF382E2B),
inverseOnSurface = Color(0xFFFFEDE8),
inversePrimary = Color(0xFFFFB596),
surfaceDim = Color(0xFFE8D6D1),
surfaceBright = Color(0xFFFFF8F6),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFFFF1ED),
surfaceContainer = Color(0xFFFBEBE7),
surfaceContainerHigh = Color(0xFFF5E5E1),
surfaceContainerHighest = Color(0xFFF0E0DB)
),
darkColors = ThemeColorSet(
primary = Color(0xFFFFB596),
secondary = Color(0xFFFFB3AE),
tertiary = Color(0xFFF5BF48),
primaryContainer = Color(0xFF943C00),
secondaryContainer = Color(0xFF7F2825),
tertiaryContainer = Color(0xFF5C4300),
onPrimary = Color(0xFF6C2900),
onSecondary = Color(0xFF611211),
onTertiary = Color(0xFF402D00),
onPrimaryContainer = Color(0xFFFFDBC8),
onSecondaryContainer = Color(0xFFFFDAD7),
onTertiaryContainer = Color(0xFFFFDF96),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFB4AB),
background = Color(0xFF1A110F),
onBackground = Color(0xFFF0E0DB),
surface = Color(0xFF1A110F),
onSurface = Color(0xFFF0E0DB),
surfaceVariant = Color(0xFF53433F),
onSurfaceVariant = Color(0xFFD8C2BB),
outline = Color(0xFFA08C87),
outlineVariant = Color(0xFF53433F),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFF0E0DB),
inverseOnSurface = Color(0xFF382E2B),
inversePrimary = Color(0xFFC55000),
surfaceDim = Color(0xFF1A110F),
surfaceBright = Color(0xFF423734),
surfaceContainerLowest = Color(0xFF140C0A),
surfaceContainerLow = Color(0xFF231917),
surfaceContainer = Color(0xFF271D1B),
surfaceContainerHigh = Color(0xFF322825),
surfaceContainerHighest = Color(0xFF3D322F)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val CitrusSplashTheme = AppTheme(
name = "Citrus Splash",
lightColors = ThemeColorSet(
primary = Color(0xFFF57F17), // Vibrant Orange (Primary)
secondary = Color(0xFFFBC02D), // Sunny Yellow (Secondary)
tertiary = Color(0xFF7CB342), // Lime Green (Tertiary)
primaryContainer = Color(0xFFFFEBC0),
secondaryContainer = Color(0xFFFFF3AD),
tertiaryContainer = Color(0xFFDDEEBF),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFF000000),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF2C1600),
onSecondaryContainer = Color(0xFF221B00),
onTertiaryContainer = Color(0xFF131F00),
error = Color(0xFFB00020),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFDE7E9),
onErrorContainer = Color(0xFF4A000B),
background = Color(0xFFFFFDF7), // Warm, off-white background
onBackground = Color(0xFF201A17), // Dark, warm text
surface = Color(0xFFFFFFFF), // Crisp white surface
onSurface = Color(0xFF201A17),
surfaceVariant = Color(0xFFF3EFE9),
onSurfaceVariant = Color(0xFF49453F),
outline = Color(0xFF7A756F),
outlineVariant = Color(0xFFCCC5BD),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF352F2B),
inverseOnSurface = Color(0xFFFBEFE8),
inversePrimary = Color(0xFFFFB86C),
surfaceDim = Color(0xFFE2D8D2),
surfaceBright = Color(0xFFFFFDF7),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFFBF2EC),
surfaceContainer = Color(0xFFF5EDE6),
surfaceContainerHigh = Color(0xFFF0E7E1),
surfaceContainerHighest = Color(0xFFEAE2DC)
),
darkColors = ThemeColorSet(
primary = Color(0xFFFFB86C), // Lighter orange for dark mode
secondary = Color(0xFFEAC248), // Lighter yellow
tertiary = Color(0xFFB8CF83), // Lighter lime
primaryContainer = Color(0xFF5A4121),
secondaryContainer = Color(0xFF564600),
tertiaryContainer = Color(0xFF404D20),
onPrimary = Color(0xFF4A2A00),
onSecondary = Color(0xFF3A3000),
onTertiary = Color(0xFF2B350A),
onPrimaryContainer = Color(0xFFFFDEB5),
onSecondaryContainer = Color(0xFFFFEAAA),
onTertiaryContainer = Color(0xFFD4EC9C),
error = Color(0xFFCF6679),
onError = Color(0xFF000000),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1F1A17), // Deep, warm brown/gray
onBackground = Color(0xFFEAE2DC), // Light, warm text
surface = Color(0xFF2A2421), // Slightly lighter warm surface
onSurface = Color(0xFFEAE2DC),
surfaceVariant = Color(0xFF443F3A),
onSurfaceVariant = Color(0xFFC9C6C0),
outline = Color(0xFF938F8A),
outlineVariant = Color(0xFF49453F),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFEAE2DC),
inverseOnSurface = Color(0xFF201A17),
inversePrimary = Color(0xFFF57F17),
surfaceDim = Color(0xFF1F1A17),
surfaceBright = Color(0xFF48403A),
surfaceContainerLowest = Color(0xFF16120F),
surfaceContainerLow = Color(0xFF1F1A17),
surfaceContainer = Color(0xFF241E1B),
surfaceContainerHigh = Color(0xFF2E2925),
surfaceContainerHighest = Color(0xFF39332F),
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val CoffeeTheme = AppTheme(
name = "Coffee",
lightColors = ThemeColorSet(
primary = Color(0xFF7A5944), // Rich Coffee Brown
secondary = Color(0xFF6F5C51), // Muted Brown
tertiary = Color(0xFF9A4524), // NEW: Warm Cinnamon Spice Accent
primaryContainer = Color(0xFFFFDBCA), // Light Creamy Brown
secondaryContainer = Color(0xFFF9DFD0),
tertiaryContainer = Color(0xFFFFDBCF), // NEW: Generated from new tertiary
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF2E1707),
onSecondaryContainer = Color(0xFF271A11),
onTertiaryContainer = Color(0xFF380D00), // NEW: Generated from new tertiary
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFF8F6), // Warm Off-White
onBackground = Color(0xFF211A17), // Dark Brown Text
surface = Color(0xFFFFF8F6),
onSurface = Color(0xFF211A17),
surfaceVariant = Color(0xFFF0E0D6),
onSurfaceVariant = Color(0xFF50453D),
outline = Color(0xFF82756C),
outlineVariant = Color(0xFFD4C4B9),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF362F2B),
inverseOnSurface = Color(0xFFFCEEE8),
inversePrimary = Color(0xFFEBBFA8),
surfaceDim = Color(0xFFE4D8D2),
surfaceBright = Color(0xFFFFF8F6),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF8F2EE),
surfaceContainer = Color(0xFFF2ECE8),
surfaceContainerHigh = Color(0xFFEDE6E2),
surfaceContainerHighest = Color(0xFFE8E1DC)
),
darkColors = ThemeColorSet(
primary = Color(0xFFD5BFA5), // SOFTER: Light Milky Coffee
secondary = Color(0xFFC9B8AB), // SOFTER: Lighter Muted Brown
tertiary = Color(0xFFFFB59B), // NEW: Light Cinnamon Spice
primaryContainer = Color(0xFF61422E), // Dark Coffee Bean
secondaryContainer = Color(0xFF56443A),
tertiaryContainer = Color(0xFF7B2D0F), // NEW: Generated from new tertiary
onPrimary = Color(0xFF472C1A),
onSecondary = Color(0xFF3F2E25),
onTertiary = Color(0xFF5C1D00), // NEW: Generated from new tertiary
onPrimaryContainer = Color(0xFFFFDBCA),
onSecondaryContainer = Color(0xFFF9DFD0),
onTertiaryContainer = Color(0xFFFFDBCF), // NEW: Generated from new tertiary
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1C1B1A), // SOFTER: Dark Charcoal Brown
onBackground = Color(0xFFE6E2E0), // SOFTER: Light Cream Text
surface = Color(0xFF1C1B1A), // SOFTER: Dark Charcoal Brown
onSurface = Color(0xFFE6E2E0), // SOFTER: Light Cream Text
surfaceVariant = Color(0xFF50453D),
onSurfaceVariant = Color(0xFFD4C4B9),
outline = Color(0xFF9C8E85),
outlineVariant = Color(0xFF50453D),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE6E2E0),
inverseOnSurface = Color(0xFF31302F),
inversePrimary = Color(0xFF7A5944),
surfaceDim = Color(0xFF141312),
surfaceBright = Color(0xFF3A3938),
surfaceContainerLowest = Color(0xFF0F0E0D),
surfaceContainerLow = Color(0xFF1C1B1A),
surfaceContainer = Color(0xFF201F1E),
surfaceContainerHigh = Color(0xFF2B2A29),
surfaceContainerHighest = Color(0xFF363433)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val CrimsonTheme = AppTheme(
name = "Crimson",
lightColors = ThemeColorSet(
primary = Color(0xFFA03F3F),
secondary = Color(0xFF775656),
tertiary = Color(0xFF755A2F),
primaryContainer = Color(0xFFFFDAD9),
secondaryContainer = Color(0xFFFFDAD9),
tertiaryContainer = Color(0xFFFFDEAD),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF410004),
onSecondaryContainer = Color(0xFF2C1515),
onTertiaryContainer = Color(0xFF281900),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFCFCFC),
onBackground = Color(0xFF201A1A),
surface = Color(0xFFFCFCFC),
onSurface = Color(0xFF201A1A),
surfaceVariant = Color(0xFFF4DDDD),
onSurfaceVariant = Color(0xFF524343),
outline = Color(0xFF857373),
outlineVariant = Color(0xFFD7C1C1),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF362F2F),
inverseOnSurface = Color(0xFFFBEDED),
inversePrimary = Color(0xFFFFB3B3),
surfaceDim = Color(0xFFE3D7D7),
surfaceBright = Color(0xFFFCFCFC),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF7F0F0),
surfaceContainer = Color(0xFFF1EAEB),
surfaceContainerHigh = Color(0xFFEBE4E5),
surfaceContainerHighest = Color(0xFFE5DFDF)
),
darkColors = ThemeColorSet(
primary = Color(0xFFFFB3B3),
secondary = Color(0xFFE6BDBC),
tertiary = Color(0xFFE5C18D),
primaryContainer = Color(0xFF812829),
secondaryContainer = Color(0xFF5D3F3F),
tertiaryContainer = Color(0xFF5B431A),
onPrimary = Color(0xFF611216),
onSecondary = Color(0xFF442929),
onTertiary = Color(0xFF412D05),
onPrimaryContainer = Color(0xFFFFDAD9),
onSecondaryContainer = Color(0xFFFFDAD9),
onTertiaryContainer = Color(0xFFFFDEAD),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF201A1A),
onBackground = Color(0xFFEBE0E0),
surface = Color(0xFF201A1A),
onSurface = Color(0xFFEBE0E0),
surfaceVariant = Color(0xFF524343),
onSurfaceVariant = Color(0xFFD7C1C1),
outline = Color(0xFFA08C8C),
outlineVariant = Color(0xFF524343),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFEBE0E0),
inverseOnSurface = Color(0xFF362F2F),
inversePrimary = Color(0xFFA03F3F),
surfaceDim = Color(0xFF171212),
surfaceBright = Color(0xFF3E3737),
surfaceContainerLowest = Color(0xFF120D0D),
surfaceContainerLow = Color(0xFF251E1E),
surfaceContainer = Color(0xFF2A2222),
surfaceContainerHigh = Color(0xFF342C2C),
surfaceContainerHighest = Color(0xFF3F3737),
)
)

View File

@@ -0,0 +1,87 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val CyberpunkTheme = AppTheme(
name = "Cyberpunk",
// Corrected Light Theme
lightColors = ThemeColorSet(
primary = Color(0xFF007ACC),
secondary = Color(0xFFE600E6),
tertiary = Color(0xFFD4D400),
primaryContainer = Color(0xFFD1E4FF),
secondaryContainer = Color(0xFFFFD7F7),
tertiaryContainer = Color(0xFFF4F37E),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF001D36),
onSecondaryContainer = Color(0xFF3B0038),
onTertiaryContainer = Color(0xFF272700),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF8F9FF), // Light background
onBackground = Color(0xFF191C20), // Dark text
surface = Color(0xFFFFFFFF), // White surface for cards, etc.
onSurface = Color(0xFF191C20), // Dark text on surface
surfaceVariant = Color(0xFFDFE2EB),
onSurfaceVariant = Color(0xFF43474E),
outline = Color(0xFF73777F),
outlineVariant = Color(0xFFC3C7CF),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2E3135),
inverseOnSurface = Color(0xFFF0F1F6),
inversePrimary = Color(0xFF9FCAFF),
surfaceDim = Color(0xFFD9DADE),
surfaceBright = Color(0xFFF8F9FF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF2F3F9),
surfaceContainer = Color(0xFFECEEF3),
surfaceContainerHigh = Color(0xFFE6E8EE),
surfaceContainerHighest = Color(0xFFE1E2E8)
),
// Unchanged Dark Theme (already correct)
darkColors = ThemeColorSet(
primary = Color(0xFF00BFFF), // Deep Sky Blue
secondary = Color(0xFFFF00FF), // Magenta
tertiary = Color(0xFFFFFF00), // Yellow
primaryContainer = Color(0xFF004C66),
secondaryContainer = Color(0xFF660066),
tertiaryContainer = Color(0xFF666600),
onPrimary = Color(0xFF000000),
onSecondary = Color(0xFF000000),
onTertiary = Color(0xFF000000),
onPrimaryContainer = Color(0xFFB3F0FF),
onSecondaryContainer = Color(0xFFFFB3FF),
onTertiaryContainer = Color(0xFFFFFFB3),
error = Color(0xFFCF6679),
onError = Color(0xFF000000),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF0A0A0A),
onBackground = Color(0xFFEAEAEA),
surface = Color(0xFF121212),
onSurface = Color(0xFFEAEAEA),
surfaceVariant = Color(0xFF1E1E1E),
onSurfaceVariant = Color(0xFFB3B3B3),
outline = Color(0xFF595959),
outlineVariant = Color(0xFF2C2C2C),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFEAEAEA),
inverseOnSurface = Color(0xFF121212),
inversePrimary = Color(0xFF0088CC),
surfaceDim = Color(0xFF0A0A0A),
surfaceBright = Color(0xFF333333),
surfaceContainerLowest = Color(0xFF050505),
surfaceContainerLow = Color(0xFF121212),
surfaceContainer = Color(0xFF1A1A1A),
surfaceContainerHigh = Color(0xFF242424),
surfaceContainerHighest = Color(0xFF2E2E2E),
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val DefaultTheme = AppTheme(
name = "Default",
lightColors = ThemeColorSet(
primary = Color(0xFF006D3D),
secondary = Color(0xFF00668A),
tertiary = Color(0xFF7D5260),
primaryContainer = Color(0xFF95F5B3),
secondaryContainer = Color(0xFFBBE9FF),
tertiaryContainer = Color(0xFFFFD8E4), // Changed from light brown
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF), // Changed
onPrimaryContainer = Color(0xFF00210E),
onSecondaryContainer = Color(0xFF001F2D),
onTertiaryContainer = Color(0xFF31111D), // Changed from dark brown
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF93000A),
background = Color(0xFFF9F9FF),
onBackground = Color(0xFF191C20),
surface = Color(0xFFF9F9FF),
onSurface = Color(0xFF191C20),
surfaceVariant = Color(0xFFDEE2ED),
onSurfaceVariant = Color(0xFF424750),
outline = Color(0xFF727781),
outlineVariant = Color(0xFFC2C6D1),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2E3035),
inverseOnSurface = Color(0xFFF0F0F6),
inversePrimary = Color(0xFF76D899),
surfaceDim = Color(0xFFD9DADF),
surfaceBright = Color(0xFFF9F9FF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF3F3F9),
surfaceContainer = Color(0xFFEDEDF3),
surfaceContainerHigh = Color(0xFFE7E8ED),
surfaceContainerHighest = Color(0xFFE1E2E8)
),
darkColors = ThemeColorSet(
primary = Color(0xFF76D899),
secondary = Color(0xFF60D4FF),
tertiary = Color(0xFFEFB8C8), // Changed from orange/gold
primaryContainer = Color(0xFF00522B),
secondaryContainer = Color(0xFF004D67),
tertiaryContainer = Color(0xFF633B48), // Changed from dark brown
onPrimary = Color(0xFF00391B),
onSecondary = Color(0xFF003548),
onTertiary = Color(0xFF492532), // Changed from dark brown
onPrimaryContainer = Color(0xFF95F5B3),
onSecondaryContainer = Color(0xFFBBE9FF),
onTertiaryContainer = Color(0xFFFFD8E4), // Changed from light brown
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF111317),
onBackground = Color(0xFFE1E2E8),
surface = Color(0xFF111317),
onSurface = Color(0xFFE1E2E8),
surfaceVariant = Color(0xFF424750),
onSurfaceVariant = Color(0xFFC2C6D1),
outline = Color(0xFF8C919B),
outlineVariant = Color(0xFF424750),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE1E2E8),
inverseOnSurface = Color(0xFF2E3035),
inversePrimary = Color(0xFF006D3D),
surfaceDim = Color(0xFF111317),
surfaceBright = Color(0xFF37393E),
surfaceContainerLowest = Color(0xFF0C0E12),
surfaceContainerLow = Color(0xFF191C20),
surfaceContainer = Color(0xFF1D2024),
surfaceContainerHigh = Color(0xFF282A2E),
surfaceContainerHighest = Color(0xFF333539)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val ForestTheme = AppTheme(
name = "Forest",
lightColors = ThemeColorSet(
primary = Color(0xFF2E6C25), // Deep Forest Green
secondary = Color(0xFF6D4C41), // Wood Brown
tertiary = Color(0xFF7E5700), // NEW: Golden Amber Accent
primaryContainer = Color(0xFFB0F59E),
secondaryContainer = Color(0xFFF8DFD1),
tertiaryContainer = Color(0xFFFFDEA7), // NEW: Generated from new tertiary
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF002200),
onSecondaryContainer = Color(0xFF261005),
onTertiaryContainer = Color(0xFF281900), // NEW: Generated from new tertiary
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFCFDF6), // Light, earthy off-white
onBackground = Color(0xFF1A1C19),
surface = Color(0xFFFCFDF6),
onSurface = Color(0xFF1A1C19),
surfaceVariant = Color(0xFFDFE4D7),
onSurfaceVariant = Color(0xFF43483F),
outline = Color(0xFF73796E),
outlineVariant = Color(0xFFC3C8BC),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2F312D),
inverseOnSurface = Color(0xFFF0F1EB),
inversePrimary = Color(0xFF95D885),
surfaceDim = Color(0xFFDCDDD8),
surfaceBright = Color(0xFFFCFDF6),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF6F7F0),
surfaceContainer = Color(0xFFF0F1EB),
surfaceContainerHigh = Color(0xFFEAEBE5),
surfaceContainerHighest = Color(0xFFE4E6E0)
),
darkColors = ThemeColorSet(
primary = Color(0xFF95D885), // Lighter Canopy Green
secondary = Color(0xFFDBBFA9), // Lighter Wood Brown
tertiary = Color(0xFFFCBC4A), // NEW: Bright Golden Amber
primaryContainer = Color(0xFF15530D),
secondaryContainer = Color(0xFF533F34),
tertiaryContainer = Color(0xFF604200), // NEW: Generated from new tertiary
onPrimary = Color(0xFF003A00), // FIX: Correct contrast
onSecondary = Color(0xFF3F2D24), // FIX: Correct contrast
onTertiary = Color(0xFF432D00), // NEW: Generated from new tertiary
onPrimaryContainer = Color(0xFFB0F59E),
onSecondaryContainer = Color(0xFFF8DFD1),
onTertiaryContainer = Color(0xFFFFDEA7), // NEW: Generated from new tertiary
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A1C19), // NEW: Deep, dark forest green background
onBackground = Color(0xFFE4E6E0),
surface = Color(0xFF1A1C19), // NEW: Deep, dark forest green background
onSurface = Color(0xFFE4E6E0),
surfaceVariant = Color(0xFF43483F),
onSurfaceVariant = Color(0xFFC3C8BC),
outline = Color(0xFF8D9387),
outlineVariant = Color(0xFF43483F),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE4E6E0),
inverseOnSurface = Color(0xFF1A1C19),
inversePrimary = Color(0xFF2E6C25),
surfaceDim = Color(0xFF121411),
surfaceBright = Color(0xFF383A36),
surfaceContainerLowest = Color(0xFF0F110E),
surfaceContainerLow = Color(0xFF1A1C19), // FIX: Neutral container colors
surfaceContainer = Color(0xFF1E201D), // FIX: Neutral container colors
surfaceContainerHigh = Color(0xFF282B27), // FIX: Neutral container colors
surfaceContainerHighest = Color(0xFF333631) // FIX: Neutral container colors
)
)

View File

@@ -0,0 +1,105 @@
@file:Suppress("HardCodedStringLiteral", "unused")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
// Official Nord Color Palette
private val nord0 = Color(0xFF2E3440) // Polar Night
private val nord1 = Color(0xFF3B4252)
private val nord2 = Color(0xFF434C5E)
private val nord3 = Color(0xFF4C566A)
private val nord4 = Color(0xFFD8DEE9) // Snow Storm
private val nord5 = Color(0xFFE5E9F0)
private val nord6 = Color(0xFFECEFF4)
private val nord7 = Color(0xFF8FBCBB) // Frost
private val nord8 = Color(0xFF88C0D0)
private val nord9 = Color(0xFF81A1C1)
private val nord10 = Color(0xFF5E81AC)
private val nord11 = Color(0xFFBF616A) // Aurora
private val nord12 = Color(0xFFD08770)
private val nord13 = Color(0xFFEBCB8B)
private val nord14 = Color(0xFFA3BE8C)
private val nord15 = Color(0xFFB48EAD)
val NordTheme = AppTheme(
name = "Nord",
lightColors = ThemeColorSet(
primary = nord10,
secondary = nord8,
tertiary = nord15,
primaryContainer = Color(0xFFDCE3F9),
secondaryContainer = Color(0xFFD0EAF3),
tertiaryContainer = Color(0xFFF0DBEB),
onPrimary = Color.White,
onSecondary = nord0,
onTertiary = nord0,
onPrimaryContainer = nord1,
onSecondaryContainer = nord1,
onTertiaryContainer = nord1,
error = nord11,
onError = Color.White,
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B),
background = nord6, // Crisper, lighter background
onBackground = nord0,
surface = Color.White, // Pure white surface for a frosty, clean look
onSurface = nord0,
surfaceVariant = nord5,
onSurfaceVariant = nord2,
outline = nord3,
outlineVariant = nord4,
scrim = Color.Black,
inverseSurface = nord1,
inverseOnSurface = nord5,
inversePrimary = nord9,
surfaceDim = nord4,
surfaceBright = nord6,
surfaceContainerLowest = Color.White,
surfaceContainerLow = nord6,
surfaceContainer = nord5,
surfaceContainerHigh = nord4,
surfaceContainerHighest = Color(0xFFD3DAE4) // Slightly darker for top layer
),
darkColors = ThemeColorSet(
primary = nord9,
secondary = nord8,
tertiary = nord15,
primaryContainer = nord3,
secondaryContainer = nord3,
tertiaryContainer = nord2,
onPrimary = nord0, // Dark text for better contrast
onSecondary = nord0, // Dark text for better contrast
onTertiary = nord0, // Dark text for better contrast
onPrimaryContainer = nord5,
onSecondaryContainer = nord5,
onTertiaryContainer = nord5,
error = nord11,
onError = nord1,
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = nord0,
onBackground = nord5,
surface = nord1, // Lifts surfaces off the background for more depth
onSurface = nord5,
surfaceVariant = nord2,
onSurfaceVariant = nord4,
outline = nord3,
outlineVariant = nord2,
scrim = Color.Black,
inverseSurface = nord5,
inverseOnSurface = nord1,
inversePrimary = nord10,
surfaceDim = nord0,
surfaceBright = nord2,
surfaceContainerLowest = Color(0xFF191C23), // Deepest polar night
surfaceContainerLow = nord0,
surfaceContainer = nord1,
surfaceContainerHigh = nord2,
surfaceContainerHighest = nord3
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val OceanicCalmTheme = AppTheme(
name = "Oceanic Calm",
lightColors = ThemeColorSet(
primary = Color(0xFF006782),
secondary = Color(0xFF4A626C),
tertiary = Color(0xFF565E7D),
primaryContainer = Color(0xFFBBE9FF),
secondaryContainer = Color(0xFFCDE7F2),
tertiaryContainer = Color(0xFFDDE1FF),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF001F29),
onSecondaryContainer = Color(0xFF051F27),
onTertiaryContainer = Color(0xFF111A36),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF7F9FA),
onBackground = Color(0xFF191C1E),
surface = Color(0xFFF7F9FA),
onSurface = Color(0xFF191C1E),
surfaceVariant = Color(0xFFDCE4E8),
onSurfaceVariant = Color(0xFF40484C),
outline = Color(0xFF70787C),
outlineVariant = Color(0xFFC0C8CC),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2E3133),
inverseOnSurface = Color(0xFFEEF1F3),
inversePrimary = Color(0xFF60D4FF),
surfaceDim = Color(0xFFD8DADD),
surfaceBright = Color(0xFFF7F9FA),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF1F3F5),
surfaceContainer = Color(0xFFEBEDEE),
surfaceContainerHigh = Color(0xFFE5E7E9),
surfaceContainerHighest = Color(0xFFE0E2E3)
),
darkColors = ThemeColorSet(
primary = Color(0xFF60D4FF),
secondary = Color(0xFFB2CBD6),
tertiary = Color(0xFFBEC5EB),
primaryContainer = Color(0xFF004D63),
secondaryContainer = Color(0xFF334A54),
tertiaryContainer = Color(0xFF3F4664),
onPrimary = Color(0xFF003545),
onSecondary = Color(0xFF1C343D),
onTertiary = Color(0xFF28304D),
onPrimaryContainer = Color(0xFFBBE9FF),
onSecondaryContainer = Color(0xFFCDE7F2),
onTertiaryContainer = Color(0xFFDDE1FF),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF191C1E),
onBackground = Color(0xFFE0E2E3),
surface = Color(0xFF191C1E),
onSurface = Color(0xFFE0E2E3),
surfaceVariant = Color(0xFF40484C),
onSurfaceVariant = Color(0xFFC0C8CC),
outline = Color(0xFF8A9296),
outlineVariant = Color(0xFF40484C),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE0E2E3),
inverseOnSurface = Color(0xFF2E3133),
inversePrimary = Color(0xFF006782),
surfaceDim = Color(0xFF111415),
surfaceBright = Color(0xFF373A3B),
surfaceContainerLowest = Color(0xFF0E1112),
surfaceContainerLow = Color(0xFF191C1E),
surfaceContainer = Color(0xFF1D2022),
surfaceContainerHigh = Color(0xFF272A2C),
surfaceContainerHighest = Color(0xFF323537)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val PixelTheme = AppTheme(
name = "Pixel",
lightColors = ThemeColorSet(
primary = Color(0xFF0061A4),
secondary = Color(0xFF535F70),
tertiary = Color(0xFF6B5778),
primaryContainer = Color(0xFFD1E4FF),
secondaryContainer = Color(0xFFD7E3F7),
tertiaryContainer = Color(0xFFF2DAFF),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF001D36),
onSecondaryContainer = Color(0xFF101C2B),
onTertiaryContainer = Color(0xFF251431),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFDFCFF),
onBackground = Color(0xFF1A1C1E),
surface = Color(0xFFFDFCFF),
onSurface = Color(0xFF1A1C1E),
surfaceVariant = Color(0xFFDFE2EB),
onSurfaceVariant = Color(0xFF42474E),
outline = Color(0xFF73777F),
outlineVariant = Color(0xFFC2C7CF),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2F3033),
inverseOnSurface = Color(0xFFF1F0F4),
inversePrimary = Color(0xFF9ECAFF),
surfaceDim = Color(0xFFD9DAE0),
surfaceBright = Color(0xFFF9F9FF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF3F3FA),
surfaceContainer = Color(0xFFEDEEF4),
surfaceContainerHigh = Color(0xFFE7E8EE),
surfaceContainerHighest = Color(0xFFE2E2E9)
),
darkColors = ThemeColorSet(
primary = Color(0xFF9ECAFF),
secondary = Color(0xFFBBC7DB),
tertiary = Color(0xFFD6BEE4),
primaryContainer = Color(0xFF00497D),
secondaryContainer = Color(0xFF3B4858),
tertiaryContainer = Color(0xFF524060),
onPrimary = Color(0xFF003258),
onSecondary = Color(0xFF253141),
onTertiary = Color(0xFF3B2948),
onPrimaryContainer = Color(0xFFD1E4FF),
onSecondaryContainer = Color(0xFFD7E3F7),
onTertiaryContainer = Color(0xFFF2DAFF),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A1C1E),
onBackground = Color(0xFFE2E2E6),
surface = Color(0xFF1A1C1E),
onSurface = Color(0xFFE2E2E6),
surfaceVariant = Color(0xFF42474E),
onSurfaceVariant = Color(0xFFC2C7CF),
outline = Color(0xFF8C9199),
outlineVariant = Color(0xFF42474E),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE2E2E6),
inverseOnSurface = Color(0xFF1A1C1E),
inversePrimary = Color(0xFF0061A4),
surfaceDim = Color(0xFF121415),
surfaceBright = Color(0xFF383A3C),
surfaceContainerLowest = Color(0xFF0D0F11),
surfaceContainerLow = Color(0xFF1A1C1E),
surfaceContainer = Color(0xFF1E2022),
surfaceContainerHigh = Color(0xFF282A2D),
surfaceContainerHighest = Color(0xFF333538)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SakuraTheme = AppTheme(
name = "Blossom Pink",
lightColors = ThemeColorSet(
primary = Color(0xFFB94565),
secondary = Color(0xFF755960),
tertiary = Color(0xFF805537),
primaryContainer = Color(0xFFFFD9DF),
secondaryContainer = Color(0xFFFFD9E2),
tertiaryContainer = Color(0xFFFFDCC2),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF40001F),
onSecondaryContainer = Color(0xFF2B171D),
onTertiaryContainer = Color(0xFF311300),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFF8F7),
onBackground = Color(0xFF221A1C),
surface = Color(0xFFFFF8F7),
onSurface = Color(0xFF221A1C),
surfaceVariant = Color(0xFFF2DEE1),
onSurfaceVariant = Color(0xFF514346),
outline = Color(0xFF837376),
outlineVariant = Color(0xFFD5C2C5),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF382E30),
inverseOnSurface = Color(0xFFFDEDEF),
inversePrimary = Color(0xFFE3B9C2),
surfaceDim = Color(0xFFE8D6D8),
surfaceBright = Color(0xFFFFF8F7),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFFFF0F1),
surfaceContainer = Color(0xFFFCEAEF),
surfaceContainerHigh = Color(0xFFF6E4E9),
surfaceContainerHighest = Color(0xFFF1DEE4)
),
darkColors = ThemeColorSet(
primary = Color(0xFFE3B9C2),
secondary = Color(0xFFE3BDC6),
tertiary = Color(0xFFF3BC95),
primaryContainer = Color(0xFF982C4D),
secondaryContainer = Color(0xFF5C4148),
tertiaryContainer = Color(0xFF653F22),
onPrimary = Color(0xFF581535),
onSecondary = Color(0xFF422C32),
onTertiary = Color(0xFF4A280D),
onPrimaryContainer = Color(0xFFFFD9DF),
onSecondaryContainer = Color(0xFFFFD9E2),
onTertiaryContainer = Color(0xFFFFDCC2),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF221A1C),
onBackground = Color(0xFFF1DEE4),
surface = Color(0xFF221A1C),
onSurface = Color(0xFFF1DEE4),
surfaceVariant = Color(0xFF514346),
onSurfaceVariant = Color(0xFFD5C2C5),
outline = Color(0xFF9D8C8F),
outlineVariant = Color(0xFF514346),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFF1DEE4),
inverseOnSurface = Color(0xFF221A1C),
inversePrimary = Color(0xFFB94565),
surfaceDim = Color(0xFF191214),
surfaceBright = Color(0xFF41373A),
surfaceContainerLowest = Color(0xFF140D0F),
surfaceContainerLow = Color(0xFF221A1C),
surfaceContainer = Color(0xFF261E20),
surfaceContainerHigh = Color(0xFF31282A),
surfaceContainerHighest = Color(0xFF3C3335)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SlateAndStoneTheme = AppTheme(
name = "Slate & Stone",
lightColors = ThemeColorSet(
primary = Color(0xFF5A6470),
secondary = Color(0xFF72787F),
tertiary = Color(0xFFD4A237),
primaryContainer = Color(0xFFDFE9F5),
secondaryContainer = Color(0xFFDCE3E9),
tertiaryContainer = Color(0xFFFAE0A2),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFF3E2E00),
onPrimaryContainer = Color(0xFF171F2B),
onSecondaryContainer = Color(0xFF2C3136),
onTertiaryContainer = Color(0xFF271A00),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF8F9FA),
onBackground = Color(0xFF1B1C1E),
surface = Color(0xFFF8F9FA),
onSurface = Color(0xFF1B1C1E),
surfaceVariant = Color(0xFFE1E2E8),
onSurfaceVariant = Color(0xFF44474B),
outline = Color(0xFF75777C),
outlineVariant = Color(0xFFC5C6CC),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2F3133),
inverseOnSurface = Color(0xFFF2F0F4),
inversePrimary = Color(0xFFB3C5D8),
surfaceDim = Color(0xFFDAD9DD),
surfaceBright = Color(0xFFF8F9FA),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF2F3F5),
surfaceContainer = Color(0xFFECEEF0),
surfaceContainerHigh = Color(0xFFE7E8EA),
surfaceContainerHighest = Color(0xFFE1E2E4)
),
darkColors = ThemeColorSet(
primary = Color(0xFFB3C5D8),
secondary = Color(0xFFBFC7CE),
tertiary = Color(0xFFE0C484),
primaryContainer = Color(0xFF424C58),
secondaryContainer = Color(0xFF595F66),
tertiaryContainer = Color(0xFF5B4300),
onPrimary = Color(0xFF2C3541),
onSecondary = Color(0xFF43484D),
onTertiary = Color(0xFF3F2E00),
onPrimaryContainer = Color(0xFFDFE9F5),
onSecondaryContainer = Color(0xFFDCE3E9),
onTertiaryContainer = Color(0xFFFAE0A2),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1D1E20),
onBackground = Color(0xFFE1E2E4),
surface = Color(0xFF1D1E20),
onSurface = Color(0xFFE1E2E4),
surfaceVariant = Color(0xFF44474B),
onSurfaceVariant = Color(0xFFC5C6CC),
outline = Color(0xFF8E9196),
outlineVariant = Color(0xFF44474B),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE1E2E4),
inverseOnSurface = Color(0xFF2F3133),
inversePrimary = Color(0xFF5A6470),
surfaceDim = Color(0xFF121415),
surfaceBright = Color(0xFF383A3B),
surfaceContainerLowest = Color(0xFF0D0F10),
surfaceContainerLow = Color(0xFF1D1E20),
surfaceContainer = Color(0xFF212224),
surfaceContainerHigh = Color(0xFF2B2D2F),
surfaceContainerHighest = Color(0xFF36383A)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SpaceTheme = AppTheme(
name = "Space Opera",
lightColors = ThemeColorSet(
primary = Color(0xFF3399FF), // Hologram Blue
secondary = Color(0xFFFFA500), // Engine Glow Orange
tertiary = Color(0xFFE0E0E0),
primaryContainer = Color(0xFFD7E8FF),
secondaryContainer = Color(0xFFFFECCF),
tertiaryContainer = Color(0xFFF0F0F0),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFF000000),
onTertiary = Color(0xFF000000),
onPrimaryContainer = Color(0xFF001D35),
onSecondaryContainer = Color(0xFF271A00),
onTertiaryContainer = Color(0xFF1F1F1F),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF8F9FA), // Cockpit White
onBackground = Color(0xFF181C20),
surface = Color(0xFFF8F9FA),
onSurface = Color(0xFF181C20),
surfaceVariant = Color(0xFFDEE3EB),
onSurfaceVariant = Color(0xFF42474E),
outline = Color(0xFF72787E),
outlineVariant = Color(0xFFC2C7CE),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2D3135),
inverseOnSurface = Color(0xFFF0F2F5),
inversePrimary = Color(0xFFADC6FF),
surfaceDim = Color(0xFFD9DADD),
surfaceBright = Color(0xFFF8F9FA),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF2F3F5),
surfaceContainer = Color(0xFFECEEF0),
surfaceContainerHigh = Color(0xFFE6E8EA),
surfaceContainerHighest = Color(0xFFE1E3E5)
),
darkColors = ThemeColorSet(
primary = Color(0xFFADC6FF), // Nebula Blue
secondary = Color(0xFFFFB74D), // Thruster Orange
tertiary = Color(0xFFE0E0E0), // Starlight
primaryContainer = Color(0xFF004488),
secondaryContainer = Color(0xFF664200),
tertiaryContainer = Color(0xFF424242),
onPrimary = Color(0xFF002F54),
onSecondary = Color(0xFF3F2800),
onTertiary = Color(0xFF000000),
onPrimaryContainer = Color(0xFFD7E8FF),
onSecondaryContainer = Color(0xFFFFDDBF),
onTertiaryContainer = Color(0xFFFAFAFA),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF101418), // Deep Space
onBackground = Color(0xFFE2E2E6),
surface = Color(0xFF101418),
onSurface = Color(0xFFE2E2E6),
surfaceVariant = Color(0xFF42474E),
onSurfaceVariant = Color(0xFFC2C7CE),
outline = Color(0xFF8C9198),
outlineVariant = Color(0xFF42474E),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE2E2E6),
inverseOnSurface = Color(0xFF181C20),
inversePrimary = Color(0xFF3399FF),
surfaceDim = Color(0xFF101418),
surfaceBright = Color(0xFF363A3F),
surfaceContainerLowest = Color(0xFF0B0F13),
surfaceContainerLow = Color(0xFF181C20),
surfaceContainer = Color(0xFF1C2024),
surfaceContainerHigh = Color(0xFF272B2F),
surfaceContainerHighest = Color(0xFF32363A)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SynthwaveTheme = AppTheme(
name = "Synthwave '84",
lightColors = ThemeColorSet(
primary = Color(0xFFC50083), // Darker Magenta for light theme contrast
secondary = Color(0xFF006874), // Darker Teal
tertiary = Color(0xFF7A5900),
primaryContainer = Color(0xFFFFD8EC),
secondaryContainer = Color(0xFFB3F0FF),
tertiaryContainer = Color(0xFFFFE26E),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF40002A),
onSecondaryContainer = Color(0xFF001F24),
onTertiaryContainer = Color(0xFF261A00),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFDF7FF), // A very light lavender/off-white
onBackground = Color(0xFF1F1A21), // Dark Purple for text
surface = Color(0xFFFDF7FF),
onSurface = Color(0xFF1F1A21),
surfaceVariant = Color(0xFFE8E0F3),
onSurfaceVariant = Color(0xFF49454E),
outline = Color(0xFF7A757E),
outlineVariant = Color(0xFFCBC4CE),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF342F36),
inverseOnSurface = Color(0xFFF5EFF7),
inversePrimary = Color(0xFFF475CB), // The vibrant pink from dark theme
surfaceDim = Color(0xFFE0D8E2),
surfaceBright = Color(0xFFFDF7FF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF7F1FA),
surfaceContainer = Color(0xFFF1EBF4),
surfaceContainerHigh = Color(0xFFECE5EE),
surfaceContainerHighest = Color(0xFFE6E0E9)
),
darkColors = ThemeColorSet(
primary = Color(0xFFF475CB), // Vibrant Magenta
secondary = Color(0xFF6AD9E8), // Electric Cyan
tertiary = Color(0xFFFFD400), // Sunset Yellow
primaryContainer = Color(0xFF660044),
secondaryContainer = Color(0xFF005A66),
tertiaryContainer = Color(0xFF665500),
onPrimary = Color(0xFF50003A),
onSecondary = Color(0xFF00363D),
onTertiary = Color(0xFF352D00),
onPrimaryContainer = Color(0xFFFFD8EC),
onSecondaryContainer = Color(0xFFB3F0FF),
onTertiaryContainer = Color(0xFFFFE26E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A103C), // Deep Indigo
onBackground = Color(0xFFE0E5FF), // Pale Lavender Text
surface = Color(0xFF1A103C),
onSurface = Color(0xFFE0E5FF),
surfaceVariant = Color(0xFF49454E),
onSurfaceVariant = Color(0xFFCBC4CE),
outline = Color(0xFF948F99),
outlineVariant = Color(0xFF49454E),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE6E0E9),
inverseOnSurface = Color(0xFF342F36),
inversePrimary = Color(0xFFC50083),
surfaceDim = Color(0xFF151218),
surfaceBright = Color(0xFF3C383E),
surfaceContainerLowest = Color(0xFF100D13),
surfaceContainerLow = Color(0xFF1F1A21),
surfaceContainer = Color(0xFF231E25),
surfaceContainerHigh = Color(0xFF2E292F),
surfaceContainerHighest = Color(0xFF39333A)
)
)

View File

@@ -0,0 +1,84 @@
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
@Suppress("HardCodedStringLiteral")
val TealTheme = AppTheme(
name = "Teal",
lightColors = ThemeColorSet(
primary = Color(0xFF006A62),
secondary = Color(0xFF4A6360),
tertiary = Color(0xFF476278),
primaryContainer = Color(0xFF95F7EB),
secondaryContainer = Color(0xFFCCE8E4),
tertiaryContainer = Color(0xFFCEE6FF),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF00201D),
onSecondaryContainer = Color(0xFF051F1D),
onTertiaryContainer = Color(0xFF001E30),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFAFDFA),
onBackground = Color(0xFF191C1C),
surface = Color(0xFFFAFDFA),
onSurface = Color(0xFF191C1C),
surfaceVariant = Color(0xFFDAE5E2),
onSurfaceVariant = Color(0xFF3F4947),
outline = Color(0xFF6F7977),
outlineVariant = Color(0xFFBEC9C6),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2D3130),
inverseOnSurface = Color(0xFFEFF1F0),
inversePrimary = Color(0xFF79DACA),
surfaceDim = Color(0xFFDADADA),
surfaceBright = Color(0xFFFAFDFA),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF4F7F5),
surfaceContainer = Color(0xFFEEF2F0),
surfaceContainerHigh = Color(0xFFE8ECE9),
surfaceContainerHighest = Color(0xFFE2E6E4)
),
darkColors = ThemeColorSet(
primary = Color(0xFF79DACA),
secondary = Color(0xFFB0CCC8),
tertiary = Color(0xFFAFCBE2),
primaryContainer = Color(0xFF00504A),
secondaryContainer = Color(0xFF334B48),
tertiaryContainer = Color(0xFF2F4A5F),
onPrimary = Color(0xFF003732),
onSecondary = Color(0xFF1C3532),
onTertiary = Color(0xFF173347),
onPrimaryContainer = Color(0xFF95F7EB),
onSecondaryContainer = Color(0xFFCCE8E4),
onTertiaryContainer = Color(0xFFCEE6FF),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF191C1C),
onBackground = Color(0xFFE0E3E2),
surface = Color(0xFF191C1C),
onSurface = Color(0xFFE0E3E2),
surfaceVariant = Color(0xFF3F4947),
onSurfaceVariant = Color(0xFFBEC9C6),
outline = Color(0xFF899391),
outlineVariant = Color(0xFF3F4947),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE0E3E2),
inverseOnSurface = Color(0xFF2D3130),
inversePrimary = Color(0xFF006A62),
surfaceDim = Color(0xFF111413),
surfaceBright = Color(0xFF373A39),
surfaceContainerLowest = Color(0xFF0C0F0E),
surfaceContainerLow = Color(0xFF1F2221),
surfaceContainer = Color(0xFF232625),
surfaceContainerHigh = Color(0xFF2E3130),
surfaceContainerHighest = Color(0xFF393C3B)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val TwilightSerenityTheme = AppTheme(
name = "Twilight Serenity",
lightColors = ThemeColorSet(
primary = Color(0xFF5A52A5),
secondary = Color(0xFF9A4555),
tertiary = Color(0xFF7A5900),
primaryContainer = Color(0xFFE2DFFF),
secondaryContainer = Color(0xFFFFD9DD),
tertiaryContainer = Color(0xFFFFDF9E),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF16035F),
onSecondaryContainer = Color(0xFF400014),
onTertiaryContainer = Color(0xFF261A00),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFEFBFF),
onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFEFBFF),
onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFE5E0EC),
onSurfaceVariant = Color(0xFF47454E),
outline = Color(0xFF78757F),
outlineVariant = Color(0xFFC8C4CF),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF313035),
inverseOnSurface = Color(0xFFF3EFF6),
inversePrimary = Color(0xFFC1C1FF),
surfaceDim = Color(0xFFDED9E0),
surfaceBright = Color(0xFFFEFBFF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF8F2FA),
surfaceContainer = Color(0xFFF2ECF4),
surfaceContainerHigh = Color(0xFFECE7EF),
surfaceContainerHighest = Color(0xFFE6E1E9)
),
darkColors = ThemeColorSet(
primary = Color(0xFFC1C1FF),
secondary = Color(0xFFFFB1BB),
tertiary = Color(0xFFF5BF48),
primaryContainer = Color(0xFF413A8C),
secondaryContainer = Color(0xFF7C2B3E),
tertiaryContainer = Color(0xFF5C4300),
onPrimary = Color(0xFF2C2275),
onSecondary = Color(0xFF5F1328),
onTertiary = Color(0xFF402D00),
onPrimaryContainer = Color(0xFFE2DFFF),
onSecondaryContainer = Color(0xFFFFD9DD),
onTertiaryContainer = Color(0xFFFFDF9E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1C1B20),
onBackground = Color(0xFFE6E1E9),
surface = Color(0xFF1C1B20),
onSurface = Color(0xFFE6E1E9),
surfaceVariant = Color(0xFF47454E),
onSurfaceVariant = Color(0xFFC8C4CF),
outline = Color(0xFF928F99),
outlineVariant = Color(0xFF47454E),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE6E1E9),
inverseOnSurface = Color(0xFF313035),
inversePrimary = Color(0xFF5A52A5),
surfaceDim = Color(0xFF141317),
surfaceBright = Color(0xFF3A383E),
surfaceContainerLowest = Color(0xFF0F0E12),
surfaceContainerLow = Color(0xFF1C1B20),
surfaceContainer = Color(0xFF201F24),
surfaceContainerHigh = Color(0xFF2B292F),
surfaceContainerHighest = Color(0xFF36343A)
)
)

View File

@@ -0,0 +1,269 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.utils
import android.content.Context
import eu.gaudian.translator.model.LanguageModel
import eu.gaudian.translator.model.communication.ApiLogEntry
import eu.gaudian.translator.model.communication.ApiManager
import eu.gaudian.translator.model.communication.ModelType
import eu.gaudian.translator.model.repository.ApiLogRepository
import eu.gaudian.translator.model.repository.ApiRepository
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
interface ApiCallback {
fun onSuccess(translatedText: String?)
fun onFailure(errorMessage: String)
}
/**
* Enhanced API request handler that serves as the single entry point for all API calls.
* Provides unified JSON parsing, error handling, logging, and response validation.
*
* This class should be the only component that directly calls ApiManager.getCompletion().
* All services should use this handler for their API requests.
*/
class ApiRequestHandler(private val apiManager: ApiManager, context: Context) {
private val apiLogRepository = ApiLogRepository(context)
private val jsonHelper = JsonHelper()
val apiRepository = ApiRepository(context)
/**
* Executes an API request using a template for type-safe, validated responses.
* This is the preferred method for all API requests.
*
* @param template The request template containing all request configuration
* @return Result containing the parsed response or error
*/
suspend fun <T> executeRequest(template: ApiRequestTemplate<T>): Result<T> {
val languageModel: LanguageModel? = when (template.modelType) {
ModelType.TRANSLATION -> apiRepository.getTranslationModel().first()
ModelType.EXERCISE -> apiRepository.getExerciseModel().first()
ModelType.VOCABULARY -> apiRepository.getVocabularyModel().first()
ModelType.DICTIONARY -> apiRepository.getDictionaryModel().first()
}
return withContext(Dispatchers.IO) {
val startTime = System.currentTimeMillis()
var prompt = ""
try {
Log.i("ApiRequestHandler", "[${template.serviceName}] Executing API request")
prompt = template.buildPrompt()
Log.d("ApiRequestHandler", "[${template.serviceName}] Generated prompt: $prompt")
val rawResponse = getCompletionFromApi(prompt, template.modelType, template.serviceName)
val duration = System.currentTimeMillis() - startTime
if (rawResponse.isFailure) {
val error = rawResponse.exceptionOrNull()
Log.e("ApiRequestHandler", "[${template.serviceName}] API request failed", error)
logInteraction(
template, prompt, null, error, duration,
providerKey = languageModel?.modelId ?: "",
method = template.modelType.name,
)
return@withContext Result.failure(error!!)
}
val responseText = rawResponse.getOrNull()!!
Log.d("ApiRequestHandler", "[${template.serviceName}] Raw API response: $responseText")
// Log the successful raw response
logInteraction(
template, prompt, responseText, null, duration,
providerKey = languageModel?.modelId ?: "",
method = template.modelType.name,
)
// Clean the response text to remove conversational wrappers and Markdown fences
val cleanedResponse = extractJsonFromResponse(responseText)
// Validate response structure if required fields are specified
if (template.requiredFields.isNotEmpty()) {
if (!jsonHelper.validateRequiredFields(cleanedResponse, template.requiredFields)) {
val errorMsg = "Response missing required fields: ${template.requiredFields}"
Log.e("ApiRequestHandler", "[${template.serviceName}] $errorMsg")
return@withContext Result.failure(ApiValidationException(errorMsg))
}
}
// Parse the response using the enhanced JsonHelper
val parseResult = jsonHelper.parseJson(cleanedResponse, template.responseSerializer, template.serviceName)
if (parseResult.isSuccess) {
Log.i("ApiRequestHandler", "[${template.serviceName}] API request completed successfully")
}
parseResult
} catch (e: Exception) {
val duration = System.currentTimeMillis() - startTime
Log.e("ApiRequestHandler", "[${template.serviceName}] Unexpected error during API request", e)
// Log the unexpected exception if we haven't logged a response yet
logInteraction(
template, prompt, null, e, duration,
providerKey = languageModel?.modelId ?: "",
method = template.modelType.name,
)
Result.failure(ApiRequestException("Unexpected error in ${template.serviceName}", e))
}
}
}
/**
* Logs the API interaction to the database.
*/
private fun <T> logInteraction(
template: ApiRequestTemplate<T>,
prompt: String,
response: String?,
error: Throwable?,
duration: Long,
providerKey: String,
method: String,
) {
try {
val entry = ApiLogEntry(
id = UUID.randomUUID().toString(),
timestamp = System.currentTimeMillis(),
providerKey = providerKey,
endpoint = template.serviceName,
method = method,
model = template.modelType.name,
requestJson = prompt,
responseCode = if (error == null) 200 else 500,
responseMessage = error?.message ?: "Success",
responseJson = response,
errorMessage = error?.message,
durationMs = duration,
exceptionType = error?.javaClass?.simpleName,
isTimeout = false,
parseErrorMessage = null,
url = null
)
// Fire and forget logging
CoroutineScope(Dispatchers.IO).launch {
try {
apiLogRepository.addLog(entry)
} catch (e: Exception) {
Log.e("ApiRequestHandler", "Failed to save API log", e)
}
}
} catch (e: Exception) {
Log.e("ApiRequestHandler", "Failed to create API log entry", e)
}
}
/**
* Robostly extracts JSON from an API response string.
* Handles:
* 1. Markdown code blocks (```json ... ```)
* 2. Conversational wrapping ("Here is the JSON: ...")
* 3. Raw JSON
*/
private fun extractJsonFromResponse(text: String): String {
val trimmed = text.trim()
// 1. Try to find content within Markdown code blocks
// This regex looks for ``` (optional json) ... content ... ```
val codeBlockRegex = Regex("```(?:json)?\\s*([\\s\\S]*?)\\s*```", RegexOption.IGNORE_CASE)
val match = codeBlockRegex.find(trimmed)
if (match != null) {
return match.groupValues[1].trim()
}
// 2. If no code block is found, try to find the outermost JSON structure (Object or Array)
val firstBrace = trimmed.indexOf('{')
val firstBracket = trimmed.indexOf('[')
var startIndex = -1
var endIndex = -1
// Determine if we should look for an Object {} or an Array []
// We pick whichever appears first
if (firstBrace != -1 && (firstBracket == -1 || firstBrace < firstBracket)) {
startIndex = firstBrace
endIndex = trimmed.lastIndexOf('}')
} else if (firstBracket != -1) {
startIndex = firstBracket
endIndex = trimmed.lastIndexOf(']')
}
if (startIndex != -1 && endIndex != -1 && endIndex > startIndex) {
return trimmed.substring(startIndex, endIndex + 1)
}
// 3. Fallback: return the original text (parsing will likely fail if it's not valid JSON)
return trimmed
}
/**
* Internal method to get completion from API manager.
* This is the only place that should call ApiManager.getCompletion().
*/
private suspend fun getCompletionFromApi(
prompt: String,
modelType: ModelType,
serviceName: String
): Result<String> {
val deferred = CompletableDeferred<Result<String>>()
try {
apiManager.getCompletion(
prompt = prompt,
callback = object : ApiCallback {
override fun onSuccess(translatedText: String?) {
if (translatedText.isNullOrBlank()) {
val errorMsg = "API returned an empty response"
Log.e("ApiRequestHandler", "[$serviceName] $errorMsg")
deferred.complete(Result.failure(ApiResponseException(errorMsg)))
return
}
Log.d("ApiRequestHandler", "[$serviceName] API response received successfully")
deferred.complete(Result.success(translatedText))
}
override fun onFailure(errorMessage: String) {
Log.e("ApiRequestHandler", "[$serviceName] API failure: $errorMessage")
deferred.complete(Result.failure(ApiResponseException(errorMessage)))
}
},
modelType = modelType
)
} catch (e: Exception) {
Log.e("ApiRequestHandler", "[$serviceName] Error calling API manager", e)
deferred.complete(Result.failure(ApiRequestException("Error calling API manager", e)))
}
return deferred.await()
}
}
/**
* Custom exception for API request errors.
*/
class ApiRequestException(message: String, cause: Throwable? = null) : Exception(message, cause)
/**
* Custom exception for API response errors.
*/
class ApiResponseException(message: String, cause: Throwable? = null) : Exception(message, cause)
/**
* Custom exception for API validation errors.
*/
class ApiValidationException(message: String, cause: Throwable? = null) : Exception(message, cause)

View File

@@ -0,0 +1,477 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.utils
import eu.gaudian.translator.model.communication.ModelType
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
/**
* Unified interface for all API request templates.
* Provides a consistent way to define API requests across all services.
*/
interface ApiRequestTemplate<T> {
/**
* The serializer for the expected response type
*/
val responseSerializer: KSerializer<T>
/**
* The model type to use for this request
*/
val modelType: ModelType
/**
* The service name for logging purposes
*/
val serviceName: String
/**
* Builds the prompt for the API request
*/
fun buildPrompt(): String
/**
* Required fields that should be present in the JSON response
*/
val requiredFields: List<String> get() = emptyList()
}
/**
* Base class for API request templates with common functionality.
*/
abstract class BaseApiRequestTemplate<T> : ApiRequestTemplate<T> {
protected val promptBuilder = PromptBuilder("")
/**
* Adds a detail to the prompt
*/
protected fun addDetail(detail: String): BaseApiRequestTemplate<T> {
promptBuilder.addDetail(detail)
return this
}
/**
* Sets the JSON response structure description
*/
protected fun withJsonResponse(description: String): BaseApiRequestTemplate<T> {
promptBuilder.withJsonResponse(description)
return this
}
/**
* Adds an example to the prompt
*/
protected fun withExample(example: String): BaseApiRequestTemplate<T> {
promptBuilder.withExample(example)
return this
}
override fun buildPrompt(): String = promptBuilder.build()
}
/**
* Template for dictionary word definition requests
*/
class DictionaryDefinitionRequest(
word: String,
language: String,
requestedParts: String
) : BaseApiRequestTemplate<DictionaryApiResponse>() {
override val responseSerializer = DictionaryApiResponse.serializer()
override val modelType = ModelType.DICTIONARY
override val serviceName = "DictionaryService"
override val requiredFields = listOf("word", "parts")
init {
promptBuilder.basePrompt = "Act as a dictionary. Define the word '$word' in $language."
addDetail("Provide the following parts: $requestedParts.")
withJsonResponse("a JSON object with a 'word' key (the original word being defined) and a 'parts' array. Each item in the 'parts' array must be an object with a 'title' (e.g., 'Definition', 'Origin') and a 'content' key (the corresponding text).")
}
}
/**
* Template for example sentence requests
*/
class ExampleSentenceRequest(
word: String,
wordTranslation: String,
languageFirst: String,
languageSecond: String
) : BaseApiRequestTemplate<ExampleSentenceApiResponse>() {
override val responseSerializer = ExampleSentenceApiResponse.serializer()
override val modelType = ModelType.DICTIONARY
override val serviceName = "DictionaryService"
override val requiredFields = listOf("word", "sourceSentence", "targetSentence")
init {
promptBuilder.basePrompt = "Provide one short, simple and clear example sentence for the word '$word' in $languageFirst and fully translate the sentence to $languageSecond, using $wordTranslation as a translation."
addDetail("Structure: { 'word': string, 'sourceSentence': string, 'targetSentence': string }.")
addDetail("Only include the fields above. Keep sentences concise and clear. Do not include any explanations or additional text.")
withJsonResponse("a JSON object with 'word' (the original word), 'sourceSentence' (the example sentence in the source language), and 'targetSentence' (the translation in the target language). Ensure all values are properly quoted strings.")
}
}
/**
* Template for vocabulary translation requests
*/
class VocabularyTranslationRequest(
words: List<String>,
languageFirst: String,
languageSecond: String
) : BaseApiRequestTemplate<VocabularyApiResponse>() {
override val responseSerializer = VocabularyApiResponse.serializer()
override val modelType = ModelType.VOCABULARY
override val serviceName = "VocabularyService"
override val requiredFields = listOf("flashcards")
init {
val wordsJson = Json { ignoreUnknownKeys = true; isLenient = true }
.encodeToString(words.filter { it.isNotBlank() }.distinct())
promptBuilder.basePrompt = "Translate a list of words between two languages."
addDetail("Source language: $languageFirst. Target language: $languageSecond.")
addDetail("Translate each source word to the target language as concise vocabulary, not sentences.")
addDetail("Here is the JSON array of words: $wordsJson")
withJsonResponse("a 'flashcards' array, where each item has 'front' and 'back' objects with 'language' and 'word'. The 'front' must be in $languageFirst and the 'back' in $languageSecond.")
}
}
/**
* Template for text translation requests
*/
class TextTranslationRequest(
text: String,
sourceLanguage: String,
targetLanguage: String,
additionalInstructions: String = ""
) : BaseApiRequestTemplate<TranslationApiResponse>() {
override val responseSerializer = TranslationApiResponse.serializer()
override val modelType = ModelType.TRANSLATION
override val serviceName = "TranslationService"
override val requiredFields = listOf("translatedText")
init {
promptBuilder.basePrompt = "Translate the following from $sourceLanguage to $targetLanguage: '$text'"
addDetail("Act like a translator and translate everything and don't return anything else.")
if (additionalInstructions.isNotBlank()) {
addDetail("Additional instructions: $additionalInstructions")
}
withJsonResponse("a JSON object with a single key 'translatedText' containing only the final translation string, without any extra commentary or formatting.")
}
}
/**
* Template for translation explanation
*/
class TranslationExplanationRequest(
sourceText: String,
translatedText: String,
sourceLanguage: String,
targetLanguage: String
) : BaseApiRequestTemplate<ExplanationApiResponse>() {
override val responseSerializer = ExplanationApiResponse.serializer()
override val modelType = ModelType.TRANSLATION
override val serviceName = "TranslationService"
override val requiredFields = listOf("explanation")
init {
promptBuilder.basePrompt = "Explain briefly the translation choice when translating from $sourceLanguage to $targetLanguage."
addDetail("Source: '$sourceText'")
addDetail("Translation: '$translatedText'")
addDetail("Focus on key word choices, grammar, tone, and word order.")
withJsonResponse("a JSON object with a single key 'explanation' containing a concise explanation (1-2 sentences) in $sourceLanguage.")
}
}
/**
* Template for rephrasing translations
*/
class RephraseRequest(
sourceText: String,
currentTranslation: String,
originalWord: String,
chosenAlternative: String,
sourceLanguage: String,
targetLanguage: String
) : BaseApiRequestTemplate<TranslationApiResponse>() {
override val responseSerializer = TranslationApiResponse.serializer()
override val modelType = ModelType.TRANSLATION
override val serviceName = "TranslationService"
override val requiredFields = listOf("translatedText")
init {
promptBuilder.basePrompt = "You are improving a translation from $sourceLanguage to $targetLanguage."
addDetail("Current translation: '$currentTranslation'.")
addDetail("Replace or adapt the word/phrase '$originalWord' with '$chosenAlternative' in a natural way.")
addDetail("Adjust grammar, word order, articles, agreement, and tone as needed so the whole sentence reads naturally in $targetLanguage.")
addDetail("Source text: '$sourceText'")
withJsonResponse("a JSON object with a single key 'translatedText' containing only the updated translation sentence.")
}
}
/**
* Template for generating a simple list of synonyms (strings)
*/
class SynonymListRequest(
word: String,
language: String,
contextPhrase: String? = null
) : BaseApiRequestTemplate<StringListApiResponse>() {
override val responseSerializer = StringListApiResponse.serializer()
override val modelType = ModelType.TRANSLATION
override val serviceName = "TranslationService"
override val requiredFields = listOf("items")
init {
promptBuilder.basePrompt = "Act like a native speaker and provide multiple alternative forms/ways for the word '$word' in the language $language."
if (!contextPhrase.isNullOrBlank()) {
addDetail("The alternative forms/words must be fitting with the context: '$contextPhrase' and retain the original meaning of the context. All alternative forms must be in the same language: $language. Do not add any additional explanations")
}
withJsonResponse("a JSON object with a key 'items' containing an array of unique strings.")
}
}
/**
* Template for text correction requests
*/
class TextCorrectionRequest(
textToCorrect: String,
language: String,
grammarOnly: Boolean,
tone: String?
) : BaseApiRequestTemplate<CorrectionResponse>() {
override val responseSerializer = CorrectionResponse.serializer()
override val modelType = ModelType.DICTIONARY
override val serviceName = "CorrectionService"
override val requiredFields = listOf("correctedText", "explanation")
init {
val base = "Correct the grammar, spelling, and punctuation of the text, which is in $language."
val extra = when {
grammarOnly -> " Do not change tone, style, or word choice beyond necessary corrections."
!tone.isNullOrBlank() -> " Also, rewrite the text in a $tone tone while preserving the original meaning."
else -> ""
}
promptBuilder.basePrompt = base + extra
withJsonResponse("containing two keys: \"correctedText\" (the fully corrected text) and \"explanation\" (a brief, one-sentence explanation of the changes).")
withExample(textToCorrect)
}
}
/**
* Template for word of the day requests
*/
class WordOfTheDayRequest(
language: String,
category: String
) : BaseApiRequestTemplate<DictionaryApiResponse>() {
override val responseSerializer = DictionaryApiResponse.serializer()
override val modelType = ModelType.DICTIONARY
override val serviceName = "DictionaryService"
override val requiredFields = listOf("word", "parts")
init {
promptBuilder.basePrompt = "Provide an interesting and uncommon word and its definition in $language from the category of '$category'."
withJsonResponse("a JSON object with a 'word' key and a 'parts' array. The 'parts' array should contain exactly one object with its 'title' as 'Definition' and 'content' as the definition of the word.")
}
}
/**
* Template for etymology requests
*/
class EtymologyRequest(
word: String,
language: String
) : BaseApiRequestTemplate<EtymologyApiResponse>() {
override val responseSerializer = EtymologyApiResponse.serializer()
override val modelType = ModelType.DICTIONARY
override val serviceName = "DictionaryService"
override val requiredFields = listOf("word", "timeline", "relatedWords")
init {
promptBuilder.basePrompt = "Provide a detailed etymology for the word '$word' in $language."
addDetail("Return strictly valid JSON (RFC 8259): double-quoted keys and string values, no comments, no trailing commas, no markdown fences.")
addDetail("Structure: { 'word': string, 'timeline': EtymologyStep[], 'relatedWords': RelatedWord[] }.")
addDetail("EtymologyStep: { 'year': string, 'language': string, 'description': string }. Year may be an exact year like '1883' or ranges/periods like '1890s' or '20th century'.")
addDetail("RelatedWord: { 'language': string, 'word': string }.")
addDetail("Only include the fields above. Keep descriptions concise and factual. Sort timeline chronologically when possible.")
withJsonResponse("a JSON object with 'word', 'timeline' (array of objects with 'year' (string), 'language' (string), 'description' (string)), and 'relatedWords' (array of objects with 'language' and 'word'). Ensure all values are properly quoted strings; no numbers for 'year'.")
}
}
/**
* Template for generating vocabulary items
*/
class VocabularyGenerationRequest(
category: String,
languageFirst: String,
languageSecond: String,
amount: Int,
customPrompt: String = ""
) : BaseApiRequestTemplate<VocabularyApiResponse>() {
override val responseSerializer = VocabularyApiResponse.serializer()
override val modelType = ModelType.VOCABULARY
override val serviceName = "VocabularyService"
override val requiredFields = listOf("flashcards")
init {
promptBuilder.basePrompt = "Your task is to create exactly $amount unique vocabulary flashcards for the category '$category'."
addDetail("Each flashcard must have a 'front' side in $languageFirst and a 'back' side in $languageSecond.")
addDetail("Each side must contain exactly one word, sentence, phrase or interjection.")
addDetail("STRICT FORMATTING RULE: Do not include any parenthetical explanations, grammatical context (e.g. 'formal', 'plural', 'masculine'), or usage notes inside the content strings.")
addDetail("Example: Write 'your', NOT 'your (informal)'. Write 'sie', NOT 'sie (they)'.")
addDetail("If a word is ambiguous, provide ONLY the word itself without disambiguation.")
if (customPrompt.isNotBlank()) {
addDetail(customPrompt)
}
withJsonResponse("a 'flashcards' array. Each object in the array must have a 'front' object and a 'back' object. The 'front' object must contain 'language': '$languageFirst' and 'word': '{the word}'. The 'back' object must contain 'language': '$languageSecond' and 'word': '{the translation}'.")
}
}
/**
* Template for generating synonyms with proximity
*/
class SynonymGenerationRequest(
amount: Int,
language: String,
term: String,
translation: String,
translationLanguage: String,
languageCode: String
) : BaseApiRequestTemplate<SynonymApiResponse>() {
override val responseSerializer = SynonymApiResponse.serializer()
override val modelType = ModelType.VOCABULARY
override val serviceName = "VocabularyService"
override val requiredFields = listOf("synonyms")
init {
promptBuilder.basePrompt = "Act as a native $language speaker. Generate $amount synonyms for the term '$term' in the $language language (ISO 639-1 code: $languageCode). The term's translation is '$translation' in $translationLanguage. For each synonym, evaluate its proximity to the original term as a percentage (100% being an identical meaning, lower percentages for broader or related terms). Respond with a JSON object containing a 'synonyms' array. Each object in the array should have a 'word' (the synonym) and a 'proximity' (the percentage as an integer). Do not return anything else."
}
}
/**
* Template for Grammar Classification
*/
class GrammarClassificationRequest(
wordsToClassifyJson: String,
possibleCategories: String
) : BaseApiRequestTemplate<VocabularyService.BatchClassificationResponse>() {
override val responseSerializer = VocabularyService.BatchClassificationResponse.serializer()
override val modelType = ModelType.VOCABULARY
override val serviceName = "VocabularyService"
override val requiredFields = listOf("results")
init {
promptBuilder.basePrompt = "For each word in the JSON array, determine its grammatical category."
addDetail("Possible categories are: $possibleCategories")
withJsonResponse("a 'results' array, each with the original 'id' and the determined 'category'.")
addDetail("Here is the JSON array: $wordsToClassifyJson")
}
}
/**
* Template for Grammar Features
*/
class GrammarFeaturesRequest(
val prompt: String
) : ApiRequestTemplate<VocabularyService.BatchGrammarResponse> {
override val responseSerializer = VocabularyService.BatchGrammarResponse.serializer()
override val modelType = ModelType.VOCABULARY
override val serviceName = "VocabularyService"
override fun buildPrompt() = prompt
// Note: The prompt is pre-built in VocabularyService because it is highly dynamic based on config
}
/**
* A generic request template that allows passing a raw prompt string and receiving a JsonElement.
* Useful for complex services like ExerciseService where the prompt generation logic is external
* and the response parsing is manual.
*/
class DynamicJsonRequest(
private val prompt: String,
override val modelType: ModelType,
override val serviceName: String
) : ApiRequestTemplate<JsonElement> {
override val responseSerializer = JsonElement.serializer()
override fun buildPrompt(): String = prompt
}
// Response data classes
@kotlinx.serialization.Serializable
data class DictionaryApiResponse(
val word: String,
val parts: List<eu.gaudian.translator.model.EntryPart>
)
@kotlinx.serialization.Serializable
data class ExampleSentenceApiResponse(
val word: String,
val sourceSentence: String,
val targetSentence: String
)
@kotlinx.serialization.Serializable
data class VocabularyApiResponse(
val flashcards: List<Flashcard>
)
@kotlinx.serialization.Serializable
data class Flashcard(
val front: CardSide,
val back: CardSide
)
@kotlinx.serialization.Serializable
data class CardSide(
val language: String,
val word: String
)
@kotlinx.serialization.Serializable
data class TranslationApiResponse(
val translatedText: String
)
@kotlinx.serialization.Serializable
data class ExplanationApiResponse(
val explanation: String
)
@kotlinx.serialization.Serializable
data class StringListApiResponse(
val items: List<String>
)
@kotlinx.serialization.Serializable
data class CorrectionResponse(
val correctedText: String,
val explanation: String
)
@kotlinx.serialization.Serializable
data class EtymologyApiResponse(
val word: String,
val timeline: List<eu.gaudian.translator.model.EtymologyStep> = emptyList(),
val relatedWords: List<eu.gaudian.translator.model.RelatedWord> = emptyList()
)
@kotlinx.serialization.Serializable
data class SynonymApiResponse(
val synonyms: List<VocabularyService.SynonymObject>
)

View File

@@ -0,0 +1,14 @@
package eu.gaudian.translator.utils
import android.content.Context
import android.content.ContextWrapper
import androidx.activity.ComponentActivity
fun Context.findActivity(): ComponentActivity {
var context = this
while (context is ContextWrapper) {
if (context is ComponentActivity) return context
context = context.baseContext
}
throw IllegalStateException("Context is not an Activity. Ensure this Composable is running inside a ComponentActivity.")
}

View File

@@ -0,0 +1,36 @@
package eu.gaudian.translator.utils
import android.content.Context
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.communication.ApiManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class CorrectionService(context: Context) {
private val apiRequestHandler = ApiRequestHandler(
ApiManager(context),
context = context
)
suspend fun correctText(
textToCorrect: String,
language: Language,
grammarOnly: Boolean,
tone: String?
): Result<CorrectionResponse> = withContext(Dispatchers.IO) {
@Suppress("HardCodedStringLiteral")
Log.d("correctText", "$textToCorrect, ${language.englishName}, tone:$tone")
val template = TextCorrectionRequest(
textToCorrect = textToCorrect,
language = language.englishName,
grammarOnly = grammarOnly,
tone = tone
)
apiRequestHandler.executeRequest(template)
}
}

View File

@@ -0,0 +1,249 @@
@file:Suppress("HardCodedStringLiteral", "RegExpRedundantEscape")
package eu.gaudian.translator.utils
import eu.gaudian.translator.model.CategorizationQuestion
import eu.gaudian.translator.model.FillInTheBlankQuestion
import eu.gaudian.translator.model.ListeningComprehensionQuestion
import eu.gaudian.translator.model.MatchingPairsQuestion
import eu.gaudian.translator.model.MultipleChoiceQuestion
import eu.gaudian.translator.model.Question
import eu.gaudian.translator.model.TrueFalseQuestion
import eu.gaudian.translator.model.VocabularyTestQuestion
import eu.gaudian.translator.model.WordOrderQuestion
import org.json.JSONObject
import java.util.UUID
import kotlin.reflect.KClass
object ExercisePromptGenerator {
fun generateVocabularyPrompt(exerciseTitle: String, languageFirst: String, languageSecond: String): String {
return """
Generate exactly 10 unique and relevant vocabulary flashcards related to the exercise topic: "$exerciseTitle".
The languages are $languageFirst and $languageSecond.
Return a single JSON object where keys are the words in "$languageFirst" and values are their translations in "$languageSecond".
""".trimIndent()
}
fun generateExerciseShellPrompt(category: String): String {
return """
Generate a concise title for an language exercise about "$category".
Return a single JSON object with the key "exerciseTitle".
""".trimIndent()
}
fun generateContextPrompt(
category: String,
difficulty: String,
languageFirst: String?,
languageSecond: String?
): String {
val langInfo = if (languageFirst != null && languageSecond != null) {
"The text must be in '$languageSecond' (target language). If a dialogue is used, speakers can be generic (A/B). Optionally provide inline glosses or simple vocabulary in $languageFirst if needed in the context title only."
} else ""
val style = if (difficulty.equals("easy", true)) {
"Write a very short, rudimentary 2-4 line dialogue suitable for beginners."
} else if (difficulty.equals("hard", true)) {
"Write a more complex paragraph (5-8 sentences) on the topic with natural language and a few advanced structures."
} else {
"Write a short paragraph (3-5 sentences) or a simple dialogue."
}
return """
Create a self-contained context text for an exercise about "$category".
$style
$langInfo
Return a JSON object with keys:
- "contextTitle": a short title for the text (string),
- "contextText": the full text (string).
""".trimIndent()
}
fun generateQuestionsPrompt(
exerciseTitle: String,
optionalVocabulary: List<String>,
questionTypes: List<KClass<out Question>>,
difficulty: String,
amount: Int,
languageFirst: String,
languageSecond: String,
contextText: String? = null
): String {
Log.d("ExercisePromptGenerator", "Generating questions for exercise: $exerciseTitle, difficulty: $difficulty, amount: $amount, types: ${questionTypes.joinToString(", ")}, context: $contextText, vocab: $optionalVocabulary, languageFirst: $languageFirst, languageSecond: $languageSecond")
val typeNames = questionTypes.joinToString(", ") { it.simpleName ?: "question" }
return """
Generate a related set of $amount of Duolingo-like questions for an exercise titled "$exerciseTitle".
The difficulty should be "$difficulty".
The question types should be: $typeNames.
include the following vocabulary words: ${optionalVocabulary.joinToString(", ")}.${if (optionalVocabulary.isNotEmpty()) " (Use them where natural.)" else ""}
${if (contextText != null) "All questions MUST be answerable solely based on the following passage. Use it as the context and do NOT require external knowledge.\nPASSAGE:\n\"\"\"$contextText\"\"\"" else "Generate questions that are coherent as a set."}
"The exercise should use the languages '$languageFirst' (from) and '$languageSecond' (to). Use these languages for all question content and translations. For VocabularyTestQuestion, set languageDirection to \"$languageFirst -> $languageSecond\"."
Return a single JSON object containing one key, "questions", which holds a JSON array of the question objects.
Each object in the array must have a "type" field and a "name" field (the question prompt).
Based on the "type", include these additional fields:
- For "TrueFalseQuestion": "correctAnswer" (boolean), "explanation" (string explaining why the statement is true or false, aimed at a learner; keep it concise).
- For "MultipleChoiceQuestion": "options" (array of strings), "correctAnswerIndex" (integer).
- For "FillInTheBlankQuestion": "name" should contain "___" for the blank, and add a "correctAnswer" (string) field. Also include optional "hintBaseForm" (e.g., base verb like "to be") and "hintOptions" (array of 2-4 strings including the correct word and plausible distractors).
- For "WordOrderQuestion": "words" (array of scrambled strings), "correctOrder" (array of strings in the correct order to form the sequence).
- For "MatchingPairsQuestion": "pairs" (a JSON object of key-value strings to match) 5 pairs max.
- For "ListeningComprehensionQuestion": "name" (the sentence to speak), "languageCode" (language code the sentence is in e.g., "en-US").
- For "CategorizationQuestion": "items" (array of at least 6 strings to categorize), "categories" (array of at least 2 category names, fitting to the topic of the exercise), "correctMapping" (a JSON object mapping each item to its category).
- For "VocabularyTestQuestion": "name" (the word to translate), "correctAnswer" (the translation), "languageDirection" (string like "$languageFirst -> $languageSecond").
""".trimIndent()
}
}
object ExerciseParser {
fun parseExerciseContext(response: String): Pair<String, String>? {
// Be robust to minor JSON issues from LLMs (smart quotes, trailing commas, code fences)
fun sanitize(input: String): String {
var s = input
// Strip Markdown code fences if present
s = s.replace("^```[a-zA-Z0-9_\\-]*\\s*".toRegex(), "")
.replace("\\s*```$".toRegex(), "")
// Normalize smart quotes to ASCII
s = s.replace("[“”]".toRegex(), "\"")
.replace("[]".toRegex(), "'")
// Remove trailing commas before } or ]
s = s.replace(",\\s*(?=[}\\]])".toRegex(), "")
return s.trim()
}
return try {
val cleaned = cleanJsonString(response)
val sanitized = sanitize(cleaned)
val jsonObject = JSONObject(sanitized)
val title = if (jsonObject.has("contextTitle")) jsonObject.optString("contextTitle", "") else ""
val text = jsonObject.optString("contextText", "")
if (text.isNotBlank()) Pair(title, text) else throw Exception("contextText missing")
} catch (e: Exception) {
// Fallback: try relaxed regex extraction
return try {
val sanitized = sanitize(cleanJsonString(response))
val titleRegex = "\\\"contextTitle\\\"\\s*:\\s*\\\"([\\s\\S]*?)\\\"".toRegex()
val textRegex = "\\\"contextText\\\"\\s*:\\s*\\\"([\\s\\S]*?)\\\"".toRegex()
val titleMatch = titleRegex.find(sanitized)
val textMatch = textRegex.find(sanitized)
val title = titleMatch?.groups?.get(1)?.value ?: ""
val text = textMatch?.groups?.get(1)?.value ?: ""
if (text.isNotBlank()) Pair(title, text) else throw Exception("Regex fallback failed")
} catch (_: Exception) {
Log.e("ExerciseParser", "Failed to parse exercise context: $response", e)
null
}
}
}
private fun cleanJsonString(response: String): String {
val firstBrace = response.indexOf('{')
val firstBracket = response.indexOf('[')
val startIndex = when {
firstBrace == -1 -> firstBracket
firstBracket == -1 -> firstBrace
else -> minOf(firstBrace, firstBracket)
}
if (startIndex == -1) return response
val lastBrace = response.lastIndexOf('}')
val lastBracket = response.lastIndexOf(']')
val endIndex = maxOf(lastBrace, lastBracket)
if (endIndex == -1) return response
return response.substring(startIndex, endIndex + 1)
}
fun parseExerciseShell(response: String): Pair<String, String>? {
return try {
val cleanedJson = cleanJsonString(response)
val jsonObject = JSONObject(cleanedJson)
val id = UUID.randomUUID().toString()
val title = jsonObject.getString("exerciseTitle")
Pair(id, title)
} catch (e: Exception) {
Log.e("ExerciseParser", "Failed to parse exercise shell: $response", e)
null
}
}
fun parseQuestions(response: String): List<Question> {
return try {
val cleanedJson = cleanJsonString(response)
val rootObject = JSONObject(cleanedJson)
val jsonArray = rootObject.getJSONArray("questions")
val questions = mutableListOf<Question>()
var nextId = (1..10000).random()
for (i in 0 until jsonArray.length()) {
val qObject = jsonArray.getJSONObject(i)
val type = qObject.getString("type")
// Provide sensible defaults for missing names to prevent parse failures
val defaultName = when (type) {
"WordOrderQuestion" -> "Tap the words below to form the sentence"
"MatchingPairsQuestion" -> "Match the pairs"
"CategorizationQuestion" -> "Categorize the items"
else -> ""
}
val name = qObject.optString("name", defaultName)
val question: Question? = when (type) {
"TrueFalseQuestion" -> TrueFalseQuestion(
id = nextId++,
name = name,
correctAnswer = qObject.getBoolean("correctAnswer"),
explanation = if (qObject.has("explanation")) qObject.getString("explanation") else ""
)
"MultipleChoiceQuestion" -> {
val options = qObject.getJSONArray("options").let { arr -> (0 until arr.length()).map { arr.getString(it) } }
MultipleChoiceQuestion(
id = nextId++, name = name, options = options,
correctAnswerIndex = qObject.getInt("correctAnswerIndex")
)
}
"FillInTheBlankQuestion" -> FillInTheBlankQuestion(
id = nextId++, name = name,
correctAnswer = qObject.getString("correctAnswer"),
hintBaseForm = if (qObject.has("hintBaseForm")) qObject.getString("hintBaseForm") else "",
hintOptions = if (qObject.has("hintOptions")) qObject.getJSONArray("hintOptions").let { arr -> (0 until arr.length()).map { arr.getString(it) } } else emptyList()
)
"WordOrderQuestion" -> {
val words = qObject.getJSONArray("words").let { arr -> (0 until arr.length()).map { arr.getString(it) } }
val correctOrder = qObject.getJSONArray("correctOrder").let { arr -> (0 until arr.length()).map { arr.getString(it) } }
WordOrderQuestion(id = nextId++, name = name, words = words, correctOrder = correctOrder)
}
"MatchingPairsQuestion" -> {
val pairs = qObject.getJSONObject("pairs").let { obj -> obj.keys().asSequence().associateWith { obj.getString(it) } }
MatchingPairsQuestion(id = nextId++, name = name, pairs = pairs)
}
"ListeningComprehensionQuestion" -> ListeningComprehensionQuestion(
id = nextId++, name = name,
languageCode = qObject.getString("languageCode")
)
"CategorizationQuestion" -> {
val items = qObject.getJSONArray("items").let { arr -> (0 until arr.length()).map { arr.getString(it) } }
val categories = qObject.getJSONArray("categories").let { arr -> (0 until arr.length()).map { arr.getString(it) } }
val mapping = qObject.getJSONObject("correctMapping").let { obj -> obj.keys().asSequence().associateWith { obj.getString(it) } }
CategorizationQuestion(id = nextId++, name = name, items = items, categories = categories, correctMapping = mapping)
}
"VocabularyTestQuestion" -> VocabularyTestQuestion(
id = nextId++,
name = name,
correctAnswer = qObject.getString("correctAnswer"),
languageDirection = qObject.getString("languageDirection")
)
else -> null
}
question?.let { questions.add(it) }
}
questions
} catch (e: Exception) {
Log.e("ExerciseParser", "Failed to parse questions: $response", e)
emptyList()
}
}
}

Some files were not shown because too many files have changed in this diff Show More