commit 269cc9e4177200c888d18992ee99c1515174b078 Author: jonasgaudian <43753916+jonasgaudian@users.noreply.github.com> Date: Fri Feb 13 00:15:36 2026 +0100 migrate to gitea diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml new file mode 100644 index 0000000..e6a8cc5 --- /dev/null +++ b/.idea/androidTestResultsUserPreferences.xml @@ -0,0 +1,35 @@ + + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..baa829c --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,41 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..bb3a40c --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..7b3006b --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..911a4a5 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,93 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..6d0ee1c --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..11b06af --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/valkyrie_settings.xml b/.idea/valkyrie_settings.xml new file mode 100644 index 0000000..c5f8696 --- /dev/null +++ b/.idea/valkyrie_settings.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..317b16d --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# Translator/Vocabulary App + +A Translator/Vocabulary App which uses the Mistral API OpenAI chat completion to perform translations, look up words in a dictionary, and generate vocabulary items. + +## Features +| Feature | Description | + |-----------------------|---------------------------------------------------------------------------------------------------------------------------------------| + | Language Selection | Selection of 50 pre-defined languages, with the ability to add custom languages and dialects | + | Translation | Translate between given and customizable languages, including a translation history and pronunciation (only some languages supported) | + | Dictionary Lookup | Look up terms in a dictionary | + | Vocabulary Generation | Generate vocabulary items with the help of AI or add them manually | + | Learning Exercises | Learn vocabulary cards by customizable exercises (e.g., spelling mode) | + | Vocabulary Management | Manage vocabulary cards by sorting them into stages (progress), languages, custom categories, etc. | + | Overview | Overview of learned vocabulary and vocabulary in different stages (new, ..., learned) | + +## UI/UX + | Aspect | Description | + |--------------|-------------------------------------------------------------------------| + | UI Framework | Uses the classic Android Fragment UI and Composable for certain dialogs | diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..1a47b46 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,177 @@ +@file:Suppress("HardCodedStringLiteral") + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.hilt.android) + id("kotlin-parcelize") + id("org.jetbrains.kotlin.plugin.serialization") + id("com.google.devtools.ksp") +} + +android { + namespace = "eu.gaudian.translator" + compileSdk = 36 + + defaultConfig { + applicationId = "eu.gaudian.translator" + minSdk = 28 + targetSdk = 36 + versionCode = 21 + versionName = "0.4.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + testOptions { + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + val buildTime = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()).format(Date()) + buildConfigField("String", "BUILD_TIME", "\"$buildTime\"") + signingConfig = signingConfigs.getByName("debug") + } + debug { + val buildTime = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault()).format(Date()) + buildConfigField("String", "BUILD_TIME", "\"$buildTime\"") + } + } + + tasks.withType().configureEach { + compilerOptions.freeCompilerArgs.addAll( + "-opt-in=kotlin.time.ExperimentalTime", + + "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", + "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi" + ) + } + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } + buildFeatures { + compose = true + viewBinding = false + dataBinding = false + buildConfig = true + } + + packaging { + // This keeps your original resource exclusions + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += "**/dump_syms.bin" + } + jniLibs { + useLegacyPackaging = true + } + } + configurations.all { + exclude(group = "com.intellij", module = "annotations") + } + buildToolsVersion = "36.0.0" +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + + // Core & UI + implementation(libs.androidx.core.ktx) + implementation(libs.material) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.timber) + + // Compose + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.foundation) + implementation(libs.androidx.foundation.layout) + implementation(libs.androidx.animation) + implementation(libs.androidx.animation.core) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + implementation(libs.reorderable) + + // Navigation + implementation(libs.androidx.navigation.compose) + + // Data + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.paging.runtime.ktx) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.serialization.json) + + // Room Database + implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime + implementation(libs.androidx.room.ktx) + implementation(libs.core.ktx) + ksp(libs.room.compiler) // CHANGED: Use ksp instead of implementation + + // Networking + implementation(libs.retrofit) + implementation(libs.converter.gson) + implementation(libs.logging.interceptor) + + implementation(libs.androidx.annotation) + implementation(libs.kotlin.stdlib) + + implementation(libs.jsoup) + implementation(libs.core) + + // Hilt + implementation(libs.hilt.android) + implementation(libs.hilt.navigation.compose) + ksp(libs.hilt.android.compiler) + + // Debug and Test + debugImplementation(libs.ui.tooling) + debugImplementation(libs.ui.test.manifest) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core.testing) + testImplementation(libs.hilt.android.testing) + testAnnotationProcessor(libs.hilt.android.compiler) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockk) + testImplementation(libs.truth) + testImplementation(libs.androidx.runner) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + androidTestImplementation(libs.androidx.navigation.testing) + + //noinspection UseTomlInstead + implementation("com.pierfrancescosoffritti.androidyoutubeplayer:core:13.0.0") + + // Compression + testImplementation (libs.zstd.jni) + implementation(libs.zstd.jni.get().toString() + "@aar") + +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/eu/gaudian/translator/ApiRequestIntegrationTest.kt b/app/src/androidTest/java/eu/gaudian/translator/ApiRequestIntegrationTest.kt new file mode 100644 index 0000000..8471d22 --- /dev/null +++ b/app/src/androidTest/java/eu/gaudian/translator/ApiRequestIntegrationTest.kt @@ -0,0 +1,177 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator + +import android.content.Context +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import eu.gaudian.translator.model.communication.ApiManager +import eu.gaudian.translator.utils.ApiRequestHandler +import eu.gaudian.translator.utils.DictionaryDefinitionRequest +import eu.gaudian.translator.utils.TextCorrectionRequest +import eu.gaudian.translator.utils.TextTranslationRequest +import eu.gaudian.translator.utils.VocabularyGenerationRequest +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ApiRequestIntegrationTest { + + private lateinit var context: Context + private lateinit var apiRequestHandler: ApiRequestHandler + private lateinit var apiManager: ApiManager + + // TAG for filtering in Logcat + @Suppress("PrivatePropertyName") + private val TAG = "ApiTest" + + @get:Rule + val watcher = object : TestWatcher() { + override fun starting(description: Description) { + Log.i(TAG, "🟢 STARTING TEST: ${description.methodName}") + } + + override fun finished(description: Description) { + Log.i(TAG, "🏁 FINISHED TEST: ${description.methodName}") + } + + override fun failed(e: Throwable?, description: Description) { + Log.e(TAG, "❌ FAILED TEST: ${description.methodName}", e) + } + } + + @Before + fun setup() { + try { + Log.d(TAG, "SETUP: Initializing Context...") + // Use targetContext to access the app's resources/files + context = InstrumentationRegistry.getInstrumentation().targetContext + + Log.d(TAG, "SETUP: Injecting Test Config into SharedPreferences...") + // Ensure we use the exact preference file name used by your SettingsRepository + // Default is usually "package_name_preferences" + val prefsName = "${context.packageName}_preferences" + val prefs = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE) + + prefs.edit().apply { + putString("api_key", TestConfig.API_KEY) + // If your ApiManager uses a specific settings key for provider, set it here: + putString("selected_ai_provider", TestConfig.PROVIDER_NAME) + putString("custom_server_url", TestConfig.BASE_URL) + commit() + } + Log.d(TAG, "SETUP: Config injected. Key present: ${prefs.contains("api_key")}") + + Log.d(TAG, "SETUP: Initializing ApiManager...") + apiManager = ApiManager(context) + + Log.d(TAG, "SETUP: Initializing ApiRequestHandler...") + apiRequestHandler = ApiRequestHandler(apiManager, context) + + Log.d(TAG, "SETUP: Complete.") + + } catch (e: Exception) { + // THIS is what you are missing in the current output + Log.e(TAG, "🔥 CRITICAL SETUP FAILURE: ${e.message}", e) + throw e // Re-throw to fail the test, but now it's logged + } + } + + @Test + fun testDictionaryDefinitionRequest() = runBlocking { + Log.d(TAG, "Testing Dictionary Definition...") + val template = DictionaryDefinitionRequest( + word = "Serendipity", + language = "English", + requestedParts = "Definition" + ) + + val result = apiRequestHandler.executeRequest(template) + + handleResult(result) { data -> + assertNotNull("Word should not be null", data.word) + assertTrue("Parts should not be empty", data.parts.isNotEmpty()) + Log.i(TAG, "✅ Dictionary Success: Defined '${data.word}'") + } + } + + @Test + fun testTextTranslationRequest() = runBlocking { + Log.d(TAG, "Testing Text Translation...") + val template = TextTranslationRequest( + text = "Hello, world!", + sourceLanguage = "English", + targetLanguage = "German" + ) + + val result = apiRequestHandler.executeRequest(template) + + handleResult(result) { data -> + assertNotNull("Translation should not be null", data.translatedText) + assertTrue("Translation should not be empty", data.translatedText.isNotBlank()) + Log.i(TAG, "✅ Translation Success: '${data.translatedText}'") + } + } + + @Test + fun testCorrectionRequest() = runBlocking { + Log.d(TAG, "Testing Text Correction...") + val template = TextCorrectionRequest( + textToCorrect = "I has went home.", + language = "English", + grammarOnly = true, + tone = null + ) + + val result = apiRequestHandler.executeRequest(template) + + handleResult(result) { data -> + assertNotEquals("Corrected text should be different", "I has went home.", data.correctedText) + Log.i(TAG, "✅ Correction Success: -> '${data.correctedText}'") + } + } + + @Test + fun testVocabularyGenerationRequest() = runBlocking { + Log.d(TAG, "Testing Vocab Generation...") + val template = VocabularyGenerationRequest( + category = "Technology", + languageFirst = "English", + languageSecond = "Spanish", + amount = 2 + ) + + val result = apiRequestHandler.executeRequest(template) + + handleResult(result) { data -> + assertEquals("Should receive exactly 2 cards", 2, data.flashcards.size) + Log.i(TAG, "✅ Vocab Gen Success: Got ${data.flashcards.size} cards") + } + } + + /** + * Helper to log results consistently and fail with clear messages + */ + private fun handleResult(result: Result, assertions: (T) -> Unit) { + if (result.isFailure) { + val error = result.exceptionOrNull() + Log.e(TAG, "❌ API REQUEST FAILED", error) + fail("API Request failed with exception: ${error?.message}") + } else { + val data = result.getOrNull()!! + Log.d(TAG, "Received Data: $data") + assertions(data) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/eu/gaudian/translator/ExampleInstrumentedTest.kt b/app/src/androidTest/java/eu/gaudian/translator/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..16a0f3c --- /dev/null +++ b/app/src/androidTest/java/eu/gaudian/translator/ExampleInstrumentedTest.kt @@ -0,0 +1,21 @@ +package eu.gaudian.translator + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("eu.gaudian.translator", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/eu/gaudian/translator/TestConfig.kt b/app/src/androidTest/java/eu/gaudian/translator/TestConfig.kt new file mode 100644 index 0000000..6dba602 --- /dev/null +++ b/app/src/androidTest/java/eu/gaudian/translator/TestConfig.kt @@ -0,0 +1,17 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator + +object TestConfig { + // REPLACE with your actual API Key for the test + const val API_KEY = "YOUR_REAL_API_KEY_HERE" + + // Set to true if you want to see full log output in Logcat + const val ENABLE_LOGGING = true + + // Optional: If your ApiManager requires a specific provider (e.g., "Mistral", "OpenAI") + const val PROVIDER_NAME = "Mistral" + + // Optional: If you need to override the Base URL + const val BASE_URL = "https://api.mistral.ai/" +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1254ca1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/language_configs/de.json b/app/src/main/assets/language_configs/de.json new file mode 100644 index 0000000..40fd4f5 --- /dev/null +++ b/app/src/main/assets/language_configs/de.json @@ -0,0 +1,159 @@ +{ + "language_code": "de", + "articles": ["der", "die", "das"], + "categories": { + "noun": { + "display_key": "category_noun", + "formatter": "({gender})", + "fields": [ + { + "key": "gender", + "display_key": "prop_gender", + "type": "enum", + "options": ["masculine", "feminine", "neuter", "plural"] + }, + { + "key": "plural", + "display_key": "prop_plural", + "type": "text" + }, + { + "key": "genitive_singular", + "display_key": "prop_genitive_singular", + "type": "text" + } + ], + "mappings": { + "gender": { + "masculine": "der", + "feminine": "die, singular", + "neuter": "das", + "plural": "die, plural" + } + }, + "declension_display": { + "cases_order": ["nominative", "genitive", "dative", "accusative"], + "case_labels": { + "nominative": "Nom.", + "genitive": "Gen.", + "dative": "Dat.", + "accusative": "Akk." + }, + "numbers_order": ["singular", "plural"], + "number_labels": { + "singular": "Sing.", + "plural": "Plur." + } + } + }, + "verb": { + "display_key": "category_verb", + "formatter": "({verb_type})", + "fields": [ + { + "key": "auxiliary_verb", + "display_key": "prop_auxiliary_verb", + "type": "enum", + "options": ["haben", "sein"] + }, + { + "key": "participle_past", + "display_key": "prop_participle_past", + "type": "text" + }, + { + "key": "verb_type", + "display_key": "prop_verb_type", + "type": "enum", + "options": ["strong", "weak", "mixed"] + } + ], + "mappings": { + "verb_type": { + "strong": "stark", + "weak": "schwach", + "mixed": "gemischt" + } + }, + "conjugation_display": { + "pronouns": ["ich", "du", "er/sie/es", "wir", "ihr", "sie"], + "tense_labels": { + "present": "PrÀsens", + "past": "PrÀteritum", + "subjunctive_i": "Konjunktiv I", + "subjunctive_ii": "Konjunktiv II" + } + } + }, + "adjective": { + "display_key": "category_adjective", + "formatter": "({comparative}, {superlative})", + "fields": [ + { + "key": "comparative", + "display_key": "prop_comparative", + "type": "text" + }, + { + "key": "superlative", + "display_key": "prop_superlative", + "type": "text" + } + ] + }, + "preposition": { + "display_key": "category_preposition", + "formatter": "(+{governs_case})", + "fields": [ + { + "key": "governs_case", + "display_key": "prop_governs_case", + "type": "enum", + "options": ["accusative", "dative", "genitive", "dative_or_accusative"] + } + ], + "mappings": { + "governs_case": { + "accusative": "Akk", + "dative": "Dat", + "genitive": "Gen", + "dative_or_accusative": "Akk/Dat" + } + } + }, + "article": { + "display_key": "category_article", + "formatter": "({article_type})", + "fields": [ + { + "key": "article_type", + "display_key": "prop_article_type", + "type": "enum", + "options": ["definite", "indefinite"] + } + ], + "mappings": { + "article_type": { + "definite": "best.", + "indefinite": "unbest." + } + } + }, + "adverb": { + "display_key": "category_adverb", + "fields": [] + }, + "pronoun": { + "display_key": "category_pronoun", + "fields": [] + }, + "conjunction": { + "display_key": "category_conjunction", + "fields": [] + }, + "sentence": { + "display_key": "category_sentence", + "fields": [] + } + } +} diff --git a/app/src/main/assets/language_configs/en.json b/app/src/main/assets/language_configs/en.json new file mode 100644 index 0000000..9c19eb6 --- /dev/null +++ b/app/src/main/assets/language_configs/en.json @@ -0,0 +1,73 @@ +{ + "language_code": "en", + "articles": ["the"], + "categories": { + "noun": { + "display_key": "category_noun", + "formatter": "(pl: {plural})", + "fields": [ + { + "key": "plural", + "display_key": "prop_plural", + "type": "text" + } + ] + }, + "verb": { + "display_key": "category_verb", + "formatter": "({past_tense}, {past_participle})", + "fields": [ + { + "key": "past_tense", + "display_key": "prop_past_tense", + "type": "text" + }, + { + "key": "past_participle", + "display_key": "prop_past_participle", + "type": "text" + } + ] + }, + "adjective": { + "display_key": "category_adjective", + "formatter": "({comparative}, {superlative})", + "fields": [ + { + "key": "comparative", + "display_key": "prop_comparative", + "type": "text" + }, + { + "key": "superlative", + "display_key": "prop_superlative", + "type": "text" + } + ] + }, + "article": { + "display_key": "category_article", + "fields": [] + }, + "preposition": { + "display_key": "category_preposition", + "fields": [] + }, + "adverb": { + "display_key": "category_adverb", + "fields": [] + }, + "pronoun": { + "display_key": "category_pronoun", + "fields": [] + }, + "conjunction": { + "display_key": "category_conjunction", + "fields": [] + }, + "sentence": { + "display_key": "category_sentence", + "fields": [] + } + } +} diff --git a/app/src/main/assets/language_configs/es.json b/app/src/main/assets/language_configs/es.json new file mode 100644 index 0000000..497f752 --- /dev/null +++ b/app/src/main/assets/language_configs/es.json @@ -0,0 +1,76 @@ +{ + "language_code": "es", + "articles": ["el", "la", "los", "las"], + "categories": { + "noun": { + "display_key": "category_noun", + "formatter": "({gender})", + "fields": [ + { + "key": "gender", + "display_key": "prop_gender", + "type": "enum", + "options": ["masculine", "feminine"] + }, + { + "key": "plural", + "display_key": "prop_plural", + "type": "text" + } + ], + "mappings": { + "gender": { + "masculine": "el", + "feminine": "la" + } + } + }, + "verb": { + "display_key": "category_verb", + "formatter": "(-{conjugation_group})", + "fields": [ + { + "key": "conjugation_group", + "display_key": "prop_conjugation_group", + "type": "enum", + "options": ["ar", "er", "ir"] + } + ] + }, + "adjective": { + "display_key": "category_adjective", + "formatter": "(f: {feminine_form})", + "fields": [ + { + "key": "feminine_form", + "display_key": "prop_feminine_form", + "type": "text" + } + ] + }, + "article": { + "display_key": "category_article", + "fields": [] + }, + "preposition": { + "display_key": "category_preposition", + "fields": [] + }, + "adverb": { + "display_key": "category_adverb", + "fields": [] + }, + "pronoun": { + "display_key": "category_pronoun", + "fields": [] + }, + "conjunction": { + "display_key": "category_conjunction", + "fields": [] + }, + "sentence": { + "display_key": "category_sentence", + "fields": [] + } + } +} diff --git a/app/src/main/assets/language_configs/fr.json b/app/src/main/assets/language_configs/fr.json new file mode 100644 index 0000000..dcedade --- /dev/null +++ b/app/src/main/assets/language_configs/fr.json @@ -0,0 +1,130 @@ +{ + "language_code": "fr", + "articles": ["le", "la", "les"], + "categories": { + "noun": { + "display_key": "category_noun", + "formatter": "({gender})", + "fields": [ + { + "key": "gender", + "display_key": "prop_gender", + "type": "enum", + "options": ["masculine", "feminine"] + }, + { + "key": "plural", + "display_key": "prop_plural", + "type": "text" + }, + { + "key": "starts_with_vowel_h", + "display_key": "prop_starts_with_vowel_h", + "type": "boolean" + } + ], + "mappings": { + "gender": { + "masculine": "m.", + "feminine": "f." + } + } + }, + "verb": { + "display_key": "category_verb", + "formatter": "(reg. -{verb_group})", + "fields": [ + { + "key": "auxiliary_verb", + "display_key": "prop_auxiliary_verb", + "type": "enum", + "options": ["avoir", "être"] + }, + { + "key": "participle_past", + "display_key": "prop_participle_past", + "type": "text" + }, + { + "key": "verb_group", + "display_key": "prop_verb_group", + "type": "enum", + "options": ["1st_group (er)", "2nd_group (ir)", "3rd_group (re)"] + } + ], + "mappings": { + "verb_group": { + "1st_group (er)": "er", + "2nd_group (ir)": "ir", + "3rd_group (re)": "re" + } + } + }, + "adjective": { + "display_key": "category_adjective", + "formatter": "(f: {feminine_form})", + "declension_display": { + "cases_order": ["masculine", "feminine"], + "numbers_order": ["singular", "plural"] + }, + "fields": [ + { + "key": "feminine_form", + "display_key": "prop_feminine_form", + "type": "text" + }, + { + "key": "position", + "display_key": "prop_position", + "type": "enum", + "options": ["before_noun", "after_noun"] + } + ] + }, + "preposition": { + "display_key": "category_preposition", + "fields": [ + { + "key": "contractions", + "display_key": "prop_contractions", + "type": "text" + } + ] + }, + "article": { + "display_key": "category_article", + "formatter": "({article_type})", + "fields": [ + { + "key": "article_type", + "display_key": "prop_article_type", + "type": "enum", + "options": ["definite", "indefinite", "partitive"] + } + ], + "mappings": { + "article_type": { + "definite": "déf.", + "indefinite": "indéf.", + "partitive": "part." + } + } + }, + "adverb": { + "display_key": "category_adverb", + "fields": [] + }, + "pronoun": { + "display_key": "category_pronoun", + "fields": [] + }, + "conjunction": { + "display_key": "category_conjunction", + "fields": [] + }, + "sentence": { + "display_key": "category_sentence", + "fields": [] + } + } +} diff --git a/app/src/main/assets/language_configs/hr.json b/app/src/main/assets/language_configs/hr.json new file mode 100644 index 0000000..c36319e --- /dev/null +++ b/app/src/main/assets/language_configs/hr.json @@ -0,0 +1,111 @@ +{ + "language_code": "hr", + "articles": [], + "categories": { + "noun": { + "display_key": "category_noun", + "formatter": "({gender})", + "fields": [ + { + "key": "gender", + "display_key": "prop_gender", + "type": "enum", + "options": ["masculine", "feminine", "neuter"] + }, + { + "key": "plural", + "display_key": "prop_plural", + "type": "text" + }, + { + "key": "genitive_singular", + "display_key": "prop_genitive_singular", + "type": "text" + } + ], + "mappings": { + "gender": { + "masculine": "m.", + "feminine": "f.", + "neuter": "n." + } + } + }, + "verb": { + "display_key": "category_verb", + "formatter": "({aspect})", + "fields": [ + { + "key": "aspect", + "display_key": "prop_aspect", + "type": "enum", + "options": ["perfective", "imperfective"] + } + ], + "mappings": { + "aspect": { + "perfective": "svrÅ¡eni", + "imperfective": "nesvrÅ¡eni" + } + } + }, + "adjective": { + "display_key": "category_adjective", + "formatter": "({comparative}, {superlative})", + "fields": [ + { + "key": "comparative", + "display_key": "prop_comparative", + "type": "text" + }, + { + "key": "superlative", + "display_key": "prop_superlative", + "type": "text" + } + ] + }, + "preposition": { + "display_key": "category_preposition", + "formatter": "(+{governs_case})", + "fields": [ + { + "key": "governs_case", + "display_key": "prop_governs_case", + "type": "enum", + "options": ["genitive", "dative", "accusative", "locative", "instrumental", "dative_locative_instrumental"] + } + ], + "mappings": { + "governs_case": { + "genitive": "Gen", + "dative": "Dat", + "accusative": "Akk", + "locative": "Lok", + "instrumental": "Inst", + "dative_locative_instrumental": "Dat/Lok/Inst" + } + } + }, + "article": { + "display_key": "category_article", + "fields": [] + }, + "adverb": { + "display_key": "category_adverb", + "fields": [] + }, + "pronoun": { + "display_key": "category_pronoun", + "fields": [] + }, + "conjunction": { + "display_key": "category_conjunction", + "fields": [] + }, + "sentence": { + "display_key": "category_sentence", + "fields": [] + } + } +} \ No newline at end of file diff --git a/app/src/main/assets/language_configs/it.json b/app/src/main/assets/language_configs/it.json new file mode 100644 index 0000000..16f47f6 --- /dev/null +++ b/app/src/main/assets/language_configs/it.json @@ -0,0 +1,69 @@ +{ + "language_code": "it", + "articles": ["il", "lo", "la", "i", "gli", "le"], + "categories": { + "noun": { + "display_key": "category_noun", + "formatter": "({gender})", + "fields": [ + { + "key": "gender", + "display_key": "prop_gender", + "type": "enum", + "options": ["masculine", "feminine"] + }, + { + "key": "plural", + "display_key": "prop_plural", + "type": "text" + } + ], + "mappings": { + "gender": { + "masculine": "il/lo", + "feminine": "la" + } + } + }, + "verb": { + "display_key": "category_verb", + "formatter": "(-{conjugation_group})", + "fields": [ + { + "key": "conjugation_group", + "display_key": "prop_conjugation_group", + "type": "enum", + "options": ["are", "ere", "ire"] + } + ] + }, + "adjective": { + "display_key": "category_adjective", + "fields": [] + }, + "article": { + "display_key": "category_article", + "fields": [] + }, + "preposition": { + "display_key": "category_preposition", + "fields": [] + }, + "adverb": { + "display_key": "category_adverb", + "fields": [] + }, + "pronoun": { + "display_key": "category_pronoun", + "fields": [] + }, + "conjunction": { + "display_key": "category_conjunction", + "fields": [] + }, + "sentence": { + "display_key": "category_sentence", + "fields": [] + } + } +} diff --git a/app/src/main/assets/language_configs/nl.json b/app/src/main/assets/language_configs/nl.json new file mode 100644 index 0000000..4fed2b2 --- /dev/null +++ b/app/src/main/assets/language_configs/nl.json @@ -0,0 +1,69 @@ +{ + "language_code": "nl", + "articles": ["de", "het", "een"], + "categories": { + "noun": { + "display_key": "category_noun", + "formatter": "({gender})", + "fields": [ + { + "key": "gender", + "display_key": "prop_gender", + "type": "enum", + "options": ["masculine_feminine", "neuter"] + }, + { + "key": "plural", + "display_key": "prop_plural", + "type": "text" + } + ], + "mappings": { + "gender": { + "masculine_feminine": "de", + "neuter": "het" + } + } + }, + "verb": { + "display_key": "category_verb", + "formatter": "({verb_type})", + "fields": [ + { + "key": "verb_type", + "display_key": "prop_verb_type", + "type": "enum", + "options": ["strong", "weak"] + } + ] + }, + "adjective": { + "display_key": "category_adjective", + "fields": [] + }, + "article": { + "display_key": "category_article", + "fields": [] + }, + "preposition": { + "display_key": "category_preposition", + "fields": [] + }, + "adverb": { + "display_key": "category_adverb", + "fields": [] + }, + "pronoun": { + "display_key": "category_pronoun", + "fields": [] + }, + "conjunction": { + "display_key": "category_conjunction", + "fields": [] + }, + "sentence": { + "display_key": "category_sentence", + "fields": [] + } + } +} diff --git a/app/src/main/assets/language_configs/pt.json b/app/src/main/assets/language_configs/pt.json new file mode 100644 index 0000000..4ebdb56 --- /dev/null +++ b/app/src/main/assets/language_configs/pt.json @@ -0,0 +1,98 @@ +{ + "language_code": "pt", + "articles": ["o", "a", "os", "as"], + "categories": { + "noun": { + "display_key": "category_noun", + "formatter": "({gender})", + "fields": [ + { + "key": "gender", + "display_key": "prop_gender", + "type": "enum", + "options": ["masculine", "feminine", "plural masculine", "plural feminine"] + }, + { + "key": "plural", + "display_key": "prop_plural", + "type": "text" + } + ], + "mappings": { + "gender": { + "masculine": "o", + "feminine": "a", + "plural masculine": "os", + "plural feminine": "as" + } + } + }, + "verb": { + "display_key": "category_verb", + "formatter": "(-{conjugation_group})", + "fields": [ + { + "key": "conjugation_group", + "display_key": "prop_conjugation_group", + "type": "enum", + "options": ["ar", "er", "ir", "irregular"] + } + ], + "mappings": { + "conjugation_group": { + "ar": "ar", + "er": "er", + "ir": "ir", + "irregular": "irregular" + } + } + }, + "adjective": { + "display_key": "category_adjective", + "formatter": "(f: {feminine_form})", + "declension_display": { + "cases_order": ["masculine", "feminine"], + "numbers_order": ["singular", "plural"] + }, + "fields": [ + { + "key": "feminine_form", + "display_key": "prop_feminine_form", + "type": "text" + } + ] + }, + "article": { + "display_key": "category_article", + "formatter": "({article_type})", + "fields": [ + { + "key": "article_type", + "display_key": "prop_article_type", + "type": "enum", + "options": ["definite", "indefinite"] + } + ] + }, + "preposition": { + "display_key": "category_preposition", + "fields": [] + }, + "adverb": { + "display_key": "category_adverb", + "fields": [] + }, + "pronoun": { + "display_key": "category_pronoun", + "fields": [] + }, + "conjunction": { + "display_key": "category_conjunction", + "fields": [] + }, + "sentence": { + "display_key": "category_sentence", + "fields": [] + } + } +} diff --git a/app/src/main/assets/providers_config.json b/app/src/main/assets/providers_config.json new file mode 100644 index 0000000..a6ecb37 --- /dev/null +++ b/app/src/main/assets/providers_config.json @@ -0,0 +1,249 @@ +{ + "providers": [ + { + "key": "together", + "displayName": "Together AI", + "baseUrl": "https://api.together.xyz/v1/", + "endpoint": "chat/completions", + "websiteUrl": "https://www.together.ai/", + "isCustom": false, + "models": [ + { + "modelId": "meta-llama/Llama-3.3-70B-Instruct-Turbo", + "displayName": "Llama 3.3 70B Turbo", + "provider": "together", + "description": "Fast, accurate, and cost-effective open-source model." + }, + { + "modelId": "meta-llama/Llama-4-Maverick-17B-Instruct", + "displayName": "Llama 4 Maverick 17B", + "provider": "together", + "description": "Next-gen efficient architecture; outperforms older 70B models." + }, + { + "modelId": "deepseek-ai/DeepSeek-V3", + "displayName": "DeepSeek V3", + "provider": "together", + "description": "Top-tier open-source model specializing in code and logic." + } + ] + }, + { + "key": "mistral", + "displayName": "Mistral AI", + "baseUrl": "https://api.mistral.ai/", + "endpoint": "v1/chat/completions", + "websiteUrl": "https://mistral.ai", + "isCustom": false, + "models": [ + { + "modelId": "ministral-8b-latest", + "displayName": "Ministral 8B", + "provider": "mistral", + "description": "Extremely efficient edge model for low-latency tasks." + }, + { + "modelId": "mistral-large-latest", + "displayName": "Mistral Large 3", + "provider": "mistral", + "description": "Flagship model with top-tier reasoning and multilingual capabilities." + } + ] + }, + { + "key": "openai", + "displayName": "OpenAI", + "baseUrl": "https://api.openai.com/", + "endpoint": "v1/chat/completions", + "websiteUrl": "https://platform.openai.com/", + "isCustom": false, + "models": [ + { + "modelId": "gpt-5.1-instant", + "displayName": "GPT-5.1 Instant", + "provider": "openai", + "description": "The standard high-speed efficiency model replacing older 'Nano' tiers." + }, + { + "modelId": "gpt-5-nano", + "displayName": "GPT-5 Nano", + "provider": "openai", + "description": "Fast and cheap model sufficient for most tasks." + } + ] + }, + { + "key": "anthropic", + "displayName": "Anthropic", + "baseUrl": "https://api.anthropic.com/", + "endpoint": "v1/messages", + "websiteUrl": "https://www.anthropic.com/", + "isCustom": false, + "models": [ + { + "modelId": "claude-sonnet-5-20260203", + "displayName": "Claude Sonnet 5", + "provider": "anthropic", + "description": "Latest stable workhorse (Feb 2026), balancing speed and top-tier reasoning." + }, + { + "modelId": "claude-4.5-haiku", + "displayName": "Claude 4.5 Haiku", + "provider": "anthropic", + "description": "Fastest Claude model for pure speed and simple tasks." + } + ] + }, + { + "key": "deepseek", + "displayName": "DeepSeek", + "baseUrl": "https://api.deepseek.com/", + "endpoint": "chat/completions", + "websiteUrl": "https://www.deepseek.com/", + "isCustom": false, + "models": [ + { + "modelId": "deepseek-reasoner", + "displayName": "DeepSeek R1", + "provider": "deepseek", + "description": "Reasoning-focused model (Chain of Thought) for complex math/code." + }, + { + "modelId": "deepseek-chat", + "displayName": "DeepSeek V3", + "provider": "deepseek", + "description": "General purpose chat model, specialized in code and reasoning." + } + ] + }, + { + "key": "gemini", + "displayName": "Google Gemini", + "baseUrl": "https://generativelanguage.googleapis.com/", + "endpoint": "v1beta/models/gemini-3-flash-preview:generateContent", + "websiteUrl": "https://ai.google/", + "isCustom": false, + "models": [ + { + "modelId": "gemini-3-flash-preview", + "displayName": "Gemini 3 Flash", + "provider": "gemini", + "description": "Current default: Massive context, grounded, and extremely fast." + }, + { + "modelId": "gemini-3-pro-preview", + "displayName": "Gemini 3 Pro", + "provider": "gemini", + "description": "Top-tier reasoning model for complex agentic workflows." + } + ] + }, + { + "key": "openrouter", + "displayName": "OpenRouter", + "baseUrl": "https://openrouter.ai/api/", + "endpoint": "v1/chat/completions", + "websiteUrl": "https://openrouter.ai", + "isCustom": false, + "models": [] + }, + { + "key": "groq", + "displayName": "Groq", + "baseUrl": "https://api.groq.com/openai/", + "endpoint": "v1/chat/completions", + "websiteUrl": "https://groq.com/", + "isCustom": false, + "models": [ + { + "modelId": "llama-4-scout-17b", + "displayName": "Llama 4 Scout", + "provider": "groq", + "description": "Powerful Llama 4 model running at extreme speed." + }, + { + "modelId": "llama-3.3-70b-versatile", + "displayName": "Llama 3.3 70B", + "provider": "groq", + "description": "Previous gen flagship, highly reliable and fast on Groq chips." + } + ] + }, + { + "key": "xai", + "displayName": "xAI Grok", + "baseUrl": "https://api.x.ai/", + "endpoint": "v1/chat/completions", + "websiteUrl": "https://x.ai", + "isCustom": false, + "models": [ + { + "modelId": "grok-4-1-fast-reasoning", + "displayName": "Grok 4.1 Fast", + "provider": "xai", + "description": "Fast, flexible, and capable of reasoning." + } + ] + }, + { + "key": "nvidia", + "displayName": "NVIDIA NIM", + "baseUrl": "https://integrate.api.nvidia.com/", + "endpoint": "v1/chat/completions", + "websiteUrl": "https://build.nvidia.com/explore", + "isCustom": false, + "models": [ + { + "modelId": "meta/llama-3.3-70b-instruct", + "displayName": "Llama 3.3 70B", + "provider": "nvidia", + "description": "Standard high-performance open model accelerated by NVIDIA." + } + ] + }, + { + "key": "cerebras", + "displayName": "Cerebras", + "baseUrl": "https://api.cerebras.ai/", + "endpoint": "v1/chat/completions", + "websiteUrl": "https://inference.cerebras.ai/", + "isCustom": false, + "models": [ + { + "modelId": "llama-3.3-70b", + "displayName": "Llama 3.3 70B (Instant)", + "provider": "cerebras", + "description": "World's fastest inference (2000+ tokens/sec) on Wafer-Scale Engines." + }, + { + "modelId": "llama3.1-8b", + "displayName": "Llama 3.1 8B", + "provider": "cerebras", + "description": "Instant speed for simple tasks." + } + ] + }, + { + "key": "huggingface", + "displayName": "Hugging Face", + "baseUrl": "https://router.huggingface.co/", + "endpoint": "v1/chat/completions", + "websiteUrl": "https://huggingface.co/settings/tokens", + "isCustom": false, + "models": [ + { + "modelId": "meta-llama/Llama-3.3-70B-Instruct", + "displayName": "Llama 3.3 70B", + "provider": "huggingface", + "description": "Hosted via the Hugging Face serverless router (Free tier limits apply)." + }, + { + "modelId": "microsoft/Phi-3.5-mini-instruct", + "displayName": "Phi 3.5 Mini", + "provider": "huggingface", + "description": "Highly capable small model from Microsoft." + } + ] + } + ] +} \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..a79ba64 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/eu/gaudian/translator/CorrectActivity.kt b/app/src/main/java/eu/gaudian/translator/CorrectActivity.kt new file mode 100644 index 0000000..c730db2 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/CorrectActivity.kt @@ -0,0 +1,45 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.Text +import androidx.compose.ui.res.stringResource +import eu.gaudian.translator.utils.Log + +class CorrectActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val intent = intent + val action = intent.action + val type = intent.type + + if (Intent.ACTION_SEND == action && type == "text/plain") { + val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT) + if (sharedText != null) { + Log.d("EditActivity", "Received text: $sharedText") + setContent { + Text(stringResource(R.string.editing_text, sharedText)) + + } + } else { + Log.e("EditActivity", getString(R.string.no_text_received)) + setContent { + Text(stringResource(R.string.error_no_text_to_edit)) + } + } + } else { + Log.d("EditActivity", "Not launched with ACTION_SEND") + setContent { + Text(stringResource(R.string.not_launched_with_text_to_edit)) + } + } + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/MyApplication.kt b/app/src/main/java/eu/gaudian/translator/MyApplication.kt new file mode 100644 index 0000000..b10d261 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/MyApplication.kt @@ -0,0 +1,36 @@ +package eu.gaudian.translator + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp +import eu.gaudian.translator.model.repository.ApiRepository +import eu.gaudian.translator.model.repository.LanguageRepository +import timber.log.Timber + +@HiltAndroidApp +class MyApplication : Application() { + + private var _languageRepository: LanguageRepository? = null + private var _apiRepository: ApiRepository? = null + + val languageRepository: LanguageRepository + get() = _languageRepository ?: synchronized(this) { + _languageRepository ?: LanguageRepository(this).also { + _languageRepository = it + } + } + + val apiRepository: ApiRepository + get() = _apiRepository ?: synchronized(this) { + _apiRepository ?: ApiRepository(this).also { + _apiRepository = it + } + } + + override fun onCreate() { + super.onCreate() + + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/di/RepositoryModule.kt b/app/src/main/java/eu/gaudian/translator/di/RepositoryModule.kt new file mode 100644 index 0000000..bd0a57c --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/di/RepositoryModule.kt @@ -0,0 +1,90 @@ +package eu.gaudian.translator.di + +import android.app.Application +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import eu.gaudian.translator.model.communication.ApiManager +import eu.gaudian.translator.model.repository.ApiLogRepository +import eu.gaudian.translator.model.repository.ApiRepository +import eu.gaudian.translator.model.repository.DictionaryFileRepository +import eu.gaudian.translator.model.repository.DictionaryLookupRepository +import eu.gaudian.translator.model.repository.DictionaryRepository +import eu.gaudian.translator.model.repository.LanguageRepository +import eu.gaudian.translator.model.repository.SettingsRepository +import eu.gaudian.translator.model.repository.VocabularyRepository +import eu.gaudian.translator.utils.StatusMessageService +import eu.gaudian.translator.utils.dictionary.DictionaryService +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RepositoryModule { + + @Provides + @Singleton + fun provideApiRepository(application: Application): ApiRepository { + return ApiRepository(application) + } + + @Provides + @Singleton + fun provideSettingsRepository(application: Application): SettingsRepository { + return SettingsRepository(application) + } + + @Provides + @Singleton + fun provideApiManager(application: Application): ApiManager { + return ApiManager(application) + } + + @Provides + @Singleton + fun provideApiLogRepository(application: Application): ApiLogRepository { + return ApiLogRepository(application) + } + + @Provides + @Singleton + fun provideDictionaryRepository(application: Application): DictionaryRepository { + return DictionaryRepository(application) + } + + @Provides + @Singleton + fun provideLanguageRepository(application: Application): LanguageRepository { + return LanguageRepository(application) + } + + @Provides + @Singleton + fun provideDictionaryFileRepository(application: Application): DictionaryFileRepository { + return DictionaryFileRepository(application) + } + + @Provides + @Singleton + fun provideDictionaryLookupRepository(application: Application): DictionaryLookupRepository { + return DictionaryLookupRepository(application) + } + + @Provides + @Singleton + fun provideDictionaryService(application: Application): DictionaryService { + return DictionaryService(application) + } + + @Provides + @Singleton + fun provideVocabularyRepository(application: Application): VocabularyRepository { + return VocabularyRepository.getInstance(application) + } + + @Provides + @Singleton + fun provideStatusMessageService(): StatusMessageService { + return StatusMessageService + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/DicitonaryEntry.kt b/app/src/main/java/eu/gaudian/translator/model/DicitonaryEntry.kt new file mode 100644 index 0000000..b33d113 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/DicitonaryEntry.kt @@ -0,0 +1,28 @@ +package eu.gaudian.translator.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@Serializable +@Parcelize +data class DictionaryEntry @OptIn(ExperimentalTime::class) constructor( + val id: Int, + val word: String, + val definition: List, //list of Word class, declination, origin, etc. + var languageCode: Int, + var languageName: String, + @Contextual val createdAt: kotlin.time.Instant? = Clock.System.now()) : + Parcelable + +@Serializable +@Parcelize +data class EntryPart( + val title: String, //e.g. Word class + val content: @RawValue JsonElement, //e.g. Noun, Verb, etc. +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/Etymology.kt b/app/src/main/java/eu/gaudian/translator/model/Etymology.kt new file mode 100644 index 0000000..1b92332 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/Etymology.kt @@ -0,0 +1,23 @@ +package eu.gaudian.translator.model + +import kotlinx.serialization.Serializable + +@Serializable +data class EtymologyStep( + val year: String, + val language: String, + val description: String +) + +@Serializable +data class RelatedWord( + val language: String, + val word: String +) + +@Serializable +data class EtymologyData( + val word: String, + val timeline: List, + val relatedWords: List +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/Exercise.kt b/app/src/main/java/eu/gaudian/translator/model/Exercise.kt new file mode 100644 index 0000000..9df441f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/Exercise.kt @@ -0,0 +1,122 @@ +package eu.gaudian.translator.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.reflect.KClass + +@Serializable +@Parcelize +data class Exercise( + val id: String, + val title: String, + val questions: List, + val associatedVocabularyIds: List = emptyList(), + val sourceLanguage: String? = null, + val targetLanguage: String? = null, + val contextTitle: String? = null, + val contextText: String? = null, + val youtubeUrl: String? = null +) : Parcelable + + +@Serializable +sealed class Question : Parcelable { + abstract val id: Int + abstract val name: String + + companion object { + + val allTypes: List> = listOf( + TrueFalseQuestion::class, + MultipleChoiceQuestion::class, + FillInTheBlankQuestion::class, + WordOrderQuestion::class, + MatchingPairsQuestion::class, + ListeningComprehensionQuestion::class, + CategorizationQuestion::class, + VocabularyTestQuestion::class + ) + } +} + +@Parcelize +@Serializable +@SerialName("TrueFalseQuestion") +data class TrueFalseQuestion( + override val id: Int, + override val name: String, + val correctAnswer: Boolean, + val explanation: String = "" +) : Question() + +@Parcelize +@Serializable +@SerialName("MultipleChoiceQuestion") +data class MultipleChoiceQuestion( + override val id: Int, + override val name: String, + val options: List, + val correctAnswerIndex: Int +) : Question() + +@Parcelize +@Serializable +@SerialName("FillInTheBlankQuestion") +data class FillInTheBlankQuestion( + override val id: Int, + override val name: String, // The sentence with a placeholder like "___" + val correctAnswer: String, + val hintBaseForm: String = "", + val hintOptions: List = emptyList() +) : Question() + +@Parcelize +@Serializable +@SerialName("WordOrderQuestion") +data class WordOrderQuestion( + override val id: Int, + override val name: String, // The instruction, e.g., "Form the sentence." + val words: List, // The scrambled words + val correctOrder: List +) : Question() + +@Parcelize +@Serializable +@SerialName("MatchingPairsQuestion") +data class MatchingPairsQuestion( + override val id: Int, + override val name: String, // e.g., "Match the English words to their German translation." + val pairs: Map // Key-value pairs to be matched +) : Question() + +@Parcelize +@Serializable +@SerialName("ListeningComprehensionQuestion") +data class ListeningComprehensionQuestion( + override val id: Int, + override val name: String, // The text to be spoken and transcribed + val languageCode: String // e.g., "en-US" for TTS +) : Question() + +@Parcelize +@Serializable +@SerialName("CategorizationQuestion") +data class CategorizationQuestion( + override val id: Int, + override val name: String, // e.g., "Sort these into 'Fruit' and 'Vegetable' categories." + val items: List, + val categories: List, + val correctMapping: Map // Maps each item to its correct category +) : Question() + +@Parcelize +@Serializable +@SerialName("VocabularyTestQuestion") +data class VocabularyTestQuestion( + override val id: Int, + override val name: String, + val correctAnswer: String, + val languageDirection: String +) : Question() \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/Language.kt b/app/src/main/java/eu/gaudian/translator/model/Language.kt new file mode 100644 index 0000000..dd85f82 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/Language.kt @@ -0,0 +1,188 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model + +import android.annotation.SuppressLint +import android.content.Context +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.Log +import kotlinx.serialization.Serializable +import java.util.Locale +import kotlin.random.Random + +@Serializable +data class Language( + val code: String, //ISO 639-1 code + val region: String, + val nameResId: Int, + val name: String, //the name is context specific and can differ in various languages -> gets loaded when App starts + val englishName: String, //to be used internally for requests etc. + val nativeName: String = englishName, //the native name of the language (e.g., "Deutsch" for German), defaults to englishName for backward compatibility + val isCustom: Boolean? = false, // there is also an option to add custom languages + var isSelected: Boolean? = false +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Language + + if (code != other.code) return false + if (region != other.region) return false + if (nameResId != other.nameResId) return false + if (name != other.name) return false + if (englishName != other.englishName) return false + if (nativeName != other.nativeName) return false + if (isCustom != other.isCustom) return false + if (isSelected != other.isSelected) return false + + return true + } + @Suppress("unused") + fun isSameAs(other: Language?): Boolean { + if (other == null) return false + + if (this.isCustom == true && other.isCustom == true) { + return this.name.equals(other.name, ignoreCase = true) && + this.code.equals(other.code, ignoreCase = true) + } + if (this.isCustom != other.isCustom) { + return false + } + return this.nameResId == other.nameResId + } + + override fun hashCode(): Int { + var result = code.hashCode() + result = 31 * result + region.hashCode() + result = 31 * result + nameResId + result = 31 * result + name.hashCode() + result = 31 * result + englishName.hashCode() + result = 31 * result + nativeName.hashCode() + result = 31 * result + (isCustom?.hashCode() ?: 0) + result = 31 * result + (isSelected?.hashCode() ?: 0) + return result + } + +} + +fun parseLanguagesFromResources(context: Context): List { + val languages = mutableListOf() + val languageCodes = context.resources.getStringArray(R.array.language_codes) + + Log.d("LanguageParser", "Starting to parse languages from resources") + + languageCodes.forEach { item -> + val parts = item.split(",") + if (parts.size == 3) { + val code = parts[0].lowercase(Locale.getDefault()) + val region = parts[1] + val nameResId = parts[2].toIntOrNull() ?: 0 + if (nameResId != 0) { + val localizedName = getCapitalizedName(context, nameResId) + val englishName = getEnglishName(context, nameResId) + Log.d("LanguageParser", "Parsed language: $code, $region, $nameResId") + val nativeName = getNativeName(context, nameResId) + languages.add( + Language( + code = code, + region = region, + nameResId = nameResId, + name = localizedName, + englishName = englishName, + nativeName = nativeName, + isCustom = false, + isSelected = true + ) + ) + } else { + Log.w("LanguageParser", "Invalid nameResId for language: $code, $region") + } + } else { + Log.e("LanguageParser", "Invalid language code format: $item") + } + } + + Log.d("LanguageParser", "Finished parsing languages. Total languages: ${languages.size}") + return languages +} + +@SuppressLint("DiscouragedApi") +private fun getCapitalizedName(context: Context, nameResId: Int): String { + return try { + val name = context.getString( + context.resources.getIdentifier("language_$nameResId", "string", context.packageName) + ) + name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } catch (e: Exception) { + Log.e("Language", "Resource not found for nameResId: $nameResId", e) + context.getString(R.string.text_unknown_language) + } +} + +@SuppressLint("AppBundleLocaleChanges", "DiscouragedApi") +private fun getEnglishName(context: Context, nameResId: Int): String { + return try { + val resName = "language_$nameResId" + val id = context.resources.getIdentifier(resName, "string", context.packageName) + if (id == 0) return context.getString(R.string.text_unknown_language) + + // Use the application context with English locale for resource lookup + val resources = context.applicationContext.resources + val configuration = android.content.res.Configuration(resources.configuration) + configuration.setLocale(Locale.ENGLISH) + val localizedContext = context.applicationContext.createConfigurationContext(configuration) + localizedContext.resources.getString(id) + } catch (e: Exception) { + Log.e("Language", "Failed to get English name for nameResId: $nameResId", e) + context.getString(R.string.text_unknown_language) + } +} + +@SuppressLint("DiscouragedApi") +fun getNativeName(context: Context, nameResId: Int): String { + + return try { + val resName = "native_language_$nameResId" + val id = context.resources.getIdentifier(resName, "string", context.packageName) + Log.d("Language", "Native name resource ID for nameResId $nameResId: $id") + if (id == 0) { + Log.w("Language", "Native name resource not found for nameResId: $nameResId") + getEnglishName(context, nameResId) + } else { + + context.getString(id) + } + } catch (e: Exception) { + Log.e("Language", "Failed to get native name for nameResId: $nameResId", e) + getEnglishName(context, nameResId) + } +} + +//meant for creating dummies, testing, etc +fun generateRandomLanguage(): Language { + return Language( + code = "random", + region = "", + nameResId = 0, + name = "Language "+ Random.nextInt(1000, 9999).toString(), + englishName = "German", + nativeName = "Deutsch", + isCustom = false, + isSelected = true + ) +} + +fun generateSimpleLanguage(name: String): Language { + return Language( + code = "random", + region = "", + nameResId = 0, + name = name, + englishName = "German", + nativeName = "Deutsch", + isCustom = false, + isSelected = true + ) +} + diff --git a/app/src/main/java/eu/gaudian/translator/model/LanguageModel.kt b/app/src/main/java/eu/gaudian/translator/model/LanguageModel.kt new file mode 100644 index 0000000..bcf6e9c --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/LanguageModel.kt @@ -0,0 +1,12 @@ +package eu.gaudian.translator.model + +import kotlinx.serialization.Serializable + +@Serializable +data class LanguageModel( + val modelId: String, + val displayName: String, + val providerKey: String, + val description: String, + val isCustom: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/MyAppLanguageLevel.kt b/app/src/main/java/eu/gaudian/translator/model/MyAppLanguageLevel.kt new file mode 100644 index 0000000..59838f9 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/MyAppLanguageLevel.kt @@ -0,0 +1,248 @@ +package eu.gaudian.translator.model + +import eu.gaudian.translator.R + + +object LanguageLevels { + val all: List = listOf( + MyAppLanguageLevel.Newborn, + MyAppLanguageLevel.EchoingEcho, + MyAppLanguageLevel.GoldfishMemory, + MyAppLanguageLevel.CleverPigeon, + MyAppLanguageLevel.KoshikTheElephant, + MyAppLanguageLevel.GossipLovingCrow, + MyAppLanguageLevel.HoneybeeCartographer, + MyAppLanguageLevel.ChattyParrotlet, + MyAppLanguageLevel.CuriousToddler, + MyAppLanguageLevel.RicoTheDog, + MyAppLanguageLevel.AuctioneerInTraining, + MyAppLanguageLevel.AlexTheParrot, + MyAppLanguageLevel.PilitaTheSeaLion, + MyAppLanguageLevel.KanziTheBonobo, + MyAppLanguageLevel.KokoTheGorilla, + MyAppLanguageLevel.ShakespeareanInsultGenerator, + MyAppLanguageLevel.FirstGrader, + MyAppLanguageLevel.PuppyInTraining, + MyAppLanguageLevel.ChaserTheSuperdog, + MyAppLanguageLevel.Bookworm, + MyAppLanguageLevel.MiddleSchooler, + MyAppLanguageLevel.AvidDebater, + MyAppLanguageLevel.HighSchoolGrad, + MyAppLanguageLevel.TheJournalist, + MyAppLanguageLevel.TheProfessor, + MyAppLanguageLevel.TheNovelist, + MyAppLanguageLevel.MasterLinguist, + MyAppLanguageLevel.ThePolyglotOracle + ) + + fun getLevelForWords(wordsLearned: Int): MyAppLanguageLevel { + return all.lastOrNull { wordsLearned >= it.wordsKnown } ?: MyAppLanguageLevel.Newborn + } + fun getNextLevel(wordsLearned: Int): MyAppLanguageLevel? { + return all.firstOrNull { wordsLearned < it.wordsKnown } + + } +} + +sealed class MyAppLanguageLevel( + val nameResId: Int, + val descriptionResId: Int, + val wordsKnown: Int, + val iconResId: Int +) { + object Newborn : MyAppLanguageLevel( + nameResId = R.string.level_newborn_name, + descriptionResId = R.string.level_newborn_description, + wordsKnown = 0, + iconResId = R.drawable.ic_level_newborn + ) + + object EchoingEcho : MyAppLanguageLevel( // New + nameResId = R.string.level_echo_name, + descriptionResId = R.string.level_echo_description, + wordsKnown = 3, + iconResId = R.drawable.ic_level_echo + ) + + object GoldfishMemory : MyAppLanguageLevel( + nameResId = R.string.level_goldfish_name, + descriptionResId = R.string.level_goldfish_description, + wordsKnown = 5, + iconResId = R.drawable.ic_level_goldfish + ) + + object CleverPigeon : MyAppLanguageLevel( + nameResId = R.string.level_pigeon_name, + descriptionResId = R.string.level_pigeon_description, + wordsKnown = 10, + iconResId = R.drawable.ic_level_pigeon + ) + + object KoshikTheElephant : MyAppLanguageLevel( + nameResId = R.string.level_elephant_name, + descriptionResId = R.string.level_elephant_description, + wordsKnown = 20, + iconResId = R.drawable.ic_level_elephant + ) + + object GossipLovingCrow : MyAppLanguageLevel( + nameResId = R.string.level_crow_name, + descriptionResId = R.string.level_crow_description, + wordsKnown = 25, + iconResId = R.drawable.ic_level_crow + ) + + object HoneybeeCartographer : MyAppLanguageLevel( + nameResId = R.string.level_honeybee_name, + descriptionResId = R.string.level_honeybee_description, + wordsKnown = 35, + iconResId = R.drawable.ic_level_bee + ) + + object ChattyParrotlet : MyAppLanguageLevel( + nameResId = R.string.level_parrotlet_name, + descriptionResId = R.string.level_parrotlet_description, + wordsKnown = 50, + iconResId = R.drawable.ic_level_parrotlet + ) + + object CuriousToddler : MyAppLanguageLevel( + nameResId = R.string.level_toddler_name, + descriptionResId = R.string.level_toddler_description, + wordsKnown = 75, + iconResId = R.drawable.ic_level_toddler + ) + + object RicoTheDog : MyAppLanguageLevel( + nameResId = R.string.level_rico_name, + descriptionResId = R.string.level_rico_description, + wordsKnown = 100, + iconResId = R.drawable.ic_level_rico + ) + + object AuctioneerInTraining : MyAppLanguageLevel( // New + nameResId = R.string.level_auctioneer_name, + descriptionResId = R.string.level_auctioneer_description, + wordsKnown = 125, + iconResId = R.drawable.ic_level_auctioneer + ) + + object AlexTheParrot : MyAppLanguageLevel( + nameResId = R.string.level_alex_name, + descriptionResId = R.string.level_alex_description, + wordsKnown = 150, + iconResId = R.drawable.ic_level_parrot + ) + + object PilitaTheSeaLion : MyAppLanguageLevel( + nameResId = R.string.level_pilita_name, + descriptionResId = R.string.level_pilita_description, + wordsKnown = 225, + iconResId = R.drawable.ic_level_sea_lion + ) + + object KanziTheBonobo : MyAppLanguageLevel( + nameResId = R.string.level_kanzi_name, + descriptionResId = R.string.level_kanzi_description, + wordsKnown = 350, + iconResId = R.drawable.ic_level_bonobo + ) + + object KokoTheGorilla : MyAppLanguageLevel( + nameResId = R.string.level_koko_name, + descriptionResId = R.string.level_koko_description, + wordsKnown = 500, + iconResId = R.drawable.ic_level_gorilla + ) + + object ShakespeareanInsultGenerator : MyAppLanguageLevel( + nameResId = R.string.level_shakespeare_name, + descriptionResId = R.string.level_shakespeare_description, + wordsKnown = 600, + iconResId = R.drawable.ic_level_shakespeare + ) + + object FirstGrader : MyAppLanguageLevel( + nameResId = R.string.level_first_grader_name, + descriptionResId = R.string.level_first_grader_description, + wordsKnown = 750, + iconResId = R.drawable.ic_level_first_grader + ) + + object PuppyInTraining : MyAppLanguageLevel( + nameResId = R.string.level_puppy_in_training_name, + descriptionResId = R.string.level_puppy_in_training_description, + wordsKnown = 900, + iconResId = R.drawable.ic_level_puppy + ) + + object ChaserTheSuperdog : MyAppLanguageLevel( + nameResId = R.string.level_chaser_name, + descriptionResId = R.string.level_chaser_description, + wordsKnown = 1100, + iconResId = R.drawable.ic_level_chaser + ) + + object Bookworm : MyAppLanguageLevel( + nameResId = R.string.level_bookworm_name, + descriptionResId = R.string.level_bookworm_description, + wordsKnown = 1600, + iconResId = R.drawable.ic_level_bookworm + ) + + object MiddleSchooler : MyAppLanguageLevel( + nameResId = R.string.level_middle_schooler_name, + descriptionResId = R.string.level_middle_schooler_description, + wordsKnown = 2300, + iconResId = R.drawable.ic_level_middle_schooler + ) + + object AvidDebater : MyAppLanguageLevel( + nameResId = R.string.level_avid_debater_name, + descriptionResId = R.string.level_avid_debater_description, + wordsKnown = 3500, + iconResId = R.drawable.ic_level_avid_debater + ) + + object HighSchoolGrad : MyAppLanguageLevel( + nameResId = R.string.level_high_school_grad_name, + descriptionResId = R.string.level_high_school_grad_description, + wordsKnown = 5000, + iconResId = R.drawable.ic_level_high_school_grad + ) + + object TheJournalist : MyAppLanguageLevel( + nameResId = R.string.level_journalist_name, + descriptionResId = R.string.level_journalist_description, + wordsKnown = 7500, + iconResId = R.drawable.ic_level_journalist + ) + + object TheProfessor : MyAppLanguageLevel( + nameResId = R.string.level_professor_name, + descriptionResId = R.string.level_professor_description, + wordsKnown = 12000, + iconResId = R.drawable.ic_level_professor + ) + + object TheNovelist : MyAppLanguageLevel( + nameResId = R.string.level_novelist_name, + descriptionResId = R.string.level_novelist_description, + wordsKnown = 18000, + iconResId = R.drawable.ic_level_novelist + ) + + object MasterLinguist : MyAppLanguageLevel( + nameResId = R.string.level_linguist_name, + descriptionResId = R.string.level_linguist_description, + wordsKnown = 25000, + iconResId = R.drawable.ic_level_master_linguist + ) + + object ThePolyglotOracle : MyAppLanguageLevel( + nameResId = R.string.level_oracle_name, + descriptionResId = R.string.level_oracle_description, + wordsKnown = 50000, + iconResId = R.drawable.ic_level_oracle + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/TranslationHistoryItem.kt b/app/src/main/java/eu/gaudian/translator/model/TranslationHistoryItem.kt new file mode 100644 index 0000000..41055d4 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/TranslationHistoryItem.kt @@ -0,0 +1,22 @@ +package eu.gaudian.translator.model + +import android.text.format.DateUtils +import kotlinx.serialization.Serializable + +@Serializable +data class TranslationHistoryItem( + val id: Long = System.currentTimeMillis(), // Unique ID for animations/deletion + val text: String, // The result text + val sourceText: String, // The original input + val sourceLanguageCode: Int?, // nameResID + val targetLanguageCode: Int?, // nameResID + val playable: Boolean? = null, + val timestamp: Long = System.currentTimeMillis(), + val translationSource: String? = null, + val translationModel: String? = null +) { + fun getRelativeTimeSpan(): String { + val now = System.currentTimeMillis() + return DateUtils.getRelativeTimeSpanString(timestamp, now, DateUtils.MINUTE_IN_MILLIS).toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/Vocabulary.kt b/app/src/main/java/eu/gaudian/translator/model/Vocabulary.kt new file mode 100644 index 0000000..2a985e4 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/Vocabulary.kt @@ -0,0 +1,147 @@ +@file:OptIn(ExperimentalTime::class) +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model + +import android.content.Context +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.PrimaryKey +import eu.gaudian.translator.R +import eu.gaudian.translator.model.grammar.VocabularyFeatures +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +val jsonParser = Json { ignoreUnknownKeys = true } + +@Serializable +@Parcelize +@Entity(tableName = "vocabulary_items") +data class VocabularyItem( + @PrimaryKey val id: Int, + val languageFirstId: Int?, + val languageSecondId: Int?, + val wordFirst: String, + val wordSecond: String, + @Contextual val createdAt: Instant? = Clock.System.now(), + val features: String? = null, + val zipfFrequencyFirst: Float? = null, + val zipfFrequencySecond: Float? = null +) : Parcelable { + + fun isDuplicate(other: VocabularyItem): Boolean { + val normalizedWords = setOf(wordFirst.lowercase(), wordSecond.lowercase()) + val otherNormalizedWords = setOf(other.wordFirst.lowercase(), other.wordSecond.lowercase()) + val normalizedIds = setOf(languageFirstId, languageSecondId) + val otherNormalizedIds = setOf(other.languageFirstId, other.languageSecondId) + return normalizedWords == otherNormalizedWords && normalizedIds == otherNormalizedIds + } + + @Suppress("unused") + fun switchOrder(): VocabularyItem { + val currentFeatures = features?.let { jsonParser.decodeFromString(it) } + val switchedFeatures = currentFeatures?.copy(first = currentFeatures.second, second = currentFeatures.first) + val switchedFeaturesJson = switchedFeatures?.let { jsonParser.encodeToString(it) } + + return this.copy( + languageFirstId = this.languageSecondId, + languageSecondId = this.languageFirstId, + wordFirst = this.wordSecond, + wordSecond = this.wordFirst, + features = switchedFeaturesJson + ) + } +} + +@Serializable +@Parcelize +data class WordDetails( + @SerialName("category") + val wordClass: String, + val properties: Map +) : Parcelable + +/** + * A container holding the grammatical details for both words in a VocabularyItem. + */ +@Serializable +@Parcelize +data class VocabularyGrammarDetails( + val first: WordDetails? = null, + val second: WordDetails? = null +) : Parcelable + + +@Serializable +sealed class VocabularyCategory { + abstract val id: Int + abstract val name: String +} + + +@Serializable +@SerialName("FilterCategory") +data class VocabularyFilter( + override val id: Int, + override val name: String, + val languages: List? = null, + @Contextual val languagePairs: Pair ? = null, + val stages: List? = null, +) : VocabularyCategory() + +@Serializable +@SerialName("TagCategory") +data class TagCategory( + override val id: Int, + override val name: String, +) : VocabularyCategory() + + +@Serializable +@Entity(tableName = "vocabulary_states") +data class VocabularyItemState( + @PrimaryKey val vocabularyItemId: Int, + @Contextual var lastCorrectAnswer: Instant? = null, + @Contextual var lastIncorrectAnswer: Instant? = null, + var correctAnswerCount: Int = 0, + var incorrectAnswerCount: Int = 0 +) + +@Serializable +enum class VocabularyStage { + NEW, + STAGE_1, + STAGE_2, + STAGE_3, + STAGE_4, + STAGE_5, + LEARNED; + + fun toString(context: Context): String { + val res = context.resources + return when (this) { + NEW -> res.getString(R.string.stage_new) + STAGE_1 -> res.getString(R.string.stage_1) + STAGE_2 -> res.getString(R.string.stage_2) + STAGE_3 -> res.getString(R.string.stage_3) + STAGE_4 -> res.getString(R.string.stage_4) + STAGE_5 -> res.getString(R.string.stage_5) + LEARNED -> res.getString(R.string.stage_learned) + } + } +} + +@Serializable +@Parcelize +data class CardSet( + val id: Int?=null, + val languageFirst: Int?=null, + val languageSecond: Int?=null, + val cards: List, +): Parcelable \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/WidgetType.kt b/app/src/main/java/eu/gaudian/translator/model/WidgetType.kt new file mode 100644 index 0000000..154fc01 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/WidgetType.kt @@ -0,0 +1,39 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model + +import androidx.annotation.StringRes +import eu.gaudian.translator.R + + +sealed class WidgetType(val id: String, @param:StringRes val titleRes: Int) { + data object Status : WidgetType("status", R.string.label_status) + data object Streak : WidgetType("streak", R.string.title_widget_streak) + data object StartButtons : WidgetType("start_buttons", R.string.label_start_exercise) + data object AllVocabulary : WidgetType("all_vocabulary", R.string.label_all_vocabulary) + data object DueToday : WidgetType("due_today", R.string.title_widget_due_today) + data object CategoryProgress : WidgetType("category_progress", R.string.title_widget_category_progress) + data object WeeklyActivityChart : WidgetType("weekly_activity_chart", R.string.text_widget_title_weekly_activity) + data object Levels : WidgetType("category_stats", R.string.levels) + + companion object { + /** + * The default order of widgets when the app is first launched or if no order is saved. + */ + val DEFAULT_ORDER = listOf( + Status, + Streak, + StartButtons, + AllVocabulary, + DueToday, + CategoryProgress , + WeeklyActivityChart, + Levels, + ) + + + fun fromId(id: String?): WidgetType? { + return DEFAULT_ORDER.find { it.id == id } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/YouTubeData.kt b/app/src/main/java/eu/gaudian/translator/model/YouTubeData.kt new file mode 100644 index 0000000..abfb9ba --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/YouTubeData.kt @@ -0,0 +1,18 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SubtitleLine( + @SerialName("start") val start: Float, + @SerialName("duration") val duration: Float, + @SerialName("text") val text: String, + // Optional translated text to be displayed alongside the original subtitle. + // Not provided by the backend; filled by the app after fetching. + @SerialName("translatedText") val translatedText: String? = null +) { + val end: Float get() = start + duration +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ApiLog.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ApiLog.kt new file mode 100644 index 0000000..85ef419 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/ApiLog.kt @@ -0,0 +1,25 @@ + +package eu.gaudian.translator.model.communication + +import kotlinx.serialization.Serializable + + +@Serializable +data class ApiLogEntry( + val id: String, + val timestamp: Long, + val providerKey: String, + val endpoint: String, + val method: String = "POST", + val model: String? = null, + val requestJson: String? = null, + val responseCode: Int? = null, + val responseMessage: String? = null, + val responseJson: String? = null, + val errorMessage: String? = null, + val durationMs: Long? = null, + val exceptionType: String? = null, + val isTimeout: Boolean? = null, + val parseErrorMessage: String? = null, + val url: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ApiManager.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ApiManager.kt new file mode 100644 index 0000000..581d3fc --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/ApiManager.kt @@ -0,0 +1,620 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.communication + +import android.content.Context +import eu.gaudian.translator.R +import eu.gaudian.translator.model.LanguageModel +import eu.gaudian.translator.model.repository.ApiRepository +import eu.gaudian.translator.model.repository.SettingsRepository +import eu.gaudian.translator.utils.ApiCallback +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.StatusAction +import eu.gaudian.translator.utils.StatusMessageService +import eu.gaudian.translator.viewmodel.MessageAction +import eu.gaudian.translator.viewmodel.MessageDisplayType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import retrofit2.Call +import retrofit2.Callback +import retrofit2.HttpException +import retrofit2.Response + +class ApiManager(private val context: Context) { + + /** + * Checks whether a base URL is reachable and the (optional) port is open. + * - If no scheme is provided, http is assumed. + * - If no port is provided, defaults to 80 for http and 443 for https. + * Tries a TCP socket connect first, then a lightweight HTTP GET to the root. + * Returns Pair. + */ + suspend fun checkProviderAvailability(baseUrl: String): Pair = withContext(Dispatchers.IO) { + try { + val normalized = try { + + var url = baseUrl.trim() + if (url.isEmpty()) url = "http://localhost/" + if (!url.startsWith("http://") && !url.startsWith("https://")) url = "http://$url" + if (!url.endsWith('/')) url += "/" + url + } catch (_: Exception) { + var url = baseUrl.trim() + if (url.isEmpty()) url = "http://localhost/" + if (!url.startsWith("http://") && !url.startsWith("https://")) url = "http://$url" + if (!url.endsWith('/')) url += "/" + url + } + + val uri = java.net.URI(normalized) + val host = uri.host ?: return@withContext Pair(false, "Invalid host") + val scheme = (uri.scheme ?: "http").lowercase() + val port = if (uri.port != -1) uri.port else if (scheme == "https") 443 else 80 + + // 1) TCP connect test + try { + java.net.Socket().use { socket -> + socket.connect(java.net.InetSocketAddress(host, port), 1500) + } + } catch (e: Exception) { + return@withContext Pair(false, "Cannot connect to $host:$port (${e.message})") + } + + // 2) HTTP GET test (non-fatal if it fails; we already know port is open) + return@withContext try { + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(2, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(3, java.util.concurrent.TimeUnit.SECONDS) + .build() + val request = okhttp3.Request.Builder().url(normalized).get().build() + client.newCall(request).execute().use { resp -> + val code = resp.code + if (code in 200..499) { + Pair(true, "Reachable ($code)") + } else { + Pair(true, "Port open; HTTP $code") + } + } + } catch (e: Exception) { + Pair(true, "Port open; HTTP check failed: ${e.message}") + } + } catch (e: Exception) { + Log.e("ApiManager", "Availability check error: ${e.message}", e) + Pair(false, e.message ?: "Unknown error") + } + } + + private val apiLogRepository = eu.gaudian.translator.model.repository.ApiLogRepository(context) + private val gson = com.google.gson.Gson() + + private val apiRepository = ApiRepository(context) + private val settingsRepository = SettingsRepository(context) + + /** + * Validates a given API key against a specific provider's endpoint. + */ + suspend fun validateApiKey(apiKey: String, provider: ApiProvider): Pair { + val validationMessage = "Validating API key for ${provider.displayName}..." + Log.d("ApiManager", validationMessage) + + val cleanApiKey = apiKey.trim() + if (cleanApiKey.isEmpty()) { + return Pair(false, "API key cannot be empty.") + } + + val tempApiService = RetrofitClient.getApiClient(cleanApiKey, provider.baseUrl, provider) + .create(LlmApiService::class.java) + + val endpointUrl = provider.endpoint + val validationPrompt = "Just respond with the word \"success\" if you received this message." + + // For custom providers or local hosts, allow saving the key without requiring a model. + val base = provider.baseUrl.trim() + val lower = base.lowercase() + val isLocalHost = ( + lower.contains("localhost") || + lower.contains("127.0.0.1") || + lower.startsWith("192.168.") || + lower.startsWith("http://192.168.") || + lower.startsWith("10.") || + lower.startsWith("http://10.") || + Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower) || + Regex("^http://172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower) + ) + if (provider.isCustom || isLocalHost) { + // We cannot reliably validate without a model; accept non-empty key for setup. + return Pair(true, "Key accepted for ${provider.displayName}.") + } + + val defaultModel = provider.defaultModel ?: return Pair(true, "No default model found; key saved. Configure a model to fully validate.") + + val result = withTimeoutOrNull(10000) { // 10-second timeout + try { + when (provider.key) { + "gemini" -> { + val testRequest = GeminiRequest( + contents = listOf(GeminiContent("user", listOf(GeminiPart(validationPrompt)))) + ) + val response: Response = tempApiService.sendGeminiRequest(endpointUrl, testRequest).execute() + Pair(response.isSuccessful, response.body()?.toString() ?: response.errorBody()?.string()) + } + else -> { + val testRequest = Request( + model = defaultModel, + messages = listOf(Request.Message("user", validationPrompt)) + ) + val response: Response = tempApiService.sendRequest(endpointUrl, testRequest).execute() + Pair(response.isSuccessful, response.body()?.toString() ?: response.errorBody()?.string()) + } + } + } catch (e: Exception) { + Log.e("ApiManager", "Error validating API key: ${e.message}", e) + Pair(false, e.message) + } + } ?: Pair(false, "Timeout validating API key.") + + return result + } + + /** + * Fetch available models from provider if supported (e.g., OpenAI/OpenRouter/Mistral-compatible GET v1/models). + * Returns Pair, String?> where second is an error message if any. + */ + suspend fun fetchAvailableModels(apiKey: String?, provider: ApiProvider): Pair, String?> { + val base = provider.baseUrl.trim() + val lower = base.lowercase() + val isLocalHost = ( + lower.contains("localhost") || + lower.contains("127.0.0.1") || + lower.startsWith("192.168.") || + lower.startsWith("http://192.168.") || + lower.startsWith("10.") || + lower.startsWith("http://10.") || + Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower) || + Regex("^http://172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower) + ) + + if (provider.key == "gemini") { + // Gemini uses a different endpoint and response shape for model listing. + val key = apiKey?.trim().orEmpty() + if (key.isEmpty() && !provider.isCustom) { + return Pair(emptyList(), "API key required to list models for this provider.") + } + return withContext(Dispatchers.IO) { + try { + val service = RetrofitClient.getApiClient(key, provider.baseUrl, provider).create(LlmApiService::class.java) + // Gemini model list endpoint (v1beta). We keep it relative to baseUrl like other calls. + val url = "v1beta/models" + val response = service.listGeminiModels(url).execute() + if (!response.isSuccessful) { + val err = response.errorBody()?.string() ?: "HTTP ${response.code()}" + Pair(emptyList(), err) + } else { + val body = response.body() + val items = body?.models ?: emptyList() + val mapped = items.map { item -> + val niceName = item.displayName ?: item.name ?: "unknown" + val parts = mutableListOf() + item.description?.let { if (it.isNotBlank()) parts.add(it) } + item.inputTokenLimit?.let { if (it > 0) parts.add("in $it ctx") } + item.outputTokenLimit?.let { if (it > 0) parts.add("out $it ctx") } + item.supportedGenerationMethods?.takeIf { it.isNotEmpty() }?.let { parts.add(it.joinToString("/")) } + val desc = parts.joinToString(" • ") + val modelIdClean = (item.name ?: niceName).substringAfterLast('/') + LanguageModel( + modelId = modelIdClean, + displayName = niceName, + providerKey = provider.key, + description = desc, + isCustom = provider.isCustom + ) + } + Pair(mapped, null) + } + } catch (e: Exception) { + Log.e("ApiManager", "Error fetching Gemini models: ${e.message}", e) + Pair(emptyList(), e.message) + } + } + } + + val key = apiKey?.trim().orEmpty() + // For custom providers (and local endpoints), allow scanning without key by attempting unauthenticated GET v1/models. + val allowNoKey = provider.isCustom || isLocalHost + val effectiveKey = if (key.isNotEmpty()) key else if (allowNoKey) "" else key + + // Perplexity does not support listing models via /v1/models; fail fast with a clear message + if (provider.key.equals("perplexity", ignoreCase = true)) { + return Pair(emptyList(), "Perplexity does not support fetching modeles.") //TODO this must be transalted! + } + + return withContext(Dispatchers.IO) { + try { + val service = RetrofitClient.getApiClient(effectiveKey, provider.baseUrl, provider).create(LlmApiService::class.java) + val url = "v1/models" + val response = service.listModels(url).execute() + Log.d("ApiManager", "Listing models response success=${response}") + if (!response.isSuccessful) { + val err = response.errorBody()?.string() ?: "HTTP ${response.code()}" + Pair(emptyList(), err) + } else { + val body = response.body() + val items = body?.data ?: emptyList() + val mapped = items.map { item -> + val niceName = item.displayName ?: item.title ?: item.name ?: item.id + // Build a compact description with the most useful info + val parts = mutableListOf() + (item.description ?: item.ownedBy ?: item.organization)?.let { if (it.isNotBlank()) parts.add(it) } + val ctx = item.contextLength ?: item.maxContext ?: item.tokenLimit + if (ctx != null && ctx > 0) parts.add("$ctx ctx") + item.capabilities?.let { cap -> + val caps = mutableListOf() + if (cap.vision == true) caps.add("vision") + if (cap.audio == true) caps.add("audio") + if (cap.tools == true) caps.add("tools") + if (cap.json == true) caps.add("json") + if (cap.reasoning == true) caps.add("reasoning") + if (caps.isNotEmpty()) parts.add(caps.joinToString("/")) + } + item.pricing?.let { p -> + val inPrice = p.input ?: p.inputAlt + val outPrice = p.output ?: p.outputAlt + val currency = p.currency ?: "$" + val unit = p.unit ?: "1K tok" + if (inPrice != null || outPrice != null) { + val priceStr = listOfNotNull( + inPrice?.let { "in ${currency}${it}/${unit}" }, + outPrice?.let { "out ${currency}${it}/${unit}" } + ).joinToString(" · ") + if (priceStr.isNotBlank()) parts.add(priceStr) + } + } + if (item.deprecated == true) parts.add("deprecated") + val desc = parts.joinToString(" • ") + LanguageModel( + modelId = item.id, + displayName = niceName, + providerKey = provider.key, + description = desc, + isCustom = provider.isCustom + ) + } + Pair(mapped, null) + } + } catch (e: Exception) { + Log.e("ApiManager", "Error fetching models: ${e.message}", e) + Pair(emptyList(), e.message) + } + } + } + + /** + * Validates a specific model for a given provider and API key. + */ + suspend fun validateModel(apiKey: String, provider: ApiProvider, model: LanguageModel): Pair { + val validationMessage = "Validating model '${model.displayName}' for provider '${provider.displayName}'..." + Log.d("ApiManager", validationMessage) + + val cleanApiKey = apiKey.trim() + if (cleanApiKey.isEmpty()) { + return Pair(false, "API key is missing for this provider.") + } + + val tempApiService = RetrofitClient.getApiClient(cleanApiKey, provider.baseUrl, provider) + .create(LlmApiService::class.java) + + val validationPrompt = "This is a test message to validate the model." + + val result = withTimeoutOrNull(10_000) { // 10-second timeout + try { + Log.d("ApiManager", "Starting validation for provider=${provider.key}, model=${model.modelId}") + + when (provider.key) { + "gemini" -> { + val modelSpecificEndpoint = "v1beta/models/${model.modelId}:generateContent" + val testRequest = GeminiRequest( + contents = listOf(GeminiContent("user", listOf(GeminiPart(validationPrompt)))) + ) + + Log.d("ApiManager", "Sending Gemini request to $modelSpecificEndpoint with prompt=$validationPrompt") + + val response: Response = withContext(Dispatchers.IO) { + tempApiService.sendGeminiRequest(modelSpecificEndpoint, testRequest).execute() + } + + Log.d("ApiManager", "Gemini response success=${response.isSuccessful}") + + val bodyOrError = try { + response.body()?.toString() + ?: response.errorBody()?.string() + ?: "Unknown error" + } catch (ioe: Exception) { + Log.e("ApiManager", "Error extracting Gemini response body: ${ioe.message}", ioe) + "Failed to parse error body: ${ioe.message}" + } + + Pair(response.isSuccessful, bodyOrError) + } + + else -> { + val testRequest = Request( + model = model.modelId, + messages = listOf(Request.Message("user", validationPrompt)) + ) + + Log.d("ApiManager", "Sending generic request to ${provider.endpoint} with model=${model.modelId}") + + val response: Response = withContext(Dispatchers.IO) { + tempApiService.sendRequest(provider.endpoint, testRequest).execute() + } + + Log.d("ApiManager", "Generic response success=${response.isSuccessful}") + + val bodyOrError = try { + response.body()?.toString() + ?: response.errorBody()?.string() + ?: "Unknown error" + } catch (ioe: Exception) { + Log.e("ApiManager", "Error extracting generic response body: ${ioe.message}", ioe) + "Failed to parse error body: ${ioe.message}" + } + + Pair(response.isSuccessful, bodyOrError) + } + } + } catch (e: Exception) { + val errorMessage = buildString { + append("Exception type=${e::class.java.simpleName}") + e.message?.let { append(", message=$it") } + if (e is HttpException) { + append(", httpCode=${e.code()}") + try { + append(", errorBody=${e.response()?.errorBody()?.string()} ") + } catch (ioe: Exception) { + append(", failed to read errorBody: ${ioe.message}") + } + } + } + Log.e("ApiManager", "Exception while validating model=${model.modelId}: $errorMessage", e) + Pair(false, errorMessage) + } + } ?: Pair(false, "Timeout validating model.") + + return result + } + + /** + * The primary method for making API requests for various tasks within the app. + */ + fun getCompletion( + prompt: String, + modelType: ModelType, + callback: ApiCallback + ) { + CoroutineScope(Dispatchers.IO).launch { + val languageModel: LanguageModel? = when (modelType) { + ModelType.TRANSLATION -> apiRepository.getTranslationModel().first() + ModelType.EXERCISE -> apiRepository.getExerciseModel().first() + ModelType.VOCABULARY -> apiRepository.getVocabularyModel().first() + ModelType.DICTIONARY -> apiRepository.getDictionaryModel().first() + } + + if (languageModel == null) { + val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context)) + StatusMessageService.trigger(StatusAction.ShowActionableMessage( + text = errorMsg, + type = MessageDisplayType.ACTIONABLE_ERROR, + action = MessageAction.NAVIGATE_TO_API_KEYS + )) + callback.onFailure(errorMsg) + return@launch + } + + val allProviders = apiRepository.getProviders().first() + val provider = allProviders.find { it.key == languageModel.providerKey } + + if (provider == null) { + val errorMsg = "Provider '${languageModel.providerKey}' not found for the selected model." + StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5)) + callback.onFailure(errorMsg) + return@launch + } + + val allApiKeys = settingsRepository.getAllApiKeys().first() + val apiKey = allApiKeys[provider.key] ?: "" + + if (apiKey.isBlank() && !provider.isCustom) { + val errorMsg = "API key for ${provider.displayName} is missing." + StatusMessageService.trigger(StatusAction.ShowActionableMessage( + text = errorMsg, + type = MessageDisplayType.ACTIONABLE_ERROR, + action = MessageAction.NAVIGATE_TO_API_KEYS + )) + callback.onFailure(errorMsg) + return@launch + } + + val apiService = RetrofitClient.getApiClient(apiKey, provider.baseUrl, provider) + .create(LlmApiService::class.java) + + val endpointUrl = provider.endpoint + Log.d("ApiManager", "Sending request to ${provider.displayName} with model ${languageModel.modelId} for task $modelType and URL ${provider.baseUrl}$endpointUrl") + + when(provider.key) { + "gemini" -> { + val request = GeminiRequest(contents = listOf(GeminiContent("user", listOf(GeminiPart(prompt))))) + val requestJson = try { gson.toJson(request) } catch (_: Exception) { null } + val logId = java.util.UUID.randomUUID().toString() + val logTimestamp = System.currentTimeMillis() + val startTime = System.nanoTime() + apiService.sendGeminiRequest(endpointUrl, request).enqueue(object: Callback { + override fun onResponse(call: Call, response: Response) { + val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0) + var parseError: String? = null + val responseJson = try { + gson.toJson(response.body()) + } catch (e: Exception) { + parseError = "JSON parse error: ${e.message}" + try { response.errorBody()?.string() } catch (_: Exception) { null } + } + val entry = ApiLogEntry( + id = logId, + timestamp = logTimestamp, + providerKey = provider.key, + endpoint = endpointUrl, + method = "POST", + model = languageModel.modelId, + requestJson = requestJson, + responseCode = response.code(), + responseMessage = response.message(), + responseJson = responseJson, + errorMessage = if (response.isSuccessful) null else (parseError ?: response.message()), + durationMs = durationMs, + exceptionType = null, + isTimeout = null, + parseErrorMessage = parseError, + url = endpointUrl + ) + CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) } + + if (response.isSuccessful && parseError == null) { + try { + callback.onSuccess(response.body()?.getResponse()) + } catch (e: Exception) { + val err = "Response processing failed: ${e.message}" + CoroutineScope(Main).launch { + StatusMessageService.trigger(StatusAction.ShowMessage(err, MessageDisplayType.ERROR, 5)) + } + callback.onFailure(err) + } + } else { + val errorMsg = parseError ?: "Response error: ${response.code()} ${response.message()}" + CoroutineScope(Main).launch { + StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5)) + } + callback.onFailure(errorMsg) + } + } + + override fun onFailure(call: Call, t: Throwable) { + val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0) + val isTimeout = t is java.net.SocketTimeoutException || t is java.io.InterruptedIOException + val entry = ApiLogEntry( + id = logId, + timestamp = logTimestamp, + providerKey = provider.key, + endpoint = endpointUrl, + method = "POST", + model = languageModel.modelId, + requestJson = requestJson, + responseCode = null, + responseMessage = null, + responseJson = null, + errorMessage = t.message, + durationMs = durationMs, + exceptionType = t::class.java.simpleName, + isTimeout = isTimeout, + parseErrorMessage = null, + url = endpointUrl + ) + CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) } + + val errorPrefix = if (isTimeout) "Request timeout" else "Request failed" + val errorMsg = "$errorPrefix: ${t.message}" + CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5)) } + callback.onFailure(errorMsg) + } + }) + } + else -> { + val request = Request(languageModel.modelId, listOf(Request.Message("user", prompt))) + val requestJson = try { gson.toJson(request) } catch (_: Exception) { null } + val logId = java.util.UUID.randomUUID().toString() + val logTimestamp = System.currentTimeMillis() + val startTime = System.nanoTime() + apiService.sendRequest(endpointUrl, request).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0) + var parseError: String? = null + val responseJson = try { + gson.toJson(response.body()) + } catch (e: Exception) { + parseError = "JSON parse error: ${e.message}" + try { response.errorBody()?.string() } catch (_: Exception) { null } + } + val entry = ApiLogEntry( + id = logId, + timestamp = logTimestamp, + providerKey = provider.key, + endpoint = endpointUrl, + method = "POST", + model = languageModel.modelId, + requestJson = requestJson, + responseCode = response.code(), + responseMessage = response.message(), + responseJson = responseJson, + errorMessage = if (response.isSuccessful) null else (parseError ?: response.message()), + durationMs = durationMs, + exceptionType = null, + isTimeout = null, + parseErrorMessage = parseError, + url = endpointUrl + ) + CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) } + + if (response.isSuccessful && parseError == null) { + try { + callback.onSuccess(response.body()?.getResponse()) + } catch (e: Exception) { + val err = "Response processing failed: ${e.message}" + CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage(err, MessageDisplayType.ERROR, 5)) } + callback.onFailure(err) + } + } else { + val errorMsg = parseError ?: "Response error: ${response.code()} ${response.message()}" + CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage( + errorMsg, MessageDisplayType.ERROR, 5)) } + callback.onFailure(errorMsg) + } + } + + override fun onFailure(call: Call, t: Throwable) { + val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0) + val isTimeout = t is java.net.SocketTimeoutException || t is java.io.InterruptedIOException + val entry = ApiLogEntry( + id = logId, + timestamp = logTimestamp, + providerKey = provider.key, + endpoint = endpointUrl, + method = "POST", + model = languageModel.modelId, + requestJson = requestJson, + responseCode = null, + responseMessage = null, + responseJson = null, + errorMessage = t.message, + durationMs = durationMs, + exceptionType = t::class.java.simpleName, + isTimeout = isTimeout, + parseErrorMessage = null, + url = endpointUrl + ) + CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) } + + val errorPrefix = if (isTimeout) "Request timeout" else "Request failed" + val errorMsg = "$errorPrefix: ${t.message}" + CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5)) } + callback.onFailure(errorMsg) + } + }) + } + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ApiProvider.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ApiProvider.kt new file mode 100644 index 0000000..217d5cc --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/ApiProvider.kt @@ -0,0 +1,142 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.communication + +import android.content.Context +import eu.gaudian.translator.model.LanguageModel +import eu.gaudian.translator.utils.ProviderConfigParser +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import java.nio.file.NoSuchFileException + +@Serializable +data class ApiProvider( + val key: String, + val displayName: String, + val baseUrl: String, + val endpoint: String, + val websiteUrl: String, + val models: List, + val isCustom: Boolean = false +) { + @Transient + var hasValidKey: Boolean = false + val defaultModel: String? get() = models.firstOrNull()?.modelId + + companion object { + + /** + * Loads providers from the JSON configuration file with fallback to hardcoded providers + * @param context Android context for accessing assets + * @return List of ApiProvider instances + */ + fun loadProviders(context: Context): List { + val providersFromJson = ProviderConfigParser.loadProvidersFromAssets(context) + return providersFromJson.ifEmpty { + // Fallback to hardcoded providers if JSON loading fails + getHardcodedProviders() + throw NoSuchFileException("providers.json not found in assets") + } + } + + /** + * Returns the hardcoded default providers list + * @return List of ApiProvider instances + */ + private fun getHardcodedProviders(): List { + return listOf( + ApiProvider( + key = "mistral", + displayName = "Mistral AI", + baseUrl = "https://api.mistral.ai/", + endpoint = "v1/chat/completions", + websiteUrl = "https://mistral.ai", + models = listOf( + LanguageModel("mistral-small-latest", "Mistral Small Latest", "mistral", "Fast and efficient for simple tasks."), + LanguageModel("open-mistral-nemo", "Mistral Nemo", "mistral", "Advanced model with native function calling."), + ) + ), + ApiProvider( + key = "openai", + displayName = "OpenAI", + baseUrl = "https://api.openai.com/", + endpoint = "v1/chat/completions", + websiteUrl = "https://platform.openai.com/", + models = listOf( + LanguageModel("gpt-5-nano", "GPT 5 Nano", "openai", "Fast and cheap model sufficient for most tasks."), + ) + ), + ApiProvider( + key = "anthropic", + displayName = "Anthropic", + baseUrl = "https://api.anthropic.com/", + endpoint = "v1/messages", + websiteUrl = "https://www.anthropic.com/", + models = listOf( + ) + ), + ApiProvider( + key = "deepseek", + displayName = "DeepSeek", + baseUrl = "https://api.deepseek.com/", + endpoint = "chat/completions", + websiteUrl = "https://www.deepseek.com/", + models = listOf( + LanguageModel("deepseek-chat", "DeepSeek Chat", "deepseek", "Specialized in code and reasoning.") + ) + ), + ApiProvider( + key = "gemini", + displayName = "Google Gemini", + baseUrl = "https://generativelanguage.googleapis.com/", + endpoint = "v1beta/models/gemini-2.5-flash:generateContent", + websiteUrl = "https://ai.google/", + models = listOf( + LanguageModel("gemini-2.5-flash", "Gemini 2.5 Flash", "gemini", "Current default: Fast, grounded, strong at conversational and search tasks."), + LanguageModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite", "gemini", "Fastest and most cost-efficient Gemini model for high throughput and low-latency needs.") + ) + ), + ApiProvider( + key = "openrouter", + displayName = "OpenRouter", + baseUrl = "https://openrouter.ai/api/", + endpoint = "v1/chat/completions", + websiteUrl = "https://openrouter.ai", + models = listOf( + ) + ), + ApiProvider( + key = "groq", + displayName = "Groq", + baseUrl = "https://api.groq.com/openai/", + endpoint = "v1/chat/completions", + websiteUrl = "https://groq.com/", + models = listOf( + LanguageModel("llama-3.1-8b-instant", "Llama 3.1 8B", "groq", "Powerful Llama 3 model running at extreme speed."), + ) + ), + ApiProvider( + key = "perplexity", + displayName = "Perplexity", + baseUrl = "https://api.perplexity.ai/", + endpoint = "chat/completions", + websiteUrl = "https://www.perplexity.ai/", + models = listOf( + LanguageModel("sonar", "Sonar Small Online", "perplexity", "A faster online model for quick, up-to-date answers."), // default + LanguageModel("sonar-pro", "Sonar Pro", "perplexity", "Advanced search-focused model for richer context and longer answers."), + ) + ), + ApiProvider( + key = "xai", + displayName = "xAI Grok", + baseUrl = "https://api.x.ai/", + endpoint = "v1/chat/completions", + websiteUrl = "https://x.ai", + models = listOf( + LanguageModel("grok-4-fast", "Grok 4 Fast", "xai", "Fast and flexible model from xAI.") + ) + ) + ) + } + } +} diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/FileDownloadManager.kt b/app/src/main/java/eu/gaudian/translator/model/communication/FileDownloadManager.kt new file mode 100644 index 0000000..b0c1c35 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/FileDownloadManager.kt @@ -0,0 +1,193 @@ +package eu.gaudian.translator.model.communication + +import android.content.Context +import androidx.core.content.edit +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.create +import java.io.File +import java.io.FileOutputStream +import java.security.MessageDigest + +/** + * Manages downloading files from the server, verifying checksums, and checking versions. + */ +class FileDownloadManager(private val context: Context) { + + private val baseUrl = "http://23.88.48.47/" + + private val retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(GsonConverterFactory.create()) + .client(OkHttpClient.Builder().build()) + .build() + + private val manifestApiService = retrofit.create() + + @Suppress("HardCodedStringLiteral") + private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE) + + /** + * Fetches the manifest from the server. + */ + suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) { + try { + val response = manifestApiService.getManifest().execute() + if (response.isSuccessful) { + response.body() + } else { + @Suppress("HardCodedStringLiteral") val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}" + + @Suppress("HardCodedStringLiteral") + Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage") + throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage)) + } + } catch (e: Exception) { + @Suppress("HardCodedStringLiteral") + Log.e("FileDownloadManager", "Error fetching manifest", e) + throw e + } + } + + /** + * Downloads all assets for a file and verifies their checksums. + */ + suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) { + val totalAssets = fileInfo.assets.size + + for ((completedAssets, asset) in fileInfo.assets.withIndex()) { + val success = downloadAsset(asset) { assetProgress -> + // Calculate overall progress + val assetContribution = assetProgress / totalAssets + val previousAssetsProgress = completedAssets.toFloat() / totalAssets + onProgress(previousAssetsProgress + assetContribution) + } + if (!success) { + return@withContext false + } + } + + // Save version after all assets are downloaded successfully + sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) } + @Suppress("HardCodedStringLiteral") + Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}") + true + } + + /** + * Downloads a specific asset and verifies its checksum. + */ + private suspend fun downloadAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) { + val fileUrl = "${baseUrl}${asset.filename}" + val localFile = File(context.filesDir, asset.filename) + + try { + val client = OkHttpClient() + val request = Request.Builder().url(fileUrl).build() + val response = client.newCall(request).execute() + + if (!response.isSuccessful) { + val errorMessage = context.getString( + R.string.text_download_failed_http, + response.code, + response.message + ) + Log.e("FileDownloadManager", errorMessage) + throw Exception(errorMessage) + } + + val body = response.body + + val contentLength = body.contentLength() + if (contentLength <= 0) { + throw Exception("Invalid file size: $contentLength") + } + + FileOutputStream(localFile).use { output -> + body.byteStream().use { input -> + val buffer = ByteArray(8192) + var bytesRead: Int + var totalBytesRead: Long = 0 + @Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256") + + while (input.read(buffer).also { bytesRead = it } != -1) { + output.write(buffer, 0, bytesRead) + digest.update(buffer, 0, bytesRead) + totalBytesRead += bytesRead + onProgress((totalBytesRead.toFloat() / contentLength)) + } + + output.flush() + + // Compute checksum + val computedChecksum = digest.digest().joinToString("") { + @Suppress("HardCodedStringLiteral") + "%02X".format(it) + } + + if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) { + @Suppress("HardCodedStringLiteral") + Log.d("FileDownloadManager", "Download successful for ${asset.filename}") + true + } else { + Log.e("FileDownloadManager", + context.getString( + R.string.text_checksum_mismatch_for_expected_got, + asset.filename, + asset.checksumSha256, + computedChecksum + )) + localFile.delete() // Delete corrupted file + throw Exception("Checksum verification failed for ${asset.filename}") + } + } + } + } catch (e: Exception) { + @Suppress("HardCodedStringLiteral") + Log.e("FileDownloadManager", "Error downloading asset", e) + // Clean up partial download + if (localFile.exists()) { + localFile.delete() + } + throw e + } + } + + /** + * Checks if a newer version is available for a file. + */ + fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean { + val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0" + return compareVersions(fileInfo.version, localVersion) > 0 + } + + /** + * Compares two version strings (assuming semantic versioning). + */ + private fun compareVersions(version1: String, version2: String): Int { + val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 } + val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 } + + for (i in 0 until maxOf(parts1.size, parts2.size)) { + val part1 = parts1.getOrElse(i) { 0 } + val part2 = parts2.getOrElse(i) { 0 } + if (part1 != part2) { + return part1.compareTo(part2) + } + } + return 0 + } + + /** + * Gets the local version of a file. + */ + fun getLocalVersion(fileId: String): String { + return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/GeminiModels.kt b/app/src/main/java/eu/gaudian/translator/model/communication/GeminiModels.kt new file mode 100644 index 0000000..a9c8c30 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/GeminiModels.kt @@ -0,0 +1,17 @@ + +package eu.gaudian.translator.model.communication + + + +data class GeminiModelsListResponse( + val models: List? +) + +data class GeminiModelItem( + val name: String?, + val displayName: String?, + val description: String?, + val inputTokenLimit: Int?, + val outputTokenLimit: Int?, + val supportedGenerationMethods: List? // e.g., ["generateContent"] +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/LlmApiService.kt b/app/src/main/java/eu/gaudian/translator/model/communication/LlmApiService.kt new file mode 100644 index 0000000..e3d297b --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/LlmApiService.kt @@ -0,0 +1,28 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.communication + +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Url + +interface LlmApiService { + @Headers("Content-Type: application/json") + @POST + fun sendRequest(@Url url: String, @Body request: Request): Call + + @Headers("Content-Type: application/json") + @POST + fun sendGeminiRequest(@Url url: String, @Body request: GeminiRequest): Call + + // Generic models listing (e.g., OpenAI-compatible: GET v1/models) + @GET + fun listModels(@Url url: String): Call + + // Gemini models listing (GET v1beta/models) + @GET + fun listGeminiModels(@Url url: String): Call +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ManifestApiService.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ManifestApiService.kt new file mode 100644 index 0000000..e1b4a49 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/ManifestApiService.kt @@ -0,0 +1,17 @@ +package eu.gaudian.translator.model.communication + +import retrofit2.Call +import retrofit2.http.GET + +/** + * API service for fetching the manifest and downloading files. + */ +interface ManifestApiService { + + /** + * Fetches the manifest from the server. + */ + @GET("manifest.json") + fun getManifest(): Call + +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ManifestModels.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ManifestModels.kt new file mode 100644 index 0000000..5915d29 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/ManifestModels.kt @@ -0,0 +1,41 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.communication + +import com.google.gson.annotations.SerializedName + +/** + * Data class representing the manifest response from the server. + */ +data class ManifestResponse( + @SerializedName("files") + val files: List +) + +/** + * Data class representing information about a downloadable file. + */ +data class FileInfo( + @SerializedName("id") + val id: String, + @SerializedName("name") + val name: String, + @SerializedName("description") + val description: String, + @SerializedName("version") + val version: String, + @SerializedName("assets") + val assets: List +) + +/** + * Data class representing an asset file within a downloadable file. + */ +data class Asset( + @SerializedName("filename") + val filename: String, + @SerializedName("size_bytes") + val sizeBytes: Long, + @SerializedName("checksum_sha256") + val checksumSha256: String +) diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ModelType.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ModelType.kt new file mode 100644 index 0000000..2b2ad73 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/ModelType.kt @@ -0,0 +1,24 @@ +package eu.gaudian.translator.model.communication + +import android.content.Context +import eu.gaudian.translator.R + +enum class ModelType(private val stringResId: Int) { + TRANSLATION(R.string.label_translation), + EXERCISE(R.string.label_exercise), + VOCABULARY(R.string.label_vocabulary), + DICTIONARY(R.string.label_dictionary); + + /** + * Returns the localized name of the model type. + * + * This function should be used instead of the default `name` property + * when displaying the model type to the user, as it provides a + * localized string. + * + * @param context The context to use for string localization. + * @return The localized name of the model type. + */ + fun getLocalizedName(context: Context): String = + context.getString(stringResId) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/ModelsListResponse.kt b/app/src/main/java/eu/gaudian/translator/model/communication/ModelsListResponse.kt new file mode 100644 index 0000000..1b3fbd0 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/ModelsListResponse.kt @@ -0,0 +1,82 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.communication + +import com.google.gson.annotations.SerializedName + +/** + * Schema for OpenAI-compatible list models response, extended with optional fields + * that some providers (OpenRouter, Mistral, etc.) may include. All extra fields are + * nullable so unknown providers won't break deserialization. + */ +data class ModelsListResponse( + val data: List = emptyList() +) + +/** + * A conservative union of known fields from various providers. + */ +data class ModelItem( + val id: String, + // Owner / organization + @SerializedName("owned_by") val ownedBy: String? = null, + @SerializedName("organization") val organization: String? = null, + + // Object type (OpenAI: "model") + @SerializedName("object") val objectType: String? = null, + + // Human-friendly name + @SerializedName("display_name") val displayName: String? = null, + @SerializedName("title") val title: String? = null, + @SerializedName("name") val name: String? = null, + + // Description and tags + @SerializedName("description") val description: String? = null, + @SerializedName("tags") val tags: List? = null, + + // Capabilities / features + @SerializedName("capabilities") val capabilities: Capabilities? = null, + + // Context window (various naming across providers) + @SerializedName("context_length") val contextLength: Int? = null, + @SerializedName("max_context") val maxContext: Int? = null, + @SerializedName("token_limit") val tokenLimit: Int? = null, + + // Pricing information (if provided) + @SerializedName("pricing") val pricing: Pricing? = null, + + // Lifecycle + @SerializedName("created") val created: Long? = null, + @SerializedName("deprecated") val deprecated: Boolean? = null, + @SerializedName("deprecation_date") val deprecationDate: String? = null, + + // Family / type + @SerializedName("family") val family: String? = null, + @SerializedName("type") val type: String? = null, + + // Aliases that may point to canonical models + @SerializedName("aliases") val aliases: List? = null +) + +// Nested shapes that some providers use + +data class Capabilities( + val vision: Boolean? = null, + val audio: Boolean? = null, + val tools: Boolean? = null, + val json: Boolean? = null, + val reasoning: Boolean? = null +) + +data class Pricing( + // Prices may come in different units; commonly per 1K or 1M tokens. We store raw doubles. + @SerializedName("prompt") val input: Double? = null, + @SerializedName("completion") val output: Double? = null, + @SerializedName("image") val image: Double? = null, + @SerializedName("request") val request: Double? = null, + // Alternative naming sometimes used + @SerializedName("input") val inputAlt: Double? = null, + @SerializedName("output") val outputAlt: Double? = null, + @SerializedName("unit") val unit: String? = null, + @SerializedName("currency") val currency: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/Request.kt b/app/src/main/java/eu/gaudian/translator/model/communication/Request.kt new file mode 100644 index 0000000..73778c2 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/Request.kt @@ -0,0 +1,54 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.communication + +data class Request( + val model: String, + val messages: List +) { + data class Message( + val role: String, + val content: String + ) +} + +data class ApiResponse( + val choices: List +) { + data class Choice( + val message: Message + ) { + data class Message( + val content: String + ) + } + + fun getResponse(): String { + return choices.firstOrNull()?.message?.content ?: "No response available" + } +} + +data class GeminiRequest( + val contents: List +) + +data class GeminiResponse( + val candidates: List? +) { + fun getResponse(): String { + return candidates?.firstOrNull()?.content?.parts?.firstOrNull()?.text ?: "No response available" + } +} + +data class GeminiCandidate( + val content: GeminiContent +) + +data class GeminiContent( + val role: String?, + val parts: List +) + +data class GeminiPart( + val text: String +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/RetrofitClient.kt b/app/src/main/java/eu/gaudian/translator/model/communication/RetrofitClient.kt new file mode 100644 index 0000000..6527f33 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/RetrofitClient.kt @@ -0,0 +1,183 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.communication + +import eu.gaudian.translator.utils.Log +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +object RetrofitClient { + + private const val W_BASE_URL = "https://en.wiktionary.org/" + + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val pkg = try { eu.gaudian.translator.BuildConfig.APPLICATION_ID } catch (_: Exception) { "eu.gaudian.translator" } + val ver = try { eu.gaudian.translator.BuildConfig.VERSION_NAME } catch (_: Exception) { "unknown" } + val ua = "GaudianTranslator/$ver ($pkg; contact: jonas@gaudian.eu)" + val request = chain.request().newBuilder() + .header("User-Agent", ua) + .build() + chain.proceed(request) + } + .build() + + val api: WiktionaryApiService by lazy { + Log.d("RetrofitClient", "Creating Wiktionary API with baseUrl=$W_BASE_URL") + // TODO: Ensure User-Agent complies with Wiktionary API policy; consider including app version. + val gson = com.google.gson.GsonBuilder() + .registerTypeAdapter(TextField::class.java, TextFieldDeserializer()) + .create() + Retrofit.Builder() + .baseUrl(W_BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(WiktionaryApiService::class.java) + } + + private fun normalizeBaseUrl(input: String): String { + var url = input.trim() + if (url.isEmpty()) return "http://localhost/" + // If scheme missing, prepend http:// + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "http://$url" + } + // If it's just host:port or IP without trailing slash, ensure trailing slash + if (!url.endsWith('/')) { + url += "/" + } + return url + } + + private val logCollector = InMemoryLogCollector() + + /** + * Creates a Retrofit instance for a specific API provider. + * @param apiKey The API key for authorization. + * @param baseUrl The base URL of the API provider (e.g., "https://api.openai.com/"). + * @param provider The ApiProvider object, used to determine which headers to add. + * @return A configured Retrofit instance. + */ + fun getApiClient(apiKey: String, baseUrl: String, provider: ApiProvider): Retrofit { + Log.d("RetrofitClient", "Creating API client with baseUrl=$baseUrl") + + val loggingInterceptor = UserVisibleLoggingInterceptor(logCollector) + + val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .addInterceptor(loggingInterceptor) + .addInterceptor { chain -> + val requestBuilder = chain.request().newBuilder() + Log.d("RetrofitClient", "Adding headers to request: $requestBuilder") + + val trimmedKey = apiKey.trim() + when (provider.key) { + "gemini" -> { + if (trimmedKey.isNotEmpty()) { + // Google Generative Language API typically accepts API key as query param `key`. + // We also send the x-goog-api-key header for compatibility. + requestBuilder.addHeader("x-goog-api-key", trimmedKey) + + try { + val originalUrl = chain.request().url + val hasKeyParam = (0 until originalUrl.querySize).any { i -> + originalUrl.queryParameterName(i).equals("key", ignoreCase = true) + } + val newUrl = if (!hasKeyParam) { + originalUrl.newBuilder().addQueryParameter("key", trimmedKey).build() + } else { + originalUrl + } + requestBuilder.url(newUrl) + } catch (_: Exception) { + // Fail-safe: if URL manipulation fails, rely on header only. + } + } + } + "openrouter" -> { + if (trimmedKey.isNotEmpty()) { + // Use Bearer token for OpenRouter and add custom headers. + requestBuilder.addHeader("Authorization", "Bearer $trimmedKey") + requestBuilder.addHeader("HTTP-Referer", "https://gaudian.eu/translator") + requestBuilder.addHeader("X-Title", "Gaudian Translator") + } + } + else -> { + // Default to the standard Bearer token for most other APIs, but only if key is present. + if (trimmedKey.isNotEmpty()) { + requestBuilder.addHeader("Authorization", "Bearer $trimmedKey") + } + } + } + chain.proceed(requestBuilder.build()) + } + .build() + + val normalizedBaseUrl = normalizeBaseUrl(baseUrl) + Log.d("RetrofitClient", "Normalized baseUrl: $normalizedBaseUrl") + + return Retrofit.Builder() + .baseUrl(normalizedBaseUrl) + .addConverterFactory(GsonConverterFactory.create()) + .client(client) + .build() + } +} + +class UserVisibleLoggingInterceptor(private val logCollector: LogCollector) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + val requestLog = StringBuilder().apply { + append("Request: ${request.method} ${request.url}\n") + request.headers.forEach { header -> + append("${header.first}: ${header.second}\n") + } + }.toString() + logCollector.addLog(requestLog) + + val response = chain.proceed(request) + + val responseLog = StringBuilder().apply { + append("Response: ${response.code} ${response.message}\n") + response.headers.forEach { header -> + append("${header.first}: ${header.second}\n") + } + }.toString() + logCollector.addLog(responseLog) + + return response + } +} + +interface LogCollector { + fun addLog(message: String) + fun getLogs(): List +} + +class InMemoryLogCollector : LogCollector { + private val logs = mutableListOf() + private val maxLogs = 100 // Keep last 100 messages + + override fun addLog(message: String) { + synchronized(logs) { + logs.add(message) + if (logs.size > maxLogs) { + logs.removeAt(0) + } + } + } + + override fun getLogs(): List { + return synchronized(logs) { + logs.toList() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryApiService.kt b/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryApiService.kt new file mode 100644 index 0000000..fe58b40 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryApiService.kt @@ -0,0 +1,18 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.communication + +import retrofit2.http.GET +import retrofit2.http.Query + +interface WiktionaryApiService { + @GET("w/api.php") + suspend fun getPageHtml( + @Query("action") action: String = "parse", + @Query("page") page: String, + @Query("prop") prop: String = "text", + @Query("format") format: String = "json", + @Query("redirects") redirects: Int = 1, + @Query("formatversion") formatVersion: Int = 2 + ): WiktionaryResponse +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryModels.kt b/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryModels.kt new file mode 100644 index 0000000..c969348 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/communication/WiktionaryModels.kt @@ -0,0 +1,45 @@ +package eu.gaudian.translator.model.communication + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import java.lang.reflect.Type + +/** + * Robust models for Wiktionary parse API that accept either an object {"*": ""} + * or a raw string for the `text` field. + */ + data class WiktionaryResponse( + val parse: ParseData? + ) + + data class ParseData( + val title: String?, + val text: TextField? + ) + + /** Wrapper for the flexible `text` payload. */ + data class TextField( + val html: String? + ) + + /** Gson deserializer that accepts either object-with-star or raw string. */ + class TextFieldDeserializer : JsonDeserializer { + override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): TextField { + if (json == null || json.isJsonNull) return TextField(null) + return try { + when { + json.isJsonPrimitive && json.asJsonPrimitive.isString -> TextField(json.asString) + json.isJsonObject -> { + val obj = json.asJsonObject + val star = obj.get("*") + val html = if (star != null && !star.isJsonNull) star.asString else null + TextField(html) + } + else -> TextField(null) + } + } catch (_: Exception) { + TextField(null) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/db/AppDatabase.kt b/app/src/main/java/eu/gaudian/translator/model/db/AppDatabase.kt new file mode 100644 index 0000000..a6a43bd --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/db/AppDatabase.kt @@ -0,0 +1,90 @@ +@file:OptIn(ExperimentalTime::class) +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.sqlite.db.SupportSQLiteDatabase +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.model.VocabularyItemState +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +@Database( + entities = [ + VocabularyItem::class, + VocabularyItemState::class, + VocabularyCategoryEntity::class, + CategoryMappingEntity::class, + StageMappingEntity::class, + DailyStatEntity::class + ], + version = 3 +) + +@TypeConverters(Converters::class) +abstract class AppDatabase : RoomDatabase() { + + abstract fun vocabularyItemDao(): VocabularyItemDao + abstract fun vocabularyStateDao(): VocabularyStateDao + abstract fun categoryDao(): CategoryDao + abstract fun mappingDao(): MappingDao + abstract fun dailyStatDao(): DailyStatDao + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + private val MIGRATION_1_2 = object : androidx.room.migration.Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE vocabulary_items ADD COLUMN zipfFrequency REAL") + } + } + + private val MIGRATION_2_3 = object : androidx.room.migration.Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + // Create new table with the updated schema + db.execSQL( + "CREATE TABLE IF NOT EXISTS `vocabulary_items_new` (" + + "`id` INTEGER NOT NULL, " + + "`languageFirstId` INTEGER, " + + "`languageSecondId` INTEGER, " + + "`wordFirst` TEXT NOT NULL, " + + "`wordSecond` TEXT NOT NULL, " + + "`createdAt` INTEGER, " + + "`features` TEXT, " + + "`zipfFrequencyFirst` REAL, " + + "`zipfFrequencySecond` REAL, " + + "PRIMARY KEY(`id`)" + + ")" + ) + // Copy data from old table mapping zipfFrequency to zipfFrequencyFirst + db.execSQL( + "INSERT INTO `vocabulary_items_new` (" + + "id, languageFirstId, languageSecondId, wordFirst, wordSecond, createdAt, features, zipfFrequencyFirst, zipfFrequencySecond" + + ") SELECT id, languageFirstId, languageSecondId, wordFirst, wordSecond, createdAt, features, zipfFrequency, NULL FROM `vocabulary_items`" + ) + // Drop old table + db.execSQL("DROP TABLE `vocabulary_items`") + // Rename new table to old name + db.execSQL("ALTER TABLE `vocabulary_items_new` RENAME TO `vocabulary_items`") + } + } + + fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "vocabulary_database" + ).addMigrations(MIGRATION_1_2, MIGRATION_2_3).build() + INSTANCE = instance + instance + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/db/Converters.kt b/app/src/main/java/eu/gaudian/translator/model/db/Converters.kt new file mode 100644 index 0000000..b1d3679 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/db/Converters.kt @@ -0,0 +1,54 @@ +@file:OptIn(ExperimentalTime::class) + +package eu.gaudian.translator.model.db + +import androidx.room.TypeConverter +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.VocabularyStage +import kotlinx.datetime.LocalDate +import kotlinx.serialization.json.Json +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Suppress("HardCodedStringLiteral", "unused") +class Converters { + @TypeConverter + fun fromTimestamp(value: Long?): Instant? { + return value?.let { Instant.fromEpochMilliseconds(it) } + } + + @TypeConverter + fun dateToTimestamp(date: Instant?): Long? { + return date?.toEpochMilliseconds() + } + + @TypeConverter + fun fromLocalDate(value: String?): LocalDate? { + return value?.let { LocalDate.parse(it) } + } + + @TypeConverter + fun toLocalDate(date: LocalDate?): String? { + return date?.toString() + } + + @TypeConverter + fun fromLanguageList(languages: List?): String? { + return languages?.let { Json.encodeToString(it) } + } + + @TypeConverter + fun toLanguageList(json: String?): List? { + return json?.let { Json.decodeFromString>(it) } + } + + @TypeConverter + fun fromStageList(stages: List?): String? { + return stages?.let { Json.encodeToString(it) } + } + + @TypeConverter + fun toStageList(json: String?): List? { + return json?.let { Json.decodeFromString>(it) } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/db/Daos.kt b/app/src/main/java/eu/gaudian/translator/model/db/Daos.kt new file mode 100644 index 0000000..18a10e7 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/db/Daos.kt @@ -0,0 +1,216 @@ +@file:OptIn(ExperimentalTime::class) +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.model.VocabularyItemState +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.LocalDate +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) + +@Dao +interface VocabularyItemDao { + @Query("DELETE FROM vocabulary_items") + suspend fun clearAllItems() + @Query("SELECT * FROM vocabulary_items") + fun getAllItemsFlow(): Flow> + + @Query("SELECT * FROM vocabulary_items") + suspend fun getAllItems(): List + + @Query("SELECT * FROM vocabulary_items WHERE id = :id") + suspend fun getItemById(id: Int): VocabularyItem? + + @Query("SELECT EXISTS(SELECT 1 FROM vocabulary_items WHERE (LOWER(wordFirst) = LOWER(:word) AND languageFirstId IS :languageId) OR (LOWER(wordSecond) = LOWER(:word) AND languageSecondId IS :languageId))") + suspend fun itemExists(word: String, languageId: Int?): Boolean + + @Upsert + suspend fun upsertItem(item: VocabularyItem) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(items: List) + + @Query("DELETE FROM vocabulary_items WHERE id = :itemId") + suspend fun deleteItemById(itemId: Int) + + @Query("DELETE FROM vocabulary_items WHERE id IN (:itemIds)") + suspend fun deleteItemsByIds(itemIds: List) + + @Query("SELECT * FROM vocabulary_items WHERE id IN (:ids)") + suspend fun getItemsByIds(ids: List): List + + @Query("SELECT MAX(id) FROM vocabulary_items") + suspend fun getMaxItemId(): Int? + + @Query(""" + SELECT i.* FROM vocabulary_items i + INNER JOIN category_mappings cm ON i.id = cm.vocabularyItemId + WHERE cm.categoryId = :categoryId +""" + ) + suspend fun getItemsByCategoryId(categoryId: Int): List + + @Query(""" + SELECT i.* FROM vocabulary_items AS i + LEFT JOIN stage_mappings AS sm ON i.id = sm.vocabularyItemId + LEFT JOIN vocabulary_states AS vs ON i.id = vs.vocabularyItemId + WHERE + -- Condition 1: Item is NEW + sm.stage IS NULL OR sm.stage = 'NEW' OR + -- Condition 2: Item has a due date that is in the past + ( + -- Use last correct or incorrect answer as the base time + (COALESCE(vs.lastCorrectAnswer, vs.lastIncorrectAnswer) / 1000) + + ( + CASE IFNULL(sm.stage, 'NEW') + WHEN 'STAGE_1' THEN :intervalStage1 * 86400 + WHEN 'STAGE_2' THEN :intervalStage2 * 86400 + WHEN 'STAGE_3' THEN :intervalStage3 * 86400 + WHEN 'STAGE_4' THEN :intervalStage4 * 86400 + WHEN 'STAGE_5' THEN :intervalStage5 * 86400 + WHEN 'LEARNED' THEN :intervalLearned * 86400 + ELSE 0 + END + ) <= :now + ) +""" + ) + fun getDueTodayItemsFlow( + now: Long, + intervalStage1: Int, + intervalStage2: Int, + intervalStage3: Int, + intervalStage4: Int, + intervalStage5: Int, + intervalLearned: Int + ): Flow> + + @Query(""" + SELECT * FROM vocabulary_items + WHERE id != :excludeId AND ( + ((languageFirstId = :lang1 AND languageSecondId = :lang2) OR (languageFirstId = :lang2 AND languageSecondId = :lang1)) + AND (wordFirst = :wordFirst OR wordSecond = :wordSecond) + ) + """) + suspend fun getSynonyms(excludeId: Int, lang1: Int, lang2: Int, wordFirst: String, wordSecond: String): List +} + +data class DailyCount(val date: LocalDate, val count: Int) + +@Dao +interface VocabularyStateDao { + + @Query("DELETE FROM vocabulary_states") + suspend fun clearAllStates() + + @Query(""" + SELECT DATE(lastCorrectAnswer / 1000, 'unixepoch') as date, COUNT(vocabularyItemId) as count + FROM vocabulary_states + WHERE lastCorrectAnswer IS NOT NULL AND date BETWEEN :startDate AND :endDate + GROUP BY date +""") + suspend fun getCorrectAnswerCountsByDate(startDate: LocalDate, endDate: LocalDate): List + @Query("SELECT * FROM vocabulary_states") + fun getAllStatesFlow(): Flow> + + @Query("SELECT * FROM vocabulary_states") + suspend fun getAllStates(): List + + @Query("SELECT * FROM vocabulary_states WHERE vocabularyItemId = :itemId") + suspend fun getStateById(itemId: Int): VocabularyItemState? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(states: List) + + @Upsert + suspend fun upsertState(state: VocabularyItemState) +} + +@Dao +interface CategoryDao { + @Query("DELETE FROM categories") + suspend fun clearAllCategories() + @Query("SELECT * FROM categories") + fun getAllCategoriesFlow(): Flow> + + @Query("SELECT * FROM categories") + suspend fun getAllCategories(): List + + @Query("SELECT * FROM categories WHERE id = :id") + suspend fun getCategoryById(id: Int): VocabularyCategoryEntity? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(categories: List) + + @Upsert + suspend fun upsertCategory(category: VocabularyCategoryEntity) + + @Query("DELETE FROM categories WHERE id = :categoryId") + suspend fun deleteCategoryById(categoryId: Int) +} + +@Dao +interface MappingDao { + @Query("DELETE FROM stage_mappings") + suspend fun clearStageMappings() + @Query("DELETE FROM stage_mappings WHERE vocabularyItemId NOT IN (:itemIds)") + suspend fun deleteStageMappingsNotIn(itemIds: List) + @Query("SELECT * FROM category_mappings") + fun getCategoryMappingsFlow(): Flow> + + @Query("SELECT * FROM category_mappings") + suspend fun getCategoryMappings(): List + + + @Query("DELETE FROM category_mappings") + suspend fun clearCategoryMappings() + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertCategoryMappings(mappings: List) + + @Transaction + suspend fun setAllCategoryMappings(mappings: List) { + clearCategoryMappings() + if (mappings.isNotEmpty()) { + insertCategoryMappings(mappings) + } + } + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun addCategoryMapping(mapping: CategoryMappingEntity) + + @Query("DELETE FROM category_mappings WHERE vocabularyItemId = :itemId AND categoryId = :listId") + suspend fun removeCategoryMapping(itemId: Int, listId: Int) + + @Query("SELECT * FROM stage_mappings") + fun getStageMappingsFlow(): Flow> + + @Query("SELECT * FROM stage_mappings") + suspend fun getStageMappings(): List + + @Upsert + suspend fun upsertStageMapping(mapping: StageMappingEntity) + + @Upsert + suspend fun upsertStageMappings(mappings: List) +} + +@Dao +interface DailyStatDao { + @Query("DELETE FROM daily_stats") + suspend fun clearAll() + @Query("SELECT * FROM daily_stats WHERE date = :date") + suspend fun getStatForDate(date: LocalDate): DailyStatEntity? + + @Upsert + suspend fun upsertStat(stat: DailyStatEntity) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/db/DatabaseEntities.kt b/app/src/main/java/eu/gaudian/translator/model/db/DatabaseEntities.kt new file mode 100644 index 0000000..c54d1ee --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/db/DatabaseEntities.kt @@ -0,0 +1,51 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.db + +import androidx.room.Entity +import androidx.room.PrimaryKey +import eu.gaudian.translator.model.VocabularyStage +import kotlinx.datetime.LocalDate + +/** + * An entity to store the different types of VocabularyCategory in a single table. + */ +@Entity(tableName = "categories") +data class VocabularyCategoryEntity( + @PrimaryKey val id: Int, + val name: String, + val type: String, + + val filterLanguages: String?, + val filterStages: String?, + + val dictLangFirst: Int?, + val dictLangSecond: Int? +) + +/** + * Entity to store the mapping between vocabulary items and categories. + */ +@Entity(tableName = "category_mappings", primaryKeys = ["vocabularyItemId", "categoryId"]) +data class CategoryMappingEntity( + val vocabularyItemId: Int, + val categoryId: Int +) + +/** + * Entity to store the learning stage for each vocabulary item. + */ +@Entity(tableName = "stage_mappings") +data class StageMappingEntity( + @PrimaryKey val vocabularyItemId: Int, + val stage: VocabularyStage +) + +/** + * Entity to store daily learning statistics, replacing dynamic DataStore keys. + */ +@Entity(tableName = "daily_stats") +data class DailyStatEntity( + @PrimaryKey val date: LocalDate, + val correctCount: Int +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/AdjectiveVariationsParser.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/AdjectiveVariationsParser.kt new file mode 100644 index 0000000..3e1ef3d --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/AdjectiveVariationsParser.kt @@ -0,0 +1,195 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.grammar + +/** + * Parser for extracting adjective variations from raw forms data. + * + * This class is responsible for parsing FormData into structured AdjectiveVariation + * objects that can be easily displayed in the UI. It separates the parsing logic + * from the UI components for better testability and maintainability. + */ +object AdjectiveVariationsParser { + + /** + * Supported languages for adjective variations. + */ + private val SUPPORTED_LANGUAGES = setOf("fr", "pt", "de") + + /** + * Gender tags that we recognize. + */ + private val GENDER_TAGS = setOf("masculine", "feminine", "neuter") + + /** + * Number tags that we recognize. + */ + private val NUMBER_TAGS = setOf("singular", "plural") + + /** + * Standard gender-number combinations to display in the table. + */ + private val STANDARD_COMBINATIONS = listOf( + GenderNumberCombination("masculine", "singular"), + GenderNumberCombination("masculine", "plural"), + GenderNumberCombination("feminine", "singular"), + GenderNumberCombination("feminine", "plural") + ) + + /** + * Data class representing a gender-number combination. + */ + data class GenderNumberCombination( + val gender: String, + val number: String + ) + + /** + * Parsed adjective variation ready for UI display. + */ + data class ParsedAdjectiveVariation( + val form: String, + val gender: String, + val number: String, + val tags: List, + val ipas: List + ) + + /** + * Result of parsing adjective variations. + */ + data class AdjectiveVariationsResult( + val variations: List, + val hasCompleteData: Boolean // Whether all 4 standard combinations are present + ) + + /** + * Check if the given language and forms data represents an adjective with variations. + * + * @param langCode Language code (e.g., "fr", "pt", "de") + * @param pos Part of speech (optional) + * @param forms List of FormData from the dictionary entry + * @return true if this appears to be an adjective with variations + */ + fun isAdjectiveWithVariations( + langCode: String, + pos: String?, + forms: List + ): Boolean { + if (langCode !in SUPPORTED_LANGUAGES || forms.isEmpty()) { + return false + } + + // Check if POS explicitly indicates adjective + val isAdjectiveByPos = pos?.equals("adjective", ignoreCase = true) == true + if (isAdjectiveByPos) { + return true + } + + // Check if forms contain gender and number tags (indicating adjective variations) + return hasGenderNumberTags(forms) + } + + /** + * Parse adjective variations from forms data. + * + * @param forms List of FormData from the dictionary entry + * @param lemma The base form of the adjective (used as fallback for missing combinations) + * @return AdjectiveVariationsResult containing parsed variations + */ + fun parseVariations( + forms: List, + lemma: String? = null + ): AdjectiveVariationsResult { + val variations = mutableListOf() + + // Parse each standard combination + STANDARD_COMBINATIONS.forEach { combination -> + val form = findFormForCombination(forms, combination) + if (form != null) { + variations.add(ParsedAdjectiveVariation( + form = form.form, + gender = combination.gender, + number = combination.number, + tags = form.tags, + ipas = form.ipas + )) + } else if (lemma != null && combination.gender == "masculine" && combination.number == "singular") { + // Use lemma as fallback for masculine singular + variations.add(ParsedAdjectiveVariation( + form = lemma, + gender = combination.gender, + number = combination.number, + tags = listOf("masculine", "singular"), + ipas = emptyList() + )) + } + } + + val hasCompleteData = variations.size == STANDARD_COMBINATIONS.size + + return AdjectiveVariationsResult( + variations = variations, + hasCompleteData = hasCompleteData + ) + } + + /** + * Get display labels for the supported languages. + */ + fun getLanguageDisplayLabels(langCode: String): Map { + return when (langCode) { + "fr" -> mapOf( + "masculine" to "Masculin", + "feminine" to "Féminin", + "singular" to "Singulier", + "plural" to "Pluriel" + ) + "pt" -> mapOf( + "masculine" to "Masculino", + "feminine" to "Feminino", + "singular" to "Singular", + "plural" to "Plural" + ) + "de" -> mapOf( + "masculine" to "Maskulin", + "feminine" to "Feminin", + "neuter" to "Neutrum", + "singular" to "Singular", + "plural" to "Plural" + ) + else -> mapOf( + "masculine" to "Masculine", + "feminine" to "Feminine", + "neuter" to "Neuter", + "singular" to "Singular", + "plural" to "Plural" + ) + } + } + + /** + * Check if forms contain gender and number tags. + */ + private fun hasGenderNumberTags(forms: List): Boolean { + return forms.any { formData -> + val tags = formData.tags.map { it.lowercase() } + val hasGender = tags.any { it in GENDER_TAGS } + val hasNumber = tags.any { it in NUMBER_TAGS } + hasGender && hasNumber + } + } + + /** + * Find the form that matches a specific gender-number combination. + */ + private fun findFormForCombination( + forms: List, + combination: GenderNumberCombination + ): FormData? { + return forms.find { formData -> + val tags = formData.tags.map { it.lowercase() } + tags.contains(combination.gender) && tags.contains(combination.number) + } + } +} diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/DictionaryJsonParser.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/DictionaryJsonParser.kt new file mode 100644 index 0000000..b0ddf96 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/DictionaryJsonParser.kt @@ -0,0 +1,439 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.grammar + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Comprehensive JSON parser for local dictionary entries. + * * Enhanced to support detailed phonetics, homophones, form variations, + * and raw tags for granular linguistic data. + * * NOW SUPPORTS: Structured Verb Objects (e.g. German "forms": { "present": [...] }) + */ +object DictionaryJsonParser { + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Parse raw JSON string into a structured DictionaryEntryData object. + */ + fun parseJson(jsonString: String): DictionaryEntryData? { + val root: JsonElement = try { + json.parseToJsonElement(jsonString) + } catch (_: Exception) { + return null + } + + val obj = root as? JsonObject ?: return null + + return DictionaryEntryData( + translations = parseTranslations(obj), + relations = parseRelations(obj), + phonetics = parsePhonetics(obj), + hyphenation = parseHyphenation(obj), + etymology = parseEtymology(obj), + senses = parseSenses(obj), + grammaticalFeatures = parseGrammaticalFeatures(obj), + grammaticalProperties = parseGrammaticalProperties(obj), + pronunciation = parsePronunciation(obj), + inflections = parseInflections(obj), + forms = parseForms(obj) // Updated to handle Object structures + ) + } + + private fun parseTranslations(obj: JsonObject): List { + val translationsElement = obj["translations"] + val array = translationsElement as? JsonArray ?: return emptyList() + + return array.mapNotNull { element -> + val o = element.jsonObject + val langCode = o["lang_code"]?.jsonPrimitive?.contentOrNull + ?: o["language_code"]?.jsonPrimitive?.contentOrNull + val word = o["word"]?.jsonPrimitive?.contentOrNull + + if (langCode.isNullOrBlank() || word.isNullOrBlank()) { + return@mapNotNull null + } + + val sense = o["sense"]?.jsonPrimitive?.contentOrNull + ?: o["sense_index"]?.jsonPrimitive?.contentOrNull + val tags = (o["tags"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + + TranslationData( + languageCode = langCode, + word = word, + sense = sense, + tags = tags + ) + } + } + + private fun parseRelations(obj: JsonObject): Map> { + val relationsElement = obj["relations"] as? JsonObject ?: return emptyMap() + + val result = mutableMapOf>() + for ((relationType, value) in relationsElement) { + val array = value as? JsonArray ?: continue + val relations = array.mapNotNull { element -> + val o = element.jsonObject + val word = o["word"]?.jsonPrimitive?.contentOrNull + if (word.isNullOrBlank()) return@mapNotNull null + + val senseIndex = o["sense_index"]?.jsonPrimitive?.contentOrNull + + // Parse raw_tags + val rawTags = (o["raw_tags"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + + RelationData( + word = word, + senseIndex = senseIndex, + rawTags = rawTags + ) + } + result[relationType] = relations + } + return result + } + + private fun parsePhonetics(obj: JsonObject): PhoneticsData? { + val phoneticsElement = obj["phonetics"] as? JsonObject ?: return null + + // standard IPA list + val ipaList = (phoneticsElement["ipa"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull?.replace(Regex("[\\[\\]]"), "") } + ?: emptyList() + + // homophones + val homophones = (phoneticsElement["homophones"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + + // IPA variations (e.g. regional pronunciations) + val variationsArray = phoneticsElement["ipa_variations"] as? JsonArray + val variations = variationsArray?.mapNotNull { element -> + val vObj = element.jsonObject + val ipa = vObj["ipa"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val rawTags = (vObj["raw_tags"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + + IpaVariationData(ipa = ipa, rawTags = rawTags) + } ?: emptyList() + + return PhoneticsData( + ipa = ipaList, + homophones = homophones, + variations = variations + ) + } + + private fun parseHyphenation(obj: JsonObject): List { + val hyphenationElement = obj["hyphenation"] as? JsonArray ?: return emptyList() + return hyphenationElement.mapNotNull { it.jsonPrimitive.contentOrNull } + } + + private fun parseEtymology(obj: JsonObject): EtymologyData { + val element = obj["etymology"] ?: return EtymologyData(texts = emptyList()) + val texts = when (element) { + is JsonArray -> element.mapNotNull { it.jsonPrimitive.contentOrNull } + is JsonObject -> { + val array = element["texts"] as? JsonArray + if (array != null) { + array.mapNotNull { it.jsonPrimitive.contentOrNull } + } else { + val singleText = element["text"]?.jsonPrimitive?.contentOrNull + if (singleText != null) listOf(singleText) else emptyList() + } + } + else -> emptyList() + } + return EtymologyData(texts = texts) + } + + private fun parseSenses(obj: JsonObject): List { + val sensesElement = obj["senses"] as? JsonArray ?: return emptyList() + + return sensesElement.mapNotNull { element -> + val senseObj = element.jsonObject + + // Handle both "gloss" (string) and "glosses" (array) formats + val glosses = when { + senseObj["glosses"] is JsonArray -> { + (senseObj["glosses"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + } + !senseObj["gloss"]?.jsonPrimitive?.contentOrNull.isNullOrBlank() -> { + listOf(senseObj["gloss"]!!.jsonPrimitive.content) + } + else -> emptyList() + } + + if (glosses.isEmpty()) return@mapNotNull null + + val topics = (senseObj["topics"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + + val examples = (senseObj["examples"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + + val tags = (senseObj["tags"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + + // Capture raw_tags + val rawTags = (senseObj["raw_tags"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + + val categories = (senseObj["categories"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + + SenseData( + glosses = glosses, + topics = topics, + examples = examples, + tags = tags, + rawTags = rawTags, + categories = categories + ) + } + } + + private fun parseGrammaticalFeatures(obj: JsonObject): GrammaticalFeaturesData? { + val featuresElement = obj["grammatical_features"] as? JsonObject ?: return null + val tags = (featuresElement["tags"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + val gender = featuresElement["gender"]?.jsonPrimitive?.contentOrNull + val number = featuresElement["number"]?.jsonPrimitive?.contentOrNull + return GrammaticalFeaturesData(tags = tags, gender = gender, number = number) + } + + private fun parseGrammaticalProperties(obj: JsonObject): GrammaticalPropertiesData? { + val propsElement = obj["grammatical_properties"] as? JsonObject ?: return null + val otherTags = (propsElement["other_tags"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + return GrammaticalPropertiesData(otherTags = otherTags) + } + + private fun parsePronunciation(obj: JsonObject): List { + val pronunciationElement = obj["pronunciation"] as? JsonArray ?: return emptyList() + return pronunciationElement.mapNotNull { element -> + val pronObj = element.jsonObject + val ipa = pronObj["ipa"]?.jsonPrimitive?.contentOrNull + val rhymes = pronObj["rhymes"]?.jsonPrimitive?.contentOrNull + if (ipa.isNullOrBlank()) return@mapNotNull null + PronunciationData(ipa = ipa, rhymes = rhymes) + } + } + + private fun parseInflections(obj: JsonObject): List { + val inflectionElement = obj["inflections"] as? JsonArray ?: return emptyList() + return inflectionElement.mapNotNull { element -> + val infObj = element.jsonObject + val form = infObj["form"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val grammaticalFeatures = (infObj["grammatical_features"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + InflectionData(form = form, grammaticalFeatures = grammaticalFeatures) + } + } + + /** + * Parse forms array from JSON. + * Handles BOTH standard Arrays and structured Objects (common in German verbs). + */ + private fun parseForms(obj: JsonObject): List { + val formsElement = obj["forms"] ?: return emptyList() + + return when (formsElement) { + is JsonArray -> parseFormsArray(formsElement) + is JsonObject -> parseFormsObject(formsElement) + else -> emptyList() + } + } + + /** + * Case 1: Standard Array of Form Objects + */ + private fun parseFormsArray(array: JsonArray): List { + return array.mapNotNull { element -> + val formObj = element.jsonObject + val form = formObj["form"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + + val tags = (formObj["tags"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + + // Capture IPAs specific to this form (often escaped like "\\ʃo\\") + val ipas = (formObj["ipas"] as? JsonArray) + ?.mapNotNull { + it.jsonPrimitive.contentOrNull?.replace("\\", "") // cleanup escapes + } + ?: emptyList() + + FormData( + form = form, + tags = tags, + ipas = ipas + ) + } + } + + /** + * Case 2: Structured Object (German Verbs in "laufen" style) + * Flattens the object keys (present, past, etc.) into FormData objects tagged with that key. + */ + private fun parseFormsObject(obj: JsonObject): List { + val result = mutableListOf() + + // 1. Handle Array Tenses (e.g. "present": ["laufe", "lÀufst"...]) + val tenseKeys = listOf( + "present", "past", "future", + "subjunctive_i", "subjunctive_ii", + "imperative", "conditional" + ) + + tenseKeys.forEach { key -> + val array = obj[key] as? JsonArray + array?.forEach { formElement -> + val form = formElement.jsonPrimitive.contentOrNull + if (!form.isNullOrBlank()) { + // We tag it with the key (e.g., "present") so the Parser can find it. + // The UnifiedMorphologyParser relies on list order for persons (1st->3rd), + // so we simply add them in order. + result.add(FormData( + form = form, + tags = listOf(key), + ipas = emptyList() + )) + } + } + } + + // 2. Handle Single String Properties (e.g. "auxiliary": "sein") + val singleKeys = listOf("auxiliary", "infinitive", "participle_perfect", "participle_present") + + singleKeys.forEach { key -> + val value = obj[key]?.jsonPrimitive?.contentOrNull + if (!value.isNullOrBlank()) { + result.add(FormData( + form = value, + tags = listOf(key), + ipas = emptyList() + )) + } + } + + return result + } +} + +// --------------------------------------------------------------------------- +// Data Classes +// --------------------------------------------------------------------------- + +data class DictionaryEntryData( + val translations: List, + val relations: Map>, + val phonetics: PhoneticsData?, + val hyphenation: List, + val etymology: EtymologyData, + val senses: List, + val grammaticalFeatures: GrammaticalFeaturesData?, + val grammaticalProperties: GrammaticalPropertiesData?, + val pronunciation: List, + val inflections: List, + val forms: List +) { + val synonyms: List + get() = relations["synonyms"] ?: emptyList() + + val hyponyms: List + get() = relations["hyponyms"] ?: emptyList() + + val allRelatedWords: List + get() = relations.values.flatten() + + val allTags: List + get() = (grammaticalFeatures?.tags.orEmpty() + grammaticalProperties?.otherTags.orEmpty()).distinct() + +} + +data class TranslationData( + val languageCode: String, + val word: String, + val sense: String?, + val tags: List +) + +data class RelationData( + val word: String, + val senseIndex: String?, + val rawTags: List = emptyList() +) + +data class EtymologyData( + val texts: List +) + +data class SenseData( + val glosses: List, + val topics: List = emptyList(), + val examples: List = emptyList(), + val tags: List = emptyList(), + val rawTags: List = emptyList(), + val categories: List = emptyList() +) { + val gloss: String + get() = glosses.firstOrNull() ?: "" +} + +data class GrammaticalPropertiesData( + val otherTags: List +) + +data class GrammaticalFeaturesData( + val tags: List, + val gender: String? = null, + val number: String? = null +) + +data class PronunciationData( + val ipa: String, + val rhymes: String? +) + +data class PhoneticsData( + val ipa: List, + val homophones: List, + val variations: List +) + +data class IpaVariationData( + val ipa: String, + val rawTags: List +) + +data class InflectionData( + val form: String, + val grammaticalFeatures: List +) + +data class FormData( + val form: String, + val tags: List, + val ipas: List +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/GrammarConstants.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/GrammarConstants.kt new file mode 100644 index 0000000..33743fc --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/GrammarConstants.kt @@ -0,0 +1,53 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.grammar + +object GrammarConstants { + // POS Types + const val POS_NOUN = "noun" + const val POS_VERB = "verb" + const val POS_ADJ = "adjective" + + // German Specific Types + const val TYPE_DE_VERB = "de_verb" + const val TYPE_DE_ADJ = "de_adj" + + // Tense/Mood Keys + const val PRESENT = "present" + const val PAST = "past" + const val IMPERFECT = "imperfect" + const val FUTURE = "future" + const val SUBJUNCTIVE_I = "subjunctive_i" + const val SUBJUNCTIVE_II = "subjunctive_ii" + const val IMPERATIVE = "imperative" + const val CONDITIONAL = "conditional" + const val INFINITIVE = "infinitive" + const val PARTICIPLE_PAST = "participle_past" + const val PARTICIPLE_PRESENT = "participle_present" + + // French Specific Keys + const val INDICATIVE_PRESENT = "indicative_present" + const val INDICATIVE_IMPERFECT = "indicative_imperfect" + const val INDICATIVE_SIMPLE_PAST = "indicative_simple_past" + const val INDICATIVE_FUTURE = "indicative_future" + const val SUBJUNCTIVE_PRESENT = "subjunctive_present" + const val CONDITIONAL_PRESENT = "conditional_present" + + // Cases + const val NOMINATIVE = "nominative" + const val GENITIVE = "genitive" + const val DATIVE = "dative" + const val ACCUSATIVE = "accusative" + + // Numbers + const val SINGULAR = "singular" + const val PLURAL = "plural" + + // JSON Keys + const val KEY_FORMS = "forms" + const val KEY_INFLECTIONS = "inflections" + const val KEY_TYPE = "type" + const val KEY_DATA = "data" + const val KEY_TAGS = "tags" + const val KEY_FORM = "form" +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/GrammaticalFeature.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/GrammaticalFeature.kt new file mode 100644 index 0000000..f78ae9e --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/GrammaticalFeature.kt @@ -0,0 +1,31 @@ +package eu.gaudian.translator.model.grammar + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * A container holding the grammatical features for BOTH words in a VocabularyItem. + * This entire object is serialized into the single `features` string. + */ +@Serializable +@Parcelize +data class VocabularyFeatures( + val first: GrammaticalFeature? = null, + val second: GrammaticalFeature? = null +) : Parcelable + +/** + * A generic, serializable container for the grammatical information of a SINGLE word. + * + * @param category The type of word, e.g., "noun", "verb". This key corresponds + * to a category in the language configuration JSON. + * @param properties A flexible map to hold any language-specific properties, + * e.g., "gender" -> "masculine", "plural" -> "die MÀnner". + */ +@Serializable +@Parcelize +data class GrammaticalFeature( + val category: String, + val properties: Map +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/HowItWorks.txt b/app/src/main/java/eu/gaudian/translator/model/grammar/HowItWorks.txt new file mode 100644 index 0000000..7fac2c8 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/HowItWorks.txt @@ -0,0 +1,50 @@ +Unified Morphology Architecture & Data Standard1. System OverviewThe app uses a Declarative, Config-Driven Architecture to parse and display grammatical forms (morphology). Instead of writing language-specific code (e.g., GermanNounParser, FrenchVerbParser), the system uses a single generic engine driven by JSON configuration.The Engine (Kotlin): UnifiedMorphologyParser knows how to build data structures (Grids, Lists, Verb Paradigms) but not what to build.The Blueprint (JSON Config): Language files (e.g., fr.json, de.json) define the dimensions (Rows/Columns for grids, Tenses for verbs).The Data (Dictionary JSON): The raw word forms tagged with grammatical features.2. Configuration Standard (LanguageConfig)The configuration file defines the "rules" for the parser. It tells the engine which strategy to use for a given Part of Speech (POS).A. The Grid Strategy (Nouns & Adjectives)Used for any 2D data structure (Case × Number, Gender × Number).Requirement: The category object MUST contain declension_display.Example (fr.json - Adjectives):"adjective": { +"declension_display": { +"cases_order": ["masculine", "feminine"], // <--- Defined ROWS +"numbers_order": ["singular", "plural"] // <--- Defined COLUMNS +} +} +Note: The keys cases_order and numbers_order are generic. For adjectives, they conceptually represent "Genders" and "Numbers".B. The Verb Strategy (Conjugations)Used for verb paradigms.Requirement: The category object MUST contain conjugation_display.Example (de.json - Verbs):"verb": { +"conjugation_display": { +"pronouns": ["ich", "du", "er/sie/es", "wir", "ihr", "sie"], // Required for mapping ordered forms +"tense_labels": { +"present": "PrÀsens", // Internal Tag -> Display Label +"past": "PrÀteritum", +"subjunctive_i": "Konjunktiv I" +} +} +} +3. Data Input Standard (DictionaryEntryData)The dictionary entry provides the raw forms. The parser supports two input formats.Format A: The Standard List (Recommended)A flat list of objects. Each object contains the form string and a list of tags.Constraint: The tags values MUST match the keys defined in the Configuration (e.g., "masculine", "present")."forms": [ + { "form": "beau", "tags": ["masculine", "singular"] }, + { "form": "belle", "tags": ["feminine", "singular"] }, + { "form": "beaux", "tags": ["masculine", "plural"] }, + { "form": "belles", "tags": ["feminine", "plural"] } + ] + Format B: The Structured Object (Legacy/German Verbs)Some entries group forms by tense keys instead of tags.Parser Behavior: The DictionaryJsonParser detects this object and flattens it into Format A automatically.Mapping: Keys like present become tags. Arrays are preserved in order.// Input (Dictionary JSON) + "forms": { + "present": ["laufe", "lÀufst", "lÀuft", "laufen", "lauft", "laufen"], + "auxiliary": "sein" + } + +// Internal Representation after Flattening: +// [ +// { "form": "laufe", "tags": ["present"] }, +// { "form": "lÀufst", "tags": ["present"] }, +// ... +// { "form": "sein", "tags": ["auxiliary"] } +// ] +4. The Parsing AlgorithmThe UnifiedMorphologyParser follows this specific decision tree:Step 1: NormalizationInputs: Lemma (e.g., "beau"), POS (e.g., "adj"), Config.Normalize POS: Maps short tags to standard keys:adj, adjectif -> adjectivenom, substantive -> nounverbe -> verbAbort: If forms list is empty.Step 2: Rule SelectionCheck JSON Config: Does config.categories[pos] exist?Yes: Does it have declension_display? -> Build Grid Rule.Yes: Does it have conjugation_display? -> Build Verb Rule.Fallback: If JSON config is missing, check MorphologyRegistry.kt for a hardcoded default rule (e.g., generic fallback).Step 3: Execution (Grid Strategy)Applies to Nouns and Adjectives.Index Forms: Creates a fast lookup map: Set -> Form.Iterate: Loops through row_tags (from Config) × col_tags (from Config).Match: For cell [row, col], looks for a form containing BOTH tags.Example: Looking for form with tags ["masculine", "plural"].Fallback (The Lemma Rule): If a match is not found:Adjective Special Case: If the cell being built is masculine + singular, the parser defaults to using the Lemma (Headword). This handles dictionary data that omits the base form.Output: Returns UnifiedMorphology.Grid (Rendered as a table).Step 4: Execution (Verb Strategy)Applies to Verbs.Iterate: Loops through tense_labels keys (from Config).Filter: Finds all forms tagged with that tense key (e.g., "present").Truncate: Takes the first $N$ forms, where $N$ is the length of the pronouns list in Config.Auxiliary: Scans for a form tagged auxiliary.Output: Returns UnifiedMorphology.Verb (Rendered as conjugation lists).5. Validating Your JSONTo ensure your JSON renders correctly in the UI, check these constraints:ComponentConstraintWhy?Configcases_order must be non-null for Nouns/Adjectives.Without row definitions, the grid cannot be built.Configtense_labels keys must match Dictionary tags."present" in config must match "present" tag in data.DataTags must be lowercase in forms.The parser normalizes tags to lowercase, but consistency prevents bugs.DataVerb arrays must be ordered by person (1st->3rd).The parser maps forms to pronouns by index position (0=I, 1=You, etc.).Example: A Valid Adjective Setuppt.json (Config):"adjective": { + "declension_display": { + "cases_order": ["masculine", "feminine"], + "numbers_order": ["singular", "plural"] + } + } + Dictionary Entry (Data):"word": "lindo", + "forms": [ + // Note: "lindo" (masc/sing) is MISSING here. + // The Parser will automatically insert the word "lindo" into the [masculine, singular] cell. + { "form": "linda", "tags": ["feminine", "singular"] }, + { "form": "lindos", "tags": ["masculine", "plural"] }, + { "form": "lindas", "tags": ["feminine", "plural"] } + ] + Resulting UI:| | Singular | Plural || :--- | :--- | :--- || Masculine | lindo (Lemma) | lindos || Feminine | linda | lindas | \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/LanguageConfigModels.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/LanguageConfigModels.kt new file mode 100644 index 0000000..3811de7 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/LanguageConfigModels.kt @@ -0,0 +1,60 @@ +@file:Suppress("PropertyName") + +package eu.gaudian.translator.model.grammar + +import kotlinx.serialization.Serializable + +/** + * Represents the entire language configuration file (e.g., de.json). + * + * @param language_code The ISO code for the language (e.g., "de"). + * @param articles A list of articles for the language (e.g., "der", "die"). This is optional. + * @param categories A map where the key is the category name (e.g., "noun") + * and the value contains the details for that category. + */ +@Serializable +data class LanguageConfig( + val language_code: String, + val articles: List? = null, + val categories: Map +) + +@Serializable +data class CategoryConfig( + @Suppress("PropertyName") val display_key: String, + val fields: List, + val formatter: String? = null, + val mappings: Map>? = null, + /** Optional configuration for how verb conjugations should be displayed. */ + val conjugation_display: VerbConjugationDisplayConfig? = null, + /** Optional configuration for how noun declension tables should be displayed. */ + val declension_display: NounDeclensionDisplayConfig? = null +) + +@Serializable +data class VerbConjugationDisplayConfig( + /** Default pronoun order for this language's verb tables. */ + val pronouns: List? = null, + /** Mapping from internal tense keys (e.g., "present") to display labels (e.g., "PrÀsens"). */ + val tense_labels: Map? = null +) + +@Serializable +data class NounDeclensionDisplayConfig( + /** Ordered list of case keys to display as rows. */ + val cases_order: List? = null, + /** Mapping from case keys to display labels (e.g., "nominative" -> "Nom."). */ + val case_labels: Map? = null, + /** Ordered list of number keys to display as columns (e.g., ["singular", "plural"]). */ + val numbers_order: List? = null, + /** Mapping from number keys to display labels (e.g., "singular" -> "Sing."). */ + val number_labels: Map? = null +) + +@Serializable +data class FieldConfig( + val key: String, + val display_key: String, + val type: String, + val options: List? = null +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/LocalDictionaryMorphologyMapper.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/LocalDictionaryMorphologyMapper.kt new file mode 100644 index 0000000..67ef23e --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/LocalDictionaryMorphologyMapper.kt @@ -0,0 +1,111 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.grammar + +import eu.gaudian.translator.utils.Log +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Maps raw local dictionary JSON into grammar models used by the UI. + * + * This implementation uses the Strategy Pattern to delegate parsing logic + * based on the language code. + */ +object LocalDictionaryMorphologyMapper { + + private val strategies = mapOf( + "de" to GermanMorphologyStrategy(), + "fr" to FrenchMorphologyStrategy() + ) + + private val genericStrategy = GenericMorphologyStrategy() + + /** + * Parse morphology information (conjugation/declension/inflections) from a + * local dictionary entry. + * + * @param langCode ISO language code, e.g. "de". + * @param pos Part of speech as stored with the entry, if available. + * @param lemma The base form / headword, used as infinitive for verbs. + * @param data Root JSON object of the dictionary entry. + * @param languageConfig Optional language configuration, used for display + * metadata like pronoun order and tense/case labels when available. + */ + fun parseMorphology( + langCode: String, + pos: String?, + lemma: String, + data: JsonObject, + languageConfig: LanguageConfig? = null + ): WordMorphology? { + Log.d("LocalDictionaryMorphologyMapper", "Parsing morphology for $langCode, word: $lemma") + + // 1. Try specific language strategy + val specificStrategy = strategies[langCode] + if (specificStrategy != null) { + val result = specificStrategy.parse(lemma, pos, data, languageConfig) + if (result != null) return result + } + + // 2. Fallback to generic strategy (valid for any language with 'inflections' array) + return genericStrategy.parse(lemma, pos, data, languageConfig) + } + + /** + * Overload that uses parsed [DictionaryEntryData] instead of raw JSON. + */ + fun parseMorphology( + langCode: String, + pos: String?, + lemma: String, + entryData: DictionaryEntryData, + languageConfig: LanguageConfig? = null + ): WordMorphology? { + Log.d("LocalDictionaryMorphologyMapper", "Parsing morphology (structured) for $langCode, word: $lemma") + + // 1. Handle adjective forms for applicable languages (check this first) + Log.d("LocalDictionaryMorphologyMapper", "Checking adjective: langCode=$langCode, pos=$pos, formsCount=${entryData.forms.size}") + + if (AdjectiveVariationsParser.isAdjectiveWithVariations(langCode, pos, entryData.forms)) { + Log.d("LocalDictionaryMorphologyMapper", "Creating adjective morphology for $lemma") + val comparison = AdjectiveComparison() // Empty comparison, forms will be accessed directly from entryData + return WordMorphology.Adjective(comparison) + } + + // 2. Try specific language strategy if 'forms' data is available + val specificStrategy = strategies[langCode] + // Check isNotEmpty() because forms is now a List, not nullable JsonElement + if (specificStrategy != null && entryData.forms.isNotEmpty()) { + + // Reconstruct the JsonArray for forms because the strategy expects raw JSON structures + val formsArray = JsonArray(entryData.forms.map { formData -> + JsonObject( + mapOf( + "form" to JsonPrimitive(formData.form), + "tags" to JsonArray(formData.tags.map { JsonPrimitive(it) }), + "ipas" to JsonArray(formData.ipas.map { JsonPrimitive(it) }) + ) + ) + }) + + // Construct a minimal JsonObject for the strategy which expects "forms" key + val syntheticData = JsonObject(mapOf("forms" to formsArray)) + + val result = specificStrategy.parse(lemma, pos, syntheticData, languageConfig) + if (result != null) return result + } + + // 3. Fallback to generic strategy using parsed inflections + if (entryData.inflections.isNotEmpty()) { + val inflections = entryData.inflections.map { + Inflection(it.form, it.grammaticalFeatures) + } + return WordMorphology.GenericInflections(inflections) + } + + return null + } +} + diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyModels.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyModels.kt new file mode 100644 index 0000000..5a1d485 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyModels.kt @@ -0,0 +1,65 @@ +package eu.gaudian.translator.model.grammar + +import kotlinx.serialization.Serializable + +/** + * Generic morphology models derived from local dictionary data. + * + * These models are UI-agnostic and can be rendered by different screens + * (dictionary results, vocabulary cards, etc.). + */ + +/** + * Conjugation data for a single verb. + * + * @param infinitive The base form of the verb. + * @param conjugations Map from an internal or display tense key to the list of + * forms for each person (same order as [pronouns]). + * @param pronouns List of pronouns corresponding to the persons in [conjugations] values. + * @param auxiliary Optional auxiliary verb used by this verb (e.g., "haben/sein"). + */ +@Serializable +data class VerbConjugation( + val infinitive: String, + val conjugations: Map>, // tense -> list of forms + val pronouns: List = emptyList(), + val auxiliary: String? = null +) + +/** + * Declension table for noun-like words. + * + * @param cases Ordered list of case keys (e.g., "nominative", "genitive", ...). + * @param numbers Ordered list of number keys (e.g., "singular", "plural"). + * @param forms Mapping from (case, number) pair to the inflected form. + */ +@Serializable +data class NounDeclensionTable( + val cases: List, + val numbers: List, + val forms: Map, String> +) + +/** + * Comparison degrees for adjectives. + */ +@Serializable +data class AdjectiveComparison( + val positive: String? = null, + val comparative: String? = null, + val superlative: String? = null +) + +/** + * High-level morphology classification for a word. + */ +sealed class WordMorphology { + data class Verb(val conjugations: List) : WordMorphology() + data class Noun(val declension: NounDeclensionTable) : WordMorphology() + data class Adjective(val comparison: AdjectiveComparison) : WordMorphology() + + /** + * Generic inflections as provided by some dictionaries (e.g. Portuguese). + */ + data class GenericInflections(val inflections: List) : WordMorphology() +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRegistry.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRegistry.kt new file mode 100644 index 0000000..49be4c2 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRegistry.kt @@ -0,0 +1,90 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.grammar + +import eu.gaudian.translator.model.grammar.GrammarConstants.POS_ADJ +import eu.gaudian.translator.model.grammar.GrammarConstants.POS_NOUN +import eu.gaudian.translator.model.grammar.GrammarConstants.POS_VERB +import eu.gaudian.translator.model.grammar.MorphologyRule.GenericRule +import eu.gaudian.translator.model.grammar.MorphologyRule.GridRule +import eu.gaudian.translator.model.grammar.MorphologyRule.VerbRule + +object MorphologyRegistry { + + /** + * Retrieves the specific rule for a Language and POS. + * Defaults to GenericRule if no specific rule is defined. + */ + fun getRule(langCode: String, pos: String?): MorphologyRule { + val normalizedPos = pos?.lowercase()?.trim() ?: return GenericRule + + // 1. Look for specific match (e.g., "de" + "noun") + return rules["$langCode|$normalizedPos"] + // 2. Look for language default (if we had one) or fallback + ?: GenericRule + } + + // ========================================================================= + // CONFIGURATION AREA - DEFINE YOUR RULES HERE + // ========================================================================= + + private val rules = mapOf( + // --- GERMAN (DE) ----------------------------------------------------- + "de|$POS_NOUN" to GridRule( + title = "Deklination", + rowTags = listOf("nominative", "genitive", "dative", "accusative"), + colTags = listOf("singular", "plural") + ), + "de|$POS_ADJ" to GridRule( + title = "Adjektivendungen", + rowTags = listOf("masculine", "feminine", "neuter"), + colTags = listOf("singular", "plural"), + fallbackStrategy = { row, col, lemma -> + // Fallback: Use lemma for Masc+Sing if missing (common in some dicts) + if (row == "masculine" && col == "singular") lemma else null + } + ), + "de|$POS_VERB" to VerbRule( + pronouns = listOf("ich", "du", "er/sie/es", "wir", "ihr", "sie"), + tenses = mapOf( + "present" to "PrÀsens", + "past" to "PrÀteritum", + "subjunctive_i" to "Konjunktiv I", + "subjunctive_ii" to "Konjunktiv II", + "imperative" to "Imperativ" + ) + ), + + // --- FRENCH (FR) ----------------------------------------------------- + "fr|$POS_NOUN" to GridRule( + title = "Nombre", + rowTags = listOf("singular", "plural"), + colTags = listOf("") // Single column (just list rows) + ), + "fr|$POS_ADJ" to GridRule( + title = "Accord", + rowTags = listOf("masculine", "feminine"), + colTags = listOf("singular", "plural") + ), + "fr|$POS_VERB" to VerbRule( + pronouns = listOf("je", "tu", "il/elle", "nous", "vous", "ils/elles"), + tenses = mapOf( + "indicative_present" to "Présent", + "indicative_imperfect" to "Imparfait", + "indicative_future" to "Futur", + "subjunctive_present" to "Subjonctif", + "conditional_present" to "Conditionnel" + ) + ), + + // --- PORTUGUESE (PT) Example ----------------------------------------- + "pt|$POS_VERB" to VerbRule( + pronouns = listOf("eu", "tu", "ele/ela", "nós", "vós", "eles/elas"), + tenses = mapOf( + "present" to "Presente", + "imperfect" to "Imperfeito", + "perfect" to "Perfeito" + ) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRules.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRules.kt new file mode 100644 index 0000000..910221f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyRules.kt @@ -0,0 +1,36 @@ +package eu.gaudian.translator.model.grammar + +/** + * Base class for all morphology rules. + */ +sealed class MorphologyRule { + + /** + * RULE: Extract a 2D Grid (e.g., German Nouns). + * @param rowTags The tags defining the rows (e.g., Nominative, Genitive). + * @param colTags The tags defining the columns (e.g., Singular, Plural). + * @param title Display title for the table. + * @param fallbackStrategy Optional: If cell is missing, how to generate it (e.g., use Lemma). + */ + data class GridRule( + val title: String, + val rowTags: List, + val colTags: List, + val fallbackStrategy: ((row: String, col: String, lemma: String) -> String?)? = null + ) : MorphologyRule() + + /** + * RULE: Extract a Verb Paradigm. + * @param tenses Map of "Internal Tag" -> "Display Label". + * @param pronouns List of pronouns to display. + */ + data class VerbRule( + val tenses: Map, + val pronouns: List + ) : MorphologyRule() + + /** + * RULE: Just show everything generic. + */ + object GenericRule : MorphologyRule() +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyStrategies.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyStrategies.kt new file mode 100644 index 0000000..fb43b08 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/MorphologyStrategies.kt @@ -0,0 +1,269 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.grammar + +import eu.gaudian.translator.model.grammar.GrammarConstants.CONDITIONAL_PRESENT +import eu.gaudian.translator.model.grammar.GrammarConstants.IMPERATIVE +import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_FUTURE +import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_IMPERFECT +import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_PRESENT +import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_SIMPLE_PAST +import eu.gaudian.translator.model.grammar.GrammarConstants.INFINITIVE +import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_DATA +import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_FORMS +import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_INFLECTIONS +import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_TYPE +import eu.gaudian.translator.model.grammar.GrammarConstants.PARTICIPLE_PAST +import eu.gaudian.translator.model.grammar.GrammarConstants.PARTICIPLE_PRESENT +import eu.gaudian.translator.model.grammar.GrammarConstants.PAST +import eu.gaudian.translator.model.grammar.GrammarConstants.POS_NOUN +import eu.gaudian.translator.model.grammar.GrammarConstants.POS_VERB +import eu.gaudian.translator.model.grammar.GrammarConstants.PRESENT +import eu.gaudian.translator.model.grammar.GrammarConstants.SUBJUNCTIVE_I +import eu.gaudian.translator.model.grammar.GrammarConstants.SUBJUNCTIVE_II +import eu.gaudian.translator.model.grammar.GrammarConstants.SUBJUNCTIVE_PRESENT +import eu.gaudian.translator.model.grammar.GrammarConstants.TYPE_DE_ADJ +import eu.gaudian.translator.model.grammar.GrammarConstants.TYPE_DE_VERB +import eu.gaudian.translator.model.grammar.WordMorphology.Adjective +import eu.gaudian.translator.model.grammar.WordMorphology.GenericInflections +import eu.gaudian.translator.model.grammar.WordMorphology.Noun +import eu.gaudian.translator.model.grammar.WordMorphology.Verb +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Interface for language-specific morphology parsing. + */ +interface MorphologyStrategy { + fun parse( + lemma: String, + pos: String?, + rootData: JsonObject, + config: LanguageConfig? + ): WordMorphology? +} + +/** + * Strategy for German (de). + */ +class GermanMorphologyStrategy : MorphologyStrategy { + override fun parse( + lemma: String, + pos: String?, + rootData: JsonObject, + config: LanguageConfig? + ): WordMorphology? { + val formsElement = rootData[KEY_FORMS] ?: return null + val categories = config?.categories + val normalizedPos = pos?.lowercase()?.trim() + + return when (formsElement) { + is JsonObject -> { + val type = formsElement[KEY_TYPE]?.jsonPrimitive?.contentOrNull + val innerData = formsElement[KEY_DATA]?.jsonObject + + when (type) { + TYPE_DE_VERB if innerData != null -> { + val verbConfig = categories?.get(POS_VERB) + val conjugation = mapJsonToVerbConjugation(lemma, innerData, verbConfig) + Verb(listOf(conjugation)) + } + TYPE_DE_ADJ if innerData != null -> { + val comparison = parseAdjectiveComparison(innerData) + Adjective(comparison) + } + else -> { + null + } + } + } + is JsonArray -> { + if (formsElement.isEmpty()) return null + + if (SharedMorphologyUtils.isNounLikeStructure(formsElement, normalizedPos)) { + val nounConfig = categories?.get(POS_NOUN) + val declension = SharedMorphologyUtils.parseNounDeclension(formsElement, nounConfig) + return Noun(declension) + } + + // Handle array of verb blocks + val verbBlocks = formsElement.mapNotNull { element -> + val obj = element.jsonObject + val type = obj[KEY_TYPE]?.jsonPrimitive?.contentOrNull + val innerData = obj[KEY_DATA]?.jsonObject + if (innerData != null && (type == TYPE_DE_VERB || obj.containsKey(KEY_DATA))) innerData else null + } + + if (verbBlocks.isNotEmpty()) { + val verbConfig = categories?.get(POS_VERB) + val conjugations = verbBlocks.map { innerData -> + mapJsonToVerbConjugation(lemma, innerData, verbConfig) + } + return Verb(conjugations) + } + null + } + else -> null + } + } + + private fun mapJsonToVerbConjugation( + infinitive: String, + data: JsonObject, + categoryConfig: CategoryConfig? + ): VerbConjugation { + fun getForms(key: String): List = + data[key]?.jsonArray?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList() + + val conjugationMap = mutableMapOf>() + val tenseLabels = categoryConfig?.conjugation_display?.tense_labels + + fun addTense(key: String) { + val forms = getForms(key) + if (forms.isNotEmpty()) { + val label = tenseLabels?.get(key) ?: key.replaceFirstChar { it.titlecase() } + conjugationMap[label] = forms + } + } + + addTense(PRESENT) + addTense(PAST) + addTense(SUBJUNCTIVE_I) + addTense(SUBJUNCTIVE_II) + + val pronouns = categoryConfig?.conjugation_display?.pronouns + ?: listOf("ich", "du", "er/sie/es", "wir", "ihr", "sie") + + val aux = data["aux"]?.jsonPrimitive?.contentOrNull + + return VerbConjugation(infinitive, conjugationMap, pronouns, aux) + } + + private fun parseAdjectiveComparison(data: JsonObject): AdjectiveComparison { + return AdjectiveComparison( + positive = data["positive_masculine"]?.jsonPrimitive?.contentOrNull + ?: data["positive_feminine"]?.jsonPrimitive?.contentOrNull, + comparative = data["comparative"]?.jsonPrimitive?.contentOrNull, + superlative = data["superlative"]?.jsonPrimitive?.contentOrNull + ) + } +} + +/** + * Strategy for French (fr). + */ +class FrenchMorphologyStrategy : MorphologyStrategy { + override fun parse( + lemma: String, + pos: String?, + rootData: JsonObject, + config: LanguageConfig? + ): WordMorphology? { + val formsElement = rootData[KEY_FORMS] ?: return null + val categories = config?.categories + val normalizedPos = pos?.lowercase()?.trim() + + return when (formsElement) { + is JsonObject -> { + // Heuristic: Check for French verb keys + val hasVerbForms = formsElement.keys.any { key -> + key.startsWith("indicative_") || key.startsWith("subjunctive_") || + key.startsWith(IMPERATIVE) || key.startsWith("conditional_") || + key == INFINITIVE || key == PARTICIPLE_PRESENT || key == PARTICIPLE_PAST + } + + if (hasVerbForms) { + val verbConfig = categories?.get(POS_VERB) + val conjugation = mapFrenchJsonToVerbConjugation(lemma, formsElement, verbConfig) + Verb(listOf(conjugation)) + } else null + } + is JsonArray -> { + if (SharedMorphologyUtils.isNounLikeStructure(formsElement, normalizedPos)) { + val nounConfig = categories?.get(POS_NOUN) + val declension = SharedMorphologyUtils.parseNounDeclension(formsElement, nounConfig) + return Noun(declension) + } + null + } + else -> null + } + } + + private fun mapFrenchJsonToVerbConjugation( + infinitive: String, + data: JsonObject, + categoryConfig: CategoryConfig? + ): VerbConjugation { + fun getForms(key: String): List = + data[key]?.jsonArray?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList() + + val conjugationMap = mutableMapOf>() + val tenseLabels = categoryConfig?.conjugation_display?.tense_labels + + fun addFrenchTense(frenchKey: String, displayKey: String) { + val forms = getForms(frenchKey) + if (forms.isNotEmpty()) { + val label = tenseLabels?.get(frenchKey) ?: displayKey + conjugationMap[label] = forms + } + } + + addFrenchTense(INDICATIVE_PRESENT, "Indicative Present") + addFrenchTense(INDICATIVE_IMPERFECT, "Indicative Imperfect") + addFrenchTense(INDICATIVE_SIMPLE_PAST, "Indicative Simple Past") + addFrenchTense(INDICATIVE_FUTURE, "Indicative Future") + addFrenchTense(SUBJUNCTIVE_PRESENT, "Subjunctive Present") + addFrenchTense(CONDITIONAL_PRESENT, "Conditional Present") + addFrenchTense(IMPERATIVE, "Imperative") + + val pronouns = categoryConfig?.conjugation_display?.pronouns + ?: listOf("je", "tu", "il/elle", "nous", "vous", "ils/elles") + + // French data sometimes puts aux in an array + val aux = data["auxiliary"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.contentOrNull + ?: data["aux"]?.jsonPrimitive?.contentOrNull + + return VerbConjugation(infinitive, conjugationMap, pronouns, aux) + } +} + +/** + * Fallback Strategy (Portuguese, Generic, etc.). + * Handles flat list of inflections. + */ +class GenericMorphologyStrategy : MorphologyStrategy { + override fun parse( + lemma: String, + pos: String?, + rootData: JsonObject, + config: LanguageConfig? + ): WordMorphology? { + val inflectionsElement = rootData[KEY_INFLECTIONS] as? JsonArray + + if (!inflectionsElement.isNullOrEmpty()) { + val inflections = inflectionsElement.mapNotNull { element -> + val obj = element.jsonObject + val form = obj["form"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + + val features = (obj["grammatical_features"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + + // FIX: Use 'tags' instead of 'grammatical_features' + // FIX: Handle nullability with '?: emptyList()' + Inflection(form = form, tags = features ?: emptyList()) + } + + // Note: Ensure GenericInflections accepts List if you haven't updated it yet + if (inflections.isNotEmpty()) { + return GenericInflections(inflections) + } + } + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/SharedMorphologyUtils.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/SharedMorphologyUtils.kt new file mode 100644 index 0000000..2cb9fc4 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/SharedMorphologyUtils.kt @@ -0,0 +1,80 @@ +package eu.gaudian.translator.model.grammar + +import eu.gaudian.translator.model.grammar.GrammarConstants.ACCUSATIVE +import eu.gaudian.translator.model.grammar.GrammarConstants.DATIVE +import eu.gaudian.translator.model.grammar.GrammarConstants.GENITIVE +import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_FORM +import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_TAGS +import eu.gaudian.translator.model.grammar.GrammarConstants.NOMINATIVE +import eu.gaudian.translator.model.grammar.GrammarConstants.PLURAL +import eu.gaudian.translator.model.grammar.GrammarConstants.SINGULAR +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object SharedMorphologyUtils { + + /** + * Parses a standard noun declension table from a list of forms. + * Used by both German and French strategies. + */ + fun parseNounDeclension( + forms: JsonArray, + categoryConfig: CategoryConfig? + ): NounDeclensionTable { + val nestedMap = mutableMapOf>() + + forms.forEach { item -> + val obj = item.jsonObject + val form = obj[KEY_FORM]?.jsonPrimitive?.contentOrNull ?: return@forEach + val tags = obj[KEY_TAGS]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + + val case = tags.firstOrNull { it in listOf(NOMINATIVE, GENITIVE, DATIVE, ACCUSATIVE) } + val number = tags.firstOrNull { it in listOf(SINGULAR, PLURAL) } + + if (case != null && number != null) { + nestedMap.getOrPut(case) { mutableMapOf() }[number] = form + } + } + + val displayConfig = categoryConfig?.declension_display + + val cases = displayConfig?.cases_order ?: listOf(NOMINATIVE, GENITIVE, DATIVE, ACCUSATIVE) + val numbers = displayConfig?.numbers_order ?: listOf(SINGULAR, PLURAL) + + val flatMap = mutableMapOf, String>() + cases.forEach { caseKey -> + numbers.forEach { numberKey -> + val form = nestedMap[caseKey]?.get(numberKey) + if (form != null) { + flatMap[caseKey to numberKey] = form + } + } + } + + return NounDeclensionTable( + cases = cases, + numbers = numbers, + forms = flatMap + ) + } + + /** + * Checks if a JSON array contains noun-like structures (Case + Number). + */ + fun isNounLikeStructure(forms: JsonArray, normalizedPos: String?): Boolean { + if (forms.isEmpty()) return false + + val hasCaseAndNumber = forms.any { formElement -> + val formObj = formElement.jsonObject + val tags = formObj[KEY_TAGS]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + val hasCase = tags.any { it in listOf(NOMINATIVE, GENITIVE, DATIVE, ACCUSATIVE) } + val hasNumber = tags.any { it in listOf(SINGULAR, PLURAL) } + hasCase && hasNumber + } + + return hasCaseAndNumber && (normalizedPos == null || normalizedPos.startsWith(GrammarConstants.POS_NOUN)) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedGrammarModels.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedGrammarModels.kt new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphology.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphology.kt new file mode 100644 index 0000000..ab6ac1f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphology.kt @@ -0,0 +1,43 @@ +package eu.gaudian.translator.model.grammar + +import kotlinx.serialization.Serializable + +@Serializable +sealed class UnifiedMorphology { + + /** + * 2D Grid for Nouns (Case x Number) and Adjectives (Gender x Number). + */ + data class Grid( + val title: String, + val rowLabels: List, + val colLabels: List, + val cells: Map + ) : UnifiedMorphology() + + /** + * Verb Paradigm (Flattened - no intermediate 'paradigm' object). + */ + data class Verb( + val infinitive: String, + val auxiliary: String?, + val tenses: Map>, + val pronouns: List + ) : UnifiedMorphology() + + /** + * Generic List. Now holds 'Inflection' objects to keep tags visible. + */ + data class ListForms( + val forms: List + ) : UnifiedMorphology() +} + +/** + * Moved here to be shared. Represents a single form with its tags. + */ +@Serializable +data class Inflection( + val form: String, + val tags: List +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphologyParser.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphologyParser.kt new file mode 100644 index 0000000..c3372e2 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/UnifiedMorphologyParser.kt @@ -0,0 +1,210 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.grammar + +import android.content.Context +import eu.gaudian.translator.R +import eu.gaudian.translator.model.grammar.GrammarConstants.POS_ADJ +import eu.gaudian.translator.model.grammar.GrammarConstants.POS_NOUN +import eu.gaudian.translator.model.grammar.GrammarConstants.POS_VERB +import eu.gaudian.translator.utils.Log + +object UnifiedMorphologyParser { + + private const val TAG = "UnifiedMorphologyParser" + + fun parse( + entry: DictionaryEntryData, + lemma: String, + pos: String?, + langCode: String, + config: LanguageConfig, + context: Context + ): UnifiedMorphology? { + // 1. Log Input + Log.d(TAG, "Request: lemma='$lemma', lang='$langCode', pos='$pos'") + + if (entry.forms.isEmpty()) { + Log.d(TAG, "Aborting: No forms data available for '$lemma'") + return null + } + + // FIX 1: Normalize POS tags (map "adj" -> "adjective", etc.) + val normalizedPos = normalizePos(pos) + + if (normalizedPos == null) { + Log.w(TAG, "Aborting: POS could not be normalized for '$lemma' (raw: $pos)") + return null + } + + // 2. Determine Rule Source + // First, check JSON Config + var rule = buildRuleFromConfig(normalizedPos, config, context) + val ruleSource = if (rule != null) "JSON Config" else "Registry Fallback" + + // If not in JSON, check Registry + if (rule == null) { + rule = MorphologyRegistry.getRule(langCode, normalizedPos) + } + + Log.d(TAG, "Strategy: Using $ruleSource for $langCode|$normalizedPos") + + // 3. Execute Extraction + val result = try { + when (rule) { + is MorphologyRule.GridRule -> { + Log.d(TAG, "Extracting Grid: ${rule.title}") + parseGrid(entry.forms, rule, lemma) + } + is MorphologyRule.VerbRule -> { + Log.d(TAG, "Extracting Verb Paradigm") + parseVerb(entry.forms, rule, lemma) + } + is MorphologyRule.GenericRule -> { + Log.d(TAG, "Extracting Generic List") + parseGeneric(entry.forms) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error extracting morphology for '$lemma': ${e.message}") + return null + } + + // 4. Log Outcome + if (result != null) { + Log.d(TAG, "Success: Generated ${result::class.java.simpleName} for '$lemma'") + } else { + Log.w(TAG, "Result was null for '$lemma'") + } + + return result + } + + /** + * Maps short/raw POS tags to the standard GrammarConstants keys. + */ + private fun normalizePos(pos: String?): String? { + val raw = pos?.lowercase()?.trim() ?: return null + return when (raw) { + "adj", "adjective", "adjectif" -> POS_ADJ // "adjective" + "noun", "substantive", "nom" -> POS_NOUN // "noun" + "verb", "verbe" -> POS_VERB // "verb" + else -> raw // Return as-is if no map found (e.g. "adverb") + } + } + + private fun buildRuleFromConfig(pos: String, config: LanguageConfig, context: Context): MorphologyRule? { + val category = config.categories[pos] ?: return null + return when (pos) { + // MERGED: Nouns AND Adjectives both use Grid Logic + POS_NOUN, POS_ADJ -> { + val display = category.declension_display ?: return null + if (display.cases_order != null && display.numbers_order != null) { + + // FIX 2: Define a fallback strategy for Adjectives. + // If a cell is missing (e.g. Masc/Sing), use the lemma. + val fallback: ((String, String, String) -> String?)? = if (pos == POS_ADJ) { + { row, col, lemma -> + // If looking for Masculine Singular, assume it's the Lemma + if (row.startsWith("masc") && col.startsWith("sing")) lemma else null + } + } else null + + MorphologyRule.GridRule( + title = if (pos == POS_ADJ) context.getString(R.string.label_variations) else context.getString(R.string.label_declension), + rowTags = display.cases_order, + colTags = display.numbers_order, + fallbackStrategy = fallback + ) + } else null + } + POS_VERB -> { + val display = category.conjugation_display ?: return null + if (display.tense_labels != null && display.pronouns != null) { + MorphologyRule.VerbRule( + tenses = display.tense_labels, + pronouns = display.pronouns + ) + } else null + } + else -> null + } + } + + // --- EXECUTION LOGIC --- + + private fun parseGrid( + forms: List, + rule: MorphologyRule.GridRule, + lemma: String + ): UnifiedMorphology.Grid { + val cells = mutableMapOf() + + // Optimization: Lookup map + val formLookup = forms.associate { formData -> + formData.tags.map { it.lowercase() }.toSet() to formData.form + } + + rule.rowTags.forEach { rowTag -> + rule.colTags.forEach { colTag -> + val requiredTags = listOf(rowTag, colTag) + .filter { it.isNotEmpty() } + .map { it.lowercase() } + + val matchEntry = formLookup.entries.find { (tags, _) -> + tags.containsAll(requiredTags) + } + + // Try to get value from match, OR use fallback (Lemma) + val cellValue = matchEntry?.value + ?: rule.fallbackStrategy?.invoke(rowTag, colTag, lemma) + + if (cellValue != null) { + val key = if (colTag.isEmpty()) rowTag else "$rowTag|$colTag" + cells[key] = cellValue + } + } + } + + return UnifiedMorphology.Grid( + title = rule.title, + rowLabels = rule.rowTags, + colLabels = rule.colTags.filter { it.isNotEmpty() }, + cells = cells + ) + } + + private fun parseVerb( + forms: List, + rule: MorphologyRule.VerbRule, + lemma: String + ): UnifiedMorphology.Verb { + val tenseResults = mutableMapOf>() + + rule.tenses.forEach { (tenseKey, displayLabel) -> + val relevantForms = forms.filter { form -> + form.tags.any { tag -> tag.equals(tenseKey, ignoreCase = true) } + } + + if (relevantForms.isNotEmpty()) { + val truncated = relevantForms.take(rule.pronouns.size).map { it.form } + tenseResults[displayLabel] = truncated + } + } + + val aux = forms.find { it.tags.contains("auxiliary") }?.form + + return UnifiedMorphology.Verb( + infinitive = lemma, + auxiliary = aux, + tenses = tenseResults, + pronouns = rule.pronouns + ) + } + + private fun parseGeneric(forms: List): UnifiedMorphology.ListForms { + return UnifiedMorphology.ListForms( + forms.map { Inflection(it.form, it.tags) } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/grammar/formatGrammarDetails.kt b/app/src/main/java/eu/gaudian/translator/model/grammar/formatGrammarDetails.kt new file mode 100644 index 0000000..48ac3ed --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/grammar/formatGrammarDetails.kt @@ -0,0 +1,38 @@ +package eu.gaudian.translator.model.grammar + +/** + * Formats the grammatical details of a word into a concise, user-friendly string + * based on the rules defined in a CategoryConfig. + * + * @param details The grammatical feature object containing the raw data. + * @param config The configuration for the specific category (e.g., "noun" in German). + * @return A formatted string like "(der)" or "(reg. -er)". + */ +fun formatGrammarDetails( + details: GrammaticalFeature, + config: CategoryConfig? +): String { + val formatter = config?.formatter ?: return details.category.replaceFirstChar { it.uppercase() } + + @Suppress("HardCodedStringLiteral") val placeholderRegex = Regex("\\{(\\w+)\\}") + + val hasDataForFormatter = placeholderRegex.findAll(formatter).any { matchResult -> + val key = matchResult.groupValues[1] + details.properties.containsKey(key) + } + + if (!hasDataForFormatter) { + return details.category.replaceFirstChar { it.uppercase() } + } + + + var result = formatter + result = placeholderRegex.replace(result) { matchResult -> + val key = matchResult.groupValues[1] + val rawValue = details.properties[key] ?: "" + val mappedValue = config.mappings?.get(key)?.get(rawValue) + mappedValue ?: rawValue + } + + return result +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/ApiLogRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/ApiLogRepository.kt new file mode 100644 index 0000000..8996dab --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/ApiLogRepository.kt @@ -0,0 +1,40 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.repository + +import android.content.Context +import eu.gaudian.translator.model.communication.ApiLogEntry +import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +class ApiLogRepository(context: Context) { + private val dataStore = context.dataStore + + fun getLogs(): Flow> = dataStore.loadObjectList(ApiStoreKeys.API_LOGS_KEY) + + suspend fun addLog(entry: ApiLogEntry, maxKeep: Int = 200) { + withContext(Dispatchers.IO) { + try { + val current = getLogs().first() + val updated = (current + entry).takeLast(maxKeep) + dataStore.saveObjectList(ApiStoreKeys.API_LOGS_KEY, updated) + } catch (e: Exception) { + Log.e("ApiLogRepository", "Failed to add log", e) + } + } + } + + suspend fun clear() { + withContext(Dispatchers.IO) { + dataStore.clear(ApiStoreKeys.API_LOGS_KEY) + } + } +} + +// Separate object to avoid cluttering the global DataStoreKeys if needed +object ApiStoreKeys { + val API_LOGS_KEY = DataStoreKeys.API_LOGS_KEY +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/ApiRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/ApiRepository.kt new file mode 100644 index 0000000..52db647 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/ApiRepository.kt @@ -0,0 +1,356 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import eu.gaudian.translator.model.LanguageModel +import eu.gaudian.translator.model.communication.ApiProvider +import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +class ApiRepository(private val context: Context) { + + private val settingsRepository = SettingsRepository(context) + private val dataStore: DataStore = context.dataStore + + @Suppress("PrivatePropertyName") + private val TAG = "ApiRepository" + + /** + * Checks and sets fallback models. + * Enforces consistency: If no key is present, default models are removed. + */ + suspend fun initialInit() { + Log.i(TAG, "Starting initial model check...") + + val jsonProviders = ApiProvider.loadProviders(context.applicationContext) + val jsonProvidersByKey = jsonProviders.associateBy { it.key } + + // Load currently saved providers + var storedProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first() + + // 1. First Run Handling + if (storedProviders.isEmpty()) { + Log.i(TAG, "DataStore empty. Initializing with JSON defaults.") + val initial = jsonProviders.map { + if (!it.isCustom) it.copy(models = emptyList()) else it + } + saveProviders(initial) + storedProviders = initial + } + + val apiKeys = getAllApiKeys().first() + var needsSync = false + + // --- NEW STEP: CLEANUP --- + // Filter out providers that are NOT custom AND are NO LONGER in the JSON + val validStoredProviders = storedProviders.filter { stored -> + val stillExistsInJson = jsonProvidersByKey.containsKey(stored.key) + val isCustom = stored.isCustom + + // Keep it if it's custom OR if it still exists in the JSON source + if (isCustom || stillExistsInJson) { + true + } else { + Log.i(TAG, "Removing obsolete provider: ${stored.displayName} (Key: ${stored.key})") + needsSync = true + false // Drop this provider + } + } + + // 2. Sync Existing Providers (Run logic on the CLEANED list) + val syncedStoredProviders = validStoredProviders.map { stored -> + if (!stored.isCustom) { + val latestDefault = jsonProvidersByKey[stored.key] + if (latestDefault != null) { + val hasKey = apiKeys[stored.key]?.isNotBlank() ?: false + val customModels = stored.models.filter { it.isCustom } + + val targetModels = if (hasKey) { + val newDefaultModels = latestDefault.models.filter { !it.isCustom } + (customModels + newDefaultModels).distinctBy { it.modelId } + } else { + customModels + } + + val needsUpdate = stored.displayName != latestDefault.displayName || + stored.baseUrl != latestDefault.baseUrl || + stored.endpoint != latestDefault.endpoint || + stored.websiteUrl != latestDefault.websiteUrl || + stored.models.size != targetModels.size || + stored.models.map { it.modelId }.toSet() != targetModels.map { it.modelId }.toSet() + + if (needsUpdate) { + needsSync = true + stored.copy( + displayName = latestDefault.displayName, + baseUrl = latestDefault.baseUrl, + endpoint = latestDefault.endpoint, + websiteUrl = latestDefault.websiteUrl, + models = targetModels, + isCustom = false + ) + } else { + stored + } + } else { + stored + } + } else { + stored + } + } + + // 3. Detect & Add NEW Providers + val existingKeys = syncedStoredProviders.map { it.key }.toSet() + val newProvidersFromJson = jsonProviders.filter { it.key !in existingKeys } + + val newProvidersInitialized = newProvidersFromJson.map { + if (!it.isCustom) it.copy(models = emptyList()) else it + } + + if (newProvidersInitialized.isNotEmpty()) { + Log.i(TAG, "Found ${newProvidersInitialized.size} new providers in JSON. Adding them.") + needsSync = true + } + + // 4. Save and Apply + val finalProviderList = syncedStoredProviders + newProvidersInitialized + + if (needsSync) { + Log.i(TAG, "Syncing providers...") + saveProviders(finalProviderList) + storedProviders = finalProviderList + } + + // 5. Fallback Selection Logic + val currentTrans = getTranslationModel().first() + val currentExer = getExerciseModel().first() + val currentVocab = getVocabularyModel().first() + val currentDict = getDictionaryModel().first() + + val validProviders = storedProviders.filter { provider -> + val hasKey = apiKeys[provider.key]?.isNotBlank() ?: false + val isLocalHost = provider.baseUrl.contains("localhost") || provider.baseUrl.contains("127.0.0.1") + hasKey || isLocalHost || provider.isCustom + } + val availableModels = validProviders.flatMap { it.models } + + var configurationValid = true + + // (Helper function to reduce repetition) + fun checkAndFallback(current: LanguageModel?, setter: suspend (LanguageModel) -> Unit) { + val isValid = current != null && availableModels.any { it.modelId == current.modelId && it.providerKey == current.providerKey } + if (!isValid) { + val fallback = findFallbackModel(availableModels) + if (fallback != null) { + // We must use a blocking call or scope here because we can't easily pass a suspend function to a lambda + // But since we are inside a suspend function, we can just call the setter directly if we unroll the loop. + // For simplicity, I'll keep the unrolled logic below. + } + } + } + + // Fallback checks + if (currentTrans == null || !availableModels.any { it.modelId == currentTrans.modelId && it.providerKey == currentTrans.providerKey }) { + findFallbackModel(availableModels)?.let { setTranslationModel(it) } ?: run { configurationValid = false } + } + + if (currentExer == null || !availableModels.any { it.modelId == currentExer.modelId && it.providerKey == currentExer.providerKey }) { + findFallbackModel(availableModels)?.let { setExerciseModel(it) } ?: run { configurationValid = false } + } + + if (currentVocab == null || !availableModels.any { it.modelId == currentVocab.modelId && it.providerKey == currentVocab.providerKey }) { + findFallbackModel(availableModels)?.let { setVocabularyModel(it) } ?: run { configurationValid = false } + } + + if (currentDict == null || !availableModels.any { it.modelId == currentDict.modelId && it.providerKey == currentDict.providerKey }) { + findFallbackModel(availableModels)?.let { setDictionaryModel(it) } ?: run { configurationValid = false } + } + + settingsRepository.connectionConfigured.set(configurationValid) + } + + /** + * Manually adds default models for a provider (Triggered on Key Activation). + */ + suspend fun addDefaultModels(providerKey: String) { + val currentProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first().toMutableList() + val index = currentProviders.indexOfFirst { it.key == providerKey } + if (index == -1) return + + val stored = currentProviders[index] + if (stored.isCustom) return // Don't touch custom providers + + val jsonProviders = ApiProvider.loadProviders(context.applicationContext) + val default = jsonProviders.find { it.key == providerKey } ?: return + + // Merge default models into existing (preserving customs) + val customModels = stored.models.filter { it.isCustom } + val defaultModels = default.models.filter { !it.isCustom } + val merged = (customModels + defaultModels).distinctBy { it.modelId } + + currentProviders[index] = stored.copy(models = merged) + saveProviders(currentProviders) + Log.i(TAG, "Added default models for $providerKey. Count: ${defaultModels.size}") + } + + /** + * Manually removes default models for a provider (Triggered on Key Deactivation). + */ + suspend fun removeDefaultModels(providerKey: String) { + val currentProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first().toMutableList() + val index = currentProviders.indexOfFirst { it.key == providerKey } + if (index == -1) return + + val stored = currentProviders[index] + if (stored.isCustom) return + + // Remove all non-custom models + val customOnly = stored.models.filter { it.isCustom } + + currentProviders[index] = stored.copy(models = customOnly) + saveProviders(currentProviders) + Log.i(TAG, "Removed default models for $providerKey.") + } + + fun getProviders(): Flow> { + val providersFlow = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).map { list -> + list.ifEmpty { + ApiProvider.loadProviders(context.applicationContext).map { + // Initial state: No key = No models + if (!it.isCustom) it.copy(models = emptyList()) else it + } + } + } + val apiKeysFlow = getAllApiKeys() + + return combine(providersFlow, apiKeysFlow) { providers, apiKeys -> + providers.map { provider -> + val hasKey = apiKeys[provider.key]?.isNotBlank() ?: false + val base = provider.baseUrl.trim().lowercase() + val isLocalHost = (base.contains("localhost") || base.contains("127.0.0.1") || base.startsWith("10.")) + + provider.copy().apply { + hasValidKey = hasKey || isLocalHost || isCustom + } + } + } + } + + fun getAllApiKeys(): Flow> { + val apiKeySuffix = "_api_key" + return dataStore.data.map { preferences -> + preferences.asMap().mapNotNull { (key, value) -> + if (key.name.endsWith(apiKeySuffix) && value is String) { + val providerKey = key.name.removeSuffix(apiKeySuffix) + providerKey to value + } else { + null + } + }.toMap() + } + } + + private fun findFallbackModel(allModels: List): LanguageModel? { + val preferredProviderOrder = listOf("mistral", "openai", "gemini", "deepseek", "openrouter") + for (providerKey in preferredProviderOrder) { + val model = allModels.firstOrNull { it.providerKey == providerKey } + if (model != null) return model + } + return allModels.firstOrNull() + } + + suspend fun saveProviders(providers: List) { + try { + dataStore.saveObjectList(DataStoreKeys.PROVIDERS_KEY, providers) + } catch (e: Exception) { + Log.e(TAG, "Error saving providers: ${e.message}", e) + } + } + + suspend fun addProvider(provider: ApiProvider) { + val currentProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first().toMutableList() + if (currentProviders.none { it.key == provider.key }) { + currentProviders.add(provider) + saveProviders(currentProviders) + initialInit() + } + } + + suspend fun updateProvider(updatedProvider: ApiProvider) { + val currentProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first().toMutableList() + val index = currentProviders.indexOfFirst { it.key == updatedProvider.key } + if (index != -1) { + currentProviders[index] = updatedProvider + saveProviders(currentProviders) + initialInit() + } + } + + suspend fun deleteProvider(providerKey: String) { + val currentProviders = dataStore.loadObjectList(DataStoreKeys.PROVIDERS_KEY).first().toMutableList() + val removed = currentProviders.removeAll { it.key == providerKey && it.isCustom } + if (removed) { + saveProviders(currentProviders) + initialInit() + } + } + + /* --- Model Selection Setters --- */ + + suspend fun setTranslationModel(model: LanguageModel) { + dataStore.saveObject(DataStoreKeys.SELECTED_TRANSLATION_MODEL_KEY, model) + settingsRepository.connectionConfigured.set(true) + } + fun getTranslationModel(): Flow = dataStore.loadObject(DataStoreKeys.SELECTED_TRANSLATION_MODEL_KEY) + + suspend fun setExerciseModel(model: LanguageModel) { + dataStore.saveObject(DataStoreKeys.SELECTED_EXERCISE_MODEL_KEY, model) + settingsRepository.connectionConfigured.set(true) + } + fun getExerciseModel(): Flow = dataStore.loadObject(DataStoreKeys.SELECTED_EXERCISE_MODEL_KEY) + + suspend fun setVocabularyModel(model: LanguageModel) { + dataStore.saveObject(DataStoreKeys.SELECTED_VOCABULARY_MODEL_KEY, model) + settingsRepository.connectionConfigured.set(true) + } + fun getVocabularyModel(): Flow = dataStore.loadObject(DataStoreKeys.SELECTED_VOCABULARY_MODEL_KEY) + + suspend fun setDictionaryModel(model: LanguageModel) { + dataStore.saveObject(DataStoreKeys.SELECTED_DICTIONARY_MODEL_KEY, model) + settingsRepository.connectionConfigured.set(true) + } + fun getDictionaryModel(): Flow = dataStore.loadObject(DataStoreKeys.SELECTED_DICTIONARY_MODEL_KEY) + + suspend fun wipeAll() { + Log.w(TAG, "Executing wipeAll()") + settingsRepository.connectionConfigured.set(false) + try { + dataStore.edit { prefs -> + prefs.remove(DataStoreKeys.PROVIDERS_KEY) + prefs.remove(DataStoreKeys.SELECTED_TRANSLATION_MODEL_KEY) + prefs.remove(DataStoreKeys.SELECTED_EXERCISE_MODEL_KEY) + prefs.remove(DataStoreKeys.SELECTED_VOCABULARY_MODEL_KEY) + prefs.remove(DataStoreKeys.SELECTED_DICTIONARY_MODEL_KEY) + + val apiKeySuffix = "_api_key" + val keysToRemove = prefs.asMap().keys.filter { it.name.endsWith(apiKeySuffix) } + for (k in keysToRemove) { + @Suppress("UNCHECKED_CAST") + val anyKey = k as Preferences.Key + prefs.remove(anyKey) + } + } + // initialInit will be called, see empty DataStore, and reload default providers (with EMPTY models) + initialInit() + } catch (e: Exception) { + Log.e(TAG, "Error during wipeAll: ${e.message}", e) + } + }} diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/DataStore.kt b/app/src/main/java/eu/gaudian/translator/model/repository/DataStore.kt new file mode 100644 index 0000000..2446232 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/DataStore.kt @@ -0,0 +1,162 @@ +@file:Suppress("HardCodedStringLiteral", "unused") + +package eu.gaudian.translator.model.repository + +import android.annotation.SuppressLint +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +object DataStoreKeys { + val PROVIDERS_KEY = stringPreferencesKey("providers") + + val EXERCISES_KEY = stringPreferencesKey("exercises") + + + + val QUESTIONS_KEY = stringPreferencesKey("questions") + + // Language initialization metadata key + val LANGUAGE_INIT_METADATA_KEY = stringPreferencesKey("language_init_metadata") + + // Language-related keys + val SELECTED_SOURCE_LANGUAGE_KEY = stringPreferencesKey("selected_source_language") + val SELECTED_TARGET_LANGUAGE_KEY = stringPreferencesKey("selected_target_language") + + val DEFAULT_LANGUAGES_KEY = stringPreferencesKey("default_languages") + val CUSTOM_LANGUAGES_KEY = stringPreferencesKey("custom_language") + val ALL_LANGUAGES_KEY = stringPreferencesKey("all_languages") + val LANGUAGE_HISTORY_KEY = stringPreferencesKey("language_history") + val FAVORITE_LANGUAGES_KEY = stringPreferencesKey("favorite_languages") + val SELECTED_DICTIONARY_LANGUAGE_KEY = stringPreferencesKey("selected_dictionary_language") + + val SELECTED_TRANSLATION_MODEL_KEY = stringPreferencesKey("selected_translation_model") + val SELECTED_EXERCISE_MODEL_KEY = stringPreferencesKey("selected_exercise_model") + val SELECTED_VOCABULARY_MODEL_KEY = stringPreferencesKey("selected_vocabulary_model") + val SELECTED_DICTIONARY_MODEL_KEY = stringPreferencesKey("selected_dictionary_model") + + val TRANSLATION_HISTORY_KEY = stringPreferencesKey("translation_history") + val API_LOGS_KEY = stringPreferencesKey("api_logs") +} + +val Context.dataStore: DataStore by preferencesDataStore(name = "app_data") + +suspend inline fun DataStore.saveObject(key: Preferences.Key, obj: T) { + edit { preferences -> + val jsonString = Json.encodeToString(obj) + preferences[key] = jsonString + } +} + +suspend fun DataStore.saveStringSet(key: Preferences.Key>, set: Set) { + edit { preferences -> + preferences[key] = set + } +} + +fun DataStore.loadStringSet(key: Preferences.Key>): Flow> { + return data.map { preferences -> + preferences[key] ?: emptySet() + } +} + +inline fun DataStore.loadObject(key: Preferences.Key): Flow { + return data.map { preferences -> + val jsonString = preferences[key] + if (jsonString != null) { + Json.decodeFromString(jsonString) + } else { + null + } + }.catch { exception -> + if (exception is SerializationException) { + Log.w("DataStore: Failed to decode object for key '$key', clearing it.", exception) + this@loadObject.edit { it.remove(key) } + emit(null) + } else { + throw exception + } + } +} + +@SuppressLint("SuspiciousIndentation") +suspend inline fun DataStore.saveObjectList(key: Preferences.Key, list: List) { + try { + edit { preferences -> + try { + val jsonString = Json.encodeToString(list) + jsonString.also { preferences[key] = it } + } catch (e: Exception) { + Log.e("DataStore", "Failed to encode list to JSON", e) + throw e + } + } + } catch (e: Exception) { + Log.e("DataStore", "Failed to save list to DataStore", e) + throw e + } +} + + +inline fun DataStore.loadObjectList(key: Preferences.Key): Flow> { + return data.map { preferences -> + val jsonString = preferences[key] + if (jsonString != null) { + Json.decodeFromString>(jsonString) + } else { + emptyList() + } + }.catch { exception -> + if (exception is SerializationException) { + Log.w("DataStore", "Failed to decode list for key '$key', clearing it.", exception) + this@loadObjectList.edit { it.remove(key) } // Clears the bad data + emit(emptyList()) // Provides a default value to continue + } else { + throw exception + } + } +} + +suspend fun DataStore.clear(key: Preferences.Key) { + edit { preferences -> + preferences.remove(key) + } +} + +@kotlinx.serialization.Serializable +data class LanguageInitializationMetadata( + val appVersion: String?, + val systemLocale: String, + val timestamp: Long +) + +@OptIn(ExperimentalTime::class) +object InstantSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Instant) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Instant { + return Instant.parse(decoder.decodeString()) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryDatabaseRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryDatabaseRepository.kt new file mode 100644 index 0000000..0056de5 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryDatabaseRepository.kt @@ -0,0 +1,137 @@ +@file:Suppress("HardCodedStringLiteral", "unused") + +package eu.gaudian.translator.model.repository + +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import eu.gaudian.translator.model.communication.FileInfo +import eu.gaudian.translator.utils.Log + +/** + * Repository for generic access and reading of downloaded dictionary .db files. + * All operations are read-only. The .db files are assumed to be SQLite databases. + */ +class DictionaryDatabaseRepository(private val context: Context) { + + @Suppress("PrivatePropertyName") + private val TAG = "DictionaryDatabaseRepository" + + /** + * Opens a read-only SQLite database for the given file info. + * Returns null if the file does not exist. + */ + internal fun getDatabase(fileInfo: FileInfo): SQLiteDatabase? { + val dbAsset = fileInfo.assets.firstOrNull { it.filename.endsWith(".db") } + if (dbAsset == null) { + Log.e(TAG, "No database asset found for ${fileInfo.id}") + return null + } + + val file = java.io.File(context.filesDir, dbAsset.filename) + return if (file.exists()) { + try { + // Use URI mode with immutable=1 to reduce locking noise + val path = "file:${file.absolutePath}?mode=ro&immutable=1" + SQLiteDatabase.openDatabase( + path, + null, + SQLiteDatabase.OPEN_READONLY + ) + } catch (e: Exception) { + Log.e(TAG, "Error opening database for ${dbAsset.filename}", e) + null + } + } else { + Log.w(TAG, "Database file does not exist: ${dbAsset.filename}") + null + } + } + + /** + * Retrieves the list of table names in the database. + */ + fun getTables(fileInfo: FileInfo): List { + val db = getDatabase(fileInfo) ?: return emptyList() + val tables = mutableListOf() + db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null).use { cursor -> + while (cursor.moveToNext()) { + tables.add(cursor.getString(0)) + } + } + db.close() + return tables + } + + /** + * Retrieves the list of column names for a specific table. + */ + fun getColumns(fileInfo: FileInfo, table: String): List { + val db = getDatabase(fileInfo) ?: return emptyList() + val columns = mutableListOf() + db.rawQuery("PRAGMA table_info($table)", null).use { cursor -> + while (cursor.moveToNext()) { + columns.add(cursor.getString(1)) // name column index is 1 + } + } + db.close() + return columns + } + + /** + * Retrieves up to [limit] rows from a table as a list of maps (column name to value). + * Defaults to first 1000 rows if limit is not specified. + */ + fun getTableData(fileInfo: FileInfo, table: String, limit: Int = 1000): List> { + val db = getDatabase(fileInfo) ?: return emptyList() + val data = mutableListOf>() + db.rawQuery("SELECT * FROM $table LIMIT $limit", null).use { cursor -> + val columnCount = cursor.columnCount + val columnNames = Array(columnCount) { i -> cursor.getColumnName(i) } + while (cursor.moveToNext()) { + val row = mutableMapOf() + for (i in 0 until columnCount) { + val name = columnNames[i] + val value = when (cursor.getType(i)) { + Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i) + Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(i) + Cursor.FIELD_TYPE_STRING -> cursor.getString(i) + Cursor.FIELD_TYPE_BLOB -> "BLOB (${cursor.getBlob(i)?.size ?: 0} bytes)" + else -> null + } + value?.let { row[name] = it } + } + data.add(row) + } + } + db.close() + return data + } + + /** + * Checks if a table exists in the database. + */ + fun tableExists(fileInfo: FileInfo, table: String): Boolean { + val db = getDatabase(fileInfo) ?: return false + val exists = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table' AND name='$table'", null).use { cursor -> + cursor.moveToFirst() + } + db.close() + return exists + } + + /** + * Gets the row count for a specific table. + */ + fun getRowCount(fileInfo: FileInfo, table: String): Int { + val db = getDatabase(fileInfo) ?: return 0 + var count = 0 + db.rawQuery("SELECT COUNT(*) FROM $table", null).use { cursor -> + if (cursor.moveToFirst()) { + count = cursor.getInt(0) + } + } + db.close() + return count + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryFileRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryFileRepository.kt new file mode 100644 index 0000000..2e8cd88 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryFileRepository.kt @@ -0,0 +1,270 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.repository + +import android.content.Context +import androidx.core.content.edit +import eu.gaudian.translator.R +import eu.gaudian.translator.model.communication.Asset +import eu.gaudian.translator.model.communication.FileDownloadManager +import eu.gaudian.translator.model.communication.FileInfo +import eu.gaudian.translator.model.communication.ManifestResponse +import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File + +/** + * Repository for managing downloaded dictionary files. + */ +class DictionaryFileRepository(private val context: Context) { + + private val fileDownloadManager = FileDownloadManager(context) + private val _downloadedDictionaries = MutableStateFlow>(emptyList()) + val downloadedDictionaries: Flow> = _downloadedDictionaries.asStateFlow() + + private val _orphanedFiles = MutableStateFlow>(emptyList()) + val orphanedFiles: Flow> = _orphanedFiles.asStateFlow() + + private val _manifest = MutableStateFlow(null) + val manifest: Flow = _manifest.asStateFlow() + + init { + loadDownloadedDictionaries() + } + + /** + * Fetches the manifest and updates the state. + */ + suspend fun fetchManifest() { + try { + val manifestResponse = fileDownloadManager.fetchManifest() + if (manifestResponse != null) { + _manifest.value = manifestResponse + loadDownloadedDictionaries() // Refresh both downloaded and orphaned lists + } else { + throw Exception("Manifest response is null") + } + } catch (e: Exception) { + Log.e("DictionaryFileRepository", "Error fetching manifest", e) + throw e + } + } + + /** + * Downloads a dictionary file. + */ + suspend fun downloadDictionary(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean { + try { + val success = fileDownloadManager.downloadFile(fileInfo, onProgress) + if (success) { + loadDownloadedDictionaries() + } + return success + } catch (e: Exception) { + Log.e("DictionaryFileRepository", "Error downloading dictionary", e) + throw e + } + } + + /** + * Checks if a newer version is available for a dictionary. + */ + fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean { + return fileDownloadManager.isNewerVersionAvailable(fileInfo) + } + + /** + * Gets the local version of a dictionary. + */ + fun getLocalVersion(fileId: String): String { + return fileDownloadManager.getLocalVersion(fileId) + } + + /** + * Deletes all assets of a downloaded dictionary. + */ + @Suppress("SameReturnValue", "SameReturnValue") + fun deleteDictionary(fileInfo: FileInfo): Boolean { + try { + var allDeleted = true + val failedFiles = mutableListOf() + + for (asset in fileInfo.assets) { + val localFile = File(context.filesDir, asset.filename) + if (localFile.exists()) { + val deleted = localFile.delete() + if (!deleted) { + allDeleted = false + failedFiles.add(asset.filename) + Log.e("DictionaryFileRepository", "Failed to delete asset: ${asset.filename}") + } else { + Log.d("DictionaryFileRepository", "Deleted asset: ${asset.filename}") + } + } else { + Log.w("DictionaryFileRepository", "Asset file not found: ${asset.filename}") + } + } + + if (allDeleted) { + // Remove version from SharedPreferences + val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE) + sharedPreferences.edit { remove(fileInfo.id) } + loadDownloadedDictionaries() + Log.d("DictionaryFileRepository", "Deleted all assets for dictionary: ${fileInfo.name}") + } else { + throw Exception("Failed to delete some assets: ${failedFiles.joinToString(", ")}") + } + + return true + } catch (e: Exception) { + Log.e("DictionaryFileRepository", "Error deleting dictionary", e) + throw e + } + } + + /** + * Deletes all downloaded dictionary files and their assets. + */ + @Suppress("SameReturnValue") + fun deleteAllDictionaries(): Boolean { + try { + val filesDir = context.filesDir + // Find all dictionary-related files (.db and .zstdict) + val dictionaryFiles = filesDir.listFiles { file -> + file.isFile && ( + file.name.endsWith(".db") || + file.name.endsWith(".zstdict") || + file.name.startsWith("dictionary") + ) && !file.name.endsWith(".db-wal") && + !file.name.endsWith(".db-shm") + } + + if (dictionaryFiles.isNullOrEmpty()) { + Log.d("DictionaryFileRepository", "No dictionary files found to delete") + return true + } + + var allDeleted = true + val failedFiles = mutableListOf() + + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryJsonService.kt b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryJsonService.kt new file mode 100644 index 0000000..52afac0 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryJsonService.kt @@ -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() + @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): Map { + 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 = + 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 +) diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryLookupRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryLookupRepository.kt new file mode 100644 index 0000000..6805da3 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryLookupRepository.kt @@ -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_.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() + + /** + * 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 { + 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() + 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 { + 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() + 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? { + return json?.let { + try { + gson.fromJson(it, object : TypeToken>() {}.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 { + 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() + 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>? { + return json?.let { + try { + gson.fromJson(it, object : TypeToken>>() {}.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(), "") +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryRepository.kt new file mode 100644 index 0000000..85dffe0 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/DictionaryRepository.kt @@ -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> { + return context.dataStore.loadObjectList(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()) + } + + suspend fun saveWordOfTheDay(entry: DictionaryEntry) { + context.dataStore.saveObject(WORD_OF_THE_DAY_KEY, entry) + } + + + fun loadWordOfTheDay(): Flow { + return context.dataStore.loadObject(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 + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/ExerciseRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/ExerciseRepository.kt new file mode 100644 index 0000000..59f2530 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/ExerciseRepository.kt @@ -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 + + + fun getAllExercisesFlow(): Flow> { + return dataStore.loadObjectList(DataStoreKeys.EXERCISES_KEY) + } + + private suspend fun getAllExercises(): List { + 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> { + return dataStore.loadObjectList(DataStoreKeys.QUESTIONS_KEY) + } + + private suspend fun getAllQuestions(): List { + 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) { + 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) { + saveQuestions(questions) + saveExercise(exercise) + Log.d(TAG, "Successfully saved new exercise '${exercise.title}' with ${questions.size} questions.") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/LanguageRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/LanguageRepository.kt new file mode 100644 index 0000000..f4dfbb5 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/LanguageRepository.kt @@ -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> { + 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): List { + 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): 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(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 = try { + context.dataStore.loadObjectList(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): List { + val master = masterLanguagesFlow().first() + return master.filter { it.nameResId in ids } + } + + fun loadMasterLanguages(): Flow> = masterLanguagesFlow() + + suspend fun setEnabledLanguagesByIds(ids: List) { + 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> { + return when (type) { + LanguageListType.ALL -> { + // Enabled languages (IDs) mapped to actual Language objects from master catalog + kotlinx.coroutines.flow.combine( + dataStore.loadObjectList(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(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) { + 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 { + return dataStore.loadObject(DataStoreKeys.SELECTED_SOURCE_LANGUAGE_KEY) + } + + suspend fun saveSelectedSourceLanguage(language: Language?) { + dataStore.saveObject(DataStoreKeys.SELECTED_SOURCE_LANGUAGE_KEY, language) + } + + fun loadSelectedTargetLanguage(): Flow { + return dataStore.loadObject(DataStoreKeys.SELECTED_TARGET_LANGUAGE_KEY) + } + + suspend fun saveSelectedTargetLanguage(language: Language?) { + dataStore.saveObject(DataStoreKeys.SELECTED_TARGET_LANGUAGE_KEY, language) + } + + fun loadSelectedDictionaryLanguage(): Flow { + 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(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 + } + } +} diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/Setting.kt b/app/src/main/java/eu/gaudian/translator/model/repository/Setting.kt new file mode 100644 index 0000000..c0411f1 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/Setting.kt @@ -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( + private val dataStore: DataStore, + private val key: Preferences.Key, + private val defaultValue: T +) { + /** + * A Flow that emits the current value of the setting. + */ + val flow: Flow = 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/SettingsRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/SettingsRepository.kt new file mode 100644 index 0000000..71f1548 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/SettingsRepository.kt @@ -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 { + 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 { + 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 = 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> { + 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)) + } + } +} diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/VocabularyFileSaver.kt b/app/src/main/java/eu/gaudian/translator/model/repository/VocabularyFileSaver.kt new file mode 100644 index 0000000..d2bb761 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/VocabularyFileSaver.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/VocabularyRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/VocabularyRepository.kt new file mode 100644 index 0000000..661b3b6 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/model/repository/VocabularyRepository.kt @@ -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 { + // 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> { + return mappingDao.getCategoryMappingsFlow().map { list -> + list.map { CategoryMapping(it.vocabularyItemId, it.categoryId) } + } + } + + suspend fun getWordsLearnedByDate(startDate: LocalDate, endDate: LocalDate): Map { + val allStates = getAllVocabularyItemStates() + val dailyStats = mutableMapOf().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) { + Log.d(TAG, "updateVocabularyItems: Updating ${items.size} items.") + items.forEach { itemDao.upsertItem(it) } + requestUpdateMappings() + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun getDueTodayItemsFlow(): Flow> { + 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> = itemDao.getAllItemsFlow() + + suspend fun getAllVocabularyItems(): List = 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) { + Log.w(TAG, "deleteVocabularyItemsByIds: Deleting ${vocabularyItemIds.size} items.") + itemDao.deleteItemsByIds(vocabularyItemIds) + requestUpdateMappings() + } + + suspend fun generateVocabularyItems( + category: String, languageFirst: Language, languageSecond: Language, amount: Int + ): Result> { + return vocabularyItemService.generateVocabularyItems(category, languageFirst, languageSecond, amount) + } + + suspend fun introduceVocabularyItems(newItems: List, categoryIds: List = 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 { + 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? = langsJson?.let { Json.decodeFromString(it) } + val pair: Pair? = 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 { + return categoryDao.getAllCategories().map { mapEntityToCategory(it) } + } + + fun getAllCategoriesFlow(): Flow> { + 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 { + 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 { + return itemDao.getItemsByCategoryId(categoryId) + } + + @Suppress("unused") + fun getAllVocabularyItemStatesFlow(): Flow> = stateDao.getAllStatesFlow() + + suspend fun getAllVocabularyItemStates(): List = 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 { + 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?, 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 { + return loadStageMapping().map { stageMap -> + stageMap[itemId] ?: VocabularyStage.NEW + } + } + + fun loadStageMapping(): Flow> { + return mappingDao.getStageMappingsFlow().map { list -> + list.associate { it.vocabularyItemId to it.stage } + } + } + + private suspend fun saveStageMapping(mapping: Map) { + 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 + ): 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> { + 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 { + val allItemIds = getAllVocabularyItems().map { it.id }.toSet() + val listIds = getAllCategories().filterIsInstance().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 { + val vocabularyItems = getAllVocabularyItems() + val autoFilters = getAllCategories().filterIsInstance() + val stageMapping = loadStageMapping().first() + val newMappings = mutableListOf() + + 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 { + return getDueTodayItemsFlow().first() + } + + suspend fun calculateStageStatistics(): List { + 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 { + val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + val counts = mutableMapOf() + 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 { + 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 = 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 { + 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().size} Tags, ${allCategories.filterIsInstance().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, + val categories: List, + val states: List, + val categoryMappings: List, + val stageMappings: List> +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/SemanticColors.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/SemanticColors.kt new file mode 100644 index 0000000..0c9b9f3 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/SemanticColors.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/Theme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/Theme.kt new file mode 100644 index 0000000..4043cf3 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/Theme.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/ThemePreview.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/ThemePreview.kt new file mode 100644 index 0000000..7d4e98f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/ThemePreview.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/Typography.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/Typography.kt new file mode 100644 index 0000000..8559da8 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/Typography.kt @@ -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"), +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/AutumnSpiceTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/AutumnSpiceTheme.kt new file mode 100644 index 0000000..abe6eb1 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/AutumnSpiceTheme.kt @@ -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) + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CitrusSplashTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CitrusSplashTheme.kt new file mode 100644 index 0000000..1e5b539 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CitrusSplashTheme.kt @@ -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), + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CoffeeTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CoffeeTheme.kt new file mode 100644 index 0000000..f7f7240 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CoffeeTheme.kt @@ -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) + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CrimsonTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CrimsonTheme.kt new file mode 100644 index 0000000..fa34afa --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CrimsonTheme.kt @@ -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), + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CyberPunkTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CyberPunkTheme.kt new file mode 100644 index 0000000..e12464a --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/CyberPunkTheme.kt @@ -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), + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/DefaultTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/DefaultTheme.kt new file mode 100644 index 0000000..c90df24 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/DefaultTheme.kt @@ -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) + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/ForestTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/ForestTheme.kt new file mode 100644 index 0000000..bfe8f6c --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/ForestTheme.kt @@ -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 + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/NordTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/NordTheme.kt new file mode 100644 index 0000000..22be732 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/NordTheme.kt @@ -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 + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/OceanTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/OceanTheme.kt new file mode 100644 index 0000000..d5fce91 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/OceanTheme.kt @@ -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) + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/Pixel.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/Pixel.kt new file mode 100644 index 0000000..d8706e5 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/Pixel.kt @@ -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) + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/SakuraTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/SakuraTheme.kt new file mode 100644 index 0000000..cfedec2 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/SakuraTheme.kt @@ -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) + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/SlatStoneTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/SlatStoneTheme.kt new file mode 100644 index 0000000..91c5efe --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/SlatStoneTheme.kt @@ -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) + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/SpaceOpera.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/SpaceOpera.kt new file mode 100644 index 0000000..6dc960d --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/SpaceOpera.kt @@ -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) + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/Synthwave.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/Synthwave.kt new file mode 100644 index 0000000..e74b931 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/Synthwave.kt @@ -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) + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/TealTheme.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/TealTheme.kt new file mode 100644 index 0000000..68e49e8 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/TealTheme.kt @@ -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) + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/ui/theme/themes/ThemeTwilight.kt b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/ThemeTwilight.kt new file mode 100644 index 0000000..446d0d5 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/ui/theme/themes/ThemeTwilight.kt @@ -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) + ) +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/ApiRequestHandler.kt b/app/src/main/java/eu/gaudian/translator/utils/ApiRequestHandler.kt new file mode 100644 index 0000000..295db86 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/ApiRequestHandler.kt @@ -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 executeRequest(template: ApiRequestTemplate): Result { + + 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 logInteraction( + template: ApiRequestTemplate, + 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 { + val deferred = CompletableDeferred>() + + + + 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) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/ApiRequestTemplates.kt b/app/src/main/java/eu/gaudian/translator/utils/ApiRequestTemplates.kt new file mode 100644 index 0000000..0c4b4b8 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/ApiRequestTemplates.kt @@ -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 { + /** + * The serializer for the expected response type + */ + val responseSerializer: KSerializer + + /** + * 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 get() = emptyList() +} + +/** + * Base class for API request templates with common functionality. + */ +abstract class BaseApiRequestTemplate : ApiRequestTemplate { + + protected val promptBuilder = PromptBuilder("") + + /** + * Adds a detail to the prompt + */ + protected fun addDetail(detail: String): BaseApiRequestTemplate { + promptBuilder.addDetail(detail) + return this + } + + /** + * Sets the JSON response structure description + */ + protected fun withJsonResponse(description: String): BaseApiRequestTemplate { + promptBuilder.withJsonResponse(description) + return this + } + + /** + * Adds an example to the prompt + */ + protected fun withExample(example: String): BaseApiRequestTemplate { + 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() { + + 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() { + + 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, + languageFirst: String, + languageSecond: String +) : BaseApiRequestTemplate() { + + 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() { + + 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() { + 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() { + 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() { + 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() { + + 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() { + + 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() { + + 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() { + + 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() { + + 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() { + 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 { + 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 { + 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 +) + +@kotlinx.serialization.Serializable +data class ExampleSentenceApiResponse( + val word: String, + val sourceSentence: String, + val targetSentence: String +) + +@kotlinx.serialization.Serializable +data class VocabularyApiResponse( + val flashcards: List +) + +@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 +) + +@kotlinx.serialization.Serializable +data class CorrectionResponse( + val correctedText: String, + val explanation: String +) + +@kotlinx.serialization.Serializable +data class EtymologyApiResponse( + val word: String, + val timeline: List = emptyList(), + val relatedWords: List = emptyList() +) + +@kotlinx.serialization.Serializable +data class SynonymApiResponse( + val synonyms: List +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/ContextExtensions.kt b/app/src/main/java/eu/gaudian/translator/utils/ContextExtensions.kt new file mode 100644 index 0000000..9dab645 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/ContextExtensions.kt @@ -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.") +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/CorrectionService.kt b/app/src/main/java/eu/gaudian/translator/utils/CorrectionService.kt new file mode 100644 index 0000000..0935319 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/CorrectionService.kt @@ -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 = 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/ExerciseHelpers.kt b/app/src/main/java/eu/gaudian/translator/utils/ExerciseHelpers.kt new file mode 100644 index 0000000..7b3fabe --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/ExerciseHelpers.kt @@ -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, + questionTypes: List>, + 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? { + // 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? { + 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 { + return try { + val cleanedJson = cleanJsonString(response) + val rootObject = JSONObject(cleanedJson) + val jsonArray = rootObject.getJSONArray("questions") + + val questions = mutableListOf() + 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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/ExerciseService.kt b/app/src/main/java/eu/gaudian/translator/utils/ExerciseService.kt new file mode 100644 index 0000000..b046400 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/ExerciseService.kt @@ -0,0 +1,420 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import android.content.Context +import eu.gaudian.translator.model.Exercise +import eu.gaudian.translator.model.Question +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.model.communication.ApiManager +import eu.gaudian.translator.model.communication.ModelType +import eu.gaudian.translator.model.repository.LanguageRepository +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.reflect.KClass + +private const val TAG = "ExerciseService" + +class ExerciseService(val context: Context) { + + private val apiRequestHandler = ApiRequestHandler(ApiManager(context), context) + private val languageRepository = LanguageRepository(context) + private val jsonHelper = JsonHelper() + + /** + * Generates 10 vocabulary items for a given exercise title. + * @return A list of the newly created VocabularyItems. + */ + suspend fun generateAndSaveVocabularyForExercise(exerciseTitle: String): List { + val sourceLang = languageRepository.loadSelectedSourceLanguage().first() + val targetLang = languageRepository.loadSelectedTargetLanguage().first() + + if (sourceLang == null || targetLang == null) { + Log.w(TAG, "Source or target language not selected. Skipping vocabulary generation.") + return emptyList() + } + + val prompt = ExercisePromptGenerator.generateVocabularyPrompt(exerciseTitle, sourceLang.englishName, targetLang.englishName) + val response = makeApiRequest(prompt) + + val vocabItems = parseVocabularyFromJson(response).map { + it.copy(languageFirstId = sourceLang.nameResId, languageSecondId = targetLang.nameResId) + } + + return vocabItems + } + + /** + * Orchestrates the entire AI generation flow for a new exercise with questions. + * Returns a Triple with the exercise, questions, and new vocabulary. + */ + suspend fun generateExerciseWithQuestions( + category: String, + questionTypes: List>, + difficulty: String, + amount: Int, + sourceLanguage: String? = null, + targetLanguage: String? = null, + progressUpdater: ((String) -> Unit)? = null + ): Triple, List>? { + try { + progressUpdater?.invoke("generateExerciseShellPrompt") + val exercisePrompt = ExercisePromptGenerator.generateExerciseShellPrompt(category) + val exerciseResponse = makeApiRequest(exercisePrompt) + val (exerciseId, exerciseTitle) = ExerciseParser.parseExerciseShell(exerciseResponse) + ?: throw Exception("Failed to parse exercise shell") + + val effectiveSource = sourceLanguage ?: languageRepository.loadSelectedSourceLanguage().first()?.englishName + val effectiveTarget = targetLanguage ?: languageRepository.loadSelectedTargetLanguage().first()?.englishName + + progressUpdater?.invoke("generateVocabularyPrompt") + val newVocabulary = generateAndSaveVocabularyForExercise(exerciseTitle) + val words = newVocabulary.map { it.wordSecond } + + // Generate context passage for this exercise (dialogue or text, depending on difficulty) + progressUpdater?.invoke("generateContextPrompt") + val contextPrompt = ExercisePromptGenerator.generateContextPrompt( + category = category, + difficulty = difficulty, + languageFirst = effectiveSource, + languageSecond = effectiveTarget + ) + val contextResponse = makeApiRequest(contextPrompt) + progressUpdater?.invoke("parseExerciseContext") + val context = ExerciseParser.parseExerciseContext(contextResponse) + val contextTitle = context?.first ?: "" + val contextText = context?.second ?: "" + + // Batch questions generation to max 10 per request + val batches = mutableListOf() + var remaining = amount + while (remaining > 0) { + val size = minOf(10, remaining) + batches.add(size) + remaining -= size + } + val allQuestions = mutableListOf() + + for ((index, batchSize) in batches.withIndex()) { + progressUpdater?.invoke("generateQuestionsPrompt ${index + 1}/${batches.size}") + val questionsPrompt = ExercisePromptGenerator.generateQuestionsPrompt( + exerciseTitle = exerciseTitle, + optionalVocabulary = words, + questionTypes = questionTypes, + difficulty = difficulty, + amount = batchSize, + languageFirst = effectiveSource!!, + languageSecond = effectiveTarget!!, + contextText = contextText.ifBlank { null } + ) + Log.d(TAG, "Questions prompt: $questionsPrompt") + val questionsResponse = makeApiRequest(questionsPrompt) + progressUpdater?.invoke("parseQuestions ${index + 1}/${batches.size}") + val batchQuestions = ExerciseParser.parseQuestions(questionsResponse) + if (batchQuestions.isEmpty()) { + Log.w(TAG, "Batch ${index + 1} returned 0 questions") + } else { + allQuestions.addAll(batchQuestions) + } + } + + if (allQuestions.isEmpty()) { + throw Exception("AI failed to generate any valid questions.") + } + + val finalExercise = Exercise( + id = exerciseId, + title = exerciseTitle, + questions = allQuestions.map { it.id }, + associatedVocabularyIds = newVocabulary.map { it.id }, + sourceLanguage = effectiveSource, + targetLanguage = effectiveTarget, + contextTitle = contextTitle.ifBlank { null }, + contextText = contextText.ifBlank { null } + ) + + return Triple(finalExercise, allQuestions, newVocabulary) + + } catch (e: Exception) { + Log.e(TAG, "Error generating exercise with questions: ${e.message}", e) + return null + } + } + + /** + * Generates questions based on YouTube video subtitles using AI. + * @param subtitles List of subtitle lines from the video + * @param videoTitle Title of the video + * @param sourceLanguage Source language code (optional) + * @param targetLanguage Target language code (optional) + * @param progressUpdater Callback for progress updates (optional) + * @return List of generated questions + */ + suspend fun generateQuestionsFromSubtitles( + subtitles: List, + videoTitle: String, + sourceLanguage: String? = null, + targetLanguage: String? = null, + progressUpdater: ((String) -> Unit)? = null + ): List { + Log.d(TAG, "Generating questions from subtitles...") + try { + if (subtitles.isEmpty()) { + throw Exception("No subtitles provided for question generation") + } + + progressUpdater?.invoke("Preparing video content for AI analysis...") + + // Combine subtitles into a coherent text + val fullText = subtitles.joinToString(" ") { it.text } + val translatedText = subtitles.filter { it.translatedText != null } + .joinToString(" ") { it.translatedText!! } + + sourceLanguage ?: languageRepository.loadSelectedSourceLanguage().first()?.englishName + val effectiveTarget = targetLanguage ?: languageRepository.loadSelectedTargetLanguage().first()?.englishName + + progressUpdater?.invoke("Generating questions with AI...") + + // Create a prompt for generating questions based on video content + val prompt = buildString { + append("Generate 8 diverse, high-quality questions based on this YouTube video content.\n\n") + append("Video Title: $videoTitle\n\n") + append("Video Content (Original Language):\n$fullText\n\n") + + if (translatedText.isNotEmpty()) { + append("Video Content (Translated to $effectiveTarget):\n$translatedText\n\n") + } + + append("Requirements:\n") + append("- Create a mix of question types: True/False, Multiple Choice, Fill-in-the-blank, Word Order\n") + append("- Questions should test comprehension of the video content\n") + append("- Include questions about key facts, concepts, and vocabulary from the video\n") + append("- Make questions challenging but fair\n") + append("- Provide clear, unambiguous correct answers\n") + append("- If using non-English content, include appropriate language questions\n\n") + + append("Return the questions in this exact JSON format:\n") + append("[\n") + append(" {\n") + append(" \"type\": \"TrueFalseQuestion\",\n") + append(" \"name\": \"Question text?\",\n") + append(" \"correctAnswer\": true/false,\n") + append(" \"explanation\": \"Why this is correct\"\n") + append(" },\n") + append(" {\n") + append(" \"type\": \"MultipleChoiceQuestion\",\n") + append(" \"name\": \"Question text?\",\n") + append(" \"options\": [\"Option A\", \"Option B\", \"Option C\", \"Option D\"],\n") + append(" \"correctAnswerIndex\": 0\n") + append(" },\n") + append(" {\n") + append(" \"type\": \"FillInTheBlankQuestion\",\n") + append(" \"name\": \"Question with ___ blank\",\n") + append(" \"correctAnswer\": \"correct answer\"\n") + append(" },\n") + append(" {\n") + append(" \"type\": \"WordOrderQuestion\",\n") + append(" \"name\": \"Rearrange the words to form a sentence from the video:\",\n") + append(" \"words\": [\"word1\", \"word2\", \"word3\"],\n") + append(" \"correctOrder\": [\"word1\", \"word2\", \"word3\"]\n") + append(" }\n") + append("]\n") + } + + val response = makeApiRequest(prompt) + + progressUpdater?.invoke("Parsing AI-generated questions...") + + // Parse the JSON response and convert to Question objects + return parseVideoQuestionsFromJson(response) + + } catch (e: Exception) { + Log.e(TAG, "Error generating questions from subtitles: ${e.message}", e) + // Fallback to simple question generation if AI fails + return generateSimpleQuestionsFromSubtitles(subtitles) + } + } + + private fun parseVideoQuestionsFromJson(jsonResponse: String): List { + val questions = mutableListOf() + var nextId = 1 + + try { + // Simple JSON parsing for the expected format + val json = jsonResponse.trim() + + // Extract question objects from JSON array + val questionMatches = Regex("\\{[^}]*\"type\"\\s*:\\s*\"([^\"]+)\"[^}]*\\}").findAll(json) + + for (match in questionMatches) { + val questionJson = match.value + val type = Regex("\"type\"\\s*:\\s*\"([^\"]+)\"").find(questionJson)?.groupValues?.get(1) + + when (type) { + "TrueFalseQuestion" -> { + val name = extractJsonString(questionJson, "name") + val correctAnswer = extractJsonBoolean(questionJson, "correctAnswer") + val explanation = extractJsonString(questionJson, "explanation") + + if (name != null && correctAnswer != null) { + questions.add( + eu.gaudian.translator.model.TrueFalseQuestion( + id = nextId++, + name = name, + correctAnswer = correctAnswer, + explanation = explanation ?: "Based on the video content" + ) + ) + } + } + "MultipleChoiceQuestion" -> { + val name = extractJsonString(questionJson, "name") + val options = extractJsonStringArray(questionJson, "options") + val correctIndex = extractJsonInt(questionJson, "correctAnswerIndex") + + if (name != null && options != null && correctIndex != null && correctIndex < options.size) { + questions.add( + eu.gaudian.translator.model.MultipleChoiceQuestion( + id = nextId++, + name = name, + options = options, + correctAnswerIndex = correctIndex + ) + ) + } + } + "FillInTheBlankQuestion" -> { + val name = extractJsonString(questionJson, "name") + val correctAnswer = extractJsonString(questionJson, "correctAnswer") + + if (name != null && correctAnswer != null) { + questions.add( + eu.gaudian.translator.model.FillInTheBlankQuestion( + id = nextId++, + name = name, + correctAnswer = correctAnswer + ) + ) + } + } + "WordOrderQuestion" -> { + val name = extractJsonString(questionJson, "name") + val words = extractJsonStringArray(questionJson, "words") + val correctOrder = extractJsonStringArray(questionJson, "correctOrder") + + if (name != null && words != null && correctOrder != null) { + questions.add( + eu.gaudian.translator.model.WordOrderQuestion( + id = nextId++, + name = name, + words = words, + correctOrder = correctOrder + ) + ) + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing video questions JSON: ${e.message}", e) + } + + return questions + } + + private fun extractJsonString(json: String, key: String): String? { + val regex = Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"") + return regex.find(json)?.groupValues?.get(1) + } + + private fun extractJsonBoolean(json: String, key: String): Boolean? { + val regex = Regex("\"$key\"\\s*:\\s*(true|false)") + return regex.find(json)?.groupValues?.get(1)?.toBoolean() + } + + private fun extractJsonInt(json: String, key: String): Int? { + val regex = Regex("\"$key\"\\s*:\\s*(\\d+)") + return regex.find(json)?.groupValues?.get(1)?.toInt() + } + + private fun extractJsonStringArray(json: String, key: String): List? { + @Suppress("RegExpRedundantEscape") val regex = Regex("\"$key\"\\s*:\\s*\\[([^\\]]+)\\]") + val match = regex.find(json) ?: return null + val arrayContent = match.groupValues[1] + + return arrayContent.split(",").map { it.trim().removeSurrounding("\"").trim() } + } + + private fun generateSimpleQuestionsFromSubtitles( + subtitles: List + ): List { + // Fallback to the simple generation method if AI fails + val questions = mutableListOf() + var nextId = 1 + + val meaningfulSubtitles = subtitles.filter { it.text.length > 10 } + if (meaningfulSubtitles.isEmpty()) return emptyList() + + // Generate simple questions as fallback + meaningfulSubtitles.take(3).forEach { subtitle -> + val statement = subtitle.text.trim() + if (statement.length > 20 && statement.contains(" ") && !statement.endsWith("?")) { + questions.add( + eu.gaudian.translator.model.TrueFalseQuestion( + id = nextId++, + name = "According to the video: $statement", + correctAnswer = true, + explanation = "This statement appears in the video content." + ) + ) + } + } + + return questions + } + + /** + * Parses vocabulary items from JSON response using the enhanced JsonHelper + */ + private fun parseVocabularyFromJson(jsonResponse: String): List { + return try { + // Clean and validate the JSON first + val cleanedJson = jsonHelper.cleanAndValidateJson(jsonResponse) + + // Parse the JSON object for vocabulary items + val jsonObject = kotlinx.serialization.json.Json.parseToJsonElement(cleanedJson).jsonObject + + val vocabularyItems = mutableListOf() + var id = 1 + + for ((wordFirst, wordSecondElement) in jsonObject) { + val wordSecond = wordSecondElement.jsonPrimitive.content.trim() + val vocabularyItem = VocabularyItem( + id = id++, + languageFirstId = -1, // Will be set by caller + languageSecondId = -1, // Will be set by caller + wordFirst = wordFirst.trim(), + wordSecond = wordSecond + ) + vocabularyItems.add(vocabularyItem) + } + + vocabularyItems + } catch (e: Exception) { + Log.e(TAG, "Error parsing vocabulary from JSON: ${e.message}", e) + emptyList() + } + } + + private suspend fun makeApiRequest(prompt: String): String { + val template = DynamicJsonRequest(prompt, ModelType.EXERCISE, "ExerciseService") + val result = apiRequestHandler.executeRequest(template) + + return if (result.isSuccess) { + result.getOrNull().toString() + } else { + throw result.exceptionOrNull() ?: Exception("API request failed without exception") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/HtmlParser.kt b/app/src/main/java/eu/gaudian/translator/utils/HtmlParser.kt new file mode 100644 index 0000000..82422ec --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/HtmlParser.kt @@ -0,0 +1,119 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import org.jsoup.Jsoup +import org.jsoup.nodes.Element + +fun parseDefinitionsFromHtml(html: String, languageName: String? = null): List { + Log.d("HtmlParser", "parseDefinitionsFromHtml: htmlLength=${html.length}") + val document = Jsoup.parse(html) + val definitions = mutableListOf() + + fun clean(text: String): String { + var t = text.trim() + // Remove bracketed references like [1] + @Suppress("RegExpRedundantEscape") + t = t.replace("\\s*\\[\\d+\\]".toRegex(), "").trim() + // Remove obvious inline citation markers like "[2]", "[a]" + @Suppress("RegExpRedundantEscape") + t = t.replace("\\s*\\[[^]]+\\]".toRegex(), "").trim() + // Remove edit bracket artifacts + t = t.replace("\\s*¶".toRegex(), "").trim() + // Collapse multiple spaces + t = t.replace("\\s+".toRegex(), " ") + return t + } + + // Remove blocks that are not relevant to definitions globally (optional) + document.select("div.interproject-box, table.audiotable, figure, div.NavFrame, div.navbox, style, link").remove() + + // Resolve language section robustly. + // Prefer English if present, else take the first language header. + // On Wiktionary, language headers can be: + // - h2#mylang (possibly wrapped in div.mw-heading2) + // - span.mw-headline#English (older markup) + val target = languageName?.trim().takeUnless { it.isNullOrEmpty() } + val lang = target ?: "English" + val langId = lang.replace(" ", "_") + val h2 = document.select("h2#$langId").firstOrNull() + val span = document.select("span.mw-headline#$langId").firstOrNull() + val anyH2 = document.select("h2[id]").firstOrNull() + val anySpan = document.select("span.mw-headline[id]").firstOrNull() + + val languageHeader: Element? = h2 ?: span ?: anyH2 ?: anySpan + + if (languageHeader != null) { + Log.d("HtmlParser", "Language header tag='${languageHeader.tagName()}', id='${languageHeader.id()}', text='${languageHeader.text()}'") + val startFrom = languageHeader.parent()?.takeIf { it.hasClass("mw-heading") } ?: languageHeader + var current: Element? = startFrom.nextElementSibling() + + val posCandidatesBase = listOf( + "Noun", "Verb", "Adjective", "Adverb", "Pronoun", "Preposition", + "Conjunction", "Interjection", "Determiner", "Article", "Proper noun" + ) + // Heuristic: prioritize Proper noun for capitalized headwords + val prioritized = listOf("Proper noun") + posCandidatesBase.filter { it != "Proper noun" } + var foundPos = false + + fun elementIsLanguageBoundary(el: Element): Boolean { + if (el.tagName() == "h2") return true + if (el.hasClass("mw-heading") && el.selectFirst("h2") != null) return true + return false + } + + fun findFirstOlAfter(nodeStart: Element?): Element? { + var node = nodeStart + var steps = 0 + while (node != null && steps < 20) { + val h3 = if (node.tagName() == "h3") node else node.selectFirst("h3") + if (h3 != null && h3 != nodeStart) break + if (elementIsLanguageBoundary(node)) break + if (node.tagName() == "ol") return node + node = node.nextElementSibling() + steps++ + } + return null + } + + fun extractDefinitionFromLi(li: Element): String { + // Clone and remove non-essential nested blocks but keep inline text + val copy = li.clone() + // Remove nested lists (examples, subsenses), tables, figures, dl/dd quotes, references + copy.select("ul, ol, table, figure, div.NavFrame, dl, blockquote").remove() + copy.select("sup.reference, sup, span.citation, span.ref, span.mwe-math-element").remove() + // Now get full text including inline anchors/spans + val raw = copy.text() + return clean(raw) + } + + while (current != null) { + if (elementIsLanguageBoundary(current)) break + + val h3 = if (current.tagName() == "h3") current else current.selectFirst("h3") + val headerText = (h3?.text() ?: current.select("span.mw-headline").firstOrNull()?.text()).orEmpty() + if (headerText.isNotEmpty() && prioritized.any { headerText.contains(it, ignoreCase = true) }) { + if (!foundPos) Log.d("HtmlParser", "POS header '$headerText' found; extracting definitions") + foundPos = true + val listEl = findFirstOlAfter(current.nextElementSibling() ?: current) + var count = 0 + listEl?.children()?.forEach { li -> + if (li.tagName() == "li") { + val text = extractDefinitionFromLi(li) + if (text.isNotBlank()) { + definitions.add(text) + count++ + } + } + } + Log.d("HtmlParser", "Collected $count definitions under '$headerText'") + if (definitions.isNotEmpty()) break + } + current = current.nextElementSibling() + } + } else { + Log.w("HtmlParser", "No language headers found; definitions likely empty") + } + Log.d("HtmlParser", "Returning ${definitions.size} definitions") + return definitions +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/JsonHelper.kt b/app/src/main/java/eu/gaudian/translator/utils/JsonHelper.kt new file mode 100644 index 0000000..d45c775 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/JsonHelper.kt @@ -0,0 +1,292 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Enhanced JSON helper with unified parsing, validation, and error handling. + * Provides robust JSON processing for all API responses in the application. + */ +class JsonHelper { + + private val jsonParser = Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + encodeDefaults = false + } + + /** + * Parses a JSON string into the specified type with comprehensive error handling. + * + * @param json The JSON string to parse + * @param serializer The serializer for the target type + * @param serviceName Name of the service calling this method for logging + * @return Result containing the parsed object or error + */ + fun parseJson( + json: String, + serializer: KSerializer, + serviceName: String = "Unknown" + ): Result { + return try { + Log.d("JsonHelper", "[$serviceName] Parsing JSON response") + val cleanedJson = cleanAndValidateJson(json) + Log.d("JsonHelper", "[$serviceName] Cleaned JSON: $cleanedJson") + + val result = jsonParser.decodeFromString(serializer, cleanedJson) + Log.i("JsonHelper", "[$serviceName] Successfully parsed JSON response") + Result.success(result) + } catch (e: SerializationException) { + val errorMsg = "JSON serialization failed in $serviceName: ${e.message}" + Log.e("JsonHelper", errorMsg, e) + Result.failure(JsonParsingException(errorMsg, e)) + } catch (e: Exception) { + val errorMsg = "Unexpected error parsing JSON in $serviceName: ${e.message}" + Log.e("JsonHelper", errorMsg, e) + Result.failure(JsonParsingException(errorMsg, e)) + } + } + + /** + * Cleans and validates a JSON string, handling common formatting issues. + */ + fun cleanAndValidateJson(json: String): String { + if (json.isBlank()) { + throw IllegalArgumentException("JSON string is blank") + } + + // Use existing JsonCleanUtil for robust cleaning + return JsonCleanUtil.cleanAndCorrectJson(json) { message, exception -> + Log.w("JsonHelper", "JSON cleaning issue: $message", exception?.cause) + } + } + + /** + * Validates that a JSON string contains expected required fields. + */ + fun validateRequiredFields(json: String, requiredFields: List): Boolean { + return try { + val element = jsonParser.parseToJsonElement(json) + if (element is JsonObject) { + requiredFields.all { field -> element.containsKey(field) } + } else { + false + } + } catch (e: Exception) { + Log.e("JsonHelper", "Failed to validate required fields: ${e.message}", e) + false + } + } + + /** + * Extracts a specific field value from JSON as string. + */ + fun extractField(json: String, fieldName: String): String? { + return try { + val element = jsonParser.parseToJsonElement(json) + if (element is JsonObject) { + element[fieldName]?.jsonPrimitive?.content + } else { + null + } + } catch (e: Exception) { + Log.e("JsonHelper", "Failed to extract field '$fieldName': ${e.message}", e) + null + } + } + + /** + * Formats JSON for display purposes. + */ + fun formatForDisplay(json: String): String { + return try { + val element = jsonParser.parseToJsonElement(json) + jsonParser.encodeToString(JsonElement.serializer(), element) + .replace(",", ",\n") + .replace("{", "{\n") + .replace("}", "\n}") + .replace("[", "[\n") + .replace("]", "\n]") + } catch (e: Exception) { + Log.w("JsonHelper", "Failed to format JSON for display: ${e.message}") + json + } + } +} + +/** + * Custom exception for JSON parsing errors. + */ +class JsonParsingException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** + * Legacy JsonHelper class for backward compatibility. + * @deprecated Use the enhanced JsonHelper class instead + */ +@Deprecated("Use the enhanced JsonHelper class instead") +class LegacyJsonHelper { + + fun cleanJson(json: String): String { + val startIndex = json.indexOf('{') + val endIndex = json.lastIndexOf('}') + + if (startIndex == -1 || endIndex == -1 || startIndex >= endIndex) { + throw IllegalArgumentException("Invalid JSON format") + } + + return json.substring(startIndex, endIndex + 1).trim() + } +} + +object JsonCleanUtil { + private val jsonParser = Json { isLenient = true; ignoreUnknownKeys = true } + + /** + * Cleans and corrects a raw string response to make it valid, parsable JSON. + */ + fun cleanAndCorrectJson( + response: String, + onFailure: ((message: String, exception: Exception?) -> Unit)? = null + ): String { + //Log.d("JsonCleanUtil", "Response: $response") + //Isolate the primary JSON block. + val jsonBlock = isolateJsonBlock(response).replace("<|END_RESPONSE|>", "") + if (jsonBlock.isBlank()) { + onFailure?.invoke("No JSON block found in response", null) + return "" + } + + val sanitizedBlock = stripJsonCommentsAndFixCommonErrors(jsonBlock) + Log.d("JsonCleanUtil", onFailure.toString()) + return correctPropertyValues(sanitizedBlock, onFailure) + } + + private fun isolateJsonBlock(response: String): String { + // Handle specific non-JSON tokens first + + // The rest of the function operates on the cleaned response + val markdownRegex = Regex("```json\\s*([\\s\\S]*?)\\s*```") + val markdownMatch = markdownRegex.find(response) + if (markdownMatch != null && markdownMatch.groupValues.size > 1) { + return markdownMatch.groupValues[1] + } + + 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 "" + + val lastBrace = response.lastIndexOf('}') + val lastBracket = response.lastIndexOf(']') + val endIndex = maxOf(lastBrace, lastBracket) + if (endIndex == -1 || startIndex >= endIndex) return "" + + return response.substring(startIndex, endIndex + 1) + } + + /** + * A more robust function to remove comments, stray characters, and fix common structural errors like trailing commas. + */ + private fun stripJsonCommentsAndFixCommonErrors(jsonString: String): String { + val lines = jsonString.lines() + val cleanedLines = mutableListOf() + + for (line in lines) { + // Discard lines that are primarily comments + if (line.trim().startsWith("//")) { + continue + } + // Find the position of a line comment and take the substring before it + val commentPosition = line.indexOf("//") + val cleanLine = if (commentPosition != -1) { + line.take(commentPosition) + } else { + line + } + + // Remove other problematic characters, like the stray '/' from the log + //cleanLine = cleanLine.replace("/", "") + + cleanedLines.add(cleanLine) + } + + var result = cleanedLines.joinToString("\n") + + + @Suppress("RegExpRedundantEscape") + result = result.replace(Regex(",(\\s*[\\]}])"), "$1") + + return result + } + + private fun correctPropertyValues( + jsonString: String, + onFailure: ((message: String, exception: Exception?) -> Unit)? + ): String { + try { + val rootElement = jsonParser.parseToJsonElement(jsonString) + if (rootElement !is JsonObject) return jsonString + + val resultsArray = rootElement["results"] as? JsonArray ?: return jsonString + val correctedResults = buildJsonArray { + for (element in resultsArray) { + if (element is JsonObject) { + val properties = element["properties"] as? JsonObject + if (properties != null) { + val correctedProperties = buildJsonObject { + for ((key, value) in properties) { + // If the AI returns a value in an array, just take the first element. + if (value is JsonArray && value.isNotEmpty()) { + put(key, value.first()) + } else { + put(key, value) + } + } + } + val correctedElement = buildJsonObject { + // Safely add properties that exist in the original element + element["id"]?.let { put("id", it) } + element["wordClass"]?.let { put("wordClass", it) } + put("properties", correctedProperties) + } + add(correctedElement) + } else { + add(element) + } + } else { + add(element) + } + } + } + + return buildJsonObject { put("results", correctedResults) }.toString() + } catch (e: Exception) { + Log.e("JsonCleanUtil", "Failed during JSON correction: ${e.message}") + onFailure?.invoke("Failed during JSON correction: ${e.message}", e) + return jsonString + } + } +} + +fun formatJsonForDisplay(json: String): String { + return try { + // Simple JSON formatting - you can enhance this later + json.replace(",", ",\n").replace("{", "{\n").replace("}", "\n}").replace("[", "[\n").replace("]", "\n]") + } catch (_: Exception) { + json // Fallback to raw JSON if formatting fails + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/Log.kt b/app/src/main/java/eu/gaudian/translator/utils/Log.kt new file mode 100644 index 0000000..d235231 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/Log.kt @@ -0,0 +1,151 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import android.annotation.SuppressLint +import timber.log.Timber + +/** + * A wrapper around Timber to centralize logging and suppress the + * "HardcodedText" lint warning for log messages, which are for + * development purposes only. + */ +object Log { + + @SuppressLint("HardcodedText") + fun d(message: String) { + Timber.d("[DEBUG] $message") + } + + @SuppressLint("HardcodedText") + fun d(tag: String, message: String) { + Timber.tag(tag).d("[DEBUG] $message") + } + + @SuppressLint("HardcodedText") + fun d(throwable: Throwable) { + Timber.d("[DEBUG] ${throwable.message}") + } + + + + @SuppressLint("HardcodedText") + fun d(throwable: Throwable, message: String) { + Timber.d(throwable, "[DEBUG] $message") + } + + @SuppressLint("HardcodedText") + fun d(message: String, throwable: Throwable) { + Timber.d(throwable, "[DEBUG] $message") + } + + @SuppressLint("HardcodedText") + fun i(message: String) { + Timber.i("[INFO] $message") + } + + @SuppressLint("HardcodedText") + fun i(tag : String, message: String) { + Timber.tag(tag).i("[INFO] $message") + } + + @SuppressLint("HardcodedText") + fun w(tag : String, message: String) { + Timber.tag(tag).w("[WARNING] $message") + } + + @SuppressLint("HardcodedText") + fun w(message: String) { + Timber.w("[WARNING] $message") + } + + + + @SuppressLint("HardcodedText", "ThrowableNotAtBeginning") + fun w(tag : String, message: String, throwable: Throwable?) { + Timber.tag(tag).w(throwable, "[WARNING] $message") + } + + + + + @SuppressLint("HardcodedText", "ThrowableNotAtBeginning") + fun w(message : String, throwable: Throwable) { + Timber.w("[WARNING] $message", throwable) + } + + @SuppressLint("HardcodedText", "ThrowableNotAtBeginning") + fun e(throwable: Throwable, message: String) { + Timber.e("[ERROR] $message", throwable) + } + + @SuppressLint("HardcodedText", "ThrowableNotAtBeginning") + fun e(message: String, throwable: Throwable) { + Timber.e("[ERROR] $message", throwable) + } + + @SuppressLint("HardcodedText", "ThrowableNotAtBeginning") + fun e(message: String) { + Timber.e("[ERROR] $message") + } + + @SuppressLint("HardcodedText", "ThrowableNotAtBeginning") + fun e(throwable: Throwable) { + Timber.e("[ERROR] ${throwable.message}") + } + + @SuppressLint("HardcodedText", "ThrowableNotAtBeginning") + fun e(tag: String, message: String) { + Timber.tag(tag).e("[ERROR] $message") + } + + @SuppressLint("HardcodedText", "ThrowableNotAtBeginning") + fun e(tag: String, message: String, throwable: Throwable?) { + Timber.tag(tag).e(throwable, "[ERROR] $message") + } + + @SuppressLint("HardcodedText") + fun v(message: String) { + Timber.v("[VERBOSE] $message") + } + + @SuppressLint("HardcodedText") + fun v(tag: String, message: String) { + Timber.tag(tag).v("[VERBOSE] $message") + } + + @SuppressLint("HardcodedText") + fun v(throwable: Throwable, message: String) { + Timber.v(throwable, "[VERBOSE] $message") + } + + @SuppressLint("HardcodedText") + fun v(message: String, throwable: Throwable) { + Timber.v(throwable, "[VERBOSE] $message") + } + + @SuppressLint("HardcodedText") + fun wtf(message: String) { + Timber.wtf("[ASSERT] $message") + } + + @SuppressLint("HardcodedText") + fun wtf(tag: String, message: String) { + Timber.tag(tag).wtf("[ASSERT] $message") + } + + @SuppressLint("HardcodedText") + fun wtf(throwable: Throwable) { + Timber.wtf(throwable, "[ASSERT] ${throwable.message}") + } + + @SuppressLint("HardcodedText") + fun wtf(throwable: Throwable, message: String) { + Timber.wtf(throwable, "[ASSERT] $message") + } + + @SuppressLint("HardcodedText") + fun wtf(message: String, throwable: Throwable) { + Timber.wtf(throwable, "[ASSERT] $message") + } +} diff --git a/app/src/main/java/eu/gaudian/translator/utils/MarkDownParser.kt b/app/src/main/java/eu/gaudian/translator/utils/MarkDownParser.kt new file mode 100644 index 0000000..c8a98a9 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/MarkDownParser.kt @@ -0,0 +1,49 @@ +package eu.gaudian.translator.utils + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight + +/** + * Formats a simple subset of Markdown (bold, italic) into an AnnotatedString. + */ +fun formatMarkdownText(text: String): AnnotatedString { + val formattedText = text.trim() + return buildAnnotatedString { + var i = 0 + while (i < formattedText.length) { + when { + formattedText.startsWith("**", i) -> { + val endIndex = formattedText.indexOf("**", i + 2) + if (endIndex != -1) { + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append(formattedText.substring(i + 2, endIndex)) + pop() + i = endIndex + 2 + } else { + append(formattedText[i]) + i++ + } + } + formattedText.startsWith("*", i) -> { + val endIndex = formattedText.indexOf("*", i + 1) + if (endIndex != -1) { + pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) + append(formattedText.substring(i + 1, endIndex)) + pop() + i = endIndex + 1 + } else { + append(formattedText[i]) + i++ + } + } + else -> { + append(formattedText[i]) + i++ + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/PromptBuilder.kt b/app/src/main/java/eu/gaudian/translator/utils/PromptBuilder.kt new file mode 100644 index 0000000..7ca0d1b --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/PromptBuilder.kt @@ -0,0 +1,40 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +class PromptBuilder(var basePrompt: String) { + + private val details = mutableListOf() + private var jsonStructure: String? = null + private var example: String? = null + + fun addDetail(detail: String): PromptBuilder { + details.add(detail) + return this + } + + fun withJsonResponse(description: String): PromptBuilder { + this.jsonStructure = "Return strictly valid JSON (RFC 8259): double-quoted keys and string values, no comments, no trailing commas, no markdown fences: $description." + return this + } + + fun withExample(originalText: String): PromptBuilder { + this.example = "Original Text: \"$originalText\"" + return this + } + + fun build(): String { + var prompt = basePrompt + if (details.isNotEmpty()) { + prompt += " " + details.joinToString(" ") + } + jsonStructure?.let { + prompt += " $it" + } + example?.let { + prompt += "\n\n$it" + } + // Ensures the prompt is clean and compact. + return prompt.trimIndent().replace(Regex("\\s+"), " ") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/ProviderConfigParser.kt b/app/src/main/java/eu/gaudian/translator/utils/ProviderConfigParser.kt new file mode 100644 index 0000000..3e00d51 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/ProviderConfigParser.kt @@ -0,0 +1,80 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import android.content.Context +import eu.gaudian.translator.model.LanguageModel +import eu.gaudian.translator.model.communication.ApiProvider +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Data classes for parsing the providers configuration JSON + */ +@Serializable +data class ProviderConfig( + val providers: List +) + +@Serializable +data class ProviderData( + val key: String, + val displayName: String, + val baseUrl: String, + val endpoint: String, + val websiteUrl: String, + val isCustom: Boolean = false, + val models: List +) + +@Serializable +data class ModelData( + val modelId: String, + val displayName: String, + val provider: String, + val description: String +) + +/** + * Utility class for parsing provider configuration from JSON + */ +object ProviderConfigParser { + + private val json = Json { ignoreUnknownKeys = true } + + /** + * Loads and parses the providers configuration from the assets folder + */ + fun loadProvidersFromAssets(context: Context): List { + return try { + val jsonString = context.assets.open("providers_config.json") + .bufferedReader() + .use { it.readText() } + + val config = json.decodeFromString(jsonString) + + config.providers.map { providerData -> + ApiProvider( + key = providerData.key, + displayName = providerData.displayName, + baseUrl = providerData.baseUrl, + endpoint = providerData.endpoint, + websiteUrl = providerData.websiteUrl, + isCustom = providerData.isCustom, + models = providerData.models.map { modelData -> + LanguageModel( + modelId = modelData.modelId, + displayName = modelData.displayName, + providerKey = modelData.provider, + description = modelData.description, + isCustom = providerData.isCustom + ) + } + ) + } + } catch (e: Exception) { + Log.e("ProviderConfigParser", "Failed to load providers from JSON", e) + emptyList() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/PunctuationPreserver.kt b/app/src/main/java/eu/gaudian/translator/utils/PunctuationPreserver.kt new file mode 100644 index 0000000..3c2cf16 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/PunctuationPreserver.kt @@ -0,0 +1,92 @@ +package eu.gaudian.translator.utils + + +object PunctuationPreserver { + + // Punctuation that should have a space after + private val requiresSpaceAfter = setOf(',', ';', ':') + + // Punctuation that in some languages commonly has a space before it (e.g., French) + private val mayRequireSpaceBefore = setOf('?', '!', ';', ':') + + fun preserveSpacing(original: String, translated: String): String { + if (original.isBlank() || translated.isBlank()) return translated + + var result = translated + + // 1) If original has space before any of the specified marks, ensure translated also has one + for (mark in mayRequireSpaceBefore) { + val origHas = originalHasSpaceBefore(original, mark) + if (origHas) { + result = ensureSpaceBefore(result, mark) + } + } + + // 2) If original has a space after commas/semicolons/colons, ensure translated also has one + for (mark in requiresSpaceAfter) { + val origHas = originalHasSpaceAfter(original, mark) + if (origHas) { + result = ensureSpaceAfter(result, mark) + } + } + + return result + } + + private fun originalHasSpaceBefore(text: String, ch: Char): Boolean { + var i = text.indexOf(ch) + while (i >= 0) { + if (i > 0 && text[i - 1].isWhitespace()) return true + i = text.indexOf(ch, i + 1) + } + return false + } + + private fun originalHasSpaceAfter(text: String, ch: Char): Boolean { + var i = text.indexOf(ch) + while (i >= 0) { + if (i < text.lastIndex && text[i + 1].isWhitespace()) return true + i = text.indexOf(ch, i + 1) + } + return false + } + + private fun ensureSpaceBefore(text: String, ch: Char): String { + // Replace occurrences of X where directly preceded by non-space with space + // Patterns: "word?" -> "word ?"; also handle cases with multiple punctuation. + val sb = StringBuilder() + var i = 0 + while (i < text.length) { + val c = text[i] + if (c == ch) { + // if there is no space before, add one (but avoid adding at start) + if (i > 0 && !text[i - 1].isWhitespace()) { + sb.append(' ') + } + sb.append(c) + i++ + } else { + sb.append(c) + i++ + } + } + return sb.toString() + } + + private fun ensureSpaceAfter(text: String, ch: Char): String { + val sb = StringBuilder() + var i = 0 + while (i < text.length) { + val c = text[i] + sb.append(c) + if (c == ch) { + val next = if (i + 1 <= text.lastIndex) text[i + 1] else null + if (next != null && !next.isWhitespace()) { + sb.append(' ') + } + } + i++ + } + return sb.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/ResourceUsageDummy.kt b/app/src/main/java/eu/gaudian/translator/utils/ResourceUsageDummy.kt new file mode 100644 index 0000000..73c6952 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/ResourceUsageDummy.kt @@ -0,0 +1,132 @@ +@file:Suppress("UNUSED_PARAMETER", "UNUSED_VARIABLE", "HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import android.content.Context +import androidx.annotation.Keep +import eu.gaudian.translator.R + +//Just a dummy so they appear as used in the XML file and I don't get a few dozens errors +@Keep +val LANGUAGE_STRING_IDS: IntArray = intArrayOf( + R.string.language_1, + R.string.language_2, + R.string.language_3, + R.string.language_4, + R.string.language_5, + R.string.language_6, + R.string.language_7, + R.string.language_8, + R.string.language_9, + R.string.language_10, + R.string.language_11, + R.string.language_12, + R.string.language_13, + R.string.language_14, + R.string.language_15, + R.string.language_16, + R.string.language_17, + R.string.language_18, + R.string.language_19, + R.string.language_20, + R.string.language_21, + R.string.language_22, + R.string.language_23, + R.string.language_24, + R.string.language_25, + R.string.language_26, + R.string.language_27, + R.string.language_28, + R.string.language_29, + R.string.language_30, + R.string.language_31, + R.string.language_32, + R.string.language_33, + R.string.language_34, + R.string.language_35, + R.string.language_36, + R.string.language_37, + R.string.language_38, + R.string.language_39, + R.string.language_40, + R.string.language_41, + R.string.language_42, + R.string.language_43, + R.string.language_44, + R.string.language_45, + R.string.language_46, + R.string.language_47, + R.string.language_48, + R.string.language_49, + R.string.language_50, + R.string.language_51, + + R.string.native_language_1, + R.string.native_language_2, + R.string.native_language_3, + R.string.native_language_4, + R.string.native_language_5, + R.string.native_language_6, + R.string.native_language_7, + R.string.native_language_8, + R.string.native_language_9, + R.string.native_language_10, + R.string.native_language_11, + R.string.native_language_12, + R.string.native_language_13, + R.string.native_language_14, + R.string.native_language_15, + R.string.native_language_16, + R.string.native_language_17, + R.string.native_language_18, + R.string.native_language_19, + R.string.native_language_20, + R.string.native_language_21, + R.string.native_language_22, + R.string.native_language_23, + R.string.native_language_24, + R.string.native_language_25, + R.string.native_language_26, + R.string.native_language_27, + R.string.native_language_28, + R.string.native_language_29, + R.string.native_language_30, + R.string.native_language_31, + R.string.native_language_32, + R.string.native_language_33, + R.string.native_language_34, + R.string.native_language_35, + R.string.native_language_36, + R.string.native_language_37, + R.string.native_language_38, + R.string.native_language_39, + R.string.native_language_40, + R.string.native_language_41, + R.string.native_language_42, + R.string.native_language_43, + R.string.native_language_44, + R.string.native_language_45, + R.string.native_language_46, + R.string.native_language_47, + R.string.native_language_48, + R.string.native_language_49, + R.string.native_language_50, + R.string.native_language_51, + + + +) + +fun useLanguageResources(context: Context?) { + val codesId = R.array.language_codes + val localContext = context ?: return + + val res = localContext.resources + val codes: Array = res.getStringArray(codesId) + val text = buildString { + for (id in LANGUAGE_STRING_IDS) { + append(res.getString(id)).append('\n') + } + if (codes.isNotEmpty()) append(codes[0]) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/StatusMessageService.kt b/app/src/main/java/eu/gaudian/translator/utils/StatusMessageService.kt new file mode 100644 index 0000000..f8ce685 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/StatusMessageService.kt @@ -0,0 +1,56 @@ +@file:Suppress("HardCodedStringLiteral") +package eu.gaudian.translator.utils + +import eu.gaudian.translator.viewmodel.MessageAction +import eu.gaudian.translator.viewmodel.MessageDisplayType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +/** + * A sealed class representing all possible actions that can be sent to the status system. + */ +sealed class StatusAction { + data class ShowMessage(val text: String, val type: MessageDisplayType, val timeoutInSeconds: Int) : StatusAction() + data class ShowPermanentMessage(val text: String, val type: MessageDisplayType) : StatusAction() + object CancelPermanentMessage : StatusAction() + data class PerformLoadingOperation(val block: suspend () -> Unit) : StatusAction() + object CancelLoadingOperation : StatusAction() + object HideMessageBar : StatusAction() + object CancelAllMessages : StatusAction() + + data class ShowActionableMessage(val text: String, val type: MessageDisplayType, val action: MessageAction) : StatusAction() +} + +/** + * A singleton object that acts as a central event bus for status messages. + * Any part of the app can trigger an action, and any StatusViewModel listening will receive it. + */ +object StatusMessageService { + private val _actions = MutableSharedFlow() + val actions = _actions.asSharedFlow() + private val scope = CoroutineScope(Dispatchers.Default) + + suspend fun trigger(action: StatusAction) { + Log.d("StatusMessageService", "Received action: $action") + _actions.emit(action) + } + + fun triggerNonSuspend(action: StatusAction) { + Log.d("StatusMessageService", "Received non-suspend action: $action") + scope.launch { + _actions.emit(action) + } + } + + fun showSimpleMessage(text: String, type: MessageDisplayType = MessageDisplayType.INFO) { + scope.launch { + _actions.emit(StatusAction.ShowMessage(text, type, 5)) + } + + + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/StringHelper.kt b/app/src/main/java/eu/gaudian/translator/utils/StringHelper.kt new file mode 100644 index 0000000..56f5e9e --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/StringHelper.kt @@ -0,0 +1,51 @@ +package eu.gaudian.translator.utils + +object StringHelper { + // Strict sentence detection: starts with capital, ends with . ! ?, and contains a space + fun isSentenceStrict(text: String): Boolean { + val trimmed = text.trim() + if (trimmed.isEmpty()) return false + val startsWithCapital = trimmed.first().isUpperCase() + val endsWithPunctuation = trimmed.endsWith('.') || trimmed.endsWith('!') || trimmed.endsWith('?') + val hasMultipleWords = trimmed.contains(" ") + return hasMultipleWords && startsWithCapital && endsWithPunctuation + } + + // Loose sentence detection for exercises: either looks like a sentence or is very long + fun isSentenceLoose(text: String): Boolean { + val trimmed = text.trim() + if (trimmed.isEmpty()) return false + val hasMultipleWords = trimmed.contains(" ") + val endsWithPunctuation = trimmed.endsWith('.') || trimmed.endsWith('!') || trimmed.endsWith('?') + val tooLong = trimmed.length > 15 + return (hasMultipleWords && endsWithPunctuation) || tooLong + } + + // Build a regex to remove any of the provided leading articles followed by space + @Suppress("HardCodedStringLiteral", "RegExpUnnecessaryNonCapturingGroup") + fun buildLeadingArticlesRegex(articles: Collection): Regex { + val cleaned = articles.filter { it.isNotBlank() }.joinToString("|") { Regex.escape(it.trim()) } + return if (cleaned.isEmpty()) Regex("^$") else Regex("^(?:$cleaned)\\s+", RegexOption.IGNORE_CASE) + } + + fun removeLeadingArticles(text: String, articles: Collection): String { + val pattern = buildLeadingArticlesRegex(articles) + return text.replace(pattern, "") + } + + fun removeLeadingArticlesOneOf(text: String, articles: Collection): String { + // remove only the first matching leading article once + val pattern = buildLeadingArticlesRegex(articles) + return text.replaceFirst(pattern, "") + } + + // Normalize answers: lowercase, trim, remove parenthetical info + fun normalizeAnswer(answer: String): String { + @Suppress("HardCodedStringLiteral") + return answer.lowercase().trim().replace(Regex("\\s*\\(.*?\\)"), "").trim() + } + + fun possibleAnswersFromSlash(answer: String): List { + return answer.split('/').map { it.trim() }.filter { it.isNotEmpty() } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/TextToSpeechHelper.kt b/app/src/main/java/eu/gaudian/translator/utils/TextToSpeechHelper.kt new file mode 100644 index 0000000..c851e14 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/TextToSpeechHelper.kt @@ -0,0 +1,170 @@ +// eu/gaudian/translator/utils/TextToSpeechHelper.kt +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import android.content.Context +import android.speech.tts.TextToSpeech +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.repository.SettingsRepository +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.Locale +import kotlin.coroutines.resume + + +object TextToSpeechHelper { + + private var textToSpeech: TextToSpeech? = null + private var languageMap: Map? = null + + private val playableCache = mutableMapOf() + private val mutex = Mutex() + + /** + * Extended speakOut allowing optional voiceName and speaking speed percentage (50..200). + */ + suspend fun speakOut(context: Context, text: String, language: Language, voiceName: String?) { + val tts = getInitializedTtsInstance(context) ?: return + + // Ensure a clean state so engine applies new params (voice/rate) reliably on all OEMs + try { tts.stop() } catch (_: Exception) {} + + val locales = getLanguageMap(context) + val locale = locales[language.code.lowercase(Locale.getDefault())] ?: Locale.US + + val result = tts.setLanguage(locale) + if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) { + Log.e("Language ${language.code} not supported.") + return + } + + // Apply voice by name if provided and available + if (!voiceName.isNullOrBlank()) { + val v = tts.voices?.firstOrNull { it.name == voiceName } + if (v != null) { + try { tts.voice = v } catch (e: Exception) { Log.w("Failed to set TTS voice: ${e.message}") } + } + } else { + // try to select a voice that matches the locale language + val v = tts.voices?.firstOrNull { it.locale?.language == locale.language } + if (v != null) { + try { tts.voice = v } catch (_: Exception) {} + } + } + + // Resolve effective speed: prefer provided value; otherwise, use saved setting + val effectiveSpeedPercent: Int = try { + SettingsRepository(context).speakingSpeed.flow.first() + } catch (_: Exception) { + Log.d("Failed to get speaking speed setting, using default") + 100 + } + Log.d("Effective speed: $effectiveSpeedPercent") + val clamped = effectiveSpeedPercent.coerceIn(50, 200) + val rate = clamped / 100f + try { tts.setSpeechRate(rate) } catch (e: Exception) { Log.w("Failed to set speech rate: ${e.message}") } + Log.d("TTS speakOut: lang=${language.code}-${language.region} voice=${voiceName ?: ""} rate=$rate (${clamped}%)") + + // Keep pitch at default (1.0f) to make rate differences clearer + try { tts.setPitch(1.0f) } catch (_: Exception) {} + + tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, null) + } + + /** + * Checks if a given language is supported and playable by the TTS engine. + * Results are cached for performance. + * + * @param language The language to check. + * @return `true` if the language is supported, `false` otherwise. + */ + suspend fun isPlayable(context: Context, language: Language?): Boolean { + if (language == null) return false + + mutex.withLock { + if (playableCache.containsKey(language)) { + val cachedValue = playableCache[language]!! + return cachedValue + } + } + + Log.d("Checking if language is playable: $language") + val tts = getInitializedTtsInstance(context) ?: return false + + val locales = getLanguageMap(context) + val locale = locales[language.code.lowercase(Locale.getDefault())] + + // 3. Perform the slow check ONLY if the result is not in the cache. + val result = tts.setLanguage(locale) + val isSupported = !(result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) + + // 4. Store the new result in the cache. + mutex.withLock { + playableCache[language] = isSupported + } + + return isSupported + } + + + private suspend fun getInitializedTtsInstance(context: Context): TextToSpeech? { + textToSpeech?.let { return it } + + return mutex.withLock { + textToSpeech?.let { return@withLock it } + + var ttsInstance: TextToSpeech? = null + val success = suspendCancellableCoroutine { continuation -> + ttsInstance = TextToSpeech(context.applicationContext) { status -> + if (continuation.isActive) { + continuation.resume(status == TextToSpeech.SUCCESS) + } + } + } + + if (success) { + textToSpeech = ttsInstance + ttsInstance + } else { + Log.e("TextToSpeech initialization failed.") + null + } + } + } + + suspend fun getVoicesForLanguage(context: Context, language: Language): List { + val tts = getInitializedTtsInstance(context) ?: return emptyList() + val locale = getLanguageMap(context)[language.code.lowercase(Locale.getDefault())] + val voices = tts.voices ?: return emptyList() + if (locale == null) return voices.toList() + + val langCode = locale.language + val regionCode = language.region.uppercase(Locale.getDefault()) + + // Be lenient: always filter by language first, then prefer exact region match + val languageMatches = voices.filter { it.locale?.language == langCode } + if (languageMatches.isNotEmpty()) { + // Sort so that exact region/country matches come first + return languageMatches.sortedByDescending { it.locale?.country.equals(regionCode, ignoreCase = true) } + } + // Fallback: if OEM reports unexpected locales, show everything rather than empty + return voices.toList() + } + + private fun getLanguageMap(context: Context): Map { + languageMap?.let { return it } + val codes = context.resources.getStringArray(R.array.language_codes) + val newMap = codes.associate { + val parts = it.split(",") + val languageName = parts[0].lowercase(Locale.getDefault()) + val localeCode = parts[1] + languageName to Locale(languageName, localeCode) + } + languageMap = newMap + return newMap + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/TranslationService.kt b/app/src/main/java/eu/gaudian/translator/utils/TranslationService.kt new file mode 100644 index 0000000..ca07360 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/TranslationService.kt @@ -0,0 +1,297 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import android.content.Context +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.TranslationHistoryItem +import eu.gaudian.translator.model.communication.ApiManager +import eu.gaudian.translator.model.repository.ApiRepository +import eu.gaudian.translator.model.repository.LanguageRepository +import eu.gaudian.translator.model.repository.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody + +class TranslationService(private val context: Context) { + + private val settingsRepository = SettingsRepository(context) + private val apiRepository = ApiRepository(context) + private val apiRequestHandler = ApiRequestHandler(ApiManager(context), context) + private val languageRepository = LanguageRepository(context) + + private val libreBaseUrl: String = "http://23.88.48.47:5001" + private val httpClient: okhttp3.OkHttpClient by lazy { okhttp3.OkHttpClient.Builder().build() } + @Volatile private var cachedLibreLangs: Set? = null + + private fun Language.toLibreCode(): String { + val c = this.code.lowercase() + val r = this.region.trim() + return if (r.isBlank()) c else "$c-$r" + } + + private suspend fun getLibreSupportedCodes(): Set = withContext(Dispatchers.IO) { + cachedLibreLangs?.let { return@withContext it } + try { + val url = "$libreBaseUrl/languages" + val req = okhttp3.Request.Builder().url(url).get().build() + val resp = httpClient.newCall(req).execute() + val body = resp.body.string() + val codes = mutableSetOf() + val arr = org.json.JSONArray(body) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + val code = obj.optString("code").trim() + if (code.isNotEmpty()) codes.add(code) + } + cachedLibreLangs = codes + codes + } catch (e: Exception) { + Log.e("TranslationService", "Failed to fetch LibreTranslate languages", e) + emptySet() + } + } + + private suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result = withContext(Dispatchers.IO) { + try { + val json = org.json.JSONObject().apply { + put("q", text) + put("source", source?.ifBlank { "auto" } ?: "auto") + put("target", target) + put("format", "text") + put("alternatives", alternatives) + put("api_key", "") + } + val media = "application/json; charset=utf-8".toMediaType() + val body = json.toString().toRequestBody(media) + val req = okhttp3.Request.Builder() + .url("$libreBaseUrl/translate") + .post(body) + .header("Content-Type", "application/json") + .build() + val resp = httpClient.newCall(req).execute() + val respBody = resp.body.string() + if (!resp.isSuccessful) return@withContext Result.failure(Exception("LibreTranslate HTTP ${resp.code}")) + val obj = org.json.JSONObject(respBody) + val translated = obj.optString("translatedText").ifBlank { null } + if (translated == null) Result.failure(Exception("No translatedText in response")) else Result.success(translated) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun simpleTranslateTo(text: String, targetLanguage: Language): Result = withContext(Dispatchers.IO) { + Log.d("simpleTranslateTo: $text, $targetLanguage") + //TODO fix this, as it is broken currently + val useLibre = settingsRepository.useLibreTranslate.flow.first() +// if (useLibre) { +// val targetCode = targetLanguage.toLibreCode() +// val supported = getLibreSupportedCodes() +// val supportsTarget = supported.contains(targetCode) || supported.contains(targetCode.substringBefore('-')) +// if (supportsTarget) { +// val sourceCodeFull = languageRepository.loadSelectedSourceLanguage().first()?.toLibreCode() +// val targetForRequest = if (supported.contains(targetCode)) targetCode else targetCode.substringBefore('-') +// val sourceForRequest = sourceCodeFull?.let { sc -> if (supported.contains(sc)) sc else sc.substringBefore('-') } +// val libreResult = libreTranslate(text, sourceForRequest, targetForRequest, alternatives = 3) +// if (libreResult.isSuccess) { +// return@withContext libreResult.map { translated -> +// PunctuationPreserver.preserveSpacing(original = text, translated = translated) +// } +// } +// } +// } + + val sourceLangName = languageRepository.loadSelectedSourceLanguage().first()?.englishName ?: "Auto" + val template = TextTranslationRequest( + text = text, + sourceLanguage = sourceLangName, + targetLanguage = targetLanguage.englishName + ) + + apiRequestHandler.executeRequest(template).map { apiResponse -> + PunctuationPreserver.preserveSpacing(original = text, translated = apiResponse.translatedText) + } + } + + suspend fun translateSentence(sentence: String): Result = withContext(Dispatchers.IO) { + val additionalInstructions = settingsRepository.customPromptTranslation.flow.first() + val selectedSource = languageRepository.loadSelectedSourceLanguage().first() + val sourceLangName = selectedSource?.englishName ?: "Auto" + val targetLang = languageRepository.loadSelectedTargetLanguage().first() + ?: return@withContext Result.failure(Exception("Target language is not selected.")) + + val useLibre = settingsRepository.useLibreTranslate.flow.first() + if (useLibre) { + val targetCode = targetLang.toLibreCode() + val supported = getLibreSupportedCodes() + val supportsTarget = supported.contains(targetCode) || supported.contains(targetCode.substringBefore('-')) + if (supportsTarget) { + val sourceCodeFull = selectedSource?.toLibreCode() + val targetForRequest = if (supported.contains(targetCode)) targetCode else targetCode.substringBefore('-') + val sourceForRequest = sourceCodeFull?.let { sc -> if (supported.contains(sc)) sc else sc.substringBefore('-') } + val libreResult = libreTranslate(sentence, sourceForRequest, targetForRequest, alternatives = 3) + if (libreResult.isSuccess) { + return@withContext libreResult.map { translated -> + val fixed = PunctuationPreserver.preserveSpacing(original = sentence, translated = translated) + TranslationHistoryItem( + text = fixed, + sourceLanguageCode = selectedSource?.nameResId, + targetLanguageCode = selectedSource?.nameResId, + playable = TextToSpeechHelper.isPlayable(context, targetLang), + sourceText = sentence, + // FIXED: Removed translatedText = fixed + translationSource = context.getString(R.string.translation_server), + translationModel = null + ) + } + } + } + } + + val template = TextTranslationRequest( + text = sentence, + sourceLanguage = sourceLangName, + targetLanguage = targetLang.englishName, + additionalInstructions = additionalInstructions + ) + + val translationModel = try { + apiRepository.getTranslationModel().first()?.displayName + } catch (e: Exception) { + Log.e("TranslationService", "Failed to get translation model", e) + null + } + + apiRequestHandler.executeRequest(template).map { apiResponse -> + val fixed = PunctuationPreserver.preserveSpacing(original = sentence, translated = apiResponse.translatedText) + TranslationHistoryItem( + text = fixed, + sourceLanguageCode = selectedSource?.nameResId, + targetLanguageCode = selectedSource?.nameResId, + playable = TextToSpeechHelper.isPlayable(context, targetLang), + sourceText = sentence, + // FIXED: Removed translatedText = fixed + translationSource = context.getString(R.string.label_ai_model), + translationModel = translationModel + ) + } + } + + suspend fun translateVocabulary(word: String): Result = withContext(Dispatchers.IO) { + val selectedSource = languageRepository.loadSelectedSourceLanguage().first() + val targetLang = languageRepository.loadSelectedTargetLanguage().first() + ?: return@withContext Result.failure(Exception("Target language is not selected.")) + + val useLibre = settingsRepository.useLibreTranslate.flow.first() + if (useLibre) { + val targetCode = targetLang.toLibreCode() + val supported = getLibreSupportedCodes() + val supportsTarget = supported.contains(targetCode) || supported.contains(targetCode.substringBefore('-')) + if (supportsTarget) { + val sourceCodeFull = selectedSource?.toLibreCode() + val targetForRequest = if (supported.contains(targetCode)) targetCode else targetCode.substringBefore('-') + val sourceForRequest = sourceCodeFull?.let { sc -> if (supported.contains(sc)) sc else sc.substringBefore('-') } + val libreResult = libreTranslate(word, sourceForRequest, targetForRequest, alternatives = 1) + if (libreResult.isSuccess) { + return@withContext libreResult.map { translatedText -> + val fixed = PunctuationPreserver.preserveSpacing(original = word, translated = translatedText) + TranslationHistoryItem( + text = fixed, + sourceLanguageCode = selectedSource?.nameResId, + targetLanguageCode = targetLang.nameResId, + sourceText = word, + // FIXED: Removed translatedText = fixed + translationSource = context.getString(R.string.translation_server), + translationModel = null + ) + } + } + } + } + + val sourceLangName = selectedSource?.englishName ?: "Auto" + val template = TextTranslationRequest( + text = word, + sourceLanguage = sourceLangName, + targetLanguage = targetLang.englishName + ) + + val translationModel = try { + apiRepository.getTranslationModel().first()?.displayName + } catch (e: Exception) { + Log.e("TranslationService", "Failed to get translation model", e) + null + } + + apiRequestHandler.executeRequest(template).map { response -> + val fixed = PunctuationPreserver.preserveSpacing(original = word, translated = response.translatedText) + TranslationHistoryItem( + text = fixed, + sourceLanguageCode = selectedSource?.nameResId, + targetLanguageCode = targetLang.nameResId, + sourceText = word, + // FIXED: Removed translatedText = fixed + translationSource = context.getString(R.string.label_ai_model), + translationModel = translationModel + ) + } + } + + suspend fun getMultipleSynonyms(word: String, contextPhrase: String? = null): Result> = withContext(Dispatchers.IO) { + val targetLang = languageRepository.loadSelectedTargetLanguage().first() + ?: return@withContext Result.failure(Exception("Target language is not selected.")) + + val template = SynonymListRequest( + word = word, + language = targetLang.englishName, + contextPhrase = contextPhrase + ) + + apiRequestHandler.executeRequest(template).map { response -> + response.items + } + } + + suspend fun explainTranslation(sourceText: String, translatedText: String): Result = withContext(Dispatchers.IO) { + val sourceLang = languageRepository.loadSelectedSourceLanguage().first()?.englishName ?: "Auto" + val targetLang = languageRepository.loadSelectedTargetLanguage().first() + ?: return@withContext Result.failure(Exception("Target language is not selected.")) + + val template = TranslationExplanationRequest( + sourceText = sourceText, + translatedText = translatedText, + sourceLanguage = sourceLang, + targetLanguage = targetLang.englishName + ) + + apiRequestHandler.executeRequest(template).map { it.explanation } + } + + suspend fun rephraseWithAlternative( + sourceText: String, + currentTranslation: String, + originalWord: String, + chosenAlternative: String + ): Result = withContext(Dispatchers.IO) { + val sourceLang = languageRepository.loadSelectedSourceLanguage().first()?.englishName ?: "Auto" + val targetLang = languageRepository.loadSelectedTargetLanguage().first() + ?: return@withContext Result.failure(Exception("Target language is not selected.")) + + val template = RephraseRequest( + sourceText = sourceText, + currentTranslation = currentTranslation, + originalWord = originalWord, + chosenAlternative = chosenAlternative, + sourceLanguage = sourceLang, + targetLanguage = targetLang.englishName + ) + + apiRequestHandler.executeRequest(template) + .map { apiResponse -> + PunctuationPreserver.preserveSpacing(original = sourceText, translated = apiResponse.translatedText) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/VocabularyService.kt b/app/src/main/java/eu/gaudian/translator/utils/VocabularyService.kt new file mode 100644 index 0000000..e6b49b3 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/VocabularyService.kt @@ -0,0 +1,560 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import android.app.Application +import android.content.Context +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.model.communication.ApiManager +import eu.gaudian.translator.model.grammar.GrammaticalFeature +import eu.gaudian.translator.model.grammar.LanguageConfig +import eu.gaudian.translator.model.grammar.VocabularyFeatures +import eu.gaudian.translator.model.repository.SettingsRepository +import eu.gaudian.translator.utils.StringHelper.isSentenceStrict +import eu.gaudian.translator.viewmodel.LanguageConfigViewModel +import eu.gaudian.translator.viewmodel.MessageDisplayType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class VocabularyService(context: Context) { + + private val apiRequestHandler = ApiRequestHandler(ApiManager(context), context) + private val settingsRepository = SettingsRepository(context) + private val languageConfigViewModel = + LanguageConfigViewModel(context.applicationContext as Application) + private val jsonParser = Json { ignoreUnknownKeys = true; isLenient = true } + + suspend fun fetchZipfFrequency(word: String, languageCode: String): Result = withContext(Dispatchers.IO) { + if (isSentenceStrict(word)) { + return@withContext Result.failure(Exception("Cannot fetch frequency for a sentence")) + } + try { + Log.d("VocabularyService", "Fetching zipf frequency for word '$word' in language '$languageCode'") + + val encodedWord = java.net.URLEncoder.encode(word, Charsets.UTF_8.name()) + val encodedLang = java.net.URLEncoder.encode(languageCode, Charsets.UTF_8.name()) + val urlString = "http://23.88.48.47:5100/zipf_frequency?word=$encodedWord&lang=$encodedLang" + + val url = java.net.URL(urlString) + val connection = (url.openConnection() as java.net.HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = 8000 + readTimeout = 8000 + } + + try { + val responseCode = connection.responseCode + val responseMessage = connection.responseMessage + val stream = if (responseCode in 200..299) connection.inputStream else connection.errorStream + val body = stream?.bufferedReader()?.use { it.readText() } ?: "" + + if (responseCode in 200..299) { + val jsonObject = Json.parseToJsonElement(body).jsonObject + val zipfFrequency = jsonObject["zipf_frequency"]?.jsonPrimitive?.content?.toFloatOrNull() + if (zipfFrequency != null) { + Log.d("VocabularyService", "Successfully fetched zipf frequency: $zipfFrequency") + Result.success(zipfFrequency) + } else { + Log.w("VocabularyService", "Invalid zipf frequency response format: $body") + Result.failure(Exception("Invalid response format")) + } + } else { + Log.w("VocabularyService", "Failed to fetch zipf frequency: $responseCode - $responseMessage | $body") + Result.failure(Exception("HTTP $responseCode: $responseMessage")) + } + } finally { + connection.disconnect() + } + } catch (e: Exception) { + Log.e("VocabularyService", "Error fetching zipf frequency: ${e.message}") + Result.failure(e) + } + } + + suspend fun translateWordsBatch( + words: List, + languageFirst: Language, + languageSecond: Language + ): Result> { + if (words.isEmpty()) return Result.success(emptyList()) + + return try { + Log.i("VocabularyService", "Translating batch of ${words.size} words from ${languageFirst.englishName} to ${languageSecond.englishName}") + + val template = VocabularyTranslationRequest( + words = words.filter { it.isNotBlank() }.distinct(), + languageFirst = languageFirst.englishName, + languageSecond = languageSecond.englishName + ) + + val result = apiRequestHandler.executeRequest(template) + + result.map { apiResponse -> + val mappedItems = apiResponse.flashcards.mapNotNull { flashcard -> + val frontIsLangFirst = flashcard.front.language.equals(languageFirst.englishName, true) + val backIsLangSecond = flashcard.back.language.equals(languageSecond.englishName, true) + val frontIsLangSecond = flashcard.front.language.equals(languageSecond.englishName, true) + val backIsLangFirst = flashcard.back.language.equals(languageFirst.englishName, true) + when { + frontIsLangFirst && backIsLangSecond -> VocabularyItem( + id = 0, + languageFirstId = languageFirst.nameResId, + languageSecondId = languageSecond.nameResId, + wordFirst = flashcard.front.word, + wordSecond = flashcard.back.word + ) + frontIsLangSecond && backIsLangFirst -> VocabularyItem( + id = 0, + languageFirstId = languageFirst.nameResId, + languageSecondId = languageSecond.nameResId, + wordFirst = flashcard.back.word, + wordSecond = flashcard.front.word + ) + else -> null + } + } + Log.i("VocabularyService", "Successfully translated ${mappedItems.size} out of ${words.size} words") + mappedItems + }.onFailure { exception -> + Log.e("VocabularyService", "Failed to translate word batch", exception) + } + } catch (e: Exception) { + Log.e("VocabularyService", "Unexpected error in translateWordsBatch", e) + Result.failure(e) + } + } + + suspend fun generateVocabularyItems( + category: String, + languageFirst: Language, + languageSecond: Language, + amount: Int + ): Result> { + return try { + Log.i("VocabularyService", "Generating $amount vocabulary items for category '$category' (${languageFirst.englishName} -> ${languageSecond.englishName})") + + val settingsPrompt = settingsRepository.customPromptVocabulary.flow.first() + + val template = VocabularyGenerationRequest( + category = category, + languageFirst = languageFirst.englishName, + languageSecond = languageSecond.englishName, + amount = amount, + customPrompt = settingsPrompt + ) + + val result = apiRequestHandler.executeRequest(template) + + result.map { apiResponse -> + Log.d("VocabularyService", "API returned ${apiResponse.flashcards.size} flashcards. Now mapping to VocabularyItem model.") + + val mappedItems = apiResponse.flashcards.mapNotNull { flashcard -> + val frontIsLangFirst = flashcard.front.language.equals(languageFirst.englishName, ignoreCase = true) + val backIsLangSecond = flashcard.back.language.equals(languageSecond.englishName, ignoreCase = true) + val frontIsLangSecond = flashcard.front.language.equals(languageSecond.englishName, ignoreCase = true) + val backIsLangFirst = flashcard.back.language.equals(languageFirst.englishName, ignoreCase = true) + + when { + frontIsLangFirst && backIsLangSecond -> VocabularyItem( + id = 0, + languageFirstId = languageFirst.nameResId, + languageSecondId = languageSecond.nameResId, + wordFirst = flashcard.front.word.trim(), + wordSecond = flashcard.back.word.trim() + ) + + frontIsLangSecond && backIsLangFirst -> VocabularyItem( + id = 0, + languageFirstId = languageFirst.nameResId, + languageSecondId = languageSecond.nameResId, + wordFirst = flashcard.back.word.trim(), + wordSecond = flashcard.front.word.trim() + ) + + else -> { + Log.w("VocabularyService", "SKIPPING flashcard. Mismatched languages. Expected '${languageFirst.englishName}/${languageSecond.englishName}', but API returned '${flashcard.front.language}/${flashcard.back.language}'.") + StatusMessageService.trigger( + StatusAction.ShowMessage( + "SKIPPING flashcard. Mismatched languages. Expected '${languageFirst.englishName}/${languageSecond.englishName}', but API returned '${flashcard.front.language}/${flashcard.back.language}'.", + MessageDisplayType.ERROR, + 5 + ) + ) + null + } + } + } + + Log.i("VocabularyService", "Successfully mapped ${mappedItems.size} out of ${apiResponse.flashcards.size} received flashcards.") + mappedItems + }.onFailure { exception -> + Log.e("VocabularyService", "Vocabulary generation failed entirely.", exception) + StatusMessageService.trigger( + StatusAction.ShowMessage( + "Vocabulary generation failed entirely: ${exception.message}", //TODO translate + MessageDisplayType.ERROR, + 5 + )) + } + } catch (e: Exception) { + Log.e("VocabularyService", "Unexpected error in generateVocabularyItems", e) + Result.failure(e) + } + } + + suspend fun generateSynonyms( + term: String, + translation: String, + language: Language, + translationLanguage: Language, + amount: Int? = 5 + ): Result> = withContext(Dispatchers.IO) { + try { + Log.i("VocabularyService", "Generating $amount synonyms for term '$term' in ${language.englishName}") + + val template = SynonymGenerationRequest( + amount = amount ?: 5, + language = language.englishName, + term = term, + translation = translation, + translationLanguage = translationLanguage.englishName, + languageCode = language.code + ) + + val result = apiRequestHandler.executeRequest(template) + + result.map { response -> + val synonyms = response.synonyms + .map { it.copy(word = it.word.trim().replace("\"", "")) } + .filter { it.word.isNotEmpty() } + Log.i("VocabularyService", "Successfully generated ${synonyms.size} synonyms for term '$term'") + synonyms + }.onFailure { exception -> + Log.e("VocabularyService", "Failed to generate synonyms for term '$term'", exception) + } + } catch (e: Exception) { + Log.e("VocabularyService", "Unexpected error in generateSynonyms for term '$term'", e) + Result.failure(e) + } + } + + suspend fun fetchAndApplyGrammaticalDetails( + items: List, + languages: Map + ): Result> = withContext(Dispatchers.IO) { + if (items.isEmpty()) return@withContext Result.success(emptyList()) + + val wordsToAnalyze = items.flatMap { item -> + listOfNotNull( + item.languageFirstId?.let { WordIdentifier(item.id, "first", item.wordFirst, it) }, + item.languageSecondId?.let { WordIdentifier(item.id, "second", item.wordSecond, it) } + ) + } + + val classificationResult = classifyWords(wordsToAnalyze, languages) + if (classificationResult.isFailure) { + return@withContext Result.failure(classificationResult.exceptionOrNull()!!) + } + val classifiedWords = classificationResult.getOrNull() ?: return@withContext Result.success(items) + + val wordsWithFeaturesToFetch = mutableListOf() + val wordsWithCategoryOnly = mutableListOf() + + classifiedWords.forEach { word -> + val config = languageConfigViewModel.getConfigForLanguage(word.languageCode) + val categoryHasFields = config?.categories?.get(word.category)?.fields?.isNotEmpty() ?: false + if (categoryHasFields) { + wordsWithFeaturesToFetch.add(word) + } else { + wordsWithCategoryOnly.add(word) + } + } + + val groupedByLangAndCategory = wordsWithFeaturesToFetch.groupBy { Pair(it.languageCode, it.category) } + + val featureResults = groupedByLangAndCategory.map { (groupKey, words) -> + async { + val (langCode, category) = groupKey + val languageConfig = languageConfigViewModel.getConfigForLanguage(langCode) + fetchFeaturesForGroup(words, languageConfig, category) + } + }.awaitAll().filterNotNull().flatten() + + val categoryOnlyResults = wordsWithCategoryOnly.map { + WordWithFeatures( + itemId = it.itemId, + position = it.position, + category = it.category, + properties = emptyMap() // No properties to add + ) + } + + val allResults = featureResults + categoryOnlyResults + val finalItems = mergeFeaturesIntoItems(items, classifiedWords, allResults) + + Result.success(finalItems) + } + + private suspend fun classifyWords( + words: List, + languages: Map + ): Result> { + // Short-circuit classification for sentences using StringHelper + val preclassified = words.associateWith { word -> + if (isSentenceStrict(word.word)) "sentence" else null + } + + val wordsNeedingApi = words.filter { preclassified[it] == null } + + if (wordsNeedingApi.isEmpty()) { + return Result.success(words.map { + ClassifiedWord( + itemId = it.itemId, + position = it.position, + word = it.word, + languageCode = languages[it.languageId]?.code ?: "", + category = "sentence" + ) + }) + } + + val wordsToClassify = wordsNeedingApi.map { + WordToAnalyze(it.tempId, it.word, languages[it.languageId]?.englishName ?: "") + } + val languageConfigs = languageConfigViewModel.configs.value + val possibleCategories = languageConfigs.values.flatMap { it.categories.keys }.distinct() + + val wordsToClassifyJson = jsonParser.encodeToString(wordsToClassify) + val template = GrammarClassificationRequest(wordsToClassifyJson, possibleCategories.joinToString(", ")) + + return apiRequestHandler.executeRequest(template).map { response -> + val apiClassified = response.results.mapNotNull { result -> + val originalWord = wordsNeedingApi.find { it.tempId == result.id } + originalWord?.let { + ClassifiedWord( + itemId = it.itemId, + position = it.position, + word = it.word, + languageCode = languages[it.languageId]?.code ?: "", + category = result.category + ) + } + } + // Add preclassified sentences + val preclassifiedItems = words.filter { preclassified[it] != null }.map { + ClassifiedWord( + itemId = it.itemId, + position = it.position, + word = it.word, + languageCode = languages[it.languageId]?.code ?: "", + category = preclassified[it] ?: "" + ) + } + apiClassified + preclassifiedItems + } + } + + private fun buildPromptForFeatures( + words: List, + languageConfig: LanguageConfig?, + category: String + ): String? { + val categoryConfig = languageConfig?.categories?.get(category) ?: return null + if (categoryConfig.fields.isEmpty()) return null + + val languageName = languageConfig.language_code + val wordsJson = words.map { WordToAnalyzeFeatures(id = it.tempId, word = it.word) } + + val promptBuilder = PromptBuilder("Analyze the grammatical properties for each '$category' in the provided JSON array for the '$languageName' language.") + + categoryConfig.fields.forEach { field -> + val propertyName = "'${field.key}'" + if (field.type == "enum" && !field.options.isNullOrEmpty()) { + val optionsString = field.options.joinToString(", ") { "'$it'" } + promptBuilder.addDetail("For the $propertyName property, choose one of the following values: $optionsString.") + } else { + promptBuilder.addDetail("For the $propertyName property, provide the appropriate value. Do not return anything else. If the property is not applicable, return an empty response.") + } + } + + promptBuilder + .withJsonResponse("a 'results' array, each with the original 'id' and a 'properties' object containing the found values.") + .addDetail("Here is the JSON array: ${jsonParser.encodeToString(wordsJson)}") + + return promptBuilder.build() + } + + private suspend fun fetchFeaturesForGroup( + words: List, + languageConfig: LanguageConfig?, + category: String + ): List? { + val prompt = buildPromptForFeatures(words, languageConfig, category) ?: return null + + Log.d(prompt) + + val template = GrammarFeaturesRequest(prompt) + + return apiRequestHandler.executeRequest(template).map { response -> + response.results.mapNotNull { result -> + words.find { it.tempId == result.id }?.let { originalWord -> + @Suppress("UNCHECKED_CAST") + WordWithFeatures( + itemId = originalWord.itemId, + position = originalWord.position, + category = category, + properties = result.properties.filterValues { it != null } as Map + ) + } + } + }.getOrNull() + } + + private fun mergeFeaturesIntoItems( + originalItems: List, + classifiedWords: List, + featureResults: List + ): List { + val featuresMap = featureResults.associateBy { Pair(it.itemId, it.position) } + val classifiedMap = classifiedWords.associateBy { Pair(it.itemId, it.position) } + val itemsMap = originalItems.associateBy { it.id }.toMutableMap() + + classifiedMap.forEach { (key, classifiedWord) -> + val (itemId, position) = key + val currentItem = itemsMap[itemId] ?: return@forEach + + val currentFeatures = currentItem.features?.let { + try { jsonParser.decodeFromString(it) } catch (_: Exception) { VocabularyFeatures() } + } ?: VocabularyFeatures() + + val wordWithFeatures = featuresMap[key] + val newGrammaticalFeature = GrammaticalFeature( + category = wordWithFeatures?.category ?: classifiedWord.category, + properties = wordWithFeatures?.properties ?: emptyMap() + ) + + var finalWord = classifiedWord.word + val languageConfig = languageConfigViewModel.getConfigForLanguage(classifiedWord.languageCode) + val categoryConfig = languageConfig?.categories?.get(newGrammaticalFeature.category) + + if (!isSentenceStrict(finalWord)) { + if (newGrammaticalFeature.category == "noun" && categoryConfig != null) { + // Prefer explicit mapping based on detected gender; otherwise fall back to removing any configured leading articles + val gender = newGrammaticalFeature.properties["gender"] + val mappedArticle = categoryConfig.mappings?.get("gender")?.get(gender) + finalWord = when { + mappedArticle != null -> StringHelper.removeLeadingArticlesOneOf(finalWord, listOf(mappedArticle)) + !languageConfig.articles.isNullOrEmpty() -> StringHelper.removeLeadingArticlesOneOf( + finalWord, + languageConfig.articles + ) + else -> finalWord + } + } + } + + val updatedFeatures = if (position == "first") { + currentFeatures.copy(first = newGrammaticalFeature) + } else { + currentFeatures.copy(second = newGrammaticalFeature) + } + + val updatedItem = if (position == "first") { + currentItem.copy( + wordFirst = finalWord, + features = jsonParser.encodeToString(updatedFeatures) + ) + } else { + currentItem.copy( + wordSecond = finalWord, + features = jsonParser.encodeToString(updatedFeatures) + ) + } + itemsMap[itemId] = updatedItem + } + + return originalItems.map { itemsMap[it.id] ?: it } + } + + // Data classes for API responses + @Serializable + data class SynonymApiResponse( + val synonyms: List + ) + + @Serializable + data class SynonymObject( + val word: String, + val proximity: Int + ) + + @Serializable + data class BatchClassificationResponse( + val results: List + ) + + @Serializable + data class ClassificationResult( + val id: Int, + val category: String + ) + + @Serializable + data class WordToAnalyze( + val id: Int, + val word: String, + val language: String + ) + + @Serializable + data class WordToAnalyzeFeatures( + val id: Int, + val word: String + ) + + @Serializable + data class BatchGrammarResponse( + val results: List + ) + + @Serializable + data class AnalyzedWordResult( + val id: Int, + val properties: Map + ) + + // Internal data classes + private data class WordIdentifier( + val itemId: Int, + val position: String, // "first" or "second" + val word: String, + val languageId: Int + ) { + val tempId = "item${itemId}_$position".hashCode() + } + + private data class ClassifiedWord( + val itemId: Int, + val position: String, + val word: String, + val languageCode: String, + val category: String + ) { + val tempId = "item${itemId}_$position".hashCode() + } + + private data class WordWithFeatures( + val itemId: Int, + val position: String, + val category: String, + val properties: Map + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/YouTubeApiService.kt b/app/src/main/java/eu/gaudian/translator/utils/YouTubeApiService.kt new file mode 100644 index 0000000..9d2e0c9 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/YouTubeApiService.kt @@ -0,0 +1,49 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request + +object YouTubeApiService { + + + private const val VIDEO_TITLE_URL = "http://23.88.48.47:5151/api/get_title/%s" + + private val client = OkHttpClient.Builder().build() + + + suspend fun getVideoTitle(videoId: String): String = withContext(Dispatchers.IO) { + try { + // The URL now points to your Flask server's endpoint. + val url = VIDEO_TITLE_URL.format(videoId) + val request = Request.Builder().url(url).build() + val start = System.currentTimeMillis() + Log.d("YouTubeApiService", "Requesting video title from server: GET $url") + val response = client.newCall(request).execute() + val took = System.currentTimeMillis() - start + val code = response.code + val jsonResponse = response.body.string() + Log.i("YouTubeApiService", "GET $url -> $code in $took ms") + Log.d("YouTubeApiService", "Video title server response: ${jsonResponse.take(200)}") + + // The JSON from your server is simple: {"title": "The Title"} + // This regex will parse it correctly. + val title = "\"title\"\\s*:\\s*\"(.*?)\"".toRegex().find(jsonResponse)?.groupValues?.get(1) ?: "Unknown Title" + + if (title == "Unknown Title") { + Log.w("YouTubeApiService", "Could not parse video title from server response for id=$videoId") + } else { + Log.i("YouTubeApiService", "Parsed video title: '${title.take(100)}'") + } + client.cache?.evictAll() + Log.d("Cache", "OkHttp cache cleared!") + title + } catch (e: Exception) { + Log.e("YouTubeApiService", "Could not fetch video title for $videoId from server", e) + "Unknown Title" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/YouTubeExerciseService.kt b/app/src/main/java/eu/gaudian/translator/utils/YouTubeExerciseService.kt new file mode 100644 index 0000000..db9769c --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/YouTubeExerciseService.kt @@ -0,0 +1,99 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import eu.gaudian.translator.model.SubtitleLine +import eu.gaudian.translator.model.jsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + + +@Serializable +data class LanguageInfo( + @Suppress("HardCodedStringLiteral") @SerialName("language_code") + val languageCode: String +) + +@Serializable +data class TranscriptListResponse( + @SerialName("available_languages") + val availableLanguages: List = emptyList() +) + +@Serializable +data class FullTranscriptResponse( + @Suppress("HardCodedStringLiteral") @SerialName("transcript") + val transcript: List = emptyList() +) + +/** + * Service that talks to the external transcript backend provided in the issue description. + * Base URL: http://23.88.48.47:5151 + */ +object YouTubeExerciseService { + private const val BASE_URL = "http://23.88.48.47:5151" + + private val client: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) // Time to establish a connection + .readTimeout(30, TimeUnit.SECONDS) // Time waiting for data after connection + .writeTimeout(30, TimeUnit.SECONDS) // Time to write data + .build() + } + + suspend fun listTranscripts(videoId: String): List = withContext(Dispatchers.IO) { + val url = "$BASE_URL/api/list_transcripts/$videoId" + Log.d("url: $url") + try { + val request = Request.Builder().url(url).build() + val response = client.newCall(request).execute() + val jsonResponse = response.body.string() + + if (response.isSuccessful && jsonResponse.isNotBlank()) { + // Parse the full object + val transcriptListResponse = jsonParser.decodeFromString(jsonResponse) + // Extract just the language codes and return them as a List + val languageCodes = transcriptListResponse.availableLanguages.map { it.languageCode } + Log.d("YouTubeExerciseService", "Found language codes for $videoId: $languageCodes") + return@withContext languageCodes + } else { + Log.w("YouTubeExerciseService", "Failed to list transcripts for $videoId. Code: ${response.code}") + return@withContext emptyList() + } + } catch (e: Exception) { + Log.e("YouTubeExerciseService", "Error listing transcripts for $videoId", e) + return@withContext emptyList() + } + } + suspend fun getTranscript(videoId: String, languageCode: String): List = withContext(Dispatchers.IO) { + val url = "$BASE_URL/api/get_transcript/$videoId/$languageCode" + Log.d("YouTubeExerciseService", "getTranscript url: $url") + try { + val request = Request.Builder().url(url).build() + val response = client.newCall(request).execute() + val jsonResponse = response.body.string() + + if (response.isSuccessful && jsonResponse.isNotBlank()) { + Log.d("YouTubeExerciseService", "getTranscript jsonResponse: ${jsonResponse.take(2000)}") + + + val fullResponse = jsonParser.decodeFromString(jsonResponse) + val subtitles = fullResponse.transcript // Then extract the list of subtitles + + Log.d("YouTubeExerciseService", "Fetched ${subtitles.size} subtitle lines for $videoId") + return@withContext subtitles + } else { + Log.w("YouTubeExerciseService", "Failed to get transcript for $videoId ($languageCode). Code: ${response.code}") + return@withContext emptyList() + } + } catch (e: Exception) { + Log.e("YouTubeExerciseService", "Error getting transcript for $videoId ($languageCode)", e) + return@withContext emptyList() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/dictionary/AbstractVerbParser.kt b/app/src/main/java/eu/gaudian/translator/utils/dictionary/AbstractVerbParser.kt new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/eu/gaudian/translator/utils/dictionary/DictionaryService.kt b/app/src/main/java/eu/gaudian/translator/utils/dictionary/DictionaryService.kt new file mode 100644 index 0000000..9b0d483 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/dictionary/DictionaryService.kt @@ -0,0 +1,252 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils.dictionary + +import android.content.Context +import eu.gaudian.translator.R +import eu.gaudian.translator.model.DictionaryEntry +import eu.gaudian.translator.model.EtymologyData +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.communication.ApiManager +import eu.gaudian.translator.model.repository.DictionaryRepository +import eu.gaudian.translator.model.repository.SettingsRepository +import eu.gaudian.translator.utils.ApiRequestHandler +import eu.gaudian.translator.utils.DictionaryDefinitionRequest +import eu.gaudian.translator.utils.EtymologyRequest +import eu.gaudian.translator.utils.ExampleSentenceRequest +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.WordOfTheDayRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +class DictionaryService(context: Context) { + private val appContext: Context = context.applicationContext + private val settingsRepository = SettingsRepository(appContext) + private val apiRequestHandler = ApiRequestHandler( + ApiManager(appContext), + context = appContext + ) + private val dictionaryRepository = DictionaryRepository(appContext) + + /** + * Retrieves user-selected dictionary options from settings and ensures + * "Definition" is always included as a required part. + * + * Stored values may be either stable keys (from R.array.dictionary_content_keys) + * or legacy, localized labels (from R.array.dictionary_content). Here we + * normalize them to the current locale's labels so prompts stay coherent + * even if the app language changes. + */ + private suspend fun getActivatedDictionaryOptions(): String { + val storedValues = settingsRepository.dictionarySwitches.flow.first() + + val resources = appContext.resources + val keys = resources.getStringArray(R.array.dictionary_content_keys) + val labels = resources.getStringArray(R.array.dictionary_content) + + val selectedLabels = mutableSetOf() + + // Map each stored entry to a label in the current locale, handling both + // the new key-based format and the legacy label-based format. + for (value in storedValues) { + val keyIndex = keys.indexOf(value) + if (keyIndex >= 0 && keyIndex < labels.size) { + // New: stored as stable key + selectedLabels.add(labels[keyIndex]) + } else { + // Legacy: stored as localized label + val labelIndex = labels.indexOf(value) + if (labelIndex >= 0) { + selectedLabels.add(labels[labelIndex]) + } else { + // Fallback: keep whatever was stored + selectedLabels.add(value) + } + } + } + + // Always ensure the "Definition" part is included. + val definitionKeyIndex = keys.indexOf("definition") + if (definitionKeyIndex >= 0 && definitionKeyIndex < labels.size) { + selectedLabels.add(labels[definitionKeyIndex]) + } else { + // Fallback if arrays are inconsistent + selectedLabels.add("Definition") + } + + return selectedLabels.joinToString(", ") + } + + /** + * Searches for a word's full definition, including user-selected parts. + * Uses the new unified API request template system. + */ + @OptIn(ExperimentalTime::class) + suspend fun searchDefinition(query: String, language: Language): Result = withContext(Dispatchers.IO) { + try { + Log.i("DictionaryService", "Searching definition for word: $query in language: ${language.name}") + + val requestedParts = getActivatedDictionaryOptions() + Log.d("DictionaryService", "Requested dictionary parts: $requestedParts") + + val template = DictionaryDefinitionRequest( + word = query, + language = language.name, + requestedParts = requestedParts + ) + + val result = apiRequestHandler.executeRequest(template) + + result.map { apiResponse -> + Log.i("DictionaryService", "Successfully retrieved definition for: $query") + DictionaryEntry( + id = 0, // Repository will set the ID upon insertion. + word = apiResponse.word, + definition = apiResponse.parts, + languageCode = language.nameResId, + languageName = language.name, + createdAt = Clock.System.now() + ) + }.onFailure { exception -> + Log.e("DictionaryService", "Failed to search definition for: $query", exception) + } + } catch (e: Exception) { + Log.e("DictionaryService", "Unexpected error in searchDefinition for: $query", e) + Result.failure(e) + } + } + + /** + * Retrieves an example sentence for a word with its translation using the new unified API request system. + */ + suspend fun getExampleSentence(word: String, wordTranslation: String, languageFirst: Language, languageSecond: Language): Result> = withContext(Dispatchers.IO) { + try { + Log.i("DictionaryService", "Getting example sentence for word: $word (${languageFirst.name} -> ${languageSecond.name})") + + val template = ExampleSentenceRequest( + word = word, + wordTranslation = wordTranslation, + languageFirst = languageFirst.name, + languageSecond = languageSecond.name + ) + + val result = apiRequestHandler.executeRequest(template) + + result.map { apiResponse -> + Log.i("DictionaryService", "Successfully retrieved example sentence for: $word") + Pair( + apiResponse.sourceSentence.trim(), + apiResponse.targetSentence.trim() + ) + }.onFailure { exception -> + Log.e("DictionaryService", "Failed to get example sentence for: $word", exception) + } + } catch (e: Exception) { + Log.e("DictionaryService", "Unexpected error in getExampleSentence for: $word", e) + Result.failure(e) + } + } + + @OptIn(ExperimentalTime::class) + suspend fun getWordOfTheDay( + language: Language, + forceRefresh: Boolean = false + ): DictionaryEntry? = withContext(Dispatchers.IO) { + try { + val today: Instant = Clock.System.now() + val todayLocalDate = java.time.Instant.ofEpochMilli(today.toEpochMilliseconds()) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + val todayString = todayLocalDate.format(DateTimeFormatter.ISO_LOCAL_DATE) + + if (!forceRefresh) { + val lastWordOfTheDay = dictionaryRepository.loadWordOfTheDay().first() + val createdAtInstant = lastWordOfTheDay?.createdAt + val createdAtLocalDate = createdAtInstant?.let { + java.time.Instant.ofEpochMilli(it.toEpochMilliseconds()) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } + if (createdAtLocalDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) == todayString) { + Log.d("DictionaryService", "Returning cached word of the day for: $todayString") + return@withContext lastWordOfTheDay + } + } + + Log.i("DictionaryService", "Generating new word of the day for: $todayString in language: ${language.name}") + + val topics = listOf( + "science", "literature", "history", "technology", "nature", + "art", "philosophy", "mathematics", "music", "architecture", + "an obscure animal", "a forgotten tool", "a culinary term", + "a mythological concept" + ) + + val randomTopic = topics.random() + Log.d("DictionaryService", "Selected topic for word of the day: $randomTopic") + + val template = WordOfTheDayRequest( + language = language.name, + category = randomTopic + ) + + val result = apiRequestHandler.executeRequest(template) + + result.fold( + onSuccess = { apiResponse -> + val newEntry = DictionaryEntry( + id = 0, + word = apiResponse.word, + definition = apiResponse.parts, + languageCode = language.nameResId, + languageName = language.name, + createdAt = today + ) + dictionaryRepository.saveWordOfTheDay(newEntry) + Log.i("DictionaryService", "Successfully generated and saved word of the day: ${apiResponse.word}") + return@withContext newEntry + }, + onFailure = { exception -> + Log.e("DictionaryService", "Error generating word of the day", exception) + return@withContext null + } + ) + } catch (e: Exception) { + Log.e("DictionaryService", "Unexpected error in getWordOfTheDay", e) + null + } + } + + suspend fun getEtymology(query: String, language: Language): Result = withContext(Dispatchers.IO) { + try { + Log.i("DictionaryService", "Getting etymology for word: $query in language: ${language.name}") + + val template = EtymologyRequest( + word = query, + language = language.englishName + ) + + val result = apiRequestHandler.executeRequest(template) + + result.map { apiResponse -> + Log.i("DictionaryService", "Successfully retrieved etymology for: $query") + EtymologyData( + word = apiResponse.word, + timeline = apiResponse.timeline, + relatedWords = apiResponse.relatedWords + ) + }.onFailure { exception -> + Log.e("DictionaryService", "Failed to get etymology for: $query", exception) + } + } catch (e: Exception) { + Log.e("DictionaryService", "Unexpected error in getEtymology for: $query", e) + Result.failure(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/dictionary/InflectionParser.kt b/app/src/main/java/eu/gaudian/translator/utils/dictionary/InflectionParser.kt new file mode 100644 index 0000000..8d5c630 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/dictionary/InflectionParser.kt @@ -0,0 +1,11 @@ +package eu.gaudian.translator.utils.dictionary + + +import eu.gaudian.translator.model.grammar.Inflection + +/** + * Interface for a language-specific inflection parser. + */ +interface InflectionParser { + fun parse(inflections: List): DisplayInflectionData +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/dictionary/LocalDictionaryAccess.kt b/app/src/main/java/eu/gaudian/translator/utils/dictionary/LocalDictionaryAccess.kt new file mode 100644 index 0000000..694c249 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/dictionary/LocalDictionaryAccess.kt @@ -0,0 +1,165 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils.dictionary + +import eu.gaudian.translator.model.repository.DictionaryWordEntry +import eu.gaudian.translator.utils.Log +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 + +/** + * Reusable parsing helpers for local (on-device) dictionary entries. + * + * This provides a stable, display-independent API so any feature can query + * translations, synonyms, hyponyms, alternative spellings, etc. for a given word. + */ + +data class LocalTranslationInfo( + val languageCode: String, + val word: String, + val sense: String? = null, + val tags: List = emptyList() +) + +data class LocalRelatedWordInfo( + /** Relation type key from the JSON, e.g. "synonyms", "hyponyms", ... */ + val relationType: String, + val word: String, + val senseIndex: String? = null +) + +data class LocalAlternativeForm( + val form: String, + val tags: List = emptyList() +) + +/** + * Aggregated semantic information for a local dictionary word. + */ +data class LocalDictionaryWordInfo( + val word: String, + val langCode: String, + val pos: String?, + val translations: List, + val relatedWords: List, + val alternativeForms: List +) { + val synonyms: List + get() = relatedWords.filter { it.relationType == "synonyms" } + + val hyponyms: List + get() = relatedWords.filter { it.relationType == "hyponyms" } +} + +object LocalDictionaryAccess { + + private val json = Json { ignoreUnknownKeys = true } + private const val TAG = "LocalDictionaryAccess" + + /** + * Parse a [DictionaryWordEntry] (from the local DB) into a structured + * [LocalDictionaryWordInfo] model. + */ + fun parseWordInfo(entry: DictionaryWordEntry): LocalDictionaryWordInfo? { + val root: JsonElement = try { + json.parseToJsonElement(entry.json) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse local dictionary JSON for '${entry.word}': ${e.message}", e) + return null + } + + val obj = root as? JsonObject ?: return null + + val translations = parseTranslations(obj) + val related = parseRelations(obj) + val alternatives = parseAlternativeForms(obj) + + return LocalDictionaryWordInfo( + word = entry.word, + langCode = entry.langCode, + pos = entry.pos, + translations = translations, + relatedWords = related, + alternativeForms = alternatives + ) + } + + // --- Internal helpers --- + + private fun parseTranslations(obj: JsonObject): List { + val translationsElement = obj["translations"] + val array = translationsElement as? JsonArray ?: return emptyList() + + return array.mapNotNull { element -> + val o = element.jsonObject + val langCode = o["lang_code"]?.jsonPrimitive?.contentOrNull + ?: o["language_code"]?.jsonPrimitive?.contentOrNull + val word = o["word"]?.jsonPrimitive?.contentOrNull + + if (langCode.isNullOrBlank() || word.isNullOrBlank()) { + return@mapNotNull null + } + + val sense = o["sense"]?.jsonPrimitive?.contentOrNull + ?: o["sense_index"]?.jsonPrimitive?.contentOrNull + val tags = (o["tags"] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + ?: emptyList() + + LocalTranslationInfo( + languageCode = langCode, + word = word, + sense = sense, + tags = tags + ) + } + } + + private fun parseRelations(obj: JsonObject): List { + val relationsElement = obj["relations"] as? JsonObject ?: return emptyList() + + val result = mutableListOf() + for ((relationType, value) in relationsElement) { + val array = value as? JsonArray ?: continue + array.forEach { element -> + val o = element.jsonObject + val word = o["word"]?.jsonPrimitive?.contentOrNull + if (word.isNullOrBlank()) return@forEach + val senseIndex = o["sense_index"]?.jsonPrimitive?.contentOrNull + result += LocalRelatedWordInfo( + relationType = relationType, + word = word, + senseIndex = senseIndex + ) + } + } + return result + } + + /** + * Extracts forms tagged as "alternative" (e.g. alternative spellings) from + * the generic "forms" array used by some dictionaries. + */ + private fun parseAlternativeForms(obj: JsonObject): List { + val formsElement = obj["forms"] ?: return emptyList() + val formsArray: JsonArray = when (formsElement) { + is JsonArray -> formsElement + is JsonObject -> formsElement["forms"] as? JsonArray ?: return emptyList() + else -> return emptyList() + } + + return formsArray.mapNotNull { element -> + val o = element.jsonObject + val form = o["form"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + val tagsArray = o["tags"] as? JsonArray + val tags = tagsArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + if ("alternative" !in tags) return@mapNotNull null + LocalAlternativeForm(form = form, tags = tags) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/dictionary/LocalDictionaryParser.kt b/app/src/main/java/eu/gaudian/translator/utils/dictionary/LocalDictionaryParser.kt new file mode 100644 index 0000000..db04a29 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/dictionary/LocalDictionaryParser.kt @@ -0,0 +1,23 @@ +package eu.gaudian.translator.utils.dictionary + +/** + * A sealed class to represent different ways of displaying inflections. + * Either a simple list or a complex, grouped verb conjugation table. + */ +sealed class DisplayInflectionData { + data class VerbConjugation( + val gerund: String? = null, + val participle: String? = null, + val moods: List + ) : DisplayInflectionData() +} + +data class DisplayMood( + val name: String, // e.g., "Indicative Present" + val persons: List +) + +data class DisplayPersonForm( + val person: String, + val form: String +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/utils/dictionary/PartOfSpeechTranslator.kt b/app/src/main/java/eu/gaudian/translator/utils/dictionary/PartOfSpeechTranslator.kt new file mode 100644 index 0000000..28056e2 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/utils/dictionary/PartOfSpeechTranslator.kt @@ -0,0 +1,86 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils.dictionary + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.gaudian.translator.R + +/** + * Service for translating Part of Speech (POS) abbreviations to their full names in English. + * This provides a reusable way to display POS information in English. + */ +object PartOfSpeechTranslator { + + @Composable + fun translatePos(pos: String?): String { + if (pos.isNullOrBlank()) { + return "" + } + + val normalizedPos = pos.lowercase().trim() + + // Map POS abbreviations to string resource names + return when (normalizedPos) { + "abbrev" -> stringResource(R.string.pos_abbrev) + "adj" -> stringResource(R.string.pos_adj) + "adj_noun" -> stringResource(R.string.pos_adj_noun) + "adj_phrase" -> stringResource(R.string.pos_adj_phrase) + "adnominal" -> stringResource(R.string.pos_adnominal) + "adv" -> stringResource(R.string.pos_adv) + "adv_phrase" -> stringResource(R.string.pos_adv_phrase) + "affix" -> stringResource(R.string.pos_affix) + "ambiposition" -> stringResource(R.string.pos_ambiposition) + "article" -> stringResource(R.string.pos_article) + "character" -> stringResource(R.string.pos_character) + "circumfix" -> stringResource(R.string.pos_circumfix) + "circumpos" -> stringResource(R.string.pos_circumpos) + "classifier" -> stringResource(R.string.pos_classifier) + "clause" -> stringResource(R.string.pos_clause) + "combining_form" -> stringResource(R.string.pos_combining_form) + "component" -> stringResource(R.string.pos_component) + "conj" -> stringResource(R.string.pos_conj) + "contraction" -> stringResource(R.string.pos_contraction) + "converb" -> stringResource(R.string.pos_converb) + "counter" -> stringResource(R.string.pos_counter) + "det" -> stringResource(R.string.pos_det) + "gerund" -> stringResource(R.string.pos_gerund) + "hard-redirect" -> stringResource(R.string.pos_hard_redirect) + "infix" -> stringResource(R.string.pos_infix) + "interfix" -> stringResource(R.string.pos_interfix) + "interj" -> stringResource(R.string.pos_interj) + "intj" -> stringResource(R.string.pos_intj) + "name" -> stringResource(R.string.pos_name) + "noun" -> stringResource(R.string.pos_noun) + "num" -> stringResource(R.string.pos_num) + "onomatopoeia" -> stringResource(R.string.pos_onomatopoeia) + "onomatopeia" -> stringResource(R.string.pos_onomatopeia) + "participle" -> stringResource(R.string.pos_participle) + "particle" -> stringResource(R.string.pos_particle) + "phrase" -> stringResource(R.string.pos_phrase) + "postp" -> stringResource(R.string.pos_postp) + "prefix" -> stringResource(R.string.pos_prefix) + "prep" -> stringResource(R.string.pos_prep) + "prep_phrase" -> stringResource(R.string.pos_prep_phrase) + "preverb" -> stringResource(R.string.pos_preverb) + "pron" -> stringResource(R.string.pos_pron) + "proverb" -> stringResource(R.string.pos_proverb) + "punct" -> stringResource(R.string.pos_punct) + "quantifier" -> stringResource(R.string.pos_quantifier) + "romanization" -> stringResource(R.string.pos_romanization) + "root" -> stringResource(R.string.pos_root) + "soft-redirect" -> stringResource(R.string.pos_soft_redirect) + "stem" -> stringResource(R.string.pos_stem) + "suffix" -> stringResource(R.string.pos_suffix) + "syllable" -> stringResource(R.string.pos_syllable) + "symbol" -> stringResource(R.string.pos_symbol) + "typographic variant" -> stringResource(R.string.pos_typographic_variant) + "unknown" -> stringResource(R.string.pos_unknown) + "verb" -> stringResource(R.string.pos_verb) + else -> pos.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + } + + + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/IntroFlow.kt b/app/src/main/java/eu/gaudian/translator/view/IntroFlow.kt new file mode 100644 index 0000000..ece9d42 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/IntroFlow.kt @@ -0,0 +1,269 @@ +package eu.gaudian.translator.view + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.PrimaryButton +import kotlinx.coroutines.launch + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun IntroNavHost(onIntroFinished: () -> Unit) { + val pages = listOf( + IntroPageData( + title = stringResource(R.string.intro_title_welcome), + description = stringResource(R.string.intro_desc_welcome), + content = { IconContent(iconRes = R.drawable.ic_intro_welcome) } + ), + IntroPageData( + title = stringResource(R.string.intro_title_ai_assistant), + description = stringResource(R.string.intro_desc_ai_assistant), + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + IconContent(iconRes = R.drawable.ic_intro_ai_agents) + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) { + SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_mistral)) }) + SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_your_own_ai)) }) + SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_openai)) }) + SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_claude)) }) + SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_gemini)) }) + SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_deepseek)) }) + SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_openrouter)) }) + SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_and_many_more)) }) + } + } + } + ), + IntroPageData( + title = stringResource(R.string.intro_title_dictionary_translator), + description = stringResource(R.string.intro_desc_dictionary_translator), + content = { IconContent(iconRes = R.drawable.ic_intro_lookup) } + + ), + IntroPageData( + title = stringResource(R.string.intro_title_flashcards), + description = stringResource(R.string.intro_desc_flashcards), + content = { FlashcardTopicsPreview() } + ), + IntroPageData( + title = stringResource(R.string.intro_title_practice), + description = stringResource(R.string.intro_desc_practice), + content = { IconContent(iconRes = R.drawable.ic_inro_practice) } + ), + IntroPageData( + title = stringResource(R.string.intro_title_learning_journey), + description = stringResource(R.string.intro_desc_learning_journey), + content = { IconContent(iconRes = R.drawable.ic_intro_learning_journey)} + ), + IntroPageData( + title = stringResource(R.string.intro_title_categories), + description = stringResource(R.string.intro_desc_categories), + content = { IconContent(iconRes = R.drawable.ic_intro_categories) } + ), + IntroPageData( + title = stringResource(R.string.intro_title_progress), + description = stringResource(R.string.intro_desc_progress), + content = { IconContent(iconRes = R.drawable.ic_intro_track_progress) } + ), + IntroPageData( + title = stringResource(R.string.intro_need_help), + description = stringResource(R.string.intro_if_you_need_help_you), + content = { IconContent(iconRes = R.drawable.ic_intro_help) } + ), + IntroPageData( + title = stringResource(R.string.intro_title_beta), + description = stringResource(R.string.intro_desc_beta), + content = { IconContent(iconRes = R.drawable.ic_icon_construction) } + ), + IntroPageData( + title = stringResource(R.string.intro_title_all_set), + description = stringResource(R.string.intro_desc_all_set), + content = { IconContent(iconRes = R.drawable.ic_intro_robot) } + ) + ) + + val pagerState = rememberPagerState(pageCount = { pages.size }) + val scope = rememberCoroutineScope() + + Scaffold(topBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + // Full-width Skip intro button aligned to end but sized like primary (fillMaxWidth) + eu.gaudian.translator.view.composable.SecondaryButton( + onClick = { onIntroFinished() }, + text = stringResource(R.string.intro_skip), + modifier = Modifier.fillMaxWidth() + ) + } + }) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f) + ) { pageIndex -> + IntroPage(pageData = pages[pageIndex]) + } + + Spacer(modifier = Modifier.height(24.dp)) + + PagerIndicator( + pageCount = pages.size, + currentPage = pagerState.currentPage + ) + + Spacer(modifier = Modifier.height(24.dp)) + + PrimaryButton( + onClick = { + if (pagerState.currentPage < pages.size - 1) { + scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } else { + onIntroFinished() + } + }, + text = if (pagerState.currentPage < pages.size - 1) stringResource(R.string.next) else stringResource(R.string.get_started), + icon = if (pagerState.currentPage < pages.size - 1)AppIcons.ArrowForwardNoChevron else null, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +private data class IntroPageData( + val title: String, + val description: String, + val content: @Composable () -> Unit +) + +@Composable +private fun IntroPage(pageData: IntroPageData) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically), + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()) // Allow scrolling for larger hint content + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = pageData.title, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = pageData.description, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + } + Box(modifier = Modifier.animateContentSize()) { + pageData.content() + } + } +} + +@Composable +private fun PagerIndicator(pageCount: Int, currentPage: Int) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + repeat(pageCount) { iteration -> + val color = if (currentPage == iteration) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(color) + ) + } + } +} + +@Composable +private fun IconContent(iconRes: Int) { + Box(modifier = Modifier.clip(RoundedCornerShape(16.dp))) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(250.dp) + ) +} +} + + +@Composable +private fun FlashcardTopicsPreview() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_intro_flashcards), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(250.dp) + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SuggestionChip(onClick = {}, label = { Text(stringResource(R.string.intro_topic_history)) }) + SuggestionChip(onClick = {}, label = { Text(stringResource(R.string.intro_topic_science)) }) + SuggestionChip(onClick = {}, label = { Text(stringResource(R.string.intro_topic_cooking)) }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/MainActivity.kt b/app/src/main/java/eu/gaudian/translator/view/MainActivity.kt new file mode 100644 index 0000000..4b825e7 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/MainActivity.kt @@ -0,0 +1,404 @@ +package eu.gaudian.translator.view + +import android.annotation.SuppressLint +import android.app.Activity +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import dagger.hilt.android.AndroidEntryPoint +import eu.gaudian.translator.BuildConfig +import eu.gaudian.translator.MyApplication +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.AllFonts +import eu.gaudian.translator.ui.theme.AllThemes +import eu.gaudian.translator.ui.theme.buildColorScheme +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.view.composable.AppAlertDialog +import eu.gaudian.translator.view.composable.BottomNavigationBar +import eu.gaudian.translator.view.composable.Screen +import eu.gaudian.translator.view.dialogs.WhatsNewDialog +import eu.gaudian.translator.view.hints.LocalShowHints +import eu.gaudian.translator.viewmodel.SettingsViewModel +import eu.gaudian.translator.viewmodel.StatusState +import eu.gaudian.translator.viewmodel.StatusViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch + +val LocalShowExperimentalFeatures = compositionLocalOf { false } +val LocalConnectionConfigured = compositionLocalOf { true } + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + private val statusViewModel: StatusViewModel by viewModels() + private val settingsViewModel: SettingsViewModel by viewModels() + private var isReady = false + private var isUiLoaded = false + private var isInitializing = true + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen().apply { + // The splash screen will now correctly wait until isReady is true + setKeepOnScreenCondition { !isReady } + } + + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + settingsViewModel.theme.drop(1).collect { } + settingsViewModel.fontPreference.drop(1).collect { } + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + + // Show UI immediately and load data in background + setContent { + AppTheme(settingsViewModel = settingsViewModel) { + TranslatorApp( + statusViewModel = statusViewModel, + settingsViewModel = settingsViewModel + ) + } + } + + // Mark UI as loaded immediately after setContent + isUiLoaded = true + + // Start initialization in background without blocking UI + initializeData() + } + + private fun initializeData() { + lifecycleScope.launch(Dispatchers.IO) { + // Get repositories from the Application instance (lazy initialization) + val myApp = application as MyApplication + val languageRepository = myApp.languageRepository + val apiRepository = myApp.apiRepository + + // Perform initialization in parallel where possible + val languageJob = launch { + languageRepository.initializeDefaultLanguages() + languageRepository.initializeAllLanguages() + } + + val apiJob = launch { + apiRepository.initialInit() + } + + // Wait for both to complete + languageJob.join() + apiJob.join() + + // Signal readiness after all work is done. + isReady = true + isInitializing = false + } + } +} + +@Suppress("AssignedValueIsNeverRead") +@SuppressLint("LocalContextResourcesRead") +@Composable +fun TranslatorApp( + statusViewModel: StatusViewModel, + settingsViewModel: SettingsViewModel +) { + val navController = rememberNavController() + val statusState by statusViewModel.status.collectAsStateWithLifecycle() + val keyboardInsets = WindowInsets.ime + val density = LocalDensity.current + val isKeyboardVisible by remember { + derivedStateOf { + keyboardInsets.getBottom(density) > 0 + } + } + val introCompleted by settingsViewModel.introCompleted.collectAsStateWithLifecycle(initialValue = true) + + val showHints by settingsViewModel.showHints.collectAsStateWithLifecycle() + val showExperimentalFeatures by settingsViewModel.experimentalFeatures.collectAsStateWithLifecycle() + val connectionConfigured by settingsViewModel.connectionConfigured.collectAsStateWithLifecycle(initialValue = true) + + val isAtRoot = navController.previousBackStackEntry == null + var showExitDialog by remember { mutableStateOf(false) } + + BackHandler(enabled = introCompleted && isAtRoot) { + showExitDialog = true + } + + val activity = LocalActivity.current + + if (showExitDialog) { + AppAlertDialog( + onDismissRequest = { showExitDialog = false }, + title = { Text(stringResource(R.string.label_quit_app)) }, + text = { Text(stringResource(R.string.text_do_you_want_to_minimize_the_app)) }, + confirmButton = { + TextButton(onClick = { + showExitDialog = false + // Minimize the app similar to default back at root behavior + activity?.moveTaskToBack(true) + }) { + Text(stringResource(R.string.quit)) + } + }, + dismissButton = { + TextButton(onClick = { showExitDialog = false }) { + Text(stringResource(R.string.label_cancel)) + } + } + ) + } + + // Check for app updates and show "What's New" dialog if needed + var showWhatsNewDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + val changelogEntries = context.resources.getStringArray(R.array.changelog_entries) + val latestChangelogEntry = changelogEntries.lastOrNull() ?: "" + + LaunchedEffect(Unit) { + try { + // Only check for updates if the intro is completed + if (introCompleted) { + val currentVersion = BuildConfig.VERSION_NAME + val hasSeenCurrentVersion = settingsViewModel.hasSeenCurrentVersion(currentVersion) + if (!hasSeenCurrentVersion) { + showWhatsNewDialog = true + } + } + } catch (e: Exception) { + @Suppress("HardCodedStringLiteral") + Log.e("TranslatorApp", "Error checking version seen status", e) + } + } + + if (showWhatsNewDialog) { + WhatsNewDialog( + onDismissRequest = { showWhatsNewDialog = false }, + onContinue = { + showWhatsNewDialog = false + settingsViewModel.markVersionAsSeen(BuildConfig.VERSION_NAME) + }, + changelogContent = latestChangelogEntry + ) + } + + AppTheme(settingsViewModel = settingsViewModel) { + CompositionLocalProvider( + LocalShowHints provides showHints, + LocalShowExperimentalFeatures provides showExperimentalFeatures, + LocalConnectionConfigured provides connectionConfigured + ) { + if (introCompleted) { + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( + modifier = Modifier + .fillMaxSize() + .animateContentSize() + .imePadding(), + bottomBar = { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + val selectedScreen = Screen.fromDestination(currentDestination) + + @Suppress("HardCodedStringLiteral") val isBottomBarHidden = currentDestination?.route?.startsWith("vocabulary_exercise") == true + val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false) + + BottomNavigationBar( + selectedItem = selectedScreen, + isVisible = !isKeyboardVisible && !isBottomBarHidden, + showLabels = showBottomNavLabels, + onItemSelected = { screen -> + val inSameSection = currentDestination?.hierarchy?.any { it.route == screen.route } == true + + // Always reset the selected section to its root and clear back stack between sections + if (inSameSection) { + // If already within the same section, ensure we are at its graph root + navController.navigate(screen.route) { + popUpTo(screen.route) { + inclusive = false + saveState = false + } + launchSingleTop = true + restoreState = false + } + } else { + // Switching sections: clear entire back stack to start to avoid back navigation results + navController.navigate(screen.route) { + popUpTo(0) { // Pop everything + inclusive = true + saveState = false + } + launchSingleTop = true + restoreState = false + } + } + } + ) + }, + containerColor = MaterialTheme.colorScheme.background + ) { innerPadding -> + val animatedBottomPadding by androidx.compose.animation.core.animateDpAsState( + targetValue = innerPadding.calculateBottomPadding(), + label = "scaffoldBottomPad" + ) + Column( + modifier = Modifier + .padding( + start = innerPadding.calculateLeftPadding(androidx.compose.ui.unit.LayoutDirection.Ltr), + top = innerPadding.calculateTopPadding(), + end = innerPadding.calculateRightPadding(androidx.compose.ui.unit.LayoutDirection.Ltr), + bottom = animatedBottomPadding + ) + .fillMaxSize() + ) { + StatusMessageSystem( + statusState = statusState, + navController = navController, + onDismiss = { statusViewModel.hideMessageBar() }, + modifier = Modifier + .fillMaxWidth() + ) + AppNavHost( + navController = navController, + modifier = Modifier.weight(1f) + ) + } + } + + if (statusState is StatusState.Loading) { + FullScreenLoadingOverlay() + } + } + } else { + IntroNavHost(onIntroFinished = { + settingsViewModel.setIntroCompleted(true) + }) + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +private fun AppTheme( + settingsViewModel: SettingsViewModel, + content: @Composable () -> Unit, +) { + val themeName by settingsViewModel.theme.collectAsStateWithLifecycle() + val darkModePref by settingsViewModel.darkModePreference.collectAsStateWithLifecycle() + val fontName by settingsViewModel.fontPreference.collectAsStateWithLifecycle() + + val useDarkTheme = when (darkModePref) { + "Light" -> false + "Dark" -> true + else -> isSystemInDarkTheme() + } + val context = LocalContext.current + val colorScheme = if (themeName == "System" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } else { + val appTheme = AllThemes.find { it.name == themeName } ?: AllThemes.first() + val colorSet = if (useDarkTheme) appTheme.darkColors else appTheme.lightColors + buildColorScheme(colorSet, useDarkTheme) + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + val windowInsetsController = WindowInsetsControllerCompat(window, view) + + //window.statusBarColor = android.graphics.Color.TRANSPARENT + //window.navigationBarColor = android.graphics.Color.TRANSPARENT + //TODO remove eventually + + windowInsetsController.isAppearanceLightStatusBars = !useDarkTheme + windowInsetsController.isAppearanceLightNavigationBars = !useDarkTheme + } + } + + val selectedFontStyle = AllFonts.find { it.name == fontName } ?: AllFonts.first() + + val fontFamily = when (selectedFontStyle.fileName) { + "default" -> FontFamily.Default + "merriweather_regular" -> FontFamily(Font(R.font.merriweather_regular)) + "lato_regular" -> FontFamily(Font(R.font.lato_regular)) + "playfairdisplay_regular" -> FontFamily(Font(R.font.playfairdisplay_regular)) + "roboto_regular" -> FontFamily(Font(R.font.roboto_regular)) + "lora_regular" -> FontFamily(Font(R.font.lora_regular)) + "opensans_regular" -> FontFamily(Font(R.font.opensans_regular)) + else -> { + Log.d("AppTheme", "Font not found: ${selectedFontStyle.name}") + FontFamily.Default + } + } + + val dynamicTypography = androidx.compose.material3.Typography( + bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = fontFamily), + bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = fontFamily), + bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = fontFamily), + headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontFamily = fontFamily), + headlineMedium = MaterialTheme.typography.headlineMedium.copy(fontFamily = fontFamily), + headlineSmall = MaterialTheme.typography.headlineSmall.copy(fontFamily = fontFamily), + titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = fontFamily), + titleMedium = MaterialTheme.typography.titleMedium.copy(fontFamily = fontFamily), + titleSmall = MaterialTheme.typography.titleSmall.copy(fontFamily = fontFamily), + labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = fontFamily), + labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = fontFamily), + labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = fontFamily) + ) + MaterialTheme( + colorScheme = colorScheme, + typography = dynamicTypography, + ) { + eu.gaudian.translator.ui.theme.ProvideSemanticColors { + content() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/Navigation.kt b/app/src/main/java/eu/gaudian/translator/view/Navigation.kt new file mode 100644 index 0000000..15a4f35 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/Navigation.kt @@ -0,0 +1,426 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view + +import android.app.Application +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import androidx.navigation.navigation +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.view.composable.Screen +import eu.gaudian.translator.view.dictionary.DictionaryResultScreen +import eu.gaudian.translator.view.dictionary.EtymologyResultScreen +import eu.gaudian.translator.view.dictionary.MainDictionaryScreen +import eu.gaudian.translator.view.exercises.ExerciseSessionScreen +import eu.gaudian.translator.view.exercises.MainExerciseScreen +import eu.gaudian.translator.view.exercises.YouTubeExerciseScreen +import eu.gaudian.translator.view.settings.DictionaryOptionsScreen +import eu.gaudian.translator.view.settings.SettingsRoutes +import eu.gaudian.translator.view.settings.TranslationSettingsScreen +import eu.gaudian.translator.view.settings.settingsGraph +import eu.gaudian.translator.view.translation.TranslationScreen +import eu.gaudian.translator.view.vocabulary.CategoryDetailScreen +import eu.gaudian.translator.view.vocabulary.CategoryListScreen +import eu.gaudian.translator.view.vocabulary.LanguageProgressScreen +import eu.gaudian.translator.view.vocabulary.MainVocabularyScreen +import eu.gaudian.translator.view.vocabulary.NoGrammarItemsScreen +import eu.gaudian.translator.view.vocabulary.StageDetailScreen +import eu.gaudian.translator.view.vocabulary.VocabularyCardHost +import eu.gaudian.translator.view.vocabulary.VocabularyExerciseHostScreen +import eu.gaudian.translator.view.vocabulary.VocabularyHeatmapScreen +import eu.gaudian.translator.view.vocabulary.VocabularyListScreen +import eu.gaudian.translator.view.vocabulary.VocabularySortingScreen +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.ExerciseViewModel +import eu.gaudian.translator.viewmodel.ProgressViewModel +import okhttp3.internal.platform.PlatformRegistry.applicationContext + +private const val ANIMATION_DURATION = 200 +private const val TRANSITION_DURATION = 300 + +@Composable +fun AppNavHost( + navController: NavHostController, + modifier: Modifier = Modifier +) { + val exerciseViewModel: ExerciseViewModel = viewModel() + val progressViewModel: ProgressViewModel = ProgressViewModel.getInstance(applicationContext as Application) + + // 1. Define your Main Tab "Leaf" Routes (the actual start destinations of your graphs) + val mainTabRoutes = setOf( + Screen.Home.route, // "home" or "main_translation" depending on which one you actually navigate to + "main_translation", + "main_dictionary", + "main_vocabulary", + "main_exercise", + SettingsRoutes.LIST + ) + + // Helper to check if a route is a top-level tab + fun isTabTransition(initial: String?, target: String?): Boolean { + return mainTabRoutes.contains(initial) && mainTabRoutes.contains(target) + } + + NavHost( + navController = navController, + startDestination = Screen.Home.route, + modifier = modifier, + + // ENTER TRANSITION + enterTransition = { + if (isTabTransition(initialState.destination.route, targetState.destination.route)) { + // Tab Switch: Just Fade In (Subtle Scale for modern feel) + fadeIn(animationSpec = tween(TRANSITION_DURATION)) + + scaleIn(initialScale = 0.96f, animationSpec = tween(TRANSITION_DURATION)) + } else { + // Detail Screen: Slide in from Right + slideInHorizontally( + initialOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() }, + animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing) + ) + fadeIn(animationSpec = tween(TRANSITION_DURATION)) + } + }, + + // EXIT TRANSITION + exitTransition = { + if (isTabTransition(initialState.destination.route, targetState.destination.route)) { + // Tab Switch: Just Fade Out + fadeOut(animationSpec = tween(TRANSITION_DURATION)) + } else { + // Detail Screen: Slide out to Left + slideOutHorizontally( + targetOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() }, + animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing) + ) + fadeOut(animationSpec = tween(TRANSITION_DURATION)) + } + }, + + // POP ENTER (Pressing Back) -> Always Slide back from left + popEnterTransition = { + slideInHorizontally( + initialOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() }, + animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing) + ) + fadeIn(animationSpec = tween(TRANSITION_DURATION)) + }, + + // POP EXIT (Pressing Back) -> Always Slide away to right + popExitTransition = { + slideOutHorizontally( + targetOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() }, + animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing) + ) + fadeOut(animationSpec = tween(TRANSITION_DURATION)) + } + ) { + composable(Screen.Home.route) { + TranslationScreen(navController = navController) + } + + // Define all other navigation graphs at the same top level. + translationGraph(navController) + dictionaryGraph(navController) + vocabularyGraph(navController, progressViewModel) + exerciseGraph(navController, exerciseViewModel) + settingsGraph(navController) + } +} +@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class) +fun NavGraphBuilder.translationGraph(navController: NavHostController) { + navigation( + startDestination = "main_translation", + route = Screen.Home.route + ) { + composable("main_translation") { + TranslationScreen(navController = navController) + } + composable("custom_translation_prompt") { + TranslationSettingsScreen(navController = navController) + } + } +} + +@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class) +fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) { + navigation( + startDestination = "main_dictionary", + route = Screen.Dictionary.route + ) { + composable("main_dictionary") { + MainDictionaryScreen(navController = navController) + } + composable("dictionary_result/{entryId}") { backStackEntry -> + val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull() + if (entryId != null) { + DictionaryResultScreen( + entryId = entryId, + navController = navController, + ) + } else { + Text("Error: Invalid Entry ID") + } + } + composable("dictionary_options") { + DictionaryOptionsScreen(navController = navController) + } + composable("etymology_result/{word}/{languageCode}") { backStackEntry -> + val word = backStackEntry.arguments?.getString("word") ?: "" + val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1 + EtymologyResultScreen( + navController = navController, + word = word, + languageCode = languageCode + ) + } + + } +} + +@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class) +fun NavGraphBuilder.vocabularyGraph( + navController: NavHostController, + progressViewModel: ProgressViewModel +) { + navigation( + startDestination = "main_vocabulary", + route = Screen.Vocabulary.route + ) { + composable("main_vocabulary") { + MainVocabularyScreen(navController = navController) + } + composable("vocabulary_sorting") { + VocabularySortingScreen( + navController = navController + ) + } + composable("vocabulary_detail/{itemId}") { backStackEntry -> + val itemId = backStackEntry.arguments?.getString("itemId")?.toIntOrNull() + if (itemId != null) { + VocabularyCardHost( + navController = navController, + itemId = itemId, + onBackPressed = { navController.popBackStack() } + ) + } else { + Text("Error: Invalid Vocabulary Item ID") + } + } + composable("dictionary_result/{entryId}") { backStackEntry -> + val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull() + if (entryId != null) { + DictionaryResultScreen( + entryId = entryId, + navController = navController, + ) + } else { + Text("Error: Invalid Entry ID") + } + } + composable("vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry -> + val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false + val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull() + VocabularyListScreen( + navController = navController, + showDueTodayOnly = showDueTodayOnly, + categoryId = categoryId, + onNavigateToItem = { item -> + navController.navigate("vocabulary_detail/${item.id}") + }, + onNavigateBack = { navController.popBackStack() }, + enableNavigationButtons = true + ) + } + composable("language_progress") { + LanguageProgressScreen( + wordsLearned = progressViewModel.totalWordsCompleted.collectAsState().value, + navController = navController + ) + + } + composable("vocabulary_heatmap") { + VocabularyHeatmapScreen( + viewModel = progressViewModel, + navController = navController, + ) + } + composable("vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry -> + val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false + val stageString = backStackEntry.arguments?.getString("stage") + val stage = stageString?.let { + if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it) + } + + VocabularyListScreen( + navController = navController, + showDueTodayOnly = showDueTodayOnly, + stage = stage, + onNavigateToItem = { item -> + navController.navigate("vocabulary_detail/${item.id}") + }, + onNavigateBack = { navController.popBackStack() }, + categoryId = 0, + enableNavigationButtons = true + ) + } + composable( + route = "vocabulary_exercise/{isSpelling}?categories={categories}&stages={stages}&languages={languages}&dailyOnly={dailyOnly}", + arguments = listOf( + navArgument("isSpelling") { type = NavType.BoolType }, + navArgument("categories") { type = NavType.StringType; nullable = true }, + navArgument("stages") { type = NavType.StringType; nullable = true }, + navArgument("languages") { type = NavType.StringType; nullable = true }, + navArgument("dailyOnly") { + type = NavType.BoolType + defaultValue = false + }, + ) + ) { backStackEntry -> + val arguments = backStackEntry.arguments + + val dailyOnly = arguments?.getBoolean("dailyOnly") ?: false + + val categoryIds = arguments?.getString("categories") + val stageNames = arguments?.getString("stages") + val languageIds = arguments?.getString("languages") + + val dailyOnlyJson = "{\"dailyOnly\": $dailyOnly}" + + VocabularyExerciseHostScreen( + categoryIdsAsJson = categoryIds, + stageNamesAsJson = stageNames, + languageIdsAsJson = languageIds, + dailyOnlyAsJson = dailyOnlyJson, + onClose = { navController.popBackStack() }, + navController = navController + ) + } + composable( + route = "vocabulary_exercise/{dailyOnly}?", + arguments = listOf( + navArgument("dailyOnly") { type = NavType.BoolType }, + ) + ) { _ -> + + VocabularyExerciseHostScreen( + categoryIdsAsJson = null, + stageNamesAsJson = null, + languageIdsAsJson = null, + onClose = { navController.popBackStack() }, + navController = navController, + dailyOnlyAsJson = "{\"dailyOnly\": true}" + ) + } + composable( + "stage_detail/{stage}", + arguments = listOf( + navArgument("stage") { + type = NavType.EnumType(VocabularyStage::class.java) + } + ) + ) + + { backStackEntry -> + @Suppress("DEPRECATION") val stage = backStackEntry.arguments?.getSerializable("stage") as VocabularyStage + //NOTE: can ignore warning for now, once moved away from min SDK 28, use: + // val stage = backStackEntry.arguments?.getSerializable("stage", VocabularyStage::class.java) + StageDetailScreen( + navController = navController, + stage = stage + ) + } + composable("category_detail/{categoryId}") { backStackEntry -> + val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull() + + if (categoryId != null) { + CategoryDetailScreen( + categoryId = categoryId, + onBackClick = { navController.popBackStack() }, + onNavigateToItem = { item -> + navController.navigate("vocabulary_detail/${item.id}") + }, + navController = navController + ) + } + } + composable("category_list_screen") { + CategoryListScreen( + categoryViewModel = CategoryViewModel.getInstance(applicationContext as Application), + progressViewModel = ProgressViewModel.getInstance(applicationContext as Application), + onNavigateBack = { navController.popBackStack() }, + onCategoryClicked = { categoryId -> + navController.navigate("category_detail/$categoryId") + } + ) + } + composable( + route = "vocabulary_sorting?mode={mode}", // Route now accepts an optional 'mode' + arguments = listOf( + navArgument("mode") { // Define the argument + type = NavType.StringType + nullable = true + } + ) + ) { backStackEntry -> + VocabularySortingScreen( + navController = navController, + // Pass the argument to the screen + initialFilterMode = backStackEntry.arguments?.getString("mode") + ) + } + composable("no_grammar_items") { + NoGrammarItemsScreen( + navController = navController + ) + } + } +} + +@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class) +fun NavGraphBuilder.exerciseGraph( + navController: NavHostController, + exerciseViewModel: ExerciseViewModel +) { + navigation( + startDestination = "main_exercise", + route = Screen.Exercises.route + ) { + composable("main_exercise") { + MainExerciseScreen( + navController = navController, + exerciseViewModel = exerciseViewModel + ) + } + composable("exercise_session") { + ExerciseSessionScreen( + navController = navController, + exerciseViewModel = exerciseViewModel + ) + } + composable("youtube_exercise") { + YouTubeExerciseScreen( + navController = navController, + exerciseViewModel = exerciseViewModel + ) + } + composable("youtube_browse") { + eu.gaudian.translator.view.exercises.YouTubeBrowserScreen( + navController = navController, + exerciseViewModel = exerciseViewModel + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/NoConncectionScreen.kt b/app/src/main/java/eu/gaudian/translator/view/NoConncectionScreen.kt new file mode 100644 index 0000000..4d1ef7c --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/NoConncectionScreen.kt @@ -0,0 +1,47 @@ +package eu.gaudian.translator.view + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppButton + + + +@Composable +fun NoConnectionScreen(onSettingsClick: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + modifier = Modifier.size(200.dp), + painter = painterResource(id = R.drawable.no_connection), + contentDescription = stringResource(id = R.string.text_no_valid_api_configuration_could_be_found) + ) + Spacer(modifier = Modifier.size(16.dp)) + Text( + text = stringResource(id = R.string.text_no_valid_api_configuration_could_be_found), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + AppButton(onClick = onSettingsClick, modifier = Modifier.padding(top = 16.dp)) { + Text(text = stringResource(id = R.string.settings_title_connection)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/StatusMessageSystem.kt b/app/src/main/java/eu/gaudian/translator/view/StatusMessageSystem.kt new file mode 100644 index 0000000..6b960e1 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/StatusMessageSystem.kt @@ -0,0 +1,311 @@ +@file:Suppress("HardCodedStringLiteral") +package eu.gaudian.translator.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.utils.StatusAction +import eu.gaudian.translator.utils.StatusMessageService +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton +import eu.gaudian.translator.view.composable.ComponentDefaults +import eu.gaudian.translator.view.settings.SettingsRoutes +import eu.gaudian.translator.viewmodel.MessageAction +import eu.gaudian.translator.viewmodel.MessageDisplayType +import eu.gaudian.translator.viewmodel.StatusState +import kotlinx.coroutines.launch + +/** + * A system for displaying status messages and loading indicators with a modern, + * floating design consistent with the rest of the app's UI components. + * + * @param statusState The current state to display (e.g., Hidden, Loading, Message). + * @param modifier The modifier to be applied to the component. + * @param onDismiss Callback invoked when a message is dismissed. + */ +@Composable +fun StatusMessageSystem( + statusState: StatusState, + navController: NavController, + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {} +) { + AnimatedVisibility( + visible = statusState != StatusState.Hidden, + label = "StatusMessageSystemVisibility", + enter = fadeIn(animationSpec = tween(150)), + exit = fadeOut(animationSpec = tween(200)), + modifier = modifier + ) { + when (statusState) { + StatusState.Hidden -> { /* Do nothing, handled by AnimatedVisibility */ + } + + StatusState.Loading -> { + FullScreenLoadingOverlay() + } + + is StatusState.Message -> { + // Position the message at the top of the screen + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 16.dp), + contentAlignment = Alignment.TopCenter + ) { + StatusMessage( + message = statusState.text, + type = statusState.type, + action = statusState.action, // ADDED: Pass the action down. + navController = navController, // ADDED: Pass the NavController down. + onDismiss = onDismiss + ) + } + } + } + } +} + +/** + * A full-screen loading indicator with a blurred background overlay. + */ +@Composable +fun FullScreenLoadingOverlay() { + Box( + modifier = Modifier + .fillMaxSize() + // A semi-transparent background provides a modern scrim effect. + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)) + .clickable(enabled = false, onClick = {}), // Consume clicks + contentAlignment = Alignment.Center + ) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.shadow(elevation = 12.dp, shape = RoundedCornerShape(24.dp)) + ) { + CircularProgressIndicator( + modifier = Modifier + .size(64.dp) + .padding(12.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 4.dp + ) + } + } +} + +/** + * A floating status message card that appears at the top of the screen. + */ +@Composable +private fun StatusMessage( + message: String, + type: MessageDisplayType, + action: MessageAction?, + navController: NavController, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val coroutineScope = rememberCoroutineScope() + + + AnimatedVisibility( + visible = true, // Controlled by the parent's state + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(), + label = "StatusMessageAnimation" + ) { + Surface( + modifier = modifier + .fillMaxWidth(0.9f) + .shadow(elevation = ComponentDefaults.DefaultElevation, shape = ComponentDefaults.DefaultShape) + .clip(ComponentDefaults.DefaultShape), + shape = ComponentDefaults.DefaultShape, + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + when (type) { + MessageDisplayType.LOADING -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 2.dp + ) + } + else -> { + val icon: ImageVector + val iconColor: Color + when (type) { + MessageDisplayType.SUCCESS -> { + icon = AppIcons.Check + iconColor = MaterialTheme.colorScheme.tertiary + } + MessageDisplayType.ERROR, MessageDisplayType.ACTIONABLE_ERROR -> { + icon = AppIcons.Error + iconColor = MaterialTheme.colorScheme.error + } + else -> { + icon = AppIcons.Info + iconColor = MaterialTheme.colorScheme.secondary + } + } + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(24.dp) + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + + if (action != null) { + AppOutlinedButton( + onClick = { + when (action) { + MessageAction.NAVIGATE_TO_API_KEYS -> { + navController.navigate(SettingsRoutes.API_KEY) + } + } + coroutineScope.launch { + StatusMessageService.trigger(StatusAction.CancelPermanentMessage) + } + }, + modifier = Modifier.padding(start = 8.dp) + ) { + Text(stringResource(R.string.title_settings)) + } + } else { + IconButton( + onClick = onDismiss, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = AppIcons.Close, + contentDescription = stringResource(R.string.dismiss), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + + +// --- Previews (with a new one for Loading Message) --- + +@ThemePreviews +@Composable +private fun StatusMessageSystemPreviewLoading() { + Box(Modifier.fillMaxSize()) { + StatusMessageSystem(statusState = StatusState.Loading, navController = NavController(context = LocalContext.current) ) + } +} + +@ThemePreviews +@Composable +private fun StatusMessageSystemPreviewSuccess() { + Box(Modifier.fillMaxSize()) { + StatusMessageSystem( + statusState = StatusState.Message( + id = 1, + text = "Operation completed successfully.", + type = MessageDisplayType.SUCCESS + ), + navController = NavController(context = LocalContext.current) // Use a dummy NavController for preview + ) + } +} + +@ThemePreviews +@Composable +private fun StatusMessageSystemPreviewError() { + Box(Modifier.fillMaxSize()) { + StatusMessageSystem( + statusState = StatusState.Message( + id = 2, + text = "An unknown error occurred.", + type = MessageDisplayType.ERROR + ), + navController = NavController(context = LocalContext.current) + ) + } +} + +@ThemePreviews +@Composable +private fun StatusMessageSystemPreviewInfo() { + Box(Modifier.fillMaxSize()) { + StatusMessageSystem( + statusState = StatusState.Message( + id = 3, + text = "This is just for your information.", + type = MessageDisplayType.INFO + ), + navController = NavController(context = LocalContext.current) + ) + } +} + +// ADDED: Preview for the new loading message bar +@ThemePreviews +@Composable +private fun StatusMessageSystemPreviewLoadingMessage() { + Box(Modifier.fillMaxSize()) { + StatusMessageSystem( + statusState = StatusState.Message( + id = 4, + text = "Processing in the background...", + type = MessageDisplayType.LOADING + ), + navController = NavController(context = LocalContext.current) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/AppBox.kt b/app/src/main/java/eu/gaudian/translator/view/composable/AppBox.kt new file mode 100644 index 0000000..35990f0 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/AppBox.kt @@ -0,0 +1,125 @@ +package eu.gaudian.translator.view.composable + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.semanticColors + + +@Composable +fun AppBox( + modifier: Modifier = Modifier, + title: String? = null, + text: String? = null, + expandable: Boolean = false, + initiallyExpanded: Boolean = false, + contentAlignment: Alignment = Alignment.TopStart, + propagateMinConstraints: Boolean = false, + content: @Composable BoxScope.() -> Unit, +) { + // State to track whether the box is expanded + var isExpanded by remember { mutableStateOf(initiallyExpanded) } + + // Rotation animation for the chevron icon + @Suppress("HardCodedStringLiteral") val rotationState by animateFloatAsState( + targetValue = if (isExpanded) 180f else 0f, + label = "Chevron Rotation" + ) + + Box( + modifier = modifier + .clip(ComponentDefaults.NoShape) + .animateContentSize().padding(0.dp), + propagateMinConstraints = propagateMinConstraints + ) { + Column(modifier = Modifier.fillMaxWidth()) { + + // Header Row: Rendered if there is a title, text, or if it is expandable + if (title != null || text != null || expandable) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = expandable) { isExpanded = !isExpanded } + .padding(16.dp), // Standard internal padding for the header + verticalAlignment = Alignment.CenterVertically + ) { + // Title and Text column + Column(modifier = Modifier.weight(1f)) { + if (!title.isNullOrBlank()) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + if (!text.isNullOrBlank()) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Chevron Icon (Only if expandable) + if (expandable) { + Icon( + imageVector = AppIcons.ArrowDropDown, + contentDescription = if (isExpanded) stringResource(R.string.label_collapse) else stringResource(R.string.label_expand), + modifier = Modifier.rotate(rotationState), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + // Content Area logic + // 1. If NOT expandable: Show content always. + // 2. If expandable: Show content only if isExpanded is true. + if (!expandable || isExpanded) { + Box( + modifier = Modifier.fillMaxWidth().fillMaxSize().padding(0.dp), + contentAlignment = contentAlignment + ) { + content() + } + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun AppBoxPreview() { + AppBox( + modifier = Modifier.background(color = MaterialTheme.semanticColors.success), + contentAlignment = Alignment.Center + ) { + Text(text = "This is a preview of the AppBox") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/AppDialogs.kt b/app/src/main/java/eu/gaudian/translator/view/composable/AppDialogs.kt new file mode 100644 index 0000000..bca62d5 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/AppDialogs.kt @@ -0,0 +1,513 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import eu.gaudian.translator.R +import eu.gaudian.translator.view.hints.HintBottomSheet +import eu.gaudian.translator.view.hints.LocalShowHints + +@Composable +fun AppDialog( + onDismissRequest: () -> Unit, + title: (@Composable () -> Unit)? = null, + hintContent: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit +) { + // 1. Swipe Resistance: Prevent accidental dismissal + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { newState -> + // Return false to block the sheet from hiding via swipe + newState != SheetValue.Hidden + } + ) + var showBottomSheet by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + // 2. Keyboard Handling (Material3 1.4.0+): Respect IME insets + contentWindowInsets = { BottomSheetDefaults.windowInsets.union(WindowInsets.ime) } + ) { + Column( + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp) + // Note: Vertical scroll is applied to the inner content column below + ) { + // 3. Header with Title, Hint Icon, and Close (X) Icon + DialogHeader( + title = title, + hintContent = hintContent, + onHintClick = { showBottomSheet = true }, + onCloseClick = onDismissRequest + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 4. Scrollable Content Area + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + ) { + content() + // Extra spacer at bottom so last field isn't glued to keyboard + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + + if (showBottomSheet) { + EnhancedHintBottomSheet( + onDismissRequest = { showBottomSheet = false }, + content = hintContent, + parentTitle = title + ) + } +} + +@Composable +fun AppAlertDialog( + onDismissRequest: () -> Unit, + confirmButton: @Composable () -> Unit, + modifier: Modifier = Modifier, + dismissButton: @Composable (() -> Unit)? = null, + icon: @Composable (() -> Unit)? = null, + title: @Composable (() -> Unit)? = null, + text: @Composable (() -> Unit)? = null, + properties: DialogProperties = DialogProperties(), + hintContent: @Composable (() -> Unit)? = null, +) { + val sheetState = rememberModalBottomSheetState() + var showBottomSheet by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = confirmButton, + modifier = modifier, + dismissButton = dismissButton, + icon = icon, + title = title?.let { + { + // Reusing a simplified title row for standard AlertDialogs + // (AlertDialogs don't need the close 'X' as much as BottomSheets do) + DialogTitleWithHint( + title = it, + hintContent = hintContent, + onHintClick = { showBottomSheet = true } + ) + } + }, + text = text, + properties = properties + ) + + if (showBottomSheet) { + HintBottomSheet( + onDismissRequest = { showBottomSheet = false }, + sheetState = sheetState, + content = hintContent + ) + } +} + +/** + * Standard Dialog Header for BottomSheets. + * Includes Title (Left), Hint Icon (Right), and Close 'X' Button (Far Right). + */ +@Composable +private fun DialogHeader( + title: (@Composable () -> Unit)?, + hintContent: @Composable (() -> Unit)?, + onHintClick: () -> Unit, + onCloseClick: () -> Unit +) { + val showHints = LocalShowHints.current + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), // Slight top padding + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Left side: Title + Box(modifier = Modifier.weight(1f)) { + if (title != null) { + ProvideTextStyle(value = MaterialTheme.typography.headlineSmall) { + title() + } + } + } + + // Right side: Icons (Hint + Close) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // Hint Icon + if (showHints && hintContent != null) { + IconButton(onClick = onHintClick) { + Icon( + imageVector = AppIcons.Help, + contentDescription = stringResource(R.string.show_hint), + tint = MaterialTheme.colorScheme.secondary + ) + } + } + + IconButton(onClick = onCloseClick) { + Icon( + imageVector = AppIcons.Close, + contentDescription = stringResource(R.string.label_close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp) + ) + } + } + } +} + +/** + * Simplified Title Row for standard AlertDialogs (no Close 'X'). + */ +@Composable +private fun DialogTitleWithHint( + title: @Composable () -> Unit, + hintContent: @Composable (() -> Unit)?, + onHintClick: () -> Unit +) { + val showHints = LocalShowHints.current + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.weight(1f)) { + ProvideTextStyle(value = MaterialTheme.typography.headlineSmall) { + title() + } + } + if (showHints && hintContent != null) { + IconButton(onClick = onHintClick) { + Icon( + imageVector = AppIcons.Help, + contentDescription = stringResource(R.string.show_hint), + tint = MaterialTheme.colorScheme.secondary + ) + } + } + } +} + +@Composable +private fun EnhancedHintBottomSheet( + onDismissRequest: () -> Unit, + content: @Composable (() -> Unit)?, + parentTitle: (@Composable () -> Unit)?, + sheetGesturesEnabled : Boolean = false +) { + + rememberCoroutineScope() + + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + sheetGesturesEnabled = sheetGesturesEnabled, + contentWindowInsets = { BottomSheetDefaults.windowInsets.union(WindowInsets.ime) } + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + // Header with back navigation context + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onDismissRequest) { + Icon( + imageVector = AppIcons.ArrowBack, + contentDescription = stringResource(R.string.cd_back), + tint = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.text_hint), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + parentTitle?.let { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "•", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Box(modifier = Modifier.weight(1f)) { + ProvideTextStyle(value = MaterialTheme.typography.bodyMedium) { + it() + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Scrollable content area + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + ) { + content?.invoke() + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Enhanced action button + AppButton( + onClick = { + onDismissRequest() + }, + modifier = Modifier + .align(Alignment.End) + .padding(bottom = 8.dp) + ) { + Text(stringResource(R.string.got_it)) + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun AppDialogPreview() { + AppDialog( + onDismissRequest = {}, + title = { Text("Dialog Title") }, + hintContent = { Text("This is a hint.") }, + content = { + Column { + Text("Content line 1") + Spacer(Modifier.height(8.dp)) + Text("Content line 2") + } + } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun AppDialogNoTitlePreview() { + AppDialog( + onDismissRequest = {}, + content = { + Column { + Text("Dialog without title") + Spacer(Modifier.height(8.dp)) + Text("Just content here") + } + } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun AppDialogNoHintPreview() { + AppDialog( + onDismissRequest = {}, + title = { Text("Dialog Without Hint") }, + content = { + Column { + Text("This dialog has no hint functionality") + Spacer(Modifier.height(8.dp)) + Text("Only title and content") + } + } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun AppDialogLongContentPreview() { + AppDialog( + onDismissRequest = {}, + title = { Text("Long Content Dialog") }, + hintContent = { Text("Hint for long content dialog") }, + content = { + Column { + Text("This is a long content dialog to test scrolling") + Spacer(Modifier.height(8.dp)) + Text("Line 2 of content") + Spacer(Modifier.height(8.dp)) + Text("Line 3 of content") + Spacer(Modifier.height(8.dp)) + Text("Line 4 of content") + Spacer(Modifier.height(8.dp)) + Text("Line 5 of content") + Spacer(Modifier.height(8.dp)) + Text("Line 6 of content") + Spacer(Modifier.height(8.dp)) + Text("Line 7 of content") + Spacer(Modifier.height(8.dp)) + Text("Line 8 - This should trigger scrolling") + } + } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun AppAlertDialogPreview() { + AppAlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton(onClick = {}) { + Text("Confirm") + } + }, + dismissButton = { + TextButton(onClick = {}) { + Text("Dismiss") + } + }, + icon = { + Icon( + imageVector = AppIcons.Help, + contentDescription = null + ) + }, + title = { Text("Alert Dialog Title") }, + text = { Text("This is the alert dialog text.") }, + hintContent = { Text("This is a hint for the alert dialog.") } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun AppAlertDialogNoIconPreview() { + AppAlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton(onClick = {}) { + Text("OK") + } + }, + title = { Text("Simple Alert") }, + text = { Text("This alert has no icon but has a title and text.") } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun AppAlertDialogOnlyConfirmPreview() { + AppAlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton(onClick = {}) { + Text("Got it") + } + }, + icon = { + Icon( + imageVector = AppIcons.Help, + contentDescription = null + ) + }, + title = { Text("Confirmation Required") }, + text = { Text("This dialog only has a confirm button.") } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun AppAlertDialogLongTextPreview() { + AppAlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton(onClick = {}) { + Text("I Understand") + } + }, + dismissButton = { + TextButton(onClick = {}) { + Text("Cancel") + } + }, + title = { Text("Terms and Conditions") }, + text = { + Column { + Text("This is a long text dialog to test how the alert dialog handles extended content.") + Spacer(Modifier.height(8.dp)) + Text("Second paragraph of the terms and conditions that explains important details.") + Spacer(Modifier.height(8.dp)) + Text("Third paragraph with additional information that users need to be aware of.") + } + }, + hintContent = { Text("This hint explains the terms in more detail.") } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun AppAlertDialogMinimalPreview() { + AppAlertDialog( + onDismissRequest = {}, + confirmButton = { + TextButton(onClick = {}) { + Text("OK") + } + }, + text = { Text("Minimal dialog with only text and confirm button.") } + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/AppDropdownMenu.kt b/app/src/main/java/eu/gaudian/translator/view/composable/AppDropdownMenu.kt new file mode 100644 index 0000000..83e3634 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/AppDropdownMenu.kt @@ -0,0 +1,478 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.composable + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.PopupProperties +import com.google.android.material.color.MaterialColors.ALPHA_DISABLED +import com.google.android.material.color.MaterialColors.ALPHA_FULL +import eu.gaudian.translator.R + +/** + * A modern, custom dropdown menu composable that provides a styled text field with a dropdown list of options. + * This implementation uses a custom dropdown for a more tailored look compared to the stock menu, behaving like a normal ExposedDropdownMenu. + * Allows managing selection and expansion, making it a convenient wrapper for dropdowns. + * + * @param expanded Whether the dropdown menu is expanded. + * @param onDismissRequest Callback invoked when the dropdown menu should be dismissed. + * @param modifier Modifier for the composable. + * @param label Composable for the label displayed in the text field. + * @param enabled Whether the dropdown is enabled. + * @param placeholder Optional placeholder text when no option is selected. + * @param selectedText The text to display in the text field for the selected option. + * @param onExpandRequest Callback invoked when the dropdown should expand. + * @param content Composable content for the dropdown items, typically using AppDropdownMenuItem. + */ +@Composable +fun AppDropdownMenu( + expanded: Boolean, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + label: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, + selectedText: String = "", + onExpandRequest: () -> Unit = {}, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, +) { + var textFieldSize by remember { mutableStateOf(Size.Zero) } + + val interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } + + Column(modifier = modifier) { + OutlinedTextField( + value = selectedText, + onValueChange = {}, + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + textFieldSize = coordinates.size.toSize() + } + .clickable( + enabled = enabled, + onClick = onExpandRequest, + interactionSource = interactionSource, + indication = null + ), + readOnly = true, + label = label, + placeholder = placeholder, + trailingIcon = { + val icon = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown + Icon( + imageVector = icon, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + shape = ComponentDefaults.DefaultShape, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_LOW), + focusedLabelColor = MaterialTheme.colorScheme.primary, + cursorColor = MaterialTheme.colorScheme.primary, + disabledBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_LOW), + disabledLabelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_MEDIUM) + ), + enabled = enabled, + interactionSource = interactionSource + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + modifier = Modifier + .width(with(LocalDensity.current) { textFieldSize.width.toDp() }), + offset = DpOffset(0.dp, 0.dp), + scrollState = rememberScrollState(), + properties = PopupProperties(focusable = true), + shape = RoundedCornerShape(8.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp, + shadowElevation = 4.dp, + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.14f)) + ) { + content() + } + } +} + +/** + * A modern and stylish composable for individual dropdown items, featuring enhanced visual design + * with subtle shadows, rounded corners, and smooth interactions. This provides a cool, contemporary look + * that aligns with modern UI trends while maintaining accessibility and usability. + * + * @param text Composable lambda for the text to display in the item. + * @param onClick Callback invoked when the item is clicked. + * @param modifier Modifier for the item. + * @param enabled Whether the item is enabled. + * @param leadingIcon Optional leading icon for the item. + * @param trailingIcon Optional trailing icon for the item. + */ +@Composable +fun AppDropdownMenuItem( + text: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + selected: Boolean = false, +) { + val contentColor = if (enabled) { + if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) // Equivalent to disabled alpha + } + + Box( + modifier = modifier + .fillMaxWidth() + .clickable(enabled = enabled) { onClick() } + ) { + androidx.compose.foundation.layout.Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + leadingIcon?.invoke() + if (leadingIcon != null) { + androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp)) + } + Box(modifier = Modifier.weight(1f)) { + CompositionLocalProvider(LocalContentColor provides contentColor) { + text() + } + } + if (trailingIcon != null) { + androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp)) + trailingIcon() + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun AppDropdownMenuPreview() { + val options = listOf("Option 1", "Option 2", "Option 3") + AppDropdownMenu( + expanded = false, + onDismissRequest = {}, + label = { Text("Select Option") }, + content = { + options.forEach { option -> + AppDropdownMenuItem( + text = { Text(text = option) }, + onClick = {} + ) + } + } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun AppDropdownMenuExpandedPreview() { + val options = listOf("English", "Spanish", "French", "German", "Italian", "Portuguese") + var expanded by remember { mutableStateOf(true) } // Force expanded state for preview + + // Since previews are static, we'll simulate the expanded state by showing the dropdown + AppDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + label = { Text("Language") }, + content = { + options.forEach { option -> + AppDropdownMenuItem( + text = { Text(text = option) }, + onClick = {} + ) + } + } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun DropDownItemPreview() { + AppDropdownMenuItem( + text = { Text("Sample Item", style = MaterialTheme.typography.titleSmall) }, + onClick = {}, + leadingIcon = { + Icon( + imageVector = AppIcons.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun DropDownItemSelectedPreview() { + AppDropdownMenuItem( + text = { Text("Selected Item", style = MaterialTheme.typography.titleSmall) }, + onClick = {}, + selected = true, + trailingIcon = { + Icon( + imageVector = AppIcons.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + ) +} + +@Composable +fun LargeDropdownMenu( + modifier: Modifier = Modifier, + enabled: Boolean = true, + label: String, + notSetLabel: String? = null, + items: List, + selectedIndex: Int = -1, + onItemSelected: (index: Int, item: T) -> Unit, + selectedItemToString: (T) -> String = { it.toString() }, + drawItem: @Composable (T, Boolean, Boolean, () -> Unit) -> Unit = { item, selected, itemEnabled, onClick -> + LargeDropdownMenuItem( + text = item.toString(), + selected = selected, + enabled = itemEnabled, + onClick = onClick, + ) + }, +) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier.height(IntrinsicSize.Min)) { + OutlinedTextField( + label = { Text(label) }, + value = items.getOrNull(selectedIndex)?.let { selectedItemToString(it) } ?: "", + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + val icon: ImageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown + Icon(icon, contentDescription = "") + }, + onValueChange = { }, + readOnly = true, + ) + + // Transparent clickable surface on top of OutlinedTextField + Surface( + modifier = Modifier + .fillMaxSize() + .padding(top = 8.dp) + .clip(MaterialTheme.shapes.extraSmall) + .clickable(enabled = enabled) { expanded = true }, + color = Color.Transparent, + ) {} + } + + if (expanded) { + Dialog( + onDismissRequest = { expanded = true }, + ) { + Surface( + shape = RoundedCornerShape(12.dp), + ) { + val listState = rememberLazyListState() + if (selectedIndex > -1) { + LaunchedEffect("ScrollToSelected") { + listState.scrollToItem(index = selectedIndex) + } + } + + LazyColumn(modifier = Modifier.fillMaxWidth(), state = listState) { + if (notSetLabel != null) { + item { + LargeDropdownMenuItem( + text = notSetLabel, + selected = false, + enabled = false, + onClick = { }, + ) + } + } + itemsIndexed(items) { index, item -> + val selectedItem = index == selectedIndex + drawItem( + item, + selectedItem, + true + ) { + onItemSelected(index, item) + expanded = false + } + + if (index < items.lastIndex) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + } + } + } + } + } + } +} + +@Composable +fun LargeDropdownMenuItem( + text: String, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + val contentColor = when { + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_DISABLED) + selected -> MaterialTheme.colorScheme.primary.copy(alpha = ALPHA_FULL) + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_FULL) + } + + CompositionLocalProvider(LocalContentColor provides contentColor) { + Box(modifier = Modifier + .clickable(enabled) { onClick() } + .fillMaxWidth() + .padding(16.dp)) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + ) + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun LargeDropdownMenuItemPreview() { + LargeDropdownMenuItem( + text = "Sample Item", + selected = false, + enabled = true, + onClick = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun LargeDropdownMenuItemSelectedPreview() { + LargeDropdownMenuItem( + text = "Selected Item", + selected = true, + enabled = true, + onClick = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun LargeDropdownMenuPreview() { + val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5") + var selectedIndex by remember { mutableIntStateOf(1) } + + LargeDropdownMenu( + label = "Select Option", + items = options, + selectedIndex = selectedIndex, + onItemSelected = { index, _ -> + selectedIndex = index + } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun LargeDropdownMenuExpandedPreview() { + val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5", "Option 6") + var selectedIndex by remember { mutableIntStateOf(2) } + + // Simulate expanded state by showing the dropdown and the dialog content + Column { + LargeDropdownMenu( + label = "Select Option", + items = options, + selectedIndex = selectedIndex, + onItemSelected = { index, _ -> + selectedIndex = index + } + ) + + // Manually show the expanded dialog content for preview + Dialog(onDismissRequest = {}) { + Surface(shape = RoundedCornerShape(12.dp)) { + val listState = rememberLazyListState() + LaunchedEffect("ScrollToSelected") { + listState.scrollToItem(index = selectedIndex) + } + + LazyColumn(modifier = Modifier.fillMaxWidth(), state = listState) { + itemsIndexed(options) { index, item -> + LargeDropdownMenuItem( + text = item, + selected = index == selectedIndex, + enabled = true, + onClick = { selectedIndex = index } + ) + + if (index < options.lastIndex) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/AppFabMenu.kt b/app/src/main/java/eu/gaudian/translator/view/composable/AppFabMenu.kt new file mode 100644 index 0000000..cfb5fe7 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/AppFabMenu.kt @@ -0,0 +1,189 @@ +package eu.gaudian.translator.view.composable + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R + + +data class FabMenuItem( + val text: String, + val imageVector: ImageVector? = null, + val painter: Painter? = null, + val onClick: () -> Unit +) + + +@Composable +fun AppFabMenu( + items: List, + modifier: Modifier = Modifier +) { + var isMenuExpanded by remember { mutableStateOf(false) } + + val rotationAngle by animateFloatAsState( + targetValue = if (isMenuExpanded) 45f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "fabRotation" + ) + + Column( + modifier = modifier, + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + AnimatedVisibility( + visible = isMenuExpanded, + enter = fadeIn(animationSpec = tween(durationMillis = 100)) + slideInVertically(initialOffsetY = { it / 2 }), + exit = fadeOut(animationSpec = tween(durationMillis = 150)) + slideOutVertically(targetOffsetY = { it / 2 }) + ) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items.forEach { item -> + MenuItem( + text = item.text, + imageVector = item.imageVector, + painter = item.painter, + onClick = { + item.onClick() + isMenuExpanded = false + } + ) + } + } + } + + FloatingActionButton( + onClick = { isMenuExpanded = !isMenuExpanded }, + modifier = Modifier.rotate(rotationAngle), + shape = RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + elevation = FloatingActionButtonDefaults.elevation(defaultElevation = ComponentDefaults.DefaultElevation) + ) { + Icon( + imageVector = AppIcons.Add, + contentDescription = stringResource(R.string.cd_toggle_menu) + ) + } + } +} + +@Preview +@Composable +fun AppFabMenuPreview() { + @Suppress("HardCodedStringLiteral") val items = remember { + mutableStateListOf( + FabMenuItem( + text = "Item 1", + imageVector = AppIcons.Add, + onClick = {} + ), + FabMenuItem(text = "Item 2", onClick = {}) + ) + } + AppFabMenu(items = items) +} + + +@Composable +private fun MenuItem( + text: String, + imageVector: ImageVector?, + painter: Painter?, + onClick: () -> Unit +) { + Surface( + modifier = Modifier + .shadow(elevation = 8.dp, shape = ComponentDefaults.DefaultShape) + .clickable( + onClick = onClick, + interactionSource = remember { MutableInteractionSource() }, + indication = null + ), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val iconContentColor = MaterialTheme.colorScheme.onSurfaceVariant + if (imageVector != null) { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = iconContentColor + ) + } else if (painter != null) { + Icon( + painter = painter, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = iconContentColor + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = text, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge + ) + } + } +} + +@Preview +@Composable +fun MenuItemPreview() { + @Suppress("HardCodedStringLiteral") + MenuItem( + text = "Menu Item", + imageVector = AppIcons.Add, + painter = null, + onClick = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/AppIcons.kt b/app/src/main/java/eu/gaudian/translator/view/composable/AppIcons.kt new file mode 100644 index 0000000..eb18fc4 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/AppIcons.kt @@ -0,0 +1,291 @@ +@file:Suppress("unused", "HardCodedStringLiteral", "DEPRECATION") + +package eu.gaudian.translator.view.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.Icons.Default +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.DriveFileMove +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.automirrored.filled.LibraryBooks +import androidx.compose.material.icons.automirrored.filled.Redo +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowCircleUp +import androidx.compose.material.icons.filled.Assessment +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.material.icons.filled.Book +import androidx.compose.material.icons.filled.BrightnessAuto +import androidx.compose.material.icons.filled.BrightnessHigh +import androidx.compose.material.icons.filled.BrightnessLow +import androidx.compose.material.icons.filled.Business +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ColorLens +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Deselect +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.DragIndicator +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Filter1 +import androidx.compose.material.icons.filled.Filter2 +import androidx.compose.material.icons.filled.Filter3 +import androidx.compose.material.icons.filled.Filter4 +import androidx.compose.material.icons.filled.Filter5 +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.FitnessCenter +import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderDelete +import androidx.compose.material.icons.filled.FolderSpecial +import androidx.compose.material.icons.filled.HelpOutline +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.LibraryBooks +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LooksTwo +import androidx.compose.material.icons.filled.MenuBook +import androidx.compose.material.icons.filled.Merge +import androidx.compose.material.icons.filled.ModelTraining +import androidx.compose.material.icons.filled.MonitorHeart +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.NoteAdd +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.PlayCircleFilled +import androidx.compose.material.icons.filled.PlaylistRemove +import androidx.compose.material.icons.filled.Quiz +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.RuleFolder +import androidx.compose.material.icons.filled.Science +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.Source +import androidx.compose.material.icons.filled.Spellcheck +import androidx.compose.material.icons.filled.Stairs +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Style +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.Today +import androidx.compose.material.icons.filled.ToggleOn +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material.icons.filled.Wallpaper +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.Book +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.FilterAlt +import androidx.compose.material.icons.outlined.FitnessCenter +import androidx.compose.material.icons.outlined.LibraryBooks +import androidx.compose.material.icons.outlined.Translate +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.ui.theme.ThemePreviews + + +object AppIcons { + val Add = Default.Add + val AddCategory = Icons.Filled.CreateNewFolder + val AddTo = Default.Add + val AddToDictionary = Icons.Filled.NoteAdd + val AI = Default.AutoAwesome + val Appearance = Icons.Filled.ColorLens + val ApiKey = Default.Key + val ArrowBack = Icons.AutoMirrored.Filled.ArrowBack + val ArrowCircleUp = Icons.Filled.ArrowCircleUp + val ArrowDropDown = Icons.Filled.KeyboardArrowDown + val ArrowDropUp = Icons.Filled.KeyboardArrowUp + val ArrowForward = Icons.Filled.ChevronRight + val ArrowForwardNoChevron = Icons.AutoMirrored.Filled.ArrowForward + val ArrowLeft = Icons.Filled.ChevronLeft + val ArrowRight = Icons.Filled.ChevronRight + val BarChart = Default.BarChart + val BrightnessAuto = Icons.Filled.BrightnessAuto + val Business = Icons.Filled.Business + val Cancel = Default.Cancel + val Category = Icons.Filled.Folder + val Check = Default.CheckCircle + val CheckCircle = Default.CheckCircle + val CheckList = Icons.Filled.Checklist + val Clean = Default.CleaningServices + val Clear = Default.Close + val Clippy = HintIcons.Clippy + val Close = Default.Close + val Copy = Default.ContentCopy + val Dashboard = Default.Dashboard + val DarkMode = Icons.Filled.BrightnessLow + val Delete = Default.Delete + val DeleteCategory = Icons.Filled.FolderDelete + val Deselect = Default.Deselect + val Dictionary = Icons.Filled.Book + val DictionaryCategory = Icons.Filled.LooksTwo + val DictionaryFilled = Icons.Filled.Book + val DictionaryOutlined = Icons.Outlined.Book + val DragHandle = Icons.Filled.DragIndicator + val Download = Default.Download + val Done = Default.CheckCircle + val Edit = Default.Edit + val EditNote = Default.EditNote + val Error = Default.Error + val Exercise = Default.FitnessCenter + val Exercises = Icons.Filled.FitnessCenter + val ExercisesFilled = Icons.Filled.FitnessCenter + val ExercisesOutlined = Icons.Outlined.FitnessCenter + val ExitToApp = Icons.AutoMirrored.Filled.ExitToApp + val Extension = Default.Extension + val ExpandMore = Default.ExpandMore + val Favorite = Icons.Filled.Favorite + val FavoriteOutline = Icons.Outlined.FavoriteBorder + val Filter = Icons.Filled.FilterList + val FilterCategory = Icons.Filled.RuleFolder + val FilterFilled = Icons.Filled.FilterAlt + val FilterList = Icons.Filled.FolderSpecial + val FilterOutlined = Icons.Outlined.FilterAlt + val Flash = Icons.Filled.FlashOn + val FitnessCenter = Default.FitnessCenter + val Guessing = Icons.Filled.HelpOutline + val Help = HintIcons.Help85 + val History = Default.History + val Home = Default.Home + val Info = Default.Info + val Key = Default.Key + val Language = Default.Language + val LightMode = Icons.Filled.BrightnessHigh + val List = Icons.Filled.Source + val Lock = Default.Lock + val Log = Icons.Filled.MonitorHeart + val MenuBook = Icons.Filled.MenuBook + val Merge = Icons.Filled.Merge + val ModelTraining = Icons.Filled.ModelTraining + val More = Default.MoreVert + val MoreVert = Default.MoreVert + val MoveTo = Icons.AutoMirrored.Filled.DriveFileMove + val Paste = Default.ContentPaste + val Play = Default.PlayArrow + val PlayCircleFilled = Icons.Filled.PlayCircleFilled + val Quiz = Icons.Filled.Quiz + val Redo = Icons.AutoMirrored.Filled.Redo + val Refresh = Default.Refresh + val Remove = Default.PlaylistRemove + val Repository = Default.Storage + val Robo = Default.SmartToy + val Science = Icons.Filled.Science + val Search = Default.Search + val SelectAll = Default.SelectAll + val Settings = Default.Settings + val SettingsFilled = Default.Settings + val SettingsOutlined = Default.Settings + val Share = Icons.Filled.Share + val Sort = Icons.AutoMirrored.Filled.Sort + val SpellCheck = Icons.Filled.Spellcheck + val Speech = Icons.AutoMirrored.Filled.VolumeUp + val Stage1 = Default.Filter1 + val Stage2 = Default.Filter2 + val Stage3 = Default.Filter3 + val Stage4 = Default.Filter4 + val Stage5 = Default.Filter5 + val StageLearned = Default.EmojiEvents + val StageNew = Default.Lightbulb + val Stages = Icons.Filled.Stairs + val Statistics = Icons.Filled.Assessment + val Streak = Icons.Filled.LocalFireDepartment + val Storage = Default.Storage + val Style = Icons.Filled.Style + val SwapHoriz = Icons.Filled.SwapHoriz + val Sync = Icons.Filled.Sync + val TextToSpeech = Icons.AutoMirrored.Filled.VolumeUp + val Today = Icons.Filled.Today + val Translate = Default.Translate + val TranslateFilled = Icons.Filled.Translate + val TranslateOutlined = Icons.Outlined.Translate + val Tune = Default.Tune + val Undo = Icons.AutoMirrored.Filled.Undo + val Upload = Default.Upload + val Vocabulary = Icons.AutoMirrored.Filled.LibraryBooks + val VocabularyFilled = Icons.Filled.LibraryBooks + val VocabularyOutlined = Icons.Outlined.LibraryBooks + val Wallpaper = Icons.Filled.Wallpaper + val Warning = Default.Warning + val SwitchOn = Default.ToggleOn +} + +@ThemePreviews +@Composable +fun AppIconsPreview() { + val allIcons = AppIcons::class.java.declaredFields.mapNotNull { + try { + val icon = when (val value = it.get(AppIcons)) { + is ImageVector -> value + is Painter -> null // Cannot preview Painters directly in this setup + else -> null + } ?: return@mapNotNull null + val name = it.name + name to icon + } catch (e: Exception) { + null + } + } + + MaterialTheme { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 80.dp), + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(allIcons) { (name, icon) -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(imageVector = icon, contentDescription = name) + Text(text = name, style = MaterialTheme.typography.bodySmall) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/AppSlider.kt b/app/src/main/java/eu/gaudian/translator/view/composable/AppSlider.kt new file mode 100644 index 0000000..e7633e9 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/AppSlider.kt @@ -0,0 +1,87 @@ +package eu.gaudian.translator.view.composable + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + + + +/** + * An AppSlider with a diamond-shaped thumb. + */ +@Composable +fun AppSlider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + enabled: Boolean = true, + onValueChangeFinished: (() -> Unit)? = null, +) { + + val sliderColors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + activeTickColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.6f), + inactiveTickColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Slider( + value = value, + onValueChange = onValueChange, + modifier = modifier, + valueRange = valueRange, + steps = steps, + enabled = enabled, + onValueChangeFinished = onValueChangeFinished, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_LOW), + activeTickColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = ComponentDefaults.ALPHA_MEDIUM), + inactiveTickColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_MEDIUM) + ), + track = { sliderState -> + SliderDefaults.Track( + colors = sliderColors, + enabled = enabled, + sliderState = sliderState, + thumbTrackGapSize = 2.dp // This is the gap between the Thumb and the slider + ) + }, + thumb = { + Box( + modifier = Modifier + .size(width = 12.dp, height = 24.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.primary) + ) + } + ) +} + +@SuppressLint("AutoboxingStateCreation") +@Preview +@Composable +fun AppSliderPreview() { + var sliderValue by remember { mutableFloatStateOf(0.5f) } + AppSlider( + value = sliderValue, + onValueChange = { } + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/AppTabLayout.kt b/app/src/main/java/eu/gaudian/translator/view/composable/AppTabLayout.kt new file mode 100644 index 0000000..5c78241 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/AppTabLayout.kt @@ -0,0 +1,166 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.composable + +import android.annotation.SuppressLint +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.ui.theme.ThemePreviews + +/** + * An interface that defines the required properties for any item + * that can be displayed as a tab in the ModernTabLayout. + */ +interface TabItem { + val title: String + val icon: ImageVector +} + +/** + * A generic, reusable tab layout composable. + * @param T The type of the tab item, which must implement the TabItem interface. + * @param tabs A list of all tab items to display. + * @param selectedTab The currently selected tab item. + * @param onTabSelected A lambda function to be invoked when a tab is clicked. + */ +@SuppressLint("UnusedBoxWithConstraintsScope", "LocalContextResourcesRead", "DiscouragedApi") +@Composable +fun AppTabLayout( + tabs: List, + selectedTab: T, + onTabSelected: (T) -> Unit, + modifier: Modifier = Modifier +) { + val selectedIndex = tabs.indexOf(selectedTab) + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 8.dp) + .height(56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = ComponentDefaults.CardShape + ) + ) { + val tabWidth = maxWidth / tabs.size + + val indicatorOffset by animateDpAsState( + targetValue = tabWidth * selectedIndex, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium), + label = "IndicatorOffset" + ) + + Box( + modifier = Modifier + .offset(x = indicatorOffset) + .width(tabWidth) + .fillMaxHeight() + .padding(4.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp) + ) + ) + + Row(modifier = Modifier.fillMaxWidth()) { + tabs.forEach { tab -> + val isSelected = tab == selectedTab + val contentColor by animateColorAsState( + targetValue = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + animationSpec = spring(stiffness = Spring.StiffnessLow) + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + onClick = { onTabSelected(tab) }, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ), + contentAlignment = Alignment.Center + ) { + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + val context = LocalContext.current + val resolvedTitle = run { + val resId = context.resources.getIdentifier(tab.title, "string", context.packageName) + if (resId != 0) stringResource(resId) else tab.title + } + Icon( + modifier = Modifier.padding(4.dp), + imageVector = tab.icon, + contentDescription = resolvedTitle, + tint = contentColor + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = resolvedTitle, + color = contentColor, + ) + } + } + } + } + } +} + +@ThemePreviews +@Composable +fun ModernTabLayoutPreview() { + data class SampleTabItem(override val title: String, override val icon: ImageVector) : TabItem + + val tabs = listOf( + SampleTabItem("All Vocabulary", AppIcons.Home), + SampleTabItem("Flashcards", AppIcons.Search), + SampleTabItem("Settings", AppIcons.Settings) + ) + + var selectedTab by remember { mutableStateOf(tabs.first()) } + + MaterialTheme { + AppTabLayout( + tabs = tabs, + selectedTab = selectedTab, + onTabSelected = { + selectedTab = it + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/AppTopAppBar.kt b/app/src/main/java/eu/gaudian/translator/view/composable/AppTopAppBar.kt new file mode 100644 index 0000000..5c9a491 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/AppTopAppBar.kt @@ -0,0 +1,229 @@ +package eu.gaudian.translator.view.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.hints.HintBottomSheet +import eu.gaudian.translator.view.hints.LocalShowHints + +@Composable +fun AppTopAppBar( + title: @Composable () -> Unit, + modifier: Modifier = Modifier, + onNavigateBack: (() -> Unit)? = null, + navigationIcon: @Composable (() -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), + hintContent: @Composable (() -> Unit)? = null +) { + val sheetState = rememberModalBottomSheetState() + var showBottomSheet by remember { mutableStateOf(false) } + + TopAppBar( + modifier = modifier.height(56.dp), + windowInsets = WindowInsets(0.dp), + colors = colors, + title = { + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + val showHints = LocalShowHints.current + if (showHints && hintContent != null) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.weight(1f)) { + title() + } + Box { + IconButton(onClick = { showBottomSheet = true }) { + Icon( + imageVector = AppIcons.Help, + contentDescription = stringResource(R.string.show_hint), + tint = MaterialTheme.colorScheme.secondary + ) + } + } + } + } else { + title() + } + } + }, + navigationIcon = { + if (onNavigateBack != null) { + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + IconButton(onClick = onNavigateBack) { + Icon( + AppIcons.ArrowBack, + contentDescription = stringResource(R.string.cd_navigate_back), + tint = LocalContentColor.current + ) + } + } + } else if (navigationIcon != null) { + Box(modifier = Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) { + navigationIcon() + } + } else { + // No navigation icon + } + }, + actions = actions + ) + + if (showBottomSheet) { + HintBottomSheet( + onDismissRequest = { + @Suppress("AssignedValueIsNeverRead") + showBottomSheet = false + }, + sheetState = sheetState, + content = hintContent + ) + } +} + + +/** + * A composable that acts as a TopAppBar, containing a back navigation icon + * and an [AppTabLayout]. + * + * @param T The type of the tab item, must implement [TabItem]. + * @param tabs The list of tab items to display. + * @param selectedTab The currently selected tab item. + * @param onTabSelected Callback function when a tab is selected. + * @param onNavigateBack Callback function when the back arrow is clicked. + * @param modifier The modifier to be applied to the layout. + */ +@Composable +fun TabbedTopAppBar( + tabs: List, + selectedTab: T, + onTabSelected: (T) -> Unit, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier +) { + // Use a Surface to provide background color and context for the app bar + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // Back navigation icon, similar to its usage in AppTopAppBar + IconButton( + onClick = onNavigateBack, + modifier = Modifier.padding(horizontal = 4.dp) + ) { + Icon( + imageVector = AppIcons.ArrowBack, + contentDescription = stringResource(R.string.cd_navigate_back), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + // The AppTabLayout, taking up the remaining space. + // Its appearance matches the provided image. + AppTabLayout( + tabs = tabs, + selectedTab = selectedTab, + onTabSelected = onTabSelected, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Suppress("HardCodedStringLiteral") +@ThemePreviews +@Composable +fun TabbedTopAppBarPreview() { + // Sample data for preview, similar to ModernTabLayoutPreview + data class SampleTabItem(override val title: String, override val icon: ImageVector) : TabItem + + val tabs = listOf( + SampleTabItem("All Vocabulary", AppIcons.Home), + SampleTabItem("Flashcards", AppIcons.Search), + SampleTabItem("Settings", AppIcons.Settings) + ) + + var selectedTab by remember { mutableStateOf(tabs.first()) } + + MaterialTheme { + TabbedTopAppBar( + tabs = tabs, + selectedTab = selectedTab, + onTabSelected = { selectedTab = it }, + onNavigateBack = {} + ) + } +} + +@Suppress("HardCodedStringLiteral") +@ThemePreviews +@Composable +fun AppTopAppBarPreview() { + AppTopAppBar( + title = { Text("Preview Title") } + ) +} + +@ThemePreviews +@Composable +fun AppTopAppBarWithNavigationIconPreview() { + AppTopAppBar( + title = { Text(stringResource(R.string.title_title_preview_title)) }, + onNavigateBack = {} + ) +} + +@ThemePreviews +@Composable +fun AppTopAppBarWithActionsPreview() { + AppTopAppBar( + title = { Text(stringResource(R.string.title_title_preview_title)) }, + actions = { + IconButton(onClick = {}) { + Icon(AppIcons.Settings, contentDescription = stringResource(R.string.title_settings)) + } + IconButton(onClick = {}) { + AppIcons.ArrowBack + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/BottomNavigationBar.kt b/app/src/main/java/eu/gaudian/translator/view/composable/BottomNavigationBar.kt new file mode 100644 index 0000000..373271f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/BottomNavigationBar.kt @@ -0,0 +1,188 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.composable + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hierarchy +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.LocalShowExperimentalFeatures + +sealed class Screen( + val route: String, + val title: Int, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector +) { + object Home : Screen("home", R.string.label_translation, AppIcons.TranslateFilled, AppIcons.TranslateOutlined) + object Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined) + object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined) + object Vocabulary : Screen("vocabulary", R.string.label_vocabulary, AppIcons.VocabularyFilled, AppIcons.VocabularyOutlined) + object Settings : Screen("settings", R.string.title_settings, AppIcons.SettingsFilled, AppIcons.SettingsOutlined) + + companion object { + fun getAllScreens(showExperimental: Boolean = false): List { + val screens = mutableListOf(Home, Dictionary, Vocabulary, Settings) + if (showExperimental) { + screens.add(2, Exercises) + } + return screens + } + + @Composable + fun fromDestination(destination: NavDestination?): Screen { + val showExperimental = LocalShowExperimentalFeatures.current + return getAllScreens(showExperimental).find { screen -> + destination?.hierarchy?.any { it.route == screen.route } == true + } ?: Home + } + } +} + +/** + * A modernized Material 3 bottom navigation bar with spring animations and haptic feedback. + */ +@SuppressLint("UnusedBoxWithConstraintsScope") +@Composable +fun BottomNavigationBar( + selectedItem: Screen, + isVisible: Boolean, + showLabels: Boolean, + onItemSelected: (Screen) -> Unit, + modifier: Modifier = Modifier, +) { + val showExperimental = LocalShowExperimentalFeatures.current + val screens = remember(showExperimental) { Screen.getAllScreens(showExperimental) } + val haptic = LocalHapticFeedback.current + + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically( + animationSpec = androidx.compose.animation.core.tween(durationMillis = 220), + initialOffsetY = { it } + ), + exit = slideOutVertically( + animationSpec = androidx.compose.animation.core.tween(durationMillis = 220), + targetOffsetY = { it } + ) + ) { + + val baseHeight = if (showLabels) 80.dp else 56.dp + val density = LocalDensity.current + val navBarDp = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } + val height = baseHeight + navBarDp + + NavigationBar( + modifier = modifier.height(height), + containerColor = MaterialTheme.colorScheme.surface, // Cleaner look than surfaceVariant + tonalElevation = 8.dp, // Slight elevation for depth + ) { + screens.forEach { screen -> + val isSelected = screen == selectedItem + val title = stringResource(id = screen.title) + + // 1. Spring Animation for the Icon Scale + val scale by animateFloatAsState( + targetValue = if (isSelected) 1.2f else 1.0f, // Subtle pop effect + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "iconScale" + ) + + NavigationBarItem( + selected = isSelected, + onClick = { + if (!isSelected) { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) // 2. Tactile feedback + onItemSelected(screen) + } + }, + label = if (showLabels) { + { + Text( + text = title, + maxLines = 1, + fontSize = 10.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if(isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else null, + icon = { + // 3. Crossfade between Outlined and Filled icons + Crossfade(targetState = isSelected, label = "iconFade") { selected -> + Icon( + imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon, + contentDescription = title, + modifier = Modifier.scale(scale) // Apply the spring scale + ) + } + }, + colors = NavigationBarItemDefaults.colors( + indicatorColor = MaterialTheme.colorScheme.primaryContainer, + selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedTextColor = MaterialTheme.colorScheme.primary, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + } +} + +@ThemePreviews +@Composable +fun BottomNavigationBarPreview() { + MaterialTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.BottomCenter + ) { + BottomNavigationBar( + selectedItem = Screen.Home, + isVisible = true, + showLabels = true, + onItemSelected = {} + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/ComponentLibrary.kt b/app/src/main/java/eu/gaudian/translator/view/composable/ComponentLibrary.kt new file mode 100644 index 0000000..22e3618 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/ComponentLibrary.kt @@ -0,0 +1,892 @@ +@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral", "unused") + +package eu.gaudian.translator.view.composable + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ButtonElevation +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.ui.theme.semanticColors +import eu.gaudian.translator.view.composable.ComponentDefaults.DefaultElevation + + +object ComponentDefaults { + // Sizing + val DefaultButtonHeight = 48.dp + val CardPadding = 8.dp + + // Elevation + val DefaultElevation = 0.dp + val NoElevation = 0.dp + + // Borders + val DefaultBorderWidth = 1.dp + + // Shapes + val DefaultCornerRadius = 16.dp + val CardClipRadius = 8.dp + val NoRounding = 0.dp + val DefaultShape = RoundedCornerShape(DefaultCornerRadius) + val CardClipShape = RoundedCornerShape(CardClipRadius) + val CardShape = RoundedCornerShape(DefaultCornerRadius) + val NoShape = RoundedCornerShape(NoRounding) + + // Opacity Levels + const val ALPHA_HIGH = 0.6f + const val ALPHA_MEDIUM = 0.5f + const val ALPHA_LOW = 0.3f +} + + + +/** + * A styled card container for displaying content with a consistent floating look. + * + * @param modifier The modifier to be applied to the card. + * @param content The content to be displayed inside the card. + */ +@Composable +fun AppCard( + modifier: Modifier = Modifier, + title: String? = null, + icon: ImageVector? = null, // New optional icon parameter + text: String? = null, + expandable: Boolean = false, + initiallyExpanded: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + var isExpanded by remember { mutableStateOf(initiallyExpanded) } + + val rotationState by animateFloatAsState( + targetValue = if (isExpanded) 180f else 0f, + label = "Chevron Rotation" + ) + + // Check if we need to render the header row + // Updated to include icon in the check + val hasHeader = title != null || text != null || expandable || icon != null + + Surface( + modifier = modifier + .fillMaxWidth() + .shadow( + DefaultElevation, + shape = ComponentDefaults.CardShape + ) + .clip(ComponentDefaults.CardClipShape) + // Animate height changes when expanding/collapsing + .animateContentSize(), + shape = ComponentDefaults.CardShape, + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Column { + // --- Header Row --- + if (hasHeader) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = expandable) { isExpanded = !isExpanded } + .padding(ComponentDefaults.CardPadding), + verticalAlignment = Alignment.CenterVertically + ) { + // 1. Optional Icon on the left + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + } + + // 2. Title and Text Column + Column(modifier = Modifier.weight(1f)) { + if (!title.isNullOrBlank()) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + // Only show spacer if both title and text exist + if (!title.isNullOrBlank() && !text.isNullOrBlank()) { + Spacer(Modifier.size(4.dp)) + } + + if (!text.isNullOrBlank()) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // 3. Expand Chevron (Far right) + if (expandable) { + Icon( + imageVector = AppIcons.ArrowDropDown, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = Modifier.rotate(rotationState), + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + // --- Content Area --- + if (!expandable || isExpanded) { + Column( + modifier = Modifier.padding( + start = ComponentDefaults.CardPadding, + end = ComponentDefaults.CardPadding, + bottom = ComponentDefaults.CardPadding, + // If we have a header, remove the top padding so content sits closer to the title. + // If no header (legacy behavior), keep the top padding. + top = if (hasHeader) 0.dp else ComponentDefaults.CardPadding + ), + content = content + ) + } + } + } +} + +@Preview +@Composable +fun AppCardPreview() { + AppCard { + Text(stringResource(R.string.this_is_the_content_inside_the_card)) + PrimaryButton(onClick = { }, text = stringResource(R.string.label_continue)) + } +} + +@Preview(showBackground = true) +@Composable +fun AppCardPreview2() { + MaterialTheme { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // 1. Expandable Card (Initially Collapsed) + AppCard( + title = "Advanced Settings", + text = "Click to reveal more options", + expandable = true, + initiallyExpanded = false + ) { + Text("Here are some hidden settings.") + Text("They are only visible when expanded.") + } + + // 2. Expandable Card (Initially Expanded) + AppCard( + title = "Translation History", + text = "Recent items", + expandable = true, + initiallyExpanded = true + ) { + Text("• Hello -> Hallo") + Text("• World -> Welt") + Text("• Sun -> Sonne") + } + + // 3. Static Card (No Title/Expand logic - Legacy behavior) + AppCard { + Text("This is a standard card without a header.") + } + } + } +} + +/** + * The primary button for the most important actions. + * + * @param onClick The callback to be invoked when the button is clicked. + * @param modifier The modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. + * @param text The text to display on the button. + * @param icon Optional leading icon for the button. + */ +@Composable +fun PrimaryButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: String, + icon: ImageVector? = null, + maxLines: Int = 1 +) { + AppButton( + onClick = onClick, + modifier = modifier.height(ComponentDefaults.DefaultButtonHeight), + enabled = enabled, + shape = ComponentDefaults.DefaultShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + } + Text(text) + } +} + +@Preview +@Composable +fun PrimaryButtonPreview() { + PrimaryButton(onClick = { }, text = stringResource(R.string.primary_button)) +} + +@Composable +fun AppButton( + onClick: () -> Unit, + modifier: Modifier? = Modifier, + enabled: Boolean = true, + shape: Shape? = null, + colors: ButtonColors = ButtonDefaults.buttonColors(), + elevation: ButtonElevation? = ButtonDefaults.buttonElevation(), + border: BorderStroke? = null, + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + interactionSource: MutableInteractionSource? = null, + content: @Composable RowScope.() -> Unit +) { + + val m = modifier ?: Modifier.height(ComponentDefaults.DefaultButtonHeight) + val s = shape ?: ComponentDefaults.DefaultShape + + Button( + onClick = onClick, + modifier = m, + enabled = enabled, + shape = s, + colors = colors, + elevation = elevation, + border = border, + contentPadding = PaddingValues( + start = 8.dp, // More horizontal padding + end = 8.dp, + top = 8.dp, // Default vertical padding + bottom = 8.dp + ), + interactionSource = interactionSource + ) { + content() + } +} + +@Composable +fun AppOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + shape: Shape = ButtonDefaults.outlinedShape, + elevation: ButtonElevation? = null, + border: BorderStroke? = ButtonDefaults.outlinedButtonBorder(enabled), + contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + interactionSource: MutableInteractionSource? = null, + borderColor: Color? = null, + content: @Composable RowScope.() -> Unit, +) { + + val borderr = if(borderColor != null) { + BorderStroke(ComponentDefaults.DefaultBorderWidth, borderColor) + } else { + border + } + + OutlinedButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = ComponentDefaults.DefaultShape, + elevation = elevation, + border = borderr, + contentPadding = contentPadding, + interactionSource = interactionSource, + content = content + ) +} + +@Preview +@Composable +fun PrimaryButtonWithIconPreview() { + PrimaryButton(onClick = { }, text = stringResource(R.string.primary_with_icon), icon = AppIcons.Add) +} + +/** + * The secondary button for less prominent actions. + * + * @param onClick The callback to be invoked when the button is clicked. + * @param modifier The modifier to be applied to the button. + * @param enabled Controls the enabled state of the button. + * @param text The text to display on the button. + * @param icon Optional leading icon for the button. + */ +@Composable +fun SecondaryButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: String, + icon: ImageVector? = null, + inverse: Boolean = false, + maxLines: Int= 1 +) { + AppOutlinedButton( + shape = ComponentDefaults.DefaultShape, + onClick = onClick, + modifier = modifier.height(ComponentDefaults.DefaultButtonHeight), + enabled = enabled, + borderColor = MaterialTheme.colorScheme.surfaceContainerHighest, + border = ButtonDefaults.outlinedButtonBorder(enabled = enabled).copy( + width = ComponentDefaults.DefaultBorderWidth + ), + contentPadding = PaddingValues( + start = 8.dp, + end = 8.dp, + top = 0.dp, + bottom = 0.dp + ) + ) { + if (!inverse) Text(text, maxLines = maxLines) + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + } + if (inverse) Text(text, maxLines = maxLines) + } +} + +@Preview +@Composable +fun SecondaryButtonPreview() { + SecondaryButton(onClick = { }, text = stringResource(R.string.secondary_button)) +} + +@Preview +@Composable +fun SecondaryButtonWithIconPreview() { + SecondaryButton(onClick = { }, text = stringResource(R.string.secondary_with_icon), icon = AppIcons.Add) +} + +@Preview +@Composable +fun SecondaryButtonInversePreview() { + SecondaryButton(onClick = { }, text = stringResource(R.string.secondary_inverse), icon = AppIcons.Add, inverse = true) +} + +/** + * Helper composable for consistent OutlinedTextField colors. + */ +@Composable +private fun appTextFieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_LOW), + focusedLabelColor = MaterialTheme.colorScheme.primary, + cursorColor = MaterialTheme.colorScheme.primary +) + +/** + * A styled text input field. + * + * @param value The input text to be shown in the text field. + * @param onValueChange The callback that is triggered when the input service updates the text. + * @param modifier The modifier to be applied to the text field. + * @param label A composable lambda for the label to be displayed inside the text field. + * @param trailingIcon A composable lambda for the trailing icon. + * @param paste Controls the visibility of the paste icon. + * @param clear Controls the visibility of the clear icon. + */ +@Composable +fun AppTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + singleLine: Boolean = false, + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + placeholder: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + readOnly: Boolean = false, + leadingIcon: @Composable (() -> Unit)? = null, + paste: Boolean = false, + clear: Boolean = false, + supportingText: @Composable (() -> Unit)? = null, +) { + val clipboardManager = LocalClipboardManager.current + + val finalTrailingIcon: @Composable (() -> Unit)? = if (paste || clear || trailingIcon != null) { + { + Row(verticalAlignment = Alignment.CenterVertically) { + if (paste && value.isEmpty()) { + IconButton(onClick = { onValueChange(clipboardManager.getText()?.text ?: "") }) { + Icon( + imageVector = AppIcons.Paste, + contentDescription = stringResource(R.string.cd_paste), + tint = MaterialTheme.colorScheme.primary + ) + } + } + if (clear && value.isNotEmpty()) { + IconButton(onClick = { onValueChange("") }) { + Icon( + imageVector = AppIcons.Clear, + contentDescription = stringResource(R.string.label_clear), + tint = MaterialTheme.colorScheme.primary + ) + } + } + if (trailingIcon != null) { + trailingIcon() + } + } + } + } else { + null + } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier.fillMaxWidth(), + label = label, + trailingIcon = finalTrailingIcon, + shape = ComponentDefaults.DefaultShape, + colors = appTextFieldColors(), + placeholder = placeholder, + enabled = enabled, + readOnly = readOnly, + leadingIcon = leadingIcon, + minLines = minLines, + maxLines = maxLines, + supportingText = supportingText, + ) +} + +@Preview +@Composable +fun AppTextFieldPreview() { + var text by remember { mutableStateOf("") } + AppTextField( + value = text, + onValueChange = { text = it }, + label = { Text(stringResource(R.string.email_address)) } + ) +} + + +@Composable +fun AppSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = ComponentDefaults.ALPHA_MEDIUM), + uncheckedThumbColor = MaterialTheme.colorScheme.onSurface, + uncheckedTrackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_LOW) + ), + enabled = enabled + ) +} + +@Preview +@Composable +fun AppSwitchPreview() { + var checked by remember { mutableStateOf(false) } + AppSwitch( + checked = checked, + onCheckedChange = { checked = it } + ) +} + +/** + * An option row with a title, an optional description below it, and a trailing AppSwitch. + * The whole row is tappable to toggle the switch (if enabled). + */ + + + +/** + * A styled checkbox for selecting an option. + * + * @param checked The current state of the checkbox. + * @param onCheckedChange The callback invoked when the checkbox is toggled. + * @param modifier The modifier to be applied to the checkbox. + */ +@Composable +fun AppCheckbox( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.primary, + uncheckedColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_HIGH), + checkmarkColor = MaterialTheme.colorScheme.onPrimary + ), + enabled = enabled + ) +} + +@Preview +@Composable +fun AppCheckboxPreview() { + var checked by remember { mutableStateOf(false) } + AppCheckbox( + checked = checked, + onCheckedChange = { checked = it } + ) +} + +@Composable +fun DialogButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + isPrimary: Boolean = false, + text: String? = null, + maxLines: Int? = 1, + content: (@Composable () -> Unit)? = null, +) { + TextButton( + onClick = onClick, + modifier = modifier.height(ComponentDefaults.DefaultButtonHeight), + enabled = enabled, + shape = ComponentDefaults.DefaultShape, + colors = ButtonDefaults.buttonColors( + containerColor = if (isPrimary) MaterialTheme.colorScheme.primary else Color.Transparent, + contentColor = if (isPrimary) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary, + ) + ) { + if (text != null) { + Text(text, maxLines = maxLines ?: 1) + } + if (content != null) { + content() + } + } +} + +@Preview +@Composable +fun DialogButtonPreview() { + DialogButton( + onClick = { }, + text = stringResource(R.string.label_confirm) + ) +} + +@Preview +@Composable +fun PrimaryDialogButtonPreview() { + DialogButton( + onClick = { }, + text = stringResource(R.string.label_confirm), + isPrimary = true, + content = { Text(stringResource(R.string.label_confirm)) } + ) +} + +@ThemePreviews +@Composable +fun AppOutlinedCardPreview() { + AppOutlinedCard { + Column { + @Suppress("HardCodedStringLiteral") + Text("This is a debug card") + } + } +} + +@ThemePreviews +@Composable +fun WrongOutlinedButtonPreview(){ + WrongOutlinedButton(onClick = { }, text = stringResource(R.string.label_continue)) +} + +@Composable +fun AppOutlinedCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + val debug = false + OutlinedCard( + modifier = modifier + .fillMaxWidth() + .padding(ComponentDefaults.NoElevation), + shape = ComponentDefaults.NoShape, + elevation = CardDefaults.cardElevation(defaultElevation = ComponentDefaults.NoElevation), + border = BorderStroke( + ComponentDefaults.DefaultBorderWidth, + if (debug) Color.Magenta else Color.Transparent + ), + content = content + ) +} + + +@Composable +fun CorrectButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: String? = null, + leadingIcon: ImageVector? = null, + trailingIcon: ImageVector? = null, + content: (@Composable RowScope.() -> Unit)? = null, +) { + AppButton( + onClick = onClick, + modifier = modifier.height(ComponentDefaults.DefaultButtonHeight), + enabled = enabled, + shape = ComponentDefaults.DefaultShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.semanticColors.success, + contentColor = MaterialTheme.semanticColors.onSuccess + ) + ) { + if (content != null) { + content() + } else { + if (leadingIcon != null) { + Icon(leadingIcon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + } + if (text != null) Text(text) + if (trailingIcon != null) { + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Icon(trailingIcon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + } + } + } +} + +@Composable +fun WrongButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: String? = null, + leadingIcon: ImageVector? = null, + trailingIcon: ImageVector? = null, + content: (@Composable RowScope.() -> Unit)? = null, +) { + AppButton( + onClick = onClick, + modifier = modifier.height(ComponentDefaults.DefaultButtonHeight), + enabled = enabled, + shape = ComponentDefaults.DefaultShape, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.semanticColors.wrong, + contentColor = MaterialTheme.semanticColors.onWrong + ) + ) { + if (content != null) { + content() + } else { + if (leadingIcon != null) { + Icon(leadingIcon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + } + if (text != null) Text(text) + if (trailingIcon != null) { + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Icon(trailingIcon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + } + } + } +} + +@Preview +@Composable +fun CorrectButtonPreview() { + CorrectButton(onClick = { }, text = stringResource(R.string.label_continue)) +} + +@Preview +@Composable +fun WrongButtonPreview() { + WrongButton(onClick = { }, text = stringResource(R.string.label_continue)) +} + +@Suppress("unused", "HardCodedStringLiteral") +@Composable +fun CorrectOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: String? = null, + leadingIcon: ImageVector? = null, + trailingIcon: ImageVector? = null, +) { + AppOutlinedButton( + onClick = onClick, + modifier = modifier.height(ComponentDefaults.DefaultButtonHeight), + enabled = enabled, + shape = ComponentDefaults.DefaultShape, + borderColor = MaterialTheme.semanticColors.onSuccessContainer, + border = BorderStroke(ComponentDefaults.DefaultBorderWidth, MaterialTheme.semanticColors.onSuccessContainer) + ) { + if (leadingIcon != null) { + Icon(leadingIcon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + } + if (text != null) Text(text) + if (trailingIcon != null) { + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Icon(trailingIcon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + } + } +} + +@Composable +fun WrongOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + text: String? = null, + leadingIcon: ImageVector? = null, + trailingIcon: ImageVector? = null, +) { + AppOutlinedButton( + onClick = onClick, + modifier = modifier.height(ComponentDefaults.DefaultButtonHeight), + enabled = enabled, + shape = ComponentDefaults.DefaultShape, + borderColor = MaterialTheme.semanticColors.wrongContainer, + border = BorderStroke(ComponentDefaults.DefaultBorderWidth, MaterialTheme.semanticColors.onWrongContainer) + ) { + if (leadingIcon != null) { + Icon(leadingIcon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + } + if (text != null) Text(text) + if (trailingIcon != null) { + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Icon(trailingIcon, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + } + } +} + + +@Composable +fun AppScaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, + containerColor: Color = MaterialTheme.colorScheme.background, + contentColor: Color = contentColorFor(containerColor), + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + content: @Composable (PaddingValues) -> Unit +) { + // A Scaffold variant that enforces no bottom bar while keeping proper content padding handling. + Scaffold( + modifier = modifier, + topBar = topBar, + snackbarHost = snackbarHost, + containerColor = containerColor, + contentColor = contentColor, + bottomBar = {}, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition + ) { innerPadding -> + // Ensure bottom padding is always 0.dp + val customPadding = PaddingValues( + top = innerPadding.calculateTopPadding(), + bottom = 0.dp, // Force bottom padding to 0.dp + start = innerPadding.calculateLeftPadding(androidx.compose.ui.unit.LayoutDirection.Ltr), + end = innerPadding.calculateRightPadding(androidx.compose.ui.unit.LayoutDirection.Ltr) + ) + content(customPadding) + } +} diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/LanguageDropDown.kt b/app/src/main/java/eu/gaudian/translator/view/composable/LanguageDropDown.kt new file mode 100644 index 0000000..5a21a0b --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/LanguageDropDown.kt @@ -0,0 +1,483 @@ +package eu.gaudian.translator.view.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.viewmodel.LanguageViewModel + + +@Composable +fun BaseLanguageDropDown( + modifier: Modifier = Modifier, + languageViewModel: LanguageViewModel, + selectedLanguage: Language?, + onLanguageSelected: (Language) -> Unit, + showAutoOption: Boolean = false, + onAutoSelected: () -> Unit = {}, + showNoneOption: Boolean = false, + onNoneSelected: () -> Unit = {}, + enableMultipleSelection: Boolean = false, + onLanguagesSelected: (List) -> Unit = {}, + alternateLanguages: List = emptyList(), + iconEnabled: Boolean = true, + noBorder: Boolean = false, +) { + val defaultLanguages by languageViewModel.allLanguages.collectAsState(initial = emptyList()) + val favoriteLanguages by languageViewModel.favoriteLanguages.collectAsState() + val languageHistory by languageViewModel.languageHistory.collectAsState() + + var expanded by remember { mutableStateOf(false) } + var searchText by remember { mutableStateOf("") } + var tempSelection by remember { mutableStateOf>(emptyList()) } + var selectedLanguagesCount by remember { mutableIntStateOf(if (enableMultipleSelection) tempSelection.size else 0) } + + val languages = remember(alternateLanguages, defaultLanguages) { + alternateLanguages.ifEmpty { defaultLanguages } + } + + val buttonText = when { + enableMultipleSelection && selectedLanguagesCount > 0 -> stringResource( + R.string.text_2d_languages_selected, + selectedLanguagesCount + ) + enableMultipleSelection && selectedLanguagesCount == 0 -> stringResource(R.string.text_select_languages) + selectedLanguage != null -> selectedLanguage.name + showAutoOption -> stringResource(R.string.label_language_auto) + else -> stringResource(R.string.label_language_none) + } + + Box(modifier = modifier) { + AppOutlinedButton( + shape = RoundedCornerShape(8.dp), + onClick = { expanded = true }, + contentPadding = if (!iconEnabled) PaddingValues(0.dp) else PaddingValues(horizontal = 16.dp, vertical = 8.dp), + borderColor = if (noBorder) Color.Unspecified else null + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = buttonText, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + maxLines = 1 + ) + if (iconEnabled) + Icon( + imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand) + ) + } + } + + DropdownMenu(modifier = modifier.fillMaxWidth(), expanded = expanded, onDismissRequest = { + expanded = false + searchText = "" + tempSelection = emptyList() // Also reset temp selection on dismiss + }) { + // Helper composable for a single language row in multiple selection mode + @Composable + fun MultiSelectItem(language: Language) { + val isSelected = tempSelection.contains(language) + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + AppCheckbox( + checked = isSelected, + onCheckedChange = { + tempSelection = if (isSelected) tempSelection - language else tempSelection + language + @Suppress("AssignedValueIsNeverRead") + selectedLanguagesCount = tempSelection.size + onLanguagesSelected(tempSelection) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(language.name) + if (language.nativeName != language.name) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "(${language.nativeName})", + style = TextStyle( + fontStyle = FontStyle.Italic, + fontFamily = FontFamily.Default + ) + ) + } + } + }, + onClick = { + tempSelection = if (isSelected) tempSelection - language else tempSelection + language + @Suppress("AssignedValueIsNeverRead") + selectedLanguagesCount = tempSelection.size + } + ) + } + + // Helper composable for a single language row in single selection mode + @Composable + fun SingleSelectItem(language: Language) { + val languageNames = languages.map { it.name } + val duplicateNames = languageNames.groupingBy { it }.eachCount().filter { it.value > 1 }.keys + val isDuplicate = duplicateNames.contains(language.name) + + DropdownMenuItem( + text = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column { + Text(text = language.name) + if (language.nativeName != language.name) { + Text( + text = language.nativeName, + style = TextStyle( + fontStyle = FontStyle.Italic, + fontSize = 12.sp, + fontFamily = FontFamily.Default + ) + ) + } + } + if (isDuplicate) { + Spacer(modifier = Modifier.width(4.dp)) + Text(text = "(${language.region})") + } + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { + val isCurrentlyFavorite = favoriteLanguages.contains(language) + val updatedFavorites = if (!isCurrentlyFavorite) favoriteLanguages + language else favoriteLanguages - language + languageViewModel.updateFavoriteLanguages(updatedFavorites) + }) { + Icon( + imageVector = if (favoriteLanguages.contains(language)) AppIcons.Favorite else AppIcons.FavoriteOutline, + contentDescription = if (favoriteLanguages.contains(language)) stringResource( + R.string.text_remove_from_favorites + ) else stringResource(R.string.text_add_to_favorites) + ) + } + } + }, + onClick = { + onLanguageSelected(language) + expanded = false + searchText = "" + } + ) + } + + + // --- Main Dropdown Content --- + Column( + modifier = Modifier + .heightIn(max = 900.dp) // Constrain the height + ) { + // Search bar with a back arrow + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { expanded = false; searchText = "" }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.label_close)) + } + TextField( + value = searchText, + onValueChange = { searchText = it }, + singleLine = true, + placeholder = { Text(stringResource(R.string.text_search_3d)) }, + trailingIcon = { + if (searchText.isNotBlank()) { + IconButton(onClick = { searchText = "" }) { + Icon(imageVector = AppIcons.Close, contentDescription = stringResource(R.string.cd_clear_search)) + } + } + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + modifier = Modifier.weight(1f) + ) + } + HorizontalDivider() + + + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + val isSearching = searchText.isNotBlank() + + if (isSearching) { + val searchResults = (favoriteLanguages + languageHistory + languages) + .distinctBy { it.nameResId } + .filter { language -> + val matchesName = language.name.contains(searchText, ignoreCase = true) + val matchesNativeName = language.nativeName.contains(searchText, ignoreCase = true) + matchesName || matchesNativeName + } + .sortedBy { it.name } + + if (enableMultipleSelection) { + searchResults.forEach { language -> MultiSelectItem(language) } + } else { + searchResults.forEach { language -> SingleSelectItem(language) } + } + + } else if (alternateLanguages.isNotEmpty()) { + val sortedAlternate = alternateLanguages.sortedBy { it.name } + if (enableMultipleSelection) { + Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) + sortedAlternate.forEach { language -> MultiSelectItem(language) } + } else { + Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) + sortedAlternate.forEach { language -> SingleSelectItem(language) } + } + + } else { + if (enableMultipleSelection) { + if (favoriteLanguages.isNotEmpty()) { + Text(stringResource(R.string.text_favorites), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) + favoriteLanguages.forEach { language -> MultiSelectItem(language) } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + val recentHistoryFiltered = languageHistory.filter { it !in favoriteLanguages }.takeLast(5) + if (recentHistoryFiltered.isNotEmpty() && alternateLanguages.isEmpty()) { + Text(stringResource(R.string.text_recent_history), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) + recentHistoryFiltered.forEach { language -> MultiSelectItem(language) } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + val remainingLanguages = languages.sortedBy { it.name } + if (remainingLanguages.isNotEmpty()) { + Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) + remainingLanguages.forEach { language -> MultiSelectItem(language) } + } + } else { + // Logic for single selection default view + if (showAutoOption) { + DropdownMenuItem(text = { Text(stringResource(R.string.text_select_auto_recognition)) }, onClick = { onAutoSelected(); expanded = false; searchText = "" }) + HorizontalDivider() + } + if (showNoneOption) { + DropdownMenuItem(text = { Text(stringResource(R.string.text_select_none)) }, onClick = { onNoneSelected(); expanded = false; searchText = "" }) + HorizontalDivider() + } + if (favoriteLanguages.any { + @Suppress("HardCodedStringLiteral") + it.code != "none" && it.code != "auto" + }) { + Text(stringResource(R.string.text_favorites), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) + favoriteLanguages.filter { + @Suppress("HardCodedStringLiteral") + it.code != "none" && it.code != "auto" + }.forEach { language -> SingleSelectItem(language) } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + val recentHistoryFiltered = languageHistory.filter { + @Suppress("HardCodedStringLiteral") + it !in favoriteLanguages && it.code != "none" && it.code != "auto" + }.takeLast(5) + if (recentHistoryFiltered.isNotEmpty()) { + Text(stringResource(R.string.text_recent_history), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) + recentHistoryFiltered.forEach { language -> SingleSelectItem(language) } + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + val remainingLanguages = languages.filter { + @Suppress("HardCodedStringLiteral") + it !in favoriteLanguages && it !in recentHistoryFiltered && it.code != "none" && it.code != "auto" + }.sortedBy { it.name } + if (remainingLanguages.isNotEmpty()) { + Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) + remainingLanguages.forEach { language -> SingleSelectItem(language) } + } + } + } + } + + // Done button for multiple selection mode + if (enableMultipleSelection) { + HorizontalDivider() + AppButton( + onClick = { + onLanguagesSelected(tempSelection) + @Suppress("AssignedValueIsNeverRead") + selectedLanguagesCount = tempSelection.size + expanded = false + searchText = "" + }, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) { + Text(stringResource(R.string.label_done)) + } + } + } + } + } +} + +@Composable +fun SourceLanguageDropdown( + modifier: Modifier = Modifier, + languageViewModel: LanguageViewModel, + autoEnabled: Boolean = false, + iconEnabled: Boolean = true, + noBorder: Boolean = false, +) { + val selectedLanguage by languageViewModel.selectedSourceLanguage.collectAsState() + val languageHistory by languageViewModel.languageHistory.collectAsState() + + BaseLanguageDropDown( + modifier = modifier, + languageViewModel = languageViewModel, + selectedLanguage = selectedLanguage, + onLanguageSelected = { lang -> + languageViewModel.setSelectedSourceLanguage(lang) + val updatedHistory = (languageHistory + lang).distinct().takeLast(5) + languageViewModel.updateLanguageHistory(updatedHistory) + }, + showAutoOption = autoEnabled, + onAutoSelected = { languageViewModel.setSelectedSourceLanguage(0) }, + showNoneOption = false, + enableMultipleSelection = false, + iconEnabled = iconEnabled, + noBorder = noBorder, + ) +} + + +@Composable +fun TargetLanguageDropdown( + modifier: Modifier = Modifier, + languageViewModel: LanguageViewModel, + iconEnabled: Boolean = true, + noBorder: Boolean = false, +) { + val selectedLanguage by languageViewModel.selectedTargetLanguage.collectAsState() + val languageHistory by languageViewModel.languageHistory.collectAsState() + + BaseLanguageDropDown( + modifier = modifier, + languageViewModel = languageViewModel, + selectedLanguage = selectedLanguage, + onLanguageSelected = { lang -> + languageViewModel.setSelectedTargetLanguage(lang) + val updatedHistory = (languageHistory + lang).distinct().takeLast(5) + languageViewModel.updateLanguageHistory(updatedHistory) + }, + showAutoOption = false, + showNoneOption = false, + enableMultipleSelection = false, + iconEnabled = iconEnabled, + noBorder = noBorder, + + ) +} + +@Composable +fun DictionaryLanguageDropDown( + modifier: Modifier = Modifier, + languageViewModel: LanguageViewModel +) { + val selectedLanguage by languageViewModel.selectedDictionaryLanguage.collectAsState() + val languageHistory by languageViewModel.languageHistory.collectAsState() + + BaseLanguageDropDown( + modifier = modifier, + languageViewModel = languageViewModel, + selectedLanguage = selectedLanguage, + onLanguageSelected = { lang -> + languageViewModel.setSelectedDictionaryLanguage(lang) + val updatedHistory = (languageHistory + lang).distinct().takeLast(5) + languageViewModel.updateLanguageHistory(updatedHistory) + }, + showAutoOption = false, + showNoneOption = false, + enableMultipleSelection = false + ) +} + + +@Composable +fun MultipleLanguageDropdown( + modifier: Modifier = Modifier, + languageViewModel: LanguageViewModel, + onLanguagesSelected: (List) -> Unit, + alternateLanguages: List = emptyList() +) { + BaseLanguageDropDown( + modifier = modifier, + languageViewModel = languageViewModel, + selectedLanguage = null, + onLanguageSelected = {}, + showAutoOption = false, + showNoneOption = true, + onNoneSelected = { onLanguagesSelected(emptyList()) }, + enableMultipleSelection = true, + onLanguagesSelected = onLanguagesSelected, + alternateLanguages = alternateLanguages + ) +} + +@Composable +fun SingleLanguageDropDown( + modifier: Modifier = Modifier, + languageViewModel: LanguageViewModel, + selectedLanguage: Language?, + onLanguageSelected: (Language) -> Unit, + showAutoOption: Boolean = false, + onAutoSelected: () -> Unit = {}, + showNoneOption: Boolean = false, + onNoneSelected: () -> Unit = {}, + alternateLanguages: List = emptyList() +) { + val languageHistory by languageViewModel.languageHistory.collectAsState() + + BaseLanguageDropDown( + modifier = modifier, + languageViewModel = languageViewModel, + selectedLanguage = selectedLanguage, + onLanguageSelected = { lang -> + onLanguageSelected(lang) + + val updatedHistory = (languageHistory + lang).distinctBy { it.code }.takeLast(5) + languageViewModel.updateLanguageHistory(updatedHistory) + }, + showAutoOption = showAutoOption, + onAutoSelected = onAutoSelected, + showNoneOption = showNoneOption, + onNoneSelected = onNoneSelected, + enableMultipleSelection = false, + alternateLanguages = alternateLanguages + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/ModelIconResolver.kt b/app/src/main/java/eu/gaudian/translator/view/composable/ModelIconResolver.kt new file mode 100644 index 0000000..157208f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/ModelIconResolver.kt @@ -0,0 +1,136 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.composable + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Centralized model icon resolver. Heuristics based on model name and provider. + * This is the single place to adjust mappings, keeping UI components simple. + */ +object ModelIconResolver { + sealed interface BadgeSpec { + data class IconSpec(val icon: ImageVector, val tint: Color? = null) : BadgeSpec + data class EmojiSpec(val emoji: String, val tint: Color? = null) : BadgeSpec + } + + fun resolveAll(modelName: String, providerKey: String?): List { + val name = modelName.lowercase() + providerKey?.lowercase() + val provider = providerKey?.lowercase() + + + val specs = mutableListOf() + + // Priority 1: Special keywords (can add multiple) + if (name.contains("free")) { + // Use money emoji for "free" to showcase emoji support + specs.add(BadgeSpec.EmojiSpec("💲", Color(0xFF2E7D32))) // green + } + if (name.contains("turbo") || name.contains("flash") || name.contains("fast")) { + specs.add(BadgeSpec.IconSpec(AppIcons.Flash, Color(0xFFFFC107))) // yellow + } + if (name.contains("-mini") || name.contains("nano") || name.contains("small") || name.contains(" mini")) { + specs.add(BadgeSpec.EmojiSpec("🪶", Color.White)) + } + if (name.contains("-large") || name.contains("pro") || name.contains("large") || name.contains(" pro")) { + specs.add(BadgeSpec.EmojiSpec("💎", Color.White)) + } + if (name.contains("-vision") || name.contains("-image") || name.contains("video")) { + specs.add(BadgeSpec.EmojiSpec("⛔", Color.White)) + specs.add(BadgeSpec.EmojiSpec("👁", Color.White)) + + } + if ( name.contains("-audio")) { + specs.add(BadgeSpec.EmojiSpec("⛔", Color.White)) + specs.add(BadgeSpec.EmojiSpec("🎧", Color.White)) + + } + + @Suppress("UnusedVariable") val sizeIcon = when { + Regex("(^|[^0-9])120b([^0-9]|$)").containsMatchIn(name) -> BadgeSpec.EmojiSpec("1⃣2⃣0⃣", Color(0xFFF44336)) + Regex("(^|[^0-9])70b([^0-9]|$)").containsMatchIn(name) -> BadgeSpec.EmojiSpec("7⃣0⃣", Color(0xFFFF9800)) + Regex("(^|[^0-9])(?!1)20b([^0-9]|$)").containsMatchIn(name) -> BadgeSpec.EmojiSpec("2⃣0⃣", Color(0xFFFFC107)) + Regex("(^|[^0-9])8b(?!0)([^0-9]|$)").containsMatchIn(name) -> BadgeSpec.EmojiSpec("8⃣", Color(0xFF43A047)) + Regex("(^|[^0-9])7b(?!0)([^0-9]|$)").containsMatchIn(name) -> BadgeSpec.EmojiSpec("7⃣", Color(0xFF4CAF50)) + Regex("(^|[^0-9])1b(?!0)([^0-9]|$)").containsMatchIn(name) -> BadgeSpec.EmojiSpec("1⃣", Color(0xFF64B5F6)) + else -> null + } + //TODO for now no size icon until it is fixed + //if (sizeIcon != null) specs.add(sizeIcon) + + // Instruction-tuned + if (name.contains("instruct")) { + specs.add(BadgeSpec.IconSpec(AppIcons.Science, null)) + } + + // Family heuristics (mix icons and emojis to demonstrate capability) + if (name.contains("llama") || name.contains("meta")) specs.add(BadgeSpec.EmojiSpec("🊙", null)) + if (name.contains("mistral") || name.contains("mixtral"))specs.add(BadgeSpec.EmojiSpec("🐱", null)) + if (name.contains("gpt") || provider == "openai") specs.add(BadgeSpec.EmojiSpec("🌀", null)) + if (name.contains("deepseek")) specs.add(BadgeSpec.EmojiSpec("🐋") ) + if (name.contains("gemini") || name.contains("google") ||name.contains("gemma") || provider == "gemini" || provider == "google") specs.add(BadgeSpec.EmojiSpec("👥") ) + if (name.contains("qwen")) specs.add(BadgeSpec.EmojiSpec("🌊") ) + if (name.contains("phi") || provider?.contains("microsoft") == true) specs.add(BadgeSpec.IconSpec(AppIcons.Science, null)) + if (name.contains("ibm") || name.contains("granite")) specs.add(BadgeSpec.IconSpec(AppIcons.Business, null)) + if (name.contains("moonshot") || name.contains("moonshotai") || name.contains("moon") ) specs.add(BadgeSpec.EmojiSpec("🌕") ) + + return specs + } + +} + +@Composable +fun ModelBadges( + modelDisplayOrId: String, + providerKey: String?, + modifier: Modifier = Modifier, + iconSizeDp: Int = 16, + spacingDp: Int = 4, +) { + val badges = ModelIconResolver.resolveAll(modelDisplayOrId, providerKey) + if (badges.isEmpty()) return + androidx.compose.foundation.layout.Row(modifier = modifier) { + badges.forEachIndexed { index, b -> + when (b) { + is ModelIconResolver.BadgeSpec.IconSpec -> { + Icon( + imageVector = b.icon, + contentDescription = null, + tint = b.tint ?: MaterialTheme.colorScheme.primary, + modifier = Modifier.size(iconSizeDp.dp) + ) + } + is ModelIconResolver.BadgeSpec.EmojiSpec -> { + // Center the emoji inside a fixed-size box and scale font to avoid clipping + Box(modifier = Modifier.size(iconSizeDp.dp), contentAlignment = Alignment.Center) { + val fs = (iconSizeDp * 0.88f).sp + Text( + text = b.emoji, + color = b.tint ?: MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Normal, + fontSize = fs, + lineHeight = fs, + maxLines = 1 + ) + } + } + } + if (index != badges.lastIndex) { + androidx.compose.foundation.layout.Spacer(Modifier.width(spacingDp.dp)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/OptionItemSwitch.kt b/app/src/main/java/eu/gaudian/translator/view/composable/OptionItemSwitch.kt new file mode 100644 index 0000000..779807f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/OptionItemSwitch.kt @@ -0,0 +1,91 @@ +package eu.gaudian.translator.view.composable + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R + +@Composable +fun OptionItemSwitch( + modifier: Modifier = Modifier, + title: String, + description: String? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, + contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 12.dp), +) { + Surface( + modifier = modifier + .fillMaxWidth(), + color = Color.Transparent + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(ComponentDefaults.CardClipShape) + .clickable(enabled = enabled) { onCheckedChange(!checked) } + .padding(contentPadding), // Apply the proper padding here + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) // Add space between text and switch + ) { + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + //color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold + ) + if (!description.isNullOrBlank()) { + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + // FIX: Removed the problematic Spacer and the fillMaxWidth() modifier from AppSwitch. + // The switch now takes its natural size and is positioned correctly by the weighted Column. + AppSwitch( + checked = checked, + onCheckedChange = { if (enabled) onCheckedChange(it) }, + enabled = enabled + ) + } + } +} + +@Preview +@Composable +fun OptionItemSwitchPreview() { + var checked by remember { mutableStateOf(true) } + OptionItemSwitch( + title = stringResource(R.string.label_general), + description = stringResource(R.string.this_is_the_content_inside_the_card), + checked = checked, + onCheckedChange = { } + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/TestIcons.kt b/app/src/main/java/eu/gaudian/translator/view/composable/TestIcons.kt new file mode 100644 index 0000000..1c2ae46 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/TestIcons.kt @@ -0,0 +1,694 @@ +@file:Suppress("UnusedReceiverParameter", "HardCodedStringLiteral", "ObjectPropertyName") + +package eu.gaudian.translator.view.composable + +// Preview imports +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.materialIcon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.unit.dp + +/** + * A collection of custom ImageVectors for use as hint icons. + */ +object HintIcons + +/** + * A classic lightbulb icon, symbolizing an idea or a bright hint. + */ +val HintIcons.Lightbulb: ImageVector + get() { + if (lightbulb != null) { + return lightbulb!! + } + lightbulb = materialIcon(name = "Hint.Lightbulb") { + path(fill = SolidColor(Color(0xFF000000)), stroke = null) { + moveTo(9.0f, 21.0f) + curveToRelative(0.0f, 0.55f, 0.45f, 1.0f, 1.0f, 1.0f) + horizontalLineToRelative(4.0f) + curveToRelative(0.55f, 0.0f, 1.0f, -0.45f, 1.0f, -1.0f) + verticalLineToRelative(-1.0f) + horizontalLineTo(9.0f) + verticalLineToRelative(1.0f) + close() + moveTo(12.0f, 2.0f) + curveTo(8.14f, 2.0f, 5.0f, 5.14f, 5.0f, 9.0f) + curveToRelative(0.0f, 2.38f, 1.19f, 4.47f, 3.0f, 5.74f) + verticalLineTo(17.0f) + curveToRelative(0.0f, 0.55f, 0.45f, 1.0f, 1.0f, 1.0f) + horizontalLineToRelative(6.0f) + curveToRelative(0.55f, 0.0f, 1.0f, -0.45f, 1.0f, -1.0f) + verticalLineToRelative(-2.26f) + curveToRelative(1.81f, -1.27f, 3.0f, -3.36f, 3.0f, -5.74f) + curveToRelative(0.0f, -3.86f, -3.14f, -7.0f, -7.0f, -7.0f) + close() + moveTo(14.85f, 13.1f) + lineToRelative(-0.85f, 0.6f) + verticalLineTo(16.0f) + horizontalLineToRelative(-4.0f) + verticalLineToRelative(-2.3f) + lineToRelative(-0.85f, -0.6f) + curveTo(7.8f, 12.16f, 7.0f, 10.63f, 7.0f, 9.0f) + curveToRelative(0.0f, -2.76f, 2.24f, -5.0f, 5.0f, -5.0f) + reflectiveCurveToRelative(5.0f, 2.24f, 5.0f, 5.0f) + curveToRelative(0.0f, 1.63f, -0.8f, 3.16f, -2.15f, 4.1f) + close() + } + } + return lightbulb!! + } +private var lightbulb: ImageVector? = null + +/** + * A question mark inside a circle, ideal for a "help" or "more info" hint. + */ +val HintIcons.QuestionCircle: ImageVector + get() { + if (_questionCircle != null) { + return _questionCircle!! + } + _questionCircle = materialIcon(name = "Hint.QuestionCircle") { + path(fill = SolidColor(Color(0xFF000000)), stroke = null) { + moveTo(11.0f, 18.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(-2.0f) + horizontalLineToRelative(-2.0f) + verticalLineToRelative(2.0f) + close() + moveTo(12.0f, 2.0f) + curveTo(6.48f, 2.0f, 2.0f, 6.48f, 2.0f, 12.0f) + reflectiveCurveToRelative(4.48f, 10.0f, 10.0f, 10.0f) + reflectiveCurveToRelative(10.0f, -4.48f, 10.0f, -10.0f) + reflectiveCurveTo(17.52f, 2.0f, 12.0f, 2.0f) + close() + moveTo(12.0f, 20.0f) + curveToRelative(-4.41f, 0.0f, -8.0f, -3.59f, -8.0f, -8.0f) + reflectiveCurveToRelative(3.59f, -8.0f, 8.0f, -8.0f) + reflectiveCurveToRelative(8.0f, 3.59f, 8.0f, 8.0f) + reflectiveCurveToRelative(-3.59f, 8.0f, -8.0f, 8.0f) + close() + moveTo(12.0f, 6.0f) + curveToRelative(-2.21f, 0.0f, -4.0f, 1.79f, -4.0f, 4.0f) + horizontalLineToRelative(2.0f) + curveToRelative(0.0f, -1.1f, 0.9f, -2.0f, 2.0f, -2.0f) + reflectiveCurveToRelative(2.0f, 0.9f, 2.0f, 2.0f) + curveToRelative(0.0f, 2.0f, -3.0f, 1.75f, -3.0f, 5.0f) + horizontalLineToRelative(2.0f) + curveToRelative(0.0f, -2.25f, 3.0f, -2.5f, 3.0f, -5.0f) + curveToRelative(0.0f, -2.21f, -1.79f, -4.0f, -4.0f, -4.0f) + close() + } + } + return _questionCircle!! + } +private var _questionCircle: ImageVector? = null + +/** + * A magnifying glass, suggesting to the user to look closer or find a clue. + */ +val HintIcons.MagnifyingGlass: ImageVector + get() { + if (magnifyingGlass != null) { + return magnifyingGlass!! + } + magnifyingGlass = materialIcon(name = "Hint.MagnifyingGlass") { + path(fill = SolidColor(Color(0xFF000000)), stroke = null) { + moveTo(15.5f, 14.0f) + horizontalLineToRelative(-0.79f) + lineToRelative(-0.28f, -0.27f) + arcTo(6.471f, 6.471f, 0.0f, isMoreThanHalf = false, isPositiveArc = false, 16.0f, 9.5f) + curveTo(16.0f, 5.91f, 13.09f, 3.0f, 9.5f, 3.0f) + reflectiveCurveTo(3.0f, 5.91f, 3.0f, 9.5f) + reflectiveCurveTo(5.91f, 16.0f, 9.5f, 16.0f) + curveToRelative(1.61f, 0.0f, 3.09f, -0.59f, 4.23f, -1.57f) + lineToRelative(0.27f, 0.28f) + verticalLineToRelative(0.79f) + lineToRelative(5.0f, 4.99f) + lineTo(20.49f, 19.0f) + lineToRelative(-4.99f, -5.0f) + close() + moveTo(9.5f, 14.0f) + curveTo(7.01f, 14.0f, 5.0f, 11.99f, 5.0f, 9.5f) + reflectiveCurveTo(7.01f, 5.0f, 9.5f, 5.0f) + reflectiveCurveTo(14.0f, 7.01f, 14.0f, 9.5f) + reflectiveCurveTo(11.99f, 14.0f, 9.5f, 14.0f) + close() + } + } + return magnifyingGlass!! + } +private var magnifyingGlass: ImageVector? = null + +/** + * A key icon, representing the unlocking of a solution or a secret. + */ +val HintIcons.Key: ImageVector + get() { + if (key != null) { + return key!! + } + key = materialIcon(name = "Hint.Key") { + path(fill = SolidColor(Color(0xFF000000)), stroke = null) { + moveTo(12.65f, 10.0f) + curveTo(11.83f, 7.67f, 9.61f, 6.0f, 7.0f, 6.0f) + curveToRelative(-3.31f, 0.0f, -6.0f, 2.69f, -6.0f, 6.0f) + reflectiveCurveToRelative(2.69f, 6.0f, 6.0f, 6.0f) + curveToRelative(2.61f, 0.0f, 4.83f, -1.67f, 5.65f, -4.0f) + horizontalLineTo(17.0f) + verticalLineToRelative(4.0f) + horizontalLineToRelative(4.0f) + verticalLineToRelative(-4.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(-4.0f) + horizontalLineTo(12.65f) + close() + moveTo(7.0f, 14.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, -0.9f, -2.0f, -2.0f) + reflectiveCurveToRelative(0.9f, -2.0f, 2.0f, -2.0f) + reflectiveCurveToRelative(2.0f, 0.9f, 2.0f, 2.0f) + reflectiveCurveToRelative(-0.9f, 2.0f, -2.0f, 2.0f) + close() + } + } + return key!! + } +private var key: ImageVector? = null + +/** + * An information 'i' in a circle, a universal symbol for information. + */ +val HintIcons.Info: ImageVector + get() { + if (_info != null) { + return _info!! + } + _info = materialIcon(name = "Hint.Info") { + path(fill = SolidColor(Color(0xFF000000)), stroke = null) { + moveTo(11.0f, 7.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(-2.0f) + close() + moveTo(11.0f, 11.0f) + horizontalLineToRelative(2.0f) + verticalLineToRelative(6.0f) + horizontalLineToRelative(-2.0f) + close() + moveTo(12.0f, 2.0f) + curveTo(6.48f, 2.0f, 2.0f, 6.48f, 2.0f, 12.0f) + reflectiveCurveToRelative(4.48f, 10.0f, 10.0f, 10.0f) + reflectiveCurveToRelative(10.0f, -4.48f, 10.0f, -10.0f) + reflectiveCurveTo(17.52f, 2.0f, 12.0f, 2.0f) + close() + moveTo(12.0f, 20.0f) + curveToRelative(-4.41f, 0.0f, -8.0f, -3.59f, -8.0f, -8.0f) + reflectiveCurveToRelative(3.59f, -8.0f, 8.0f, -8.0f) + reflectiveCurveToRelative(8.0f, 3.59f, 8.0f, 8.0f) + reflectiveCurveToRelative(-3.59f, 8.0f, -8.0f, 8.0f) + close() + } + } + return _info!! + } +private var _info: ImageVector? = null + +/** + * A puzzle piece, indicating a piece of the solution or a helpful clue. + */ +val HintIcons.Puzzle: ImageVector + get() { + if (_puzzle != null) { + return _puzzle!! + } + _puzzle = materialIcon(name = "Hint.Puzzle") { + path(fill = SolidColor(Color(0xFF000000)), stroke = null) { + moveTo(640f, 661.3f) + arcToRelative(106.7f, 106.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, -106.7f, 106.7f) + arcToRelative(106.7f, 106.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, -106.7f, -106.7f) + verticalLineToRelative(-74.7f) + arcToRelative(32f, 32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 32f, -32f) + arcToRelative(32f, 32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 32f, 32f) + verticalLineToRelative(74.7f) + arcToRelative(42.7f, 42.7f, 0f, isMoreThanHalf = false, isPositiveArc = false, 42.7f, 42.7f) + arcToRelative(42.7f, 42.7f, 0f, isMoreThanHalf = false, isPositiveArc = false, 42.7f, -42.7f) + verticalLineToRelative(-154f) + curveToRelative(-37.1f, -11.9f, -64f, -43.5f, -64f, -80.6f) + curveToRelative(0f, -46.9f, 42.7f, -85.3f, 96f, -85.3f) + reflectiveCurveToRelative(96f, 38.4f, 96f, 85.3f) + curveToRelative(0f, 37.1f, -26.9f, 68.7f, -64f, 80.6f) + verticalLineToRelative(154f) + moveTo(352f, 341.3f) + curveToRelative(53.3f, 0f, 96f, 38.4f, 96f, 85.3f) + curveToRelative(0f, 37.1f, -26.9f, 68.7f, -64f, 80.6f) + verticalLineToRelative(228.7f) + arcToRelative(138.7f, 138.7f, 0f, isMoreThanHalf = false, isPositiveArc = false, 138.7f, 138.7f) + arcToRelative(138.7f, 138.7f, 0f, isMoreThanHalf = false, isPositiveArc = false, 138.7f, -138.7f) + verticalLineToRelative(-149.3f) + arcToRelative(32f, 32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 32f, -32f) + arcToRelative(32f, 32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 32f, 32f) + verticalLineToRelative(149.3f) + arcTo(202.7f, 202.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, 522.7f, 938.7f) + arcToRelative(202.7f, 202.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, -202.7f, -202.7f) + verticalLineToRelative(-228.7f) + curveTo(282.9f, 495.4f, 256f, 463.8f, 256f, 426.7f) + curveToRelative(0f, -46.9f, 42.7f, -85.3f, 96f, -85.3f) + moveToRelative(77.2f, -79.8f) + lineToRelative(-18.3f, 62.3f) + curveToRelative(-17.5f, -9.4f, -37.5f, -14.5f, -58.9f, -14.5f) + curveToRelative(-38.8f, 0f, -73.4f, 17.1f, -94.7f, 43.5f) + lineToRelative(-51.2f, -38.4f) + curveTo(233f, 280.3f, 273.5f, 256f, 320f, 247.9f) + verticalLineToRelative(-2.6f) + arcTo(160f, 160f, 0f, isMoreThanHalf = false, isPositiveArc = true, 480f, 85.3f) + arcTo(160f, 160f, 0f, isMoreThanHalf = false, isPositiveArc = true, 640f, 245.3f) + verticalLineToRelative(2.6f) + curveToRelative(46.5f, 8.1f, 87f, 32.4f, 113.9f, 66.6f) + lineToRelative(-51.2f, 38.4f) + curveToRelative(-21.3f, -26.5f, -55.9f, -43.5f, -94.7f, -43.5f) + curveToRelative(-21.3f, 0f, -41.4f, 5.1f, -58.9f, 14.5f) + lineToRelative(-18.3f, -62.3f) + curveToRelative(14.1f, -5.5f, 29.4f, -11.1f, 45.2f, -13.7f) + verticalLineToRelative(-2.6f) + curveToRelative(0f, -53.3f, -42.7f, -96f, -96f, -96f) + reflectiveCurveTo(384f, 192f, 384f, 245.3f) + verticalLineToRelative(2.6f) + curveToRelative(15.8f, 2.6f, 31.1f, 8.1f, 45.2f, 13.7f) + moveToRelative(178.8f, 133.1f) + curveToRelative(-23.5f, 0f, -42.7f, 14.5f, -42.7f, 32f) + reflectiveCurveToRelative(19.2f, 32f, 42.7f, 32f) + reflectiveCurveToRelative(42.7f, -14.5f, 42.7f, -32f) + reflectiveCurveToRelative(-19.2f, -32f, -42.7f, -32f) + moveToRelative(-256f, 0f) + curveToRelative(-23.5f, 0f, -42.7f, 14.5f, -42.7f, 32f) + reflectiveCurveToRelative(19.2f, 32f, 42.7f, 32f) + reflectiveCurveToRelative(42.7f, -14.5f, 42.7f, -32f) + reflectiveCurveToRelative(-19.2f, -32f, -42.7f, -32f) + close() + } + } + return _puzzle!! + } +private var _puzzle: ImageVector? = null + +/** + * A magic wand with sparkles, for a special or "magical" hint. + */ +val HintIcons.MagicWand: ImageVector + get() { + if (_magicWand != null) { + return _magicWand!! + } + _magicWand = materialIcon(name = "Hint.MagicWand") { + path(fill = SolidColor(Color(0xFF000000)), stroke = null) { + moveTo(20.0f, 7.0f) + lineToRelative(-1.41f, -1.41f) + lineToRelative(-3.09f, 3.09f) + lineToRelative(-3.09f, -3.09f) + lineTo(11.0f, 7.0f) + lineToRelative(3.09f, 3.09f) + lineToRelative(-3.09f, 3.09f) + lineToRelative(1.41f, 1.41f) + lineToRelative(3.09f, -3.09f) + lineToRelative(3.09f, 3.09f) + lineToRelative(1.41f, -1.41f) + lineToRelative(-3.09f, -3.09f) + close() + moveTo(8.0f, 4.0f) + curveToRelative(-0.55f, 0.0f, -1.0f, 0.45f, -1.0f, 1.0f) + reflectiveCurveToRelative(0.45f, 1.0f, 1.0f, 1.0f) + reflectiveCurveToRelative(1.0f, -0.45f, 1.0f, -1.0f) + reflectiveCurveToRelative(-0.45f, -1.0f, -1.0f, -1.0f) + close() + moveTo(4.0f, 8.0f) + curveToRelative(-0.55f, 0.0f, -1.0f, 0.45f, -1.0f, 1.0f) + reflectiveCurveToRelative(0.45f, 1.0f, 1.0f, 1.0f) + reflectiveCurveToRelative(1.0f, -0.45f, 1.0f, -1.0f) + reflectiveCurveToRelative(-0.45f, -1.0f, -1.0f, -1.0f) + close() + moveTo(4.0f, 16.0f) + curveToRelative(-0.55f, 0.0f, -1.0f, 0.45f, -1.0f, 1.0f) + reflectiveCurveToRelative(0.45f, 1.0f, 1.0f, 1.0f) + reflectiveCurveToRelative(1.0f, -0.45f, 1.0f, -1.0f) + reflectiveCurveToRelative(-0.45f, -1.0f, -1.0f, -1.0f) + close() + moveTo(16.0f, 12.0f) + curveToRelative(-0.55f, 0.0f, -1.0f, 0.45f, -1.0f, 1.0f) + reflectiveCurveToRelative(0.45f, 1.0f, 1.0f, 1.0f) + reflectiveCurveToRelative(1.0f, -0.45f, 1.0f, -1.0f) + reflectiveCurveToRelative(-0.45f, -1.0f, -1.0f, -1.0f) + close() + } + } + return _magicWand!! + } +private var _magicWand: ImageVector? = null + +/** + * A compass, to provide guidance or point the user in the right direction. + */ +val HintIcons.Compass: ImageVector + get() { + if (_compass != null) { + return _compass!! + } + _compass = materialIcon(name = "Hint.Compass") { + path(fill = SolidColor(Color(0xFF000000)), stroke = null) { + moveTo(12.0f, 12.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, -0.9f, -2.0f, -2.0f) + reflectiveCurveToRelative(0.9f, -2.0f, 2.0f, -2.0f) + reflectiveCurveToRelative(2.0f, 0.9f, 2.0f, 2.0f) + reflectiveCurveToRelative(-0.9f, 2.0f, -2.0f, 2.0f) + close() + moveTo(12.0f, 2.0f) + curveTo(6.48f, 2.0f, 2.0f, 6.48f, 2.0f, 12.0f) + reflectiveCurveToRelative(4.48f, 10.0f, 10.0f, 10.0f) + reflectiveCurveToRelative(10.0f, -4.48f, 10.0f, -10.0f) + reflectiveCurveTo(17.52f, 2.0f, 12.0f, 2.0f) + close() + moveTo(12.0f, 20.0f) + curveToRelative(-4.42f, 0.0f, -8.0f, -3.58f, -8.0f, -8.0f) + reflectiveCurveToRelative(3.58f, -8.0f, 8.0f, -8.0f) + reflectiveCurveToRelative(8.0f, 3.58f, 8.0f, 8.0f) + reflectiveCurveToRelative(-3.58f, 8.0f, -8.0f, 8.0f) + close() + moveTo(9.12f, 16.33f) + lineToRelative(1.83f, -3.87f) + lineToRelative(3.87f, -1.83f) + lineToRelative(-1.83f, 3.87f) + lineToRelative(-3.87f, 1.83f) + close() + } + } + return _compass!! + } +private var _compass: ImageVector? = null + +/** + * A pointing hand, useful for a "tap here" or "look at this" type of hint. + */ +val HintIcons.PointingHand: ImageVector + get() { + if (_pointingHand != null) { + return _pointingHand!! + } + _pointingHand = materialIcon(name = "Hint.PointingHand") { + path(fill = SolidColor(Color(0xFF000000)), stroke = null) { + moveTo(9.0f, 11.3f) + lineToRelative(2.43f, 2.43f) + curveToRelative(0.2f, 0.2f, 0.45f, 0.3f, 0.71f, 0.3f) + curveToRelative(0.26f, 0.0f, 0.51f, -0.1f, 0.71f, -0.29f) + lineTo(19.0f, 7.5f) + verticalLineTo(11.0f) + horizontalLineToRelative(2.0f) + verticalLineTo(4.0f) + horizontalLineToRelative(-7.0f) + verticalLineToRelative(2.0f) + horizontalLineToRelative(3.5f) + lineToRelative(-5.29f, 5.29f) + lineTo(11.3f, 9.0f) + horizontalLineTo(9.0f) + close() + moveTo(3.0f, 3.0f) + verticalLineToRelative(18.0f) + horizontalLineToRelative(18.0f) + verticalLineTo(3.0f) + horizontalLineTo(3.0f) + close() + moveTo(19.0f, 19.0f) + horizontalLineTo(5.0f) + verticalLineTo(5.0f) + horizontalLineToRelative(14.0f) + verticalLineToRelative(14.0f) + close() + } + } + return _pointingHand!! + } +private var _pointingHand: ImageVector? = null + +/** + * An old scroll, representing a hidden clue, a story, or detailed information. + */ +val HintIcons.Scroll: ImageVector + get() { + if (_scroll != null) { + return _scroll!! + } + _scroll = materialIcon(name = "Hint.Scroll") { + path(fill = SolidColor(Color(0xFF000000)), stroke = null) { + moveTo(14.0f, 2.0f) + horizontalLineTo(6.0f) + curveToRelative(-1.1f, 0.0f, -1.99f, 0.9f, -1.99f, 2.0f) + lineTo(4.0f, 20.0f) + curveToRelative(0.0f, 1.1f, 0.89f, 2.0f, 1.99f, 2.0f) + horizontalLineTo(18.0f) + curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f) + verticalLineTo(8.0f) + lineToRelative(-6.0f, -6.0f) + close() + moveTo(16.0f, 18.0f) + horizontalLineTo(8.0f) + verticalLineToRelative(-2.0f) + horizontalLineToRelative(8.0f) + verticalLineToRelative(2.0f) + close() + moveTo(16.0f, 14.0f) + horizontalLineTo(8.0f) + verticalLineToRelative(-2.0f) + horizontalLineToRelative(8.0f) + verticalLineToRelative(2.0f) + close() + moveTo(13.0f, 9.0f) + verticalLineTo(3.5f) + lineTo(18.5f, 9.0f) + horizontalLineTo(13.0f) + close() + } + } + return _scroll!! + + + } +private var _scroll: ImageVector? = null + +// ----- Preview of all HintIcons ----- + +@Composable +private fun HintIconRow(label: String, vector: ImageVector) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + androidx.compose.material3.Icon( + painter = rememberVectorPainter(image = vector), + contentDescription = label, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text(text = label, style = MaterialTheme.typography.bodyLarge) + } +} + +@eu.gaudian.translator.ui.theme.ThemePreviews +@Composable +fun AllHintIconsPreview() { + MaterialTheme { + Surface(color = MaterialTheme.colorScheme.background) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("HintIcons preview", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + HintIconRow("Lightbulb", HintIcons.Lightbulb) + HintIconRow("QuestionCircle", HintIcons.QuestionCircle) + HintIconRow("MagnifyingGlass", HintIcons.MagnifyingGlass) + HintIconRow("Key", HintIcons.Key) + HintIconRow("Info", HintIcons.Info) + HintIconRow("Puzzle", HintIcons.Puzzle) + HintIconRow("MagicWand", HintIcons.MagicWand) + HintIconRow("Compass", HintIcons.Compass) + HintIconRow("PointingHand", HintIcons.PointingHand) + HintIconRow("Scroll", HintIcons.Scroll) + } + } + } +} + +val HintIcons.Clippy: ImageVector + get() { + if (_Clippy != null) { + return _Clippy!! + } + _Clippy = ImageVector.Builder( + name = "Clippy", + defaultWidth = 1024.dp, + defaultHeight = 1024.dp, + viewportWidth = 1024f, + viewportHeight = 1024f + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(640f, 661.3f) + arcToRelative(106.7f, 106.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, -106.7f, 106.7f) + arcToRelative(106.7f, 106.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, -106.7f, -106.7f) + verticalLineToRelative(-74.7f) + arcToRelative(32f, 32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 32f, -32f) + arcToRelative(32f, 32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 32f, 32f) + verticalLineToRelative(74.7f) + arcToRelative(42.7f, 42.7f, 0f, isMoreThanHalf = false, isPositiveArc = false, 42.7f, 42.7f) + arcToRelative(42.7f, 42.7f, 0f, isMoreThanHalf = false, isPositiveArc = false, 42.7f, -42.7f) + verticalLineToRelative(-154f) + curveToRelative(-37.1f, -11.9f, -64f, -43.5f, -64f, -80.6f) + curveToRelative(0f, -46.9f, 42.7f, -85.3f, 96f, -85.3f) + reflectiveCurveToRelative(96f, 38.4f, 96f, 85.3f) + curveToRelative(0f, 37.1f, -26.9f, 68.7f, -64f, 80.6f) + verticalLineToRelative(154f) + moveTo(352f, 341.3f) + curveToRelative(53.3f, 0f, 96f, 38.4f, 96f, 85.3f) + curveToRelative(0f, 37.1f, -26.9f, 68.7f, -64f, 80.6f) + verticalLineToRelative(228.7f) + arcToRelative(138.7f, 138.7f, 0f, isMoreThanHalf = false, isPositiveArc = false, 138.7f, 138.7f) + arcToRelative(138.7f, 138.7f, 0f, isMoreThanHalf = false, isPositiveArc = false, 138.7f, -138.7f) + verticalLineToRelative(-149.3f) + arcToRelative(32f, 32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 32f, -32f) + arcToRelative(32f, 32f, 0f, isMoreThanHalf = false, isPositiveArc = true, 32f, 32f) + verticalLineToRelative(149.3f) + arcTo(202.7f, 202.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, 522.7f, 938.7f) + arcToRelative(202.7f, 202.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, -202.7f, -202.7f) + verticalLineToRelative(-228.7f) + curveTo(282.9f, 495.4f, 256f, 463.8f, 256f, 426.7f) + curveToRelative(0f, -46.9f, 42.7f, -85.3f, 96f, -85.3f) + moveToRelative(77.2f, -79.8f) + lineToRelative(-18.3f, 62.3f) + curveToRelative(-17.5f, -9.4f, -37.5f, -14.5f, -58.9f, -14.5f) + curveToRelative(-38.8f, 0f, -73.4f, 17.1f, -94.7f, 43.5f) + lineToRelative(-51.2f, -38.4f) + curveTo(233f, 280.3f, 273.5f, 256f, 320f, 247.9f) + verticalLineToRelative(-2.6f) + arcTo(160f, 160f, 0f, isMoreThanHalf = false, isPositiveArc = true, 480f, 85.3f) + arcTo(160f, 160f, 0f, isMoreThanHalf = false, isPositiveArc = true, 640f, 245.3f) + verticalLineToRelative(2.6f) + curveToRelative(46.5f, 8.1f, 87f, 32.4f, 113.9f, 66.6f) + lineToRelative(-51.2f, 38.4f) + curveToRelative(-21.3f, -26.5f, -55.9f, -43.5f, -94.7f, -43.5f) + curveToRelative(-21.3f, 0f, -41.4f, 5.1f, -58.9f, 14.5f) + lineToRelative(-18.3f, -62.3f) + curveToRelative(14.1f, -5.5f, 29.4f, -11.1f, 45.2f, -13.7f) + verticalLineToRelative(-2.6f) + curveToRelative(0f, -53.3f, -42.7f, -96f, -96f, -96f) + reflectiveCurveTo(384f, 192f, 384f, 245.3f) + verticalLineToRelative(2.6f) + curveToRelative(15.8f, 2.6f, 31.1f, 8.1f, 45.2f, 13.7f) + moveToRelative(178.8f, 133.1f) + curveToRelative(-23.5f, 0f, -42.7f, 14.5f, -42.7f, 32f) + reflectiveCurveToRelative(19.2f, 32f, 42.7f, 32f) + reflectiveCurveToRelative(42.7f, -14.5f, 42.7f, -32f) + reflectiveCurveToRelative(-19.2f, -32f, -42.7f, -32f) + moveToRelative(-256f, 0f) + curveToRelative(-23.5f, 0f, -42.7f, 14.5f, -42.7f, 32f) + reflectiveCurveToRelative(19.2f, 32f, 42.7f, 32f) + reflectiveCurveToRelative(42.7f, -14.5f, 42.7f, -32f) + reflectiveCurveToRelative(-19.2f, -32f, -42.7f, -32f) + close() + } + }.build() + + return _Clippy!! + } + +private var _Clippy: ImageVector? = null + +private const val HELP85_SCALE: Float = 0.70f + +val HintIcons.Help85: ImageVector + get() { + if (_Help85 != null) { + return _Help85!! + } + _Help85 = ImageVector.Builder( + name = "Help85", + defaultWidth = 1024.dp, + defaultHeight = 1024.dp, + viewportWidth = 1024f, + viewportHeight = 1024f + ).apply { + group( + pivotX = 512f, + pivotY = 512f, + scaleX = HELP85_SCALE, + scaleY = HELP85_SCALE + ) { + path(fill = SolidColor(Color.Black)) { + moveTo(512f, 993.9f) + curveTo(245.9f, 993.9f, 30.1f, 778.1f, 30.1f, 512f) + reflectiveCurveTo(245.9f, 30.1f, 512f, 30.1f) + reflectiveCurveToRelative(481.9f, 215.7f, 481.9f, 481.9f) + reflectiveCurveToRelative(-215.7f, 481.9f, -481.9f, 481.9f) + close() + moveTo(512f, 933.6f) + curveToRelative(232.9f, 0f, 421.6f, -188.8f, 421.6f, -421.6f) + reflectiveCurveTo(744.9f, 90.4f, 512f, 90.4f) + reflectiveCurveTo(90.4f, 279.1f, 90.4f, 512f) + reflectiveCurveToRelative(188.8f, 421.6f, 421.6f, 421.6f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveTo(510.1f, 778.5f) + arcToRelative(43.8f, 43.8f, 0f, isMoreThanHalf = true, isPositiveArc = true, 0f, -87.5f) + arcToRelative(43.8f, 43.8f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0f, 87.5f) + close() + moveTo(391.8f, 433.8f) + arcToRelative(30.7f, 30.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, -30.2f, -34.4f) + curveToRelative(2f, -17f, 5.2f, -30.9f, 9.5f, -41.7f) + arcToRelative(124.3f, 124.3f, 0f, isMoreThanHalf = false, isPositiveArc = true, 33.4f, -46.6f) + arcToRelative(149f, 149f, 0f, isMoreThanHalf = false, isPositiveArc = true, 51.1f, -30.2f) + arcTo(180.6f, 180.6f, 0f, isMoreThanHalf = false, isPositiveArc = true, 515.1f, 271.1f) + curveToRelative(42.2f, 0f, 77.3f, 12.4f, 105.3f, 37.3f) + curveToRelative(28.1f, 24.8f, 42.1f, 57.8f, 42.1f, 99.1f) + curveToRelative(0f, 18.6f, -3.6f, 35.4f, -10.8f, 50.4f) + curveToRelative(-7.2f, 15.1f, -22.5f, 33.9f, -45.9f, 56.4f) + curveToRelative(-23.5f, 22.5f, -38.9f, 38.5f, -46.6f, 48f) + arcToRelative(98.2f, 98.2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -17.3f, 34.1f) + curveToRelative(-2.1f, 7f, -3.6f, 15.2f, -4.4f, 24.6f) + curveToRelative(-1.4f, 16f, -14.3f, 28.6f, -30.3f, 28.6f) + arcToRelative(30.7f, 30.7f, 0f, isMoreThanHalf = false, isPositiveArc = true, -30.3f, -33.6f) + curveToRelative(1.1f, -11f, 2.7f, -20.5f, 4.9f, -28.6f) + curveToRelative(4.1f, -15.5f, 10.4f, -29.1f, 18.8f, -40.7f) + curveToRelative(8.3f, -11.6f, 23.4f, -28.7f, 45.2f, -51.2f) + curveToRelative(21.8f, -22.5f, 35.8f, -38.9f, 42.2f, -49f) + curveToRelative(6.2f, -10.2f, 12.7f, -26.8f, 12.7f, -47f) + reflectiveCurveToRelative(-10.6f, -36.6f, -24.9f, -52.1f) + curveToRelative(-14.4f, -15.5f, -35.3f, -23.3f, -62.6f, -23.3f) + curveToRelative(-53.4f, 0f, -83.7f, 27.5f, -90.8f, 82.5f) + curveToRelative(-2f, 15.5f, -14.6f, 27.4f, -30.2f, 27.4f) + horizontalLineToRelative(-0.1f) + close() + } + } + }.build() + + return _Help85!! + } + + +@Suppress("ObjectPropertyName") +private var _Help85: ImageVector? = null \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/TextComponents.kt b/app/src/main/java/eu/gaudian/translator/view/composable/TextComponents.kt new file mode 100644 index 0000000..93faf49 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/composable/TextComponents.kt @@ -0,0 +1,80 @@ +package eu.gaudian.translator.view.composable + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign + +@Composable + fun AutoResizeSingleLineText( + text: String, + modifier: Modifier = Modifier, + style: androidx.compose.ui.text.TextStyle, + minFontSize: androidx.compose.ui.unit.TextUnit = style.fontSize * 0.5f, + maxFontSize: androidx.compose.ui.unit.TextUnit = style.fontSize, + textAlign: TextAlign = TextAlign.Start, + color: Color = Color.Unspecified, + fontWeight: FontWeight? = null, +) { + var fontSize by remember(text) { mutableStateOf(maxFontSize) } + + Text( + text = text, + maxLines = 1, + softWrap = false, + textAlign = textAlign, + style = style.copy(fontSize = fontSize), + onTextLayout = { result -> + if (result.didOverflowWidth) { + val next = fontSize * 0.95f + if (next.value >= minFontSize.value && next != fontSize) { + fontSize = next + } + } + }, + modifier = modifier, + color = color + ) +} + + + fun insertBreakOpportunities(text: String): String { + if (text.isEmpty()) return text + val zwsp = "\u200B" + return text + // Keep hyphen (or dash) at the end of the line and allow a break after it + .replace("‑", "‑$zwsp") // non-breaking hyphen U+2011 + .replace("-", "-$zwsp") // hyphen-minus + .replace("–", "–$zwsp") // en dash + .replace("—", "—$zwsp") // em dash + .replace("·", "·$zwsp") // middle dot + .replace("/", "/$zwsp") // slash +} + +@Suppress("DEPRECATION") //TODO find a solution for this +@Composable +fun ClickableText( + text: AnnotatedString, + style: androidx.compose.ui.text.TextStyle, +){ + val uriHandler = LocalUriHandler.current + + ClickableText( + text = text, + style = style, + onClick = { offset -> + text.getStringAnnotations("URL", offset, offset) + .firstOrNull()?.let { uriHandler.openUri(it.item) } + } + ) + +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/AddCategoryDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/AddCategoryDialog.kt new file mode 100644 index 0000000..9e837d8 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/AddCategoryDialog.kt @@ -0,0 +1,415 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.TagCategory +import eu.gaudian.translator.model.VocabularyFilter +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.MultipleLanguageDropdown +import eu.gaudian.translator.view.hints.CategoryHint +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel + +enum class DialogCategoryType { TAG, FILTER } + +@Composable +fun AddCategoryDialog( + onDismiss: () -> Unit, + categoryViewModel: CategoryViewModel +) { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + var categoryName by remember { mutableStateOf("") } + var selectedLanguages by remember { mutableStateOf>(emptyList()) } + var selectedStages by remember { mutableStateOf>(emptyList()) } + var selectedTab by remember { mutableStateOf(DialogCategoryType.TAG) } + var selectedDictionaryPair by remember { mutableStateOf?>(null) } + + val categoryTypes = DialogCategoryType.entries.map { + when (it) { + DialogCategoryType.TAG -> stringResource(R.string.text_list) + DialogCategoryType.FILTER -> stringResource(R.string.text_filter) + } + } + + val isConfirmEnabled = categoryName.isNotBlank() + + AppDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.label_add_category)) }, + hintContent = { CategoryHint() }, + content = { + Column(modifier = Modifier.fillMaxWidth()) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + categoryTypes.forEachIndexed { index, label -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index = index, count = categoryTypes.size), + onClick = { selectedTab = DialogCategoryType.entries[index] }, + selected = index == selectedTab.ordinal + ) { + Text(label) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + AppTextField( + value = categoryName, + onValueChange = { categoryName = it }, + label = { Text(stringResource(R.string.text_category_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + if (selectedTab == DialogCategoryType.FILTER) { + Spacer(modifier = Modifier.height(16.dp)) + + var isLanguageFilterEnabled by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = stringResource(R.string.language_filter)) + Switch( + checked = isLanguageFilterEnabled, + onCheckedChange = { + isLanguageFilterEnabled = it + if (!it) { // Reset on disable + selectedLanguages = emptyList() + selectedDictionaryPair = null + } + } + ) + } + + // **Conditional Language Filter Options** + if (isLanguageFilterEnabled) { + Spacer(modifier = Modifier.height(8.dp)) + var languageFilterType by remember { mutableIntStateOf(0) } // 0=Languages, 1=Dictionary + val filterOptions = listOf( + stringResource(R.string.label_languages), + stringResource(R.string.language_pair) + ) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + filterOptions.forEachIndexed { index, label -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index = index, count = filterOptions.size), + onClick = { + languageFilterType = index + // Reset other option's selection when switching + if (languageFilterType == 0) selectedDictionaryPair = null + if (languageFilterType == 1) selectedLanguages = emptyList() + }, + selected = index == languageFilterType, + ) { Text(label) } + } + } + + if (languageFilterType == 0) { // Languages + MultipleLanguageDropdown( + languageViewModel = languageViewModel, + modifier = Modifier.fillMaxWidth(), + onLanguagesSelected = { languages -> + selectedLanguages = languages + }, + ) + } else { // Dictionary + DictionarySelectionContent( + categoryViewModel = categoryViewModel, + selectedPair = selectedDictionaryPair, + onPairSelected = { selectedDictionaryPair = it } + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // **Stage Filter Switch** + var isStageFilterEnabled by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = stringResource(R.string.stage_filter)) + Switch( + checked = isStageFilterEnabled, + onCheckedChange = { + isStageFilterEnabled = it + if (!it) { // Reset on disable + selectedStages = emptyList() + } + } + ) + } + + // **Conditional Stage Filter Options** + if (isStageFilterEnabled) { + VocabularyStageDropDown( + onStageSelected = { stages -> + selectedStages = stages.filterNotNull() + }, + modifier = Modifier.fillMaxWidth(), + multipleSelectable = true, + preselectedStages = emptyList(), + noneSelectable = true + ) + } + } + + if (selectedTab == DialogCategoryType.TAG) { + Spacer(modifier = Modifier.height(8.dp)) + Text(text = stringResource(R.string.text_a_simple_list_to)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.label_cancel)) + } + Spacer(modifier = Modifier.widthIn(8.dp)) + TextButton( + enabled = isConfirmEnabled, + onClick = { + when (selectedTab) { + DialogCategoryType.TAG -> { + val newList = TagCategory(id = 0, name = categoryName.trim()) + categoryViewModel.createCategory(newList) + } + + DialogCategoryType.FILTER -> { + val newFilter = VocabularyFilter( + id = 0, + name = categoryName.trim(), + languages = if (selectedDictionaryPair == null) selectedLanguages.map { it.nameResId } + .ifEmpty { null } else null, + languagePairs = selectedDictionaryPair, + stages = selectedStages.filterNotNull().ifEmpty { null } + ) + categoryViewModel.createCategory(newFilter) + } + } + onDismiss() + } + ) { + Text(stringResource(R.string.label_add)) + } + } + } + } + ) +} + +@Composable +private fun DictionarySelectionContent( + categoryViewModel: CategoryViewModel, + selectedPair: Pair?, + onPairSelected: (Pair) -> Unit +) { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + var allDictionaries by remember { mutableStateOf>>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + val allCategories by categoryViewModel.categories.collectAsState() + + LaunchedEffect(allCategories) { + isLoading = true + val existingIdPairs = allCategories.filterIsInstance().mapNotNull { it.languagePairs } + val possibleIdPairs = categoryViewModel.calculateNewDictionaries() + + // Unify existing and possible pairs into a single set to remove duplicates + val allUniqueIdPairs = (possibleIdPairs.toSet() + existingIdPairs.toSet()).toList() + + @Suppress("HardCodedStringLiteral") + suspend fun resolveLanguage(id: Int): Language? = languageViewModel.getLanguageById(id).takeIf { it.code != "error" } + + val resolvedPairs = mutableListOf>() + for ((id1, id2) in allUniqueIdPairs) { + val lang1 = resolveLanguage(id1) + val lang2 = resolveLanguage(id2) + if (lang1 != null && lang2 != null) { + resolvedPairs.add(lang1 to lang2) + } + } + + // Sort alphabetically for consistent ordering + allDictionaries = resolvedPairs.sortedBy { it.first.name + " - " + it.second.name } + isLoading = false + } + + Spacer(modifier = Modifier.height(16.dp)) + + when { + isLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + allDictionaries.isEmpty() -> { + Text( + text = stringResource(R.string.text_no_dictionary_language_pairs_found), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + else -> { + LazyColumn(modifier = Modifier.heightIn(max = 200.dp)) { + item { + Text( + text = stringResource(R.string.text_available_to_create_2d), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(vertical = 8.dp) + ) + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + } + items(allDictionaries) { (lang1, lang2) -> + val currentPairIds = Pair(lang1.nameResId, lang2.nameResId) + DictionaryRow( + lang1 = lang1, + lang2 = lang2, + isSelected = selectedPair == currentPairIds, + onClick = { onPairSelected(currentPairIds) }, + enabled = true // All rows are always enabled + ) + } + } + } + } +} + +@androidx.compose.ui.tooling.preview.Preview +@Composable +fun AddCategoryDialogPreview() { + AddCategoryDialog( + onDismiss = {}, + categoryViewModel = viewModel() + ) +} + +@androidx.compose.ui.tooling.preview.Preview +@Composable +fun DictionarySelectionContentPreview() { + LocalContext.current + DictionarySelectionContent( + categoryViewModel = viewModel(), + selectedPair = Pair(R.string.language_1, R.string.language_2), + onPairSelected = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@androidx.compose.ui.tooling.preview.Preview +@Composable +fun DictionaryRowPreview() { + DictionaryRow( + lang1 = Language( + code = "en", + region = "US", + nameResId = R.string.language_1, + name = "English", + englishName = "English", + isCustom = false, + isSelected = false + ), + lang2 = Language( + code = "es", + region = "ES", + nameResId = R.string.language_2, + name = "Spanish", + englishName = "Spanish", + isCustom = false, + isSelected = false + ), + isSelected = true, + enabled = false, + onClick = {} + ) +} + +@Composable +private fun DictionaryRow( + lang1: Language, + lang2: Language, + isSelected: Boolean, + enabled: Boolean = true, + onClick: () -> Unit = {} +) { + val textColor = when { + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + else -> MaterialTheme.colorScheme.onSurface + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "${lang1.name} - ${lang2.name}", + style = MaterialTheme.typography.bodyMedium, + color = textColor + ) + if (isSelected) { + Icon( + imageVector = AppIcons.Check, + contentDescription = stringResource(R.string.text_selected), + tint = MaterialTheme.colorScheme.primary + ) + } + } +} diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/AddCostumLanguageDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/AddCostumLanguageDialog.kt new file mode 100644 index 0000000..63a1206 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/AddCostumLanguageDialog.kt @@ -0,0 +1,122 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.DialogButton + +@Composable +fun AddCustomLanguageDialog( + showDialog: Boolean, + onDismiss: () -> Unit, + onAddLanguage: (Language) -> Unit +) { + if (showDialog) { + var languageCode by remember { mutableStateOf("") } + var languageRegion by remember { mutableStateOf("") } + var languageName by remember { mutableStateOf("") } + var englishName by remember { mutableStateOf("") } + + AppDialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.text_add_custom_language), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + + AppTextField( + value = languageCode, + onValueChange = { languageCode = it }, + label = { Text(text = stringResource(R.string.text_language_code)) }, + placeholder = { Text(text = stringResource(R.string.text_e_g_en)) }, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = languageRegion, + onValueChange = { languageRegion = it }, + label = { Text(text = stringResource(R.string.text_region)) }, + placeholder = { Text(text = stringResource(R.string.text_e_g_us)) }, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = languageName, + onValueChange = { languageName = it }, + label = { Text(text = stringResource(R.string.text_name_of_the_language)) }, + placeholder = { Text(text = stringResource(R.string.text_e_g_english)) }, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = englishName, + onValueChange = { englishName = it }, + label = { Text(text = stringResource(R.string.name_in_english)) }, + placeholder = { Text(text = stringResource(R.string.text_e_g_english)) }, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.label_cancel)) + } + DialogButton( + onClick = { + val customLanguage = Language( + code = languageCode.lowercase(), + region = languageRegion, + nameResId = -1, + name = languageName, + englishName = englishName, + isCustom = true, + isSelected = true, + ) + onAddLanguage(customLanguage) + onDismiss() + }, + enabled = languageCode.isNotBlank() && languageName.isNotBlank() && languageRegion.isNotBlank(), + content = { + Text(text = stringResource(R.string.label_add)) + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/AddVocabularyDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/AddVocabularyDialog.kt new file mode 100644 index 0000000..eec1ba8 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/AddVocabularyDialog.kt @@ -0,0 +1,371 @@ +package eu.gaudian.translator.view.dialogs + +import android.app.Application +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.LocalConnectionConfigured +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCheckbox +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.SourceLanguageDropdown +import eu.gaudian.translator.view.composable.TargetLanguageDropdown +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.StatusViewModel +import eu.gaudian.translator.viewmodel.TranslationViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import okhttp3.internal.platform.PlatformRegistry.applicationContext + + +enum class VocabularyDialogTab { SINGLE, MULTIPLE, TEXT } + +@Composable +fun AddVocabularyDialog( + statusViewModel: StatusViewModel, + languageViewModel: LanguageViewModel, + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application), + initialWord: String? = null, + translation: String? = null, + onDismissRequest: () -> Unit, + showMultiple: Boolean = true +) { + LocalContext.current + val coroutineScope = rememberCoroutineScope() + val translationViewModel: TranslationViewModel = viewModel() + val categoryViewModel: CategoryViewModel = viewModel() + val connectionConfigured = LocalConnectionConfigured.current + + + + var selectedTab by remember { mutableStateOf(VocabularyDialogTab.SINGLE) } + var word by remember { mutableStateOf(initialWord ?: "") } + var showSuccessMessage by remember { mutableStateOf(false) } + + var singleTranslation by remember { mutableStateOf(translation ?: "") } + val translatedText by translationViewModel.translatedVocabulary.collectAsState() + + var multipleTranslations by remember { mutableStateOf>(emptyList()) } + val selectedTranslations = remember { mutableStateListOf() } + + val selectedSourceLanguage by languageViewModel.selectedSourceLanguage.collectAsState() + val selectedTargetLanguage by languageViewModel.selectedTargetLanguage.collectAsState() + + var showReview by remember { mutableStateOf(false) } + + val isAddButtonEnabled = word.isNotBlank() && + (selectedTab == VocabularyDialogTab.SINGLE && singleTranslation.isNotBlank() || + selectedTab == VocabularyDialogTab.MULTIPLE && selectedTranslations.isNotEmpty()) && + selectedTab != VocabularyDialogTab.TEXT + + LaunchedEffect(translatedText, selectedTab) { + if (selectedTab == VocabularyDialogTab.SINGLE) { + translatedText?.let { singleTranslation = it } + } + } + + AppDialog(onDismissRequest = onDismissRequest, title = { Text(stringResource(R.string.label_add_vocabulary)) }) { + Column( + modifier = Modifier.padding(0.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + if(showMultiple){ + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = selectedTab == VocabularyDialogTab.SINGLE, + onClick = { selectedTab = VocabularyDialogTab.SINGLE }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = if (connectionConfigured) 3 else 1) + ) { + Text(stringResource(R.string.title_single)) + } + if(connectionConfigured){ + SegmentedButton( + selected = selectedTab == VocabularyDialogTab.MULTIPLE, + onClick = { selectedTab = VocabularyDialogTab.MULTIPLE }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 3) + ) { + Text(stringResource(R.string.title_multiple)) + } + + SegmentedButton( + selected = selectedTab == VocabularyDialogTab.TEXT, + onClick = { selectedTab = VocabularyDialogTab.TEXT }, + shape = SegmentedButtonDefaults.itemShape(index = 2, count = 3) + ) { + Text(stringResource(R.string.text_text)) + } + }} + } + + if (selectedTab != VocabularyDialogTab.TEXT) { + AppTextField( + value = word, + onValueChange = { word = it }, + label = { Text(stringResource(R.string.text_label_word)) }, + modifier = Modifier.fillMaxWidth(), + ) + } + SourceLanguageDropdown( + languageViewModel = languageViewModel, + modifier = Modifier.fillMaxWidth() + ) + TargetLanguageDropdown( + languageViewModel = languageViewModel, + modifier = Modifier.fillMaxWidth() + ) + + when (selectedTab) { + VocabularyDialogTab.SINGLE -> { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if(connectionConfigured) { + AppButton( + onClick = { + if (word.isNotBlank()) translationViewModel.translateVocabulary( + word + ) + }, + modifier = Modifier.fillMaxWidth() + ) { Text(stringResource(R.string.label_translate)) } + } + AppTextField( + value = singleTranslation, + onValueChange = { singleTranslation = it }, + label = { Text(stringResource(R.string.text_translation)) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + VocabularyDialogTab.MULTIPLE -> { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + val textFailedToGetTranslations = stringResource(R.string.text_failed_to_get_translations) + AppButton( + onClick = { + if (word.isNotBlank()) { + coroutineScope.launch { + translationViewModel.getMultipleTranslations(word) + .onSuccess { fetched -> + multipleTranslations = fetched + selectedTranslations.clear() + } + .onFailure { exception -> + statusViewModel.showErrorMessage( + textFailedToGetTranslations + exception.message) + } + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { Text(stringResource(R.string.find_translations)) } + + if (multipleTranslations.isNotEmpty()) { + Text(stringResource(R.string.text_select_translations_to_add), style = MaterialTheme.typography.titleMedium) + LazyColumn(modifier = Modifier.heightIn(max = 150.dp)) { + items(multipleTranslations) { trans -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + if (selectedTranslations.contains(trans)) selectedTranslations.remove( + trans + ) + else selectedTranslations.add(trans) + } + .padding(vertical = 4.dp) + ) { + AppCheckbox( + checked = selectedTranslations.contains(trans), + onCheckedChange = { isChecked -> + if (isChecked) selectedTranslations.add(trans) + else selectedTranslations.remove(trans) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(trans, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + } + } + VocabularyDialogTab.TEXT -> { + val isGenerating by vocabularyViewModel.isGenerating.collectAsState() + val generated by vocabularyViewModel.generatedVocabularyItems.collectAsState() + LaunchedEffect(isGenerating, generated) { + if (!isGenerating && showReview) { + //TODO think if we still need this + } + } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.text_enter_a_text_to_extract)) + Box(Modifier.fillMaxWidth().padding(0.dp).heightIn(max = 300.dp)){ + AppTextField( + value = word, + onValueChange = { word = it }, + label = { Text(stringResource(R.string.label_enter_a_text)) }, + modifier = Modifier.fillMaxWidth(), + maxLines = 5, + paste = true, + clear = true, + + )} + if (isGenerating) { + androidx.compose.material3.CircularProgressIndicator() + } else { + AppButton( + onClick = { + if (word.isNotBlank()) { + vocabularyViewModel.generateVocabularyFromText(word) + // defer showing review until generation completes + showReview = true + } + }, + modifier = Modifier.fillMaxWidth(), + ) { Text(stringResource(R.string.find_translations)) } + } + } + } + } + + AnimatedVisibility( + visible = showSuccessMessage, + enter = fadeIn(), + exit = fadeOut() + ) { + Text( + text = stringResource(R.string.vocabulary_added_successfully), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.label_close)) } + Spacer(modifier = Modifier.width(8.dp)) + TextButton( + enabled = isAddButtonEnabled, + onClick = { + coroutineScope.launch { + val newItems = when (selectedTab) { + VocabularyDialogTab.SINGLE -> listOf( + VocabularyItem( + languageFirstId = selectedSourceLanguage?.nameResId, + languageSecondId = selectedTargetLanguage?.nameResId, + wordFirst = word, + wordSecond = singleTranslation, + id = 0, + ) + ) + VocabularyDialogTab.MULTIPLE -> selectedTranslations.map { selectedTrans -> + VocabularyItem( + languageFirstId = selectedSourceLanguage?.nameResId, + languageSecondId = selectedTargetLanguage?.nameResId, + wordFirst = word, + wordSecond = selectedTrans, + id = 0, + ) + } + VocabularyDialogTab.TEXT -> emptyList() + } + vocabularyViewModel.addVocabularyItems(newItems) + + word = "" + singleTranslation = "" + multipleTranslations = emptyList() + selectedTranslations.clear() + showSuccessMessage = true + delay(2000) + showSuccessMessage = false + } + } + ) { Text(stringResource(R.string.label_add)) } + } + } + } + if (showReview && !vocabularyViewModel.isGenerating.collectAsState().value) { + androidx.compose.ui.window.Dialog( + onDismissRequest = { showReview = false }, + properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) + ) { + androidx.compose.material3.Surface(modifier = Modifier.fillMaxSize()) { + VocabularyReviewScreen( + vocabularyViewModel = vocabularyViewModel, + onConfirm = { selectedItems, categoryIds -> + vocabularyViewModel.addVocabularyItems(selectedItems, categoryIds) + showReview = false + onDismissRequest() + }, + onCancel = { showReview = false }, + categoryViewModel = categoryViewModel + ) + } + } + } +} + +@ThemePreviews +@Composable +fun AddVocabularyDialogPreview() { + val activity = LocalContext.current.findActivity() + val statusViewModel: StatusViewModel = viewModel() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(LocalContext.current.applicationContext as Application) + + AddVocabularyDialog( + statusViewModel = statusViewModel, + languageViewModel = languageViewModel, + vocabularyViewModel = vocabularyViewModel, + onDismissRequest = {}) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/CategoryDropdown.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/CategoryDropdown.kt new file mode 100644 index 0000000..a9a144b --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/CategoryDropdown.kt @@ -0,0 +1,235 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.TagCategory +import eu.gaudian.translator.model.VocabularyCategory +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCheckbox +import eu.gaudian.translator.view.composable.AppDropdownMenuItem +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.viewmodel.CategoryViewModel + + +@Composable +fun CategoryDropdown( + categoryViewModel: CategoryViewModel, + initialCategoryId: Int? = null, + onCategorySelected: (List) -> Unit, + noneSelectable: Boolean? = true, + multipleSelectable: Boolean = false, + onlyLists: Boolean = false, + addCategory: Boolean = false +) { + var expanded by remember { mutableStateOf(false) } + val categories by categoryViewModel.categories.collectAsState(initial = emptyList()) + val selectableCategories = if (onlyLists) categories.filterIsInstance() else categories + val initialCategory = remember(categories, initialCategoryId) { + categories.find { it.id == initialCategoryId } + + } + var selectedCategories by remember { + mutableStateOf>(if (initialCategory != null) listOf(initialCategory) else emptyList()) + } + var newCategoryName by remember { mutableStateOf("") } + + + AppOutlinedButton( + shape = RoundedCornerShape(8.dp), + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Text(text = when { + selectedCategories.isEmpty() -> stringResource(R.string.text_select_category) + selectedCategories.size == 1 -> selectedCategories.first()?.name ?: stringResource(R.string.text_none) + else -> stringResource(R.string.text_2d_categories_selected, selectedCategories.size) + }, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center + ) + Icon( + imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource( + R.string.cd_expand + ) + ) + } + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxWidth(), + ) { + if (noneSelectable == true) { + val noneSelected = selectedCategories.contains(null) + AppDropdownMenuItem( + text = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (multipleSelectable) { + AppCheckbox( + checked = noneSelected, + onCheckedChange = { + selectedCategories = if (noneSelected) selectedCategories.filterNotNull() else selectedCategories + listOf(null) + onCategorySelected(selectedCategories) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.text_none)) + } + }, + onClick = { + if (multipleSelectable) { + selectedCategories = if (noneSelected) { + selectedCategories.filterNotNull() + } else { + selectedCategories + listOf(null) + } + onCategorySelected(selectedCategories) + } else { + selectedCategories = listOf(null) + onCategorySelected(selectedCategories) + expanded = false + } + } + ) + } + selectableCategories.forEach { category -> + val isSelected = selectedCategories.contains(category) + AppDropdownMenuItem( + text = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (multipleSelectable) { + AppCheckbox( + checked = isSelected, + onCheckedChange = { + selectedCategories = if (isSelected) selectedCategories - category else selectedCategories + category + onCategorySelected(selectedCategories) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(category.name) + } + }, + onClick = { + if (multipleSelectable) { + selectedCategories = if (category in selectedCategories) { + selectedCategories - category + } else { + selectedCategories + category + } + onCategorySelected(selectedCategories) + } else { + selectedCategories = listOf(category) + onCategorySelected(selectedCategories) + expanded = false + } + } + ) + } + + if(addCategory) { + + HorizontalDivider() + + // Create new category section + AppDropdownMenuItem( + text = { + Text(stringResource(R.string.label_add_category)) + }, + onClick = {}, + modifier = Modifier.padding(4.dp) + ) + + AppDropdownMenuItem( + text = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + AppTextField( + value = newCategoryName, + onValueChange = { newCategoryName = it }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { + if (newCategoryName.isNotBlank()) { + val newList = + TagCategory(id = 0, name = newCategoryName.trim()) + categoryViewModel.createCategory(newList) + newCategoryName = "" + // Optionally, select the new category if single selection + if (!multipleSelectable) { + expanded = false + } + } + }, + enabled = newCategoryName.isNotBlank() + ) { + Icon( + imageVector = AppIcons.Add, + contentDescription = stringResource(R.string.label_add) + ) + } + } + }, + onClick = {} // No action on click + ) + } + + + if (multipleSelectable) { + Spacer(modifier = Modifier.height(8.dp)) + AppButton( + onClick = { expanded = false }, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Text(stringResource(R.string.label_done)) + } + } + } +} + +@Preview +@Composable +fun CategoryDropdownPreview() { + CategoryDropdown( + categoryViewModel = viewModel(), + onCategorySelected = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/CategorySelectionDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/CategorySelectionDialog.kt new file mode 100644 index 0000000..07f051e --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/CategorySelectionDialog.kt @@ -0,0 +1,69 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyCategory +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.DialogButton +import eu.gaudian.translator.viewmodel.CategoryViewModel + +@Composable +fun CategorySelectionDialog( + viewModel: CategoryViewModel, + onCategorySelected: (List) -> Unit, + onDismissRequest: () -> Unit, +) { + var selectedCategory by remember { mutableStateOf>(emptyList()) } + + AppDialog(onDismissRequest = onDismissRequest, title = { + Text(text = stringResource(R.string.text_select_categories)) + }) { + + + + CategoryDropdown( + categoryViewModel = viewModel, + onCategorySelected = { categories -> + selectedCategory = categories + }, + noneSelectable = false, + multipleSelectable = true, + onlyLists = true, + addCategory = true + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + DialogButton(onClick = onDismissRequest) { + Text(stringResource(R.string.label_cancel)) + } + + DialogButton( + onClick = { + onCategorySelected(selectedCategory) + onDismissRequest() + }, + enabled = true + ) { + Text(stringResource(R.string.label_confirm)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/CreateCategoryListDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/CreateCategoryListDialog.kt new file mode 100644 index 0000000..f8666f3 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/CreateCategoryListDialog.kt @@ -0,0 +1,91 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppTextField + +@Composable +fun CreateCategoryListDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var categoryName by remember { mutableStateOf("") } + + AppDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.create_new_category), + ) + }, + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.create_new_category), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + + AppTextField( + value = categoryName, + onValueChange = { categoryName = it }, + label = { Text(stringResource(R.string.text_category_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.label_cancel)) + } + AppButton( + onClick = { + if (categoryName.isNotBlank()) { + onConfirm(categoryName) + } + }, + enabled = categoryName.isNotBlank() + ) { + Text(stringResource(R.string.label_create)) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/DeleteCategoryDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/DeleteCategoryDialog.kt new file mode 100644 index 0000000..5f0a751 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/DeleteCategoryDialog.kt @@ -0,0 +1,44 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppAlertDialog +import eu.gaudian.translator.viewmodel.CategoryViewModel + +@Composable +fun DeleteCategoryDialog( + viewModel: CategoryViewModel, + onDismiss: () -> Unit, +) { + val categoryToDelete = viewModel.categoryToDelete + + AppAlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(R.string.text_delete_category)) + }, + text = { + Text(text = stringResource(R.string.text_are_you_sure_you_want_to_delete_this_category)) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.deleteCategoryById(id = categoryToDelete) + onDismiss() + } + ) { + Text(text = stringResource(R.string.label_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + ) { + Text(text = stringResource(R.string.label_cancel)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/DeleteItemsDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/DeleteItemsDialog.kt new file mode 100644 index 0000000..8ce88c4 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/DeleteItemsDialog.kt @@ -0,0 +1,50 @@ +package eu.gaudian.translator.view.dialogs + +import android.app.Application +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.view.composable.AppAlertDialog +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import okhttp3.internal.platform.PlatformRegistry.applicationContext + +@Composable +fun DeleteItemsDialog( + viewModel: VocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application), + onDismiss: () -> Unit, + categoryId: Int, +) { + val categoryVocabularyItemDelete = viewModel.categoryVocabularyItemDelete + @Suppress("HardCodedStringLiteral") + Log.d("DeleteItemsDialog", "categoryVocabularyItemDelete: $categoryVocabularyItemDelete") + + AppAlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(R.string.label_delete_items)) + }, + text = { + Text(text = stringResource(R.string.text_are_you_sure_you_want_to_delete_all_items_in_this_category)) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.deleteVocabularyItemsByCategory(categoryId) + onDismiss() + } + ) { + Text(stringResource(R.string.label_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss + ) { + Text(stringResource(R.string.label_cancel)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/DeleteMultipleCategoriesDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/DeleteMultipleCategoriesDialog.kt new file mode 100644 index 0000000..2473682 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/DeleteMultipleCategoriesDialog.kt @@ -0,0 +1,51 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppAlertDialog +import eu.gaudian.translator.viewmodel.CategoryViewModel + +@Composable +fun DeleteMultipleCategoriesDialog( + categoryIds: List, + categoryViewModel: CategoryViewModel, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AppAlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(R.string.text_delete_category)) + }, + text = { + if (categoryIds.size == 1) { + Text(text = stringResource(R.string.text_are_you_sure_you_want_to_delete_this_category)) + } else { + Text(text = stringResource(R.string.text_are_you_sure_you_want_to_delete_these_categories, categoryIds.size)) + } + }, + confirmButton = { + TextButton( + onClick = { + categoryIds.forEach { categoryId -> + categoryViewModel.deleteCategoryById(categoryId) + } + onConfirm() + onDismiss() + } + ) { + Text(text = stringResource(R.string.label_confirm)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + ) { + Text(text = stringResource(R.string.label_cancel)) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/EditCategoryDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/EditCategoryDialog.kt new file mode 100644 index 0000000..0e3ef79 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/EditCategoryDialog.kt @@ -0,0 +1,149 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +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.VocabularyStage +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.MultipleLanguageDropdown +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import kotlinx.coroutines.flow.collectLatest + +@Suppress("HardCodedStringLiteral") +@Composable +fun EditCategoryDialog( + languageViewModel: LanguageViewModel, + categoryViewModel: CategoryViewModel, + onDismiss: () -> Unit, +) { + var categoryToEdit by remember { mutableStateOf(null) } + + var categoryName by remember { mutableStateOf("") } + var selectedLanguages by remember { mutableStateOf>(emptyList()) } + var selectedStages by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(categoryViewModel.categoryToEdit) { + if (categoryViewModel.categoryToEdit != 0) { + categoryViewModel.getCategoryById(categoryViewModel.categoryToEdit).collectLatest { category -> + categoryToEdit = category + category?.let { + categoryName = it.name + // Only set languages and stages if it's a filter + if (it is VocabularyFilter) { + selectedLanguages = it.languages?.mapNotNull { id -> languageViewModel.getLanguageById(id).takeIf { l -> l.code != "error" } } ?: emptyList() + selectedStages = it.stages ?: emptyList() + } + } + } + } + } + + AppDialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(24.dp) + ) { + Text( + text = stringResource(R.string.text_edit_category), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 16.dp) + ) + + // Content + if (categoryToEdit != null) { + Column(modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + AppTextField( + value = categoryName, + onValueChange = { categoryName = it }, + label = { Text(stringResource(R.string.text_category_name)) }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + + if (categoryToEdit is VocabularyFilter) { + MultipleLanguageDropdown( + languageViewModel = languageViewModel, + modifier = Modifier.fillMaxWidth(), + onLanguagesSelected = { languages -> + selectedLanguages = languages + }, + ) + VocabularyStageDropDown( + preselectedStages = selectedStages.filterNotNull(), + onStageSelected = { newStages -> + @Suppress("UNCHECKED_CAST") + val list = newStages + selectedStages = list.filterNotNull() + }, + modifier = Modifier.fillMaxWidth(), + multipleSelectable = true + ) + } + } + } + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.label_cancel)) + } + TextButton( + enabled = categoryToEdit != null && categoryName.isNotBlank(), + onClick = { + categoryToEdit?.let { category -> + val updatedCategory = when (category) { + is TagCategory -> category.copy(name = categoryName.trim()) + is VocabularyFilter -> category.copy( + name = categoryName.trim(), + languages = selectedLanguages.map { it.nameResId }.ifEmpty { null }, + stages = selectedStages.filterNotNull().ifEmpty { null } + ) + } + updatedCategory.let { + categoryViewModel.updateCategory(it) + } + } + onDismiss() + } + ) { Text(stringResource(R.string.label_save)) } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/EditLanguageDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/EditLanguageDialog.kt new file mode 100644 index 0000000..a3e70a2 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/EditLanguageDialog.kt @@ -0,0 +1,100 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.DialogButton + +@Composable +fun EditLanguageDialog( + language: Language, + onDismiss: () -> Unit, + onSave: (name: String, code: String, region: String) -> Unit +) { + var code by remember(language) { mutableStateOf(language.code) } + var region by remember(language) { mutableStateOf(language.region) } + var name by remember(language) { mutableStateOf(language.name) } + + AppDialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.edit), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + // Language name: editable only for custom languages + AppTextField( + value = name, + onValueChange = { if (language.isCustom == true) name = it }, + enabled = language.isCustom == true, + label = { Text(text = stringResource(R.string.text_name_of_the_language)) }, + modifier = Modifier.fillMaxWidth() + ) + + AppTextField( + value = code, + onValueChange = { code = it }, + label = { Text(text = stringResource(R.string.text_language_code)) }, + placeholder = { Text(text = stringResource(R.string.text_e_g_en)) }, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = region, + onValueChange = { region = it }, + label = { Text(text = stringResource(R.string.text_region)) }, + placeholder = { Text(text = stringResource(R.string.text_e_g_us)) }, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.label_cancel)) + } + DialogButton( + onClick = { onSave(name, code, region) }, + enabled = name.isNotBlank() && code.isNotBlank() && region.isNotBlank(), + modifier = Modifier.padding(start = 8.dp) + ) { + Text(text = stringResource(R.string.label_save)) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/ImportVocabularyDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/ImportVocabularyDialog.kt new file mode 100644 index 0000000..4fe19d0 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/ImportVocabularyDialog.kt @@ -0,0 +1,208 @@ +package eu.gaudian.translator.view.dialogs + +import android.app.Application +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.navigation.NavController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppSlider +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.DialogButton +import eu.gaudian.translator.view.composable.SourceLanguageDropdown +import eu.gaudian.translator.view.composable.TargetLanguageDropdown +import eu.gaudian.translator.view.hints.getImportVocabularyHint +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.launch +import okhttp3.internal.platform.PlatformRegistry.applicationContext + +@Composable +fun ImportVocabularyDialog( + onDismiss: () -> Unit, + languageViewModel: LanguageViewModel, + categoryViewModel: CategoryViewModel, + vocabularyViewModel : VocabularyViewModel, + optionalDescription: String? = null, + optionalSearchTerm: String? = null +) { + + val navController = rememberNavController() + NavHost(navController = navController, startDestination = "import") { + composable("import") { + ImportDialogContent( + navController = navController, + onDismiss = onDismiss, + languageViewModel = languageViewModel, + vocabularyViewModel = vocabularyViewModel, + optionalDescription = optionalDescription, + optionalSearchTerm = optionalSearchTerm + ) + } + @Suppress("HardCodedStringLiteral") + composable("review") { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + // Full-screen surface to ensure the dialog covers content and stays above the main FAB/menu + Surface(modifier = Modifier.fillMaxSize()) { + VocabularyReviewScreen( + vocabularyViewModel = vocabularyViewModel, + onConfirm = { selectedItems, categoryIds -> + vocabularyViewModel.addVocabularyItems(selectedItems, categoryIds) + onDismiss() + }, + onCancel = onDismiss, + categoryViewModel = categoryViewModel + ) + } + } + } + } +} + +@Composable +fun ImportDialogContent( + navController: NavController, + onDismiss: () -> Unit, + languageViewModel: LanguageViewModel, + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application), + optionalDescription: String? = null, + optionalSearchTerm: String? = null +) { + var category by remember { mutableStateOf(optionalSearchTerm ?: "") } + var amount by remember { mutableFloatStateOf(1f) } + val coroutineScope = rememberCoroutineScope() + val descriptionText = optionalDescription ?: stringResource(R.string.text_let_ai_find_vocabulary_for_you) + val isGenerating by vocabularyViewModel.isGenerating.collectAsState() + + AppDialog( + onDismissRequest = onDismiss, + title = { Text(descriptionText) }, + hintContent = { getImportVocabularyHint() }, + content = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + ) { + if (isGenerating) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + AppTextField( + value = category, + onValueChange = { category = it }, + label = { Text(stringResource(R.string.text_search_term)) }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) + Text( + text = stringResource(R.string.text_hint_you_can_search), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.text_select_languages), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(bottom = 8.dp) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(1.dp) + ) { + SourceLanguageDropdown(languageViewModel = languageViewModel) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(1.dp) + ) { + TargetLanguageDropdown(languageViewModel = languageViewModel) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.text_select_amount), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(bottom = 8.dp) + ) + AppSlider( + value = amount, + onValueChange = { amount = it }, + valueRange = 1f..25f, + steps = 24, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.text_amount_2d, amount.toInt()), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Action buttons + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + DialogButton( + onClick = onDismiss, + content = { Text(stringResource(R.string.label_cancel)) } + ) + if (category.isNotBlank() && !isGenerating) { + Spacer(modifier = Modifier.widthIn(8.dp)) + DialogButton(onClick = { + coroutineScope.launch { + vocabularyViewModel.generateVocabularyItems(category, amount.toInt()) + @Suppress("HardCodedStringLiteral") + navController.navigate("review") + } + }) { Text(stringResource(R.string.text_generate)) } + } + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/MissingLanguageDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/MissingLanguageDialog.kt new file mode 100644 index 0000000..8fae022 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/MissingLanguageDialog.kt @@ -0,0 +1,228 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppOutlinedButton +import eu.gaudian.translator.view.composable.DialogButton +import eu.gaudian.translator.view.composable.SingleLanguageDropDown +import eu.gaudian.translator.viewmodel.LanguageViewModel + +@Composable +fun MissingLanguageDialog( + showDialog: Boolean, + missingLanguageId: Int, + affectedItems: List, + languageViewModel: LanguageViewModel, + onDismiss: () -> Unit, + onDelete: (List) -> Unit, + onReplace: (Int, Int) -> Unit, + onCreate: (Language) -> Unit +) { + if (!showDialog) return + + var showAddLanguageDialog by remember { mutableStateOf(false) } + var selectedReplacementLanguage by remember { mutableStateOf(null) } + + if (showAddLanguageDialog) { + AddCustomLanguageDialog( + showDialog = true, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showAddLanguageDialog = false + }, + onAddLanguage = { newLanguage -> + onCreate(newLanguage) + @Suppress("AssignedValueIsNeverRead") + showAddLanguageDialog = false + onDismiss() + } + ) + } + + AppDialog(onDismissRequest = onDismiss, title = { + Text(text = stringResource(R.string.resolve_missing_language_id, missingLanguageId)) + }) { Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.resolve_missing_language_id, missingLanguageId), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Text( + stringResource( + R.string.found_d_items_using_this_missing_language_id, + affectedItems.size + ), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 8.dp) + ) + + var itemsVisible by remember { mutableStateOf(false) } + TextButton(onClick = { itemsVisible = !itemsVisible }) { + Text(if (itemsVisible) stringResource(R.string.hide_affected_items) else stringResource( + R.string.show_affected_items + )) + } + + if (itemsVisible) { + Card(modifier = Modifier.heightIn(max = 150.dp)) { + LazyColumn(modifier = Modifier.padding(8.dp)) { + items(affectedItems) { item -> + Text("• ${item.wordFirst} / ${item.wordSecond}") + } + } + } + } + + Spacer(Modifier.height(16.dp)) + + Text(stringResource(R.string.solution_1_delete_items), fontWeight = FontWeight.Bold) + Text( + stringResource( + R.string.permanently_delete_all_d_affected_vocabulary_items, + affectedItems.size + ), style = MaterialTheme.typography.bodySmall) + AppButton( + onClick = { onDelete(affectedItems) }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) { + Text(stringResource(R.string.delete_d_items, affectedItems.size)) + } + HorizontalDivider( + Modifier.padding(vertical = 12.dp), + DividerDefaults.Thickness, + DividerDefaults.color + ) + + Text(stringResource(R.string.solution_2_replace_id), fontWeight = FontWeight.Bold) + Text(stringResource(R.string.text_assign_a_different_language_items), style = MaterialTheme.typography.bodySmall) + + SingleLanguageDropDown( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + languageViewModel = languageViewModel, + selectedLanguage = selectedReplacementLanguage, + onLanguageSelected = { selectedReplacementLanguage = it } + ) + + AppButton( + onClick = { + selectedReplacementLanguage?.nameResId?.let { + onReplace(missingLanguageId, it) + } + }, + enabled = selectedReplacementLanguage != null, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) { + Text( + stringResource( + R.string.replace_with_d, + selectedReplacementLanguage?.name ?: "..." + )) + } + HorizontalDivider( + Modifier.padding(vertical = 12.dp), + DividerDefaults.Thickness, + DividerDefaults.color + ) + + Text(stringResource(R.string.solution_3_create_language), fontWeight = FontWeight.Bold) + Text(stringResource(R.string.create_a_new_custom_language_entry_for_this_id), style = MaterialTheme.typography.bodySmall) + AppOutlinedButton( + onClick = { + @Suppress("AssignedValueIsNeverRead") + showAddLanguageDialog = true + }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) { + Text(stringResource(R.string.create_new_language)) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + DialogButton( + content = { Text(stringResource(R.string.label_close)) }, + onClick = onDismiss + ) + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@OptIn(ExperimentalComposeUiApi::class) +@Preview +@Composable +fun MissingLanguageDialogPreview() { + LocalContext.current + val activity = LocalContext.current.findActivity() + val affectedItems = listOf( + VocabularyItem(id = 1, languageFirstId = 1, languageSecondId = 2, wordFirst = "Hello", wordSecond = "Hola"), + VocabularyItem(id = 2, languageFirstId = 1, languageSecondId = 2, wordFirst = "World", wordSecond = "Mundo"), + VocabularyItem(id = 3, languageFirstId = 1, languageSecondId = 2, wordFirst = "Cat", wordSecond = "Gato") + ) + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + + MissingLanguageDialog( + showDialog = true, + missingLanguageId = 999, + affectedItems = affectedItems, + languageViewModel = languageViewModel, + onDismiss = {}, + onDelete = {}, + onReplace = { _, _ -> }, + onCreate = {} + ) +} diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/StageSelectionDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/StageSelectionDialog.kt new file mode 100644 index 0000000..9d83d02 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/StageSelectionDialog.kt @@ -0,0 +1,76 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.ComponentDefaults +import eu.gaudian.translator.view.composable.DialogButton + +@Composable +fun StageSelectionDialog( + onStageSelected: (VocabularyStage?) -> Unit, + onDismissRequest: () -> Unit +) { + + AppDialog(onDismissRequest = onDismissRequest, title = { + Text(text = stringResource(R.string.label_select_stage)) + }) { + + + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(ComponentDefaults.CardShape) + ) { + VocabularyStage.entries.forEach { stage -> + ListItem( + headlineContent = { + Text(text = stage.toString(LocalContext.current)) + }, + modifier = Modifier + .fillMaxWidth() + .clickable { + onStageSelected(stage) + onDismissRequest() + } + .padding(vertical = 0.dp) + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.End + ) { + DialogButton( + onClick = onDismissRequest, + ) { + Text(stringResource(R.string.label_cancel)) + } + } + } +} + +@Preview +@Composable +fun StageSelectionDialogPreview() { + StageSelectionDialog(onStageSelected = {}, onDismissRequest = {}) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/StartExerciseDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/StartExerciseDialog.kt new file mode 100644 index 0000000..35cb3e6 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/StartExerciseDialog.kt @@ -0,0 +1,128 @@ +package eu.gaudian.translator.view.dialogs + +import android.app.Application +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.VocabularyCategory +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.MultipleLanguageDropdown +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.launch +import okhttp3.internal.platform.PlatformRegistry.applicationContext + +@Composable +fun StartExerciseDialog( + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application), + categoryViewModel: CategoryViewModel = CategoryViewModel.getInstance(applicationContext as Application), + onDismiss: () -> Unit, + onConfirm: ( + categories: List, + stages: List, + languageIds: List + ) -> Unit +) { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val coroutineScope = rememberCoroutineScope() + var lids by remember { mutableStateOf>(emptyList()) } + var languages by remember { mutableStateOf>(emptyList()) } + // Map displayed Language to its DB id (lid) using position mapping from load + var languageIdMap by remember { mutableStateOf>(emptyMap()) } + + LaunchedEffect(Unit) { + coroutineScope.launch { + lids = vocabularyViewModel.getAllLanguagesIdsPresent().filterNotNull().toList() + languages = lids.map { lid -> + languageViewModel.getLanguageById(lid) + } + // build reverse map + languageIdMap = languages.mapIndexed { index, lang -> lang to lids.getOrNull(index) }.filter { it.second != null }.associate { it.first to it.second!! } + } + } + var selectedLanguages by remember { mutableStateOf>(emptyList()) } + var selectedCategories by remember { mutableStateOf>(emptyList()) } + var selectedStages by remember { mutableStateOf>(emptyList()) } + + AppDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.label_start_exercise)) }) { + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + MultipleLanguageDropdown( + modifier = Modifier.fillMaxWidth(), + languageViewModel = languageViewModel, + onLanguagesSelected = { langs -> + selectedLanguages = langs + }, + languages + ) + CategoryDropdown( + categoryViewModel = categoryViewModel, + onCategorySelected = { categories -> + selectedCategories = categories.filterIsInstance() + }, + multipleSelectable = true + ) + VocabularyStageDropDown( + modifier = Modifier.fillMaxWidth(), + preselectedStages = selectedStages, + onStageSelected = { stages -> + @Suppress("FilterIsInstanceResultIsAlwaysEmpty") + selectedStages = stages.filterIsInstance() + }, + multipleSelectable = true + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = onDismiss, + ) { + Text(stringResource(R.string.label_cancel)) + } + TextButton( + onClick = { + run { + val ids = selectedLanguages.mapNotNull { languageIdMap[it] } + onConfirm(selectedCategories, selectedStages, ids) + } + } + ) { + Text(stringResource(R.string.label_start_exercise)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyMenu.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyMenu.kt new file mode 100644 index 0000000..da51dd5 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyMenu.kt @@ -0,0 +1,82 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.dialogs + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppFabMenu +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.FabMenuItem +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.StatusViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel + +@Composable +fun VocabularyMenu( + modifier: Modifier = Modifier, + statusViewModel: StatusViewModel = viewModel(), + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(LocalContext.current.applicationContext as android.app.Application), + categoryViewModel: CategoryViewModel = viewModel(), +) { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + var showAddVocabularyDialog by remember { mutableStateOf(false) } + var showImportVocabularyDialog by remember { mutableStateOf(false) } + var showAddCategoryDialog by remember { mutableStateOf(false) } + + val menuItems = listOf( + FabMenuItem( + text = stringResource(R.string.label_add_vocabulary), + imageVector = AppIcons.Add, + onClick = { showAddVocabularyDialog = true } + ), + FabMenuItem( + text = stringResource(R.string.menu_import_vocabulary), + imageVector = AppIcons.AI, + onClick = { showImportVocabularyDialog = true } + ), + FabMenuItem( + text = stringResource(R.string.label_add_category), + imageVector = AppIcons.Add, + onClick = { showAddCategoryDialog = true } + ) + ) + + AppFabMenu(items = menuItems, modifier = modifier) + + if (showAddVocabularyDialog) { + AddVocabularyDialog( + statusViewModel = statusViewModel, + languageViewModel = languageViewModel, + vocabularyViewModel = vocabularyViewModel, + onDismissRequest = { showAddVocabularyDialog = false } + ) + } + + if (showImportVocabularyDialog) { + ImportVocabularyDialog( + languageViewModel = languageViewModel, + categoryViewModel = categoryViewModel, + vocabularyViewModel = vocabularyViewModel, + onDismiss = { showImportVocabularyDialog = false } + ) + } + + if (showAddCategoryDialog) { + AddCategoryDialog( + categoryViewModel = categoryViewModel, + onDismiss = { showAddCategoryDialog = false } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyReviewScreen.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyReviewScreen.kt new file mode 100644 index 0000000..abe4b8f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyReviewScreen.kt @@ -0,0 +1,156 @@ +package eu.gaudian.translator.view.dialogs + +import android.app.Application +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyCategory +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCheckbox +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.hints.getVocabularyReviewHint +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import okhttp3.internal.platform.PlatformRegistry.applicationContext + +@Composable +fun VocabularyReviewScreen( + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application), + categoryViewModel: CategoryViewModel, + onConfirm: (List, List) -> Unit, + onCancel: () -> Unit +) { + val generatedItems: List by vocabularyViewModel.generatedVocabularyItems.collectAsState() + val selectedItems = remember { mutableStateListOf() } + val duplicates = remember { mutableStateListOf() } + var selectedCategoryId by remember { mutableStateOf>(emptyList()) } + LocalContext.current + + LaunchedEffect(generatedItems) { + val duplicateResults = vocabularyViewModel.findDuplicates(generatedItems) + duplicates.clear() + duplicates.addAll(duplicateResults) + selectedItems.clear() + selectedItems.addAll(generatedItems.filterIndexed { index, _ -> !duplicateResults[index] }) + } + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.found_items)) }, + hintContent = { getVocabularyReviewHint() } + ) + }, + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = stringResource(R.string.select_items_to_add), style = MaterialTheme.typography.titleMedium) + val allSelected = generatedItems.isNotEmpty() && selectedItems.size == generatedItems.size + AppCheckbox( + checked = allSelected, + onCheckedChange = { isChecked -> + if (isChecked) { + selectedItems.clear() + selectedItems.addAll(generatedItems) + } else { + selectedItems.clear() + } + } + ) + } + val duplicate = stringResource(R.string.duplicate) + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + itemsIndexed(generatedItems) { index, item -> + val isDuplicate = duplicates.getOrNull(index) == true + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + AppCheckbox( + checked = selectedItems.contains(item), + onCheckedChange = { isChecked -> + if (isChecked) { + selectedItems.add(item) + } else { + selectedItems.remove(item) + } + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text(text = item.wordFirst, style = MaterialTheme.typography.bodyLarge) + Text(text = item.wordSecond, style = MaterialTheme.typography.bodyMedium) + } + if (isDuplicate) { + Spacer(modifier = Modifier.weight(1f)) + Text(duplicate, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + } + } + Text( + text = stringResource(R.string.select_list_optional), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(8.dp) + ) + CategoryDropdown( + categoryViewModel = categoryViewModel, + onCategorySelected = { categories: List -> + selectedCategoryId = categories.filterNotNull().map { it.id } + }, + onlyLists = true + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onCancel) { + Text(stringResource(R.string.label_cancel)) + } + Spacer(modifier = Modifier.width(8.dp)) + AppButton(onClick = { + onConfirm(selectedItems.toList(), selectedCategoryId) + }) { + Text(stringResource(R.string.label_add_, selectedItems.size)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyStageDropdown.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyStageDropdown.kt new file mode 100644 index 0000000..bed8c29 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyStageDropdown.kt @@ -0,0 +1,169 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCheckbox +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton + + +@Composable +fun VocabularyStageDropDown( + preselectedStages: List, + onStageSelected: (List) -> Unit, + modifier: Modifier = Modifier, + noneSelectable: Boolean? = true, + multipleSelectable: Boolean = false, +) { + var expanded by remember { mutableStateOf(false) } + var selectedStages by remember { mutableStateOf(preselectedStages) } + val context = LocalContext.current + + Box( + modifier = modifier, + contentAlignment = Alignment.CenterEnd + ) { + AppOutlinedButton( + shape = RoundedCornerShape(8.dp), + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Text(text = when { + selectedStages.isEmpty() -> stringResource(R.string.label_select_stage) + selectedStages.size == 1 -> selectedStages.first()?.toString(context)?:stringResource(R.string.text_none) + else -> stringResource(R.string.stages_selected, selectedStages.size) + }, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand) + ) + } + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxWidth() + ) { + if (noneSelectable == true) { + val noneSelected = selectedStages.contains(null) + DropdownMenuItem( + text = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (multipleSelectable) { + AppCheckbox( + checked = noneSelected, + onCheckedChange = { + selectedStages = if (noneSelected) selectedStages.filterNotNull() else selectedStages + listOf(null) + onStageSelected(selectedStages) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(text = stringResource(R.string.text_none)) + } + }, + onClick = { + if (multipleSelectable) { + selectedStages = if (null in selectedStages) { + selectedStages.filterNotNull() + } else { + selectedStages + listOf(null) + } + onStageSelected(selectedStages) + } else { + selectedStages = listOf(null) + onStageSelected(selectedStages) + expanded = false + } + } + ) + } + + VocabularyStage.entries.forEach { stage -> + val isSelected = selectedStages.contains(stage) + DropdownMenuItem( + text = { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (multipleSelectable) { + AppCheckbox( + checked = isSelected, + onCheckedChange = { + selectedStages = if (isSelected) selectedStages - stage else selectedStages + stage + onStageSelected(selectedStages) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stage.toString(context)) + } + }, + onClick = { + if (multipleSelectable) { + selectedStages = if (stage in selectedStages) { + selectedStages - stage + } else { + selectedStages + stage + } + onStageSelected(selectedStages) + } else { + selectedStages = listOf(stage) + onStageSelected(selectedStages) + expanded = false + } + } + ) + } + + if (multipleSelectable) { + HorizontalDivider() + AppButton( + onClick = { expanded = false }, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Text(stringResource(R.string.label_done)) + } + } + } + } +} + +@Preview +@Composable +fun VocabularyStageDropDownPreview() { + VocabularyStageDropDown( + preselectedStages = listOf(VocabularyStage.NEW), + onStageSelected = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/WhatsNewDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/WhatsNewDialog.kt new file mode 100644 index 0000000..6aaf1ce --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/WhatsNewDialog.kt @@ -0,0 +1,94 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.composable.AppAlertDialog +import eu.gaudian.translator.view.composable.DialogButton + +@Composable +fun WhatsNewDialog( + onDismissRequest: () -> Unit, + onContinue: () -> Unit, + changelogContent: String +) { + AppAlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.End + ) { + DialogButton( + onClick = onContinue, + text = stringResource(R.string.label_continue), + isPrimary = false + ) + } + }, + title = { + Text( + text = stringResource(R.string.whats_new_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text( + text = stringResource(R.string.whats_new_changelog_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 8.dp) + ) + Text( + text = changelogContent, + style = MaterialTheme.typography.bodyMedium + ) + } + } + ) +} + +@Suppress("HardCodedStringLiteral") +@ThemePreviews +@Composable +fun WhatsNewDialogPreview() { + WhatsNewDialog( + onDismissRequest = {}, + onContinue = {}, + changelogContent = """ + • New feature: Dark mode support for a better viewing experience in low-light environments. + • New feature: Offline translation capabilities. Download language packs to translate without an internet connection. + • New feature: Voice input. Speak directly into your device for instant translation. + • New feature: Conversation mode. A split-screen interface to facilitate bilingual conversations. + • Improvement: Enhanced translation accuracy for several language pairs. + • Improvement: UI has been redesigned for a more intuitive and modern look. + • Improvement: Performance has been optimized, resulting in faster translation times. + • Improvement: Reduced application size. + • Bug fix: Fixed an issue where the app would crash on certain older devices. + • Bug fix: Corrected a bug that caused incorrect translations for specific phrases. + • Bug fix: Addressed a memory leak that could slow down the device over time. + • New language: Added support for Klingon. + • New language: Added support for Elvish. + • Removed: Phased out support for Internet Explorer 6. + • Just kidding about those last three. Or are we? + • Lots and lots of other minor bug fixes and performance improvements to make your experience smoother. We've been busy! + """.trimIndent() + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/CorrectionScreen.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/CorrectionScreen.kt new file mode 100644 index 0000000..3dde9cd --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/CorrectionScreen.kt @@ -0,0 +1,555 @@ +package eu.gaudian.translator.view.dictionary + +import android.content.ClipData +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.toClipEntry +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.semanticColors +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton +import eu.gaudian.translator.view.composable.AppSwitch +import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown +import eu.gaudian.translator.viewmodel.CorrectionViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import kotlinx.coroutines.launch + +// 1. STATEFUL COMPONENT (Connects to ViewModels) +@Composable +fun CorrectionScreen( + correctionViewModel: CorrectionViewModel, + languageViewModel: LanguageViewModel +) { + val textFieldValue by correctionViewModel.textFieldValue.collectAsState() + val explanation by correctionViewModel.explanation.collectAsState() + val isLoading by correctionViewModel.isLoading.collectAsState() + val correctedText by correctionViewModel.correctedText.collectAsState() + val grammarOnly by correctionViewModel.grammarOnly.collectAsState() + val selectedTone by correctionViewModel.tone.collectAsState() + val errorMessage by correctionViewModel.error.collectAsState() + + val successColor = MaterialTheme.semanticColors.success + + CorrectionScreenContent( + textFieldValue = textFieldValue, + explanation = explanation, + isLoading = isLoading, + correctedText = correctedText, + grammarOnly = grammarOnly, + selectedTone = selectedTone, + errorMessage = errorMessage, + onInputTextChanged = correctionViewModel::onInputTextChanged, + onClearText = correctionViewModel::clearText, + onSetCorrectToneEnabled = correctionViewModel::setCorrectToneEnabled, + onToneSelected = correctionViewModel::setTone, + onCorrectClick = { + languageViewModel.selectedDictionaryLanguage.value?.let { lang -> + correctionViewModel.performCorrection(lang, successColor) + } + }, + languageDropdownContent = { + DictionaryLanguageDropDown( + languageViewModel = languageViewModel, + modifier = Modifier.fillMaxWidth() + ) + } + ) +} + +// 2. STATELESS COMPONENT (Handles UI Layout) +@Composable +fun CorrectionScreenContent( + textFieldValue: TextFieldValue, + explanation: String?, + isLoading: Boolean, + correctedText: String?, + grammarOnly: Boolean, + selectedTone: CorrectionViewModel.Tone, + errorMessage: String?, + onInputTextChanged: (TextFieldValue) -> Unit, + onClearText: () -> Unit, + onSetCorrectToneEnabled: (Boolean) -> Unit, + onToneSelected: (CorrectionViewModel.Tone) -> Unit, + onCorrectClick: () -> Unit, + languageDropdownContent: @Composable () -> Unit +) { + val clipboard = LocalClipboard.current + val clipboardManager = LocalClipboardManager.current + val scope = rememberCoroutineScope() + val context = LocalContext.current + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // --- Editor Section --- + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + ) + ) { + // Box is used to overlay the buttons on top of the text field + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.BottomEnd + ) { + // Input Field + OutlinedTextField( + value = textFieldValue, + onValueChange = onInputTextChanged, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 200.dp, max = 500.dp), // Height accommodates text + bottom buttons + enabled = !isLoading, + label = { Text(stringResource(R.string.text_enter_text_to_correct)) }, + //placeholder = { Text("Type or paste text here...") } + ) + + // Action Buttons (Paste / Copy / Clear) - Floating inside the Box + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + val textCopied = stringResource(R.string.text_copied_to_clipboard) + + // 1. Paste Button (Visible only when text is empty) + val textClipBoardEmpty = stringResource(R.string.text_clipboard_empty) + AnimatedVisibility( + visible = textFieldValue.text.isEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + IconButton(onClick = { + val clipboardText = clipboardManager.getText()?.text + if (!clipboardText.isNullOrBlank()) { + onInputTextChanged(TextFieldValue(clipboardText)) + } else { + Toast.makeText(context, textClipBoardEmpty, Toast.LENGTH_SHORT).show() + } + }) { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = stringResource(R.string.label_paste), + tint = MaterialTheme.colorScheme.primary + ) + } + } + + // 2. Copy Button (Visible only when there is a result) + AnimatedVisibility( + visible = correctedText != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + correctedText?.let { textToCopy -> + IconButton( + onClick = { + Toast.makeText(context, textCopied, Toast.LENGTH_SHORT).show() + scope.launch { + val clipData = ClipData.newPlainText(textToCopy, textToCopy) + clipboard.setClipEntry(clipData.toClipEntry()) + } + }, + ) { + Icon( + imageVector = AppIcons.Copy, + contentDescription = stringResource(R.string.text_copy_corrected_text), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + // 3. Clear Button (Visible only when text is NOT empty) + AnimatedVisibility(visible = textFieldValue.text.isNotEmpty()) { + IconButton(onClick = onClearText) { + Icon( + imageVector = AppIcons.Clear, + contentDescription = stringResource(R.string.cd_clear_text), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + + // --- Settings Section --- + Card( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + + + // --- Action Bar --- + Column { + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 8.dp, start = 8.dp) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Language Dropdown Slot + Box(modifier = Modifier.weight(1f)) { + languageDropdownContent() + } + + // Correct Button + AppButton( + onClick = onCorrectClick, + enabled = textFieldValue.text.isNotBlank() && !isLoading, + modifier = Modifier.height(50.dp) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text( + text = stringResource(R.string.label_action_correct), + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + + + + + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = AppIcons.Settings, // Fallback icon + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = stringResource(R.string.correct_tone), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + if (grammarOnly) { + Text( + text = stringResource(R.string.label_grammar_only), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + AppSwitch( + checked = !grammarOnly, + onCheckedChange = onSetCorrectToneEnabled, + enabled = !isLoading + ) + } + + if (!grammarOnly) { + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.label_target_tone), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 16.dp) + ) + Box(modifier = Modifier.weight(1f)) { + ToneDropdown( + selectedTone = selectedTone, + onToneSelected = onToneSelected, + enabled = !isLoading + ) + } + } + } + + } + } + + // --- Explanation / Results Section --- + AnimatedVisibility( + visible = explanation != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f) + ) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = AppIcons.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.text_explanation), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + SelectionContainer { + Text( + text = explanation ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 22.sp + ) + } + } + } + } + + + } +} + +@Composable +private fun ToneDropdown( + selectedTone: CorrectionViewModel.Tone, + onToneSelected: (CorrectionViewModel.Tone) -> Unit, + enabled: Boolean +) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxWidth()) { + AppOutlinedButton( + onClick = { if (enabled) expanded = true }, + enabled = enabled, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = labelFor(selectedTone), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Icon( + imageVector = if(expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + } + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) + ) { + DropdownMenuItem( + text = { Text(text = stringResource(R.string.text_none)) }, + onClick = { + onToneSelected(CorrectionViewModel.Tone.NONE) + expanded = false + }, + enabled = enabled + ) + HorizontalDivider() + val options = listOf( + CorrectionViewModel.Tone.FORMAL, + CorrectionViewModel.Tone.CASUAL, + CorrectionViewModel.Tone.COLLOQUIAL, + CorrectionViewModel.Tone.POLITE, + CorrectionViewModel.Tone.PROFESSIONAL, + CorrectionViewModel.Tone.FRIENDLY, + CorrectionViewModel.Tone.ACADEMIC, + CorrectionViewModel.Tone.CREATIVE + ) + options.forEach { tone -> + DropdownMenuItem( + text = { Text(text = labelFor(tone)) }, + onClick = { + onToneSelected(tone) + expanded = false + }, + enabled = enabled + ) + } + } + } +} + +@Composable +fun labelFor(tone: CorrectionViewModel.Tone): String = when (tone) { + CorrectionViewModel.Tone.NONE -> stringResource(R.string.text_none) + CorrectionViewModel.Tone.FORMAL -> stringResource(R.string.formal) + CorrectionViewModel.Tone.CASUAL -> stringResource(R.string.label_casual) + CorrectionViewModel.Tone.COLLOQUIAL -> stringResource(R.string.label_colloquial) + CorrectionViewModel.Tone.POLITE -> stringResource(R.string.polite) + CorrectionViewModel.Tone.PROFESSIONAL -> stringResource(R.string.professional) + CorrectionViewModel.Tone.FRIENDLY -> stringResource(R.string.friendly) + CorrectionViewModel.Tone.ACADEMIC -> stringResource(R.string.label_academic) + CorrectionViewModel.Tone.CREATIVE -> stringResource(R.string.creative) +} + +// --- PREVIEWS --- + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "1. Empty State") +@Composable +private fun CorrectionScreenPreview() { + MaterialTheme { + CorrectionScreenContent( + textFieldValue = TextFieldValue(""), + explanation = null, + isLoading = false, + correctedText = null, + grammarOnly = true, + selectedTone = CorrectionViewModel.Tone.NONE, + errorMessage = null, + onInputTextChanged = {}, + onClearText = {}, + onSetCorrectToneEnabled = {}, + onToneSelected = {}, + onCorrectClick = {}, + languageDropdownContent = { + AppOutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { + Text("German") + } + } + ) + } +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "2. Results State") +@Composable +private fun CorrectionScreenResultsPreview() { + MaterialTheme { + CorrectionScreenContent( + textFieldValue = TextFieldValue("In diesen Satz ist ein Fehler"), + explanation = "Korrektur der Deklination von 'dieser' (Dativ Singular Maskulinum: 'diesem' statt 'diesen') und ErgÀnzung des fehlenden Schlusszeichens.", + isLoading = false, + correctedText = "In diesem Satz ist ein Fehler.", + grammarOnly = false, + selectedTone = CorrectionViewModel.Tone.PROFESSIONAL, + errorMessage = null, + onInputTextChanged = {}, + onClearText = {}, + onSetCorrectToneEnabled = {}, + onToneSelected = {}, + onCorrectClick = {}, + languageDropdownContent = { + AppOutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { + Text("German") + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryManagerContent.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryManagerContent.kt new file mode 100644 index 0000000..2d50ac5 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryManagerContent.kt @@ -0,0 +1,181 @@ +package eu.gaudian.translator.view.dictionary + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.communication.FileInfo +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.viewmodel.DictionaryViewModel + +@Composable +fun DictionaryManagerContent( + dictionaryViewModel: DictionaryViewModel, +) { + val manifest by dictionaryViewModel.manifest.collectAsState() + val downloadedDictionaries by dictionaryViewModel.downloadedDictionaries.collectAsState() + val orphanedFiles by dictionaryViewModel.orphanedFiles.collectAsState() + val downloadProgress by dictionaryViewModel.downloadProgress.collectAsState() + val isDownloading by dictionaryViewModel.isDownloading.collectAsState() + + var showDeleteAllDialog by remember { mutableStateOf(false) } + + if (manifest == null) return + + AppCard( + title = stringResource(R.string.label_dictionary_manager), + text = stringResource(R.string.text_dictionary_manager_description), + expandable = true + ) { + Column(modifier = Modifier.fillMaxWidth()) { + if (manifest!!.files.isNotEmpty()) { + DictionaryDownloadList( + files = manifest!!.files, + downloadedDictionaries = downloadedDictionaries, + downloadProgress = downloadProgress, + isDownloading = isDownloading, + dictionaryViewModel = dictionaryViewModel + ) + } + + if (orphanedFiles.isNotEmpty()) { + OrphanedFilesList( + files = orphanedFiles, + dictionaryViewModel = dictionaryViewModel + ) + } + + if ((downloadedDictionaries.isNotEmpty() || orphanedFiles.isNotEmpty())) { + AppButton( + onClick = { showDeleteAllDialog = true }, + enabled = !isDownloading, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + Text(stringResource(R.string.label_delete_all)) + } + } + + if (manifest!!.files.isEmpty() && orphanedFiles.isEmpty()) { + Text( + text = stringResource(R.string.text_no_dictionaries_available), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 16.dp) + ) + } + } + } + + if (showDeleteAllDialog) { + DeleteAllDialog( + onConfirm = { + dictionaryViewModel.deleteAllDictionaries() + @Suppress("AssignedValueIsNeverRead") + showDeleteAllDialog = false + }, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showDeleteAllDialog = false + } + ) + } +} + +@Composable +private fun DictionaryDownloadList( + files: List, + downloadedDictionaries: List, + downloadProgress: Float?, + isDownloading: Boolean, + dictionaryViewModel: DictionaryViewModel +) { + // Local state to track which ID is currently being processed + var currentDownloadingId by remember { mutableStateOf(null) } + + // Reset when progress clears + LaunchedEffect(downloadProgress) { + if (downloadProgress == null) currentDownloadingId = null + } + + Text( + text = stringResource(R.string.text_available_dictionaries), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + + Column { + files.forEach { fileInfo -> + val isDownloaded = downloadedDictionaries.any { it.id == fileInfo.id } + // Using ViewModel's getDictionaryUiItems for pre-calculated values + val uiItems = dictionaryViewModel.getDictionaryUiItems(files, downloadedDictionaries) + val uiItem = uiItems.find { it.fileInfo.id == fileInfo.id } + + DictionaryManagerItem( + fileInfo = fileInfo, + isDownloaded = uiItem?.isDownloaded ?: isDownloaded, + hasUpdate = uiItem?.hasUpdate ?: false, + size = uiItem?.size ?: fileInfo.assets.sumOf { it.sizeBytes }, + downloadProgress = if (fileInfo.id == currentDownloadingId) downloadProgress else null, + isDownloading = isDownloading, + onDownload = { + currentDownloadingId = fileInfo.id + dictionaryViewModel.downloadDictionary(fileInfo) + }, + onUpdate = { + currentDownloadingId = fileInfo.id + dictionaryViewModel.downloadDictionary(fileInfo) + }, + onDelete = { dictionaryViewModel.deleteDictionary(fileInfo) } + ) + } + } +} + +@Composable +private fun OrphanedFilesList( + files: List, + dictionaryViewModel: DictionaryViewModel +) { + Text( + text = stringResource(R.string.label_orphaned_files), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + Text( + text = stringResource(R.string.text_these_files_exist_locally), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Column { + files.forEach { fileInfo -> + OrphanedFileItem( + fileInfo = fileInfo, + size = dictionaryViewModel.getDictionarySize(fileInfo), + onDelete = { dictionaryViewModel.deleteOrphanedFile(fileInfo) } + ) + } + } +} diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryResultComponents.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryResultComponents.kt new file mode 100644 index 0000000..1d573b3 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryResultComponents.kt @@ -0,0 +1,478 @@ +package eu.gaudian.translator.view.dictionary + +import android.content.ClipData +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.toClipEntry +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.DictionaryEntry +import eu.gaudian.translator.model.EntryPart +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.repository.DictionaryWordEntry +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.formatMarkdownText +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AutoResizeSingleLineText +import eu.gaudian.translator.view.composable.ClickableText +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive + +@Composable +fun DictionaryResultBreadcrumbs( + breadcrumbs: List, + navController: NavController +) { + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryResultComponents", "[Breadcrumb] DictionaryResultBreadcrumbs called with ${breadcrumbs.size} items: ${breadcrumbs.map { it.word }}") + + // Only show breadcrumbs if there are 2 or more entries (meaning there's something to navigate back to) + if (breadcrumbs.size >= 2) { + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryResultComponents", "[Breadcrumb] Rendering breadcrumbs: ${breadcrumbs.map { it.word }}") + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + items(breadcrumbs) { item -> + val isLast = item == breadcrumbs.last() + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryResultComponents", "[Breadcrumb] Rendering item: '${item.word}' (isLast: $isLast)") + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable(enabled = !isLast) { + // Navigate back to this item by popping the back stack the required number of times + val index = breadcrumbs.indexOf(item) + if (index != -1) { + val stepsToPop = (breadcrumbs.size - 1) - index + repeat(stepsToPop) { + navController.popBackStack() + } + } + } + ) { + Text( + text = item.word, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isLast) FontWeight.Bold else FontWeight.Normal, + color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary + ) + if (!isLast) { + Icon( + imageVector = AppIcons.ArrowRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + HorizontalDivider() + } else { + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryResultComponents", "[Breadcrumb] Breadcrumbs list has ${breadcrumbs.size} items - not rendering (need 2 or more for navigation)") + } +} + +@Composable +fun DictionaryResultEntryHeader( + entry: DictionaryEntry +) { + AutoResizeSingleLineText( + text = entry.word, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp) + ) +} + +@Composable +fun DictionaryResultLocalEntriesSection( + localEntries: List, + dictionaryViewModel: DictionaryViewModel, + allLanguages: List, + isDeveloperModeEnabled: Boolean +) { + if (localEntries.isNotEmpty()) { + + localEntries.forEach { entry -> + + + + LocalWordEntryDisplay( + entry = entry, + dictionaryViewModel = dictionaryViewModel, + allLanguages = allLanguages, + expandable = localEntries.size>1 + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + WiktionaryContentAcknowledgment() + + // Developer mode: Show raw JSON of local dictionary entries + if (isDeveloperModeEnabled) { + DictionaryResultDeveloperJsonSection(localEntries) + } + } +} + +@Composable +fun WiktionaryContentAcknowledgment( + modifier: Modifier = Modifier, + wordUrl: String = "https://www.wiktionary.org/" +) { + + val licenseUrl = "https://creativecommons.org/licenses/by-sa/3.0/" + + val annotatedText = buildAnnotatedString { + append(stringResource(R.string.text_content_sourced_from)) + + // Wiktionary Link + pushStringAnnotation(tag = "URL", annotation = wordUrl) + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ) + ) { + append(stringResource(R.string.label_wiktionary)) + } + pop() + + append(". " + (stringResource(R.string.licensed_under) + " ")) + + // License Link + pushStringAnnotation(tag = "URL", annotation = licenseUrl) + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline + ) + ) { + @Suppress("HardCodedStringLiteral") + append("CC BY-SA 3.0") + } + pop() + append(".") + } + + Box( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + ClickableText( + text = annotatedText, + style = MaterialTheme.typography.bodySmall.copy( + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun WiktionaryAcknowledgmentPreview() { + MaterialTheme { + WiktionaryContentAcknowledgment() + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +fun DictionaryResultDeveloperJsonSection( + localEntries: List +) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text( + text = stringResource(R.string.label_developer_json), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.dp) + ) + + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Button( + onClick = { + val allJson = localEntries.joinToString("\n\n") { entry -> + "Entry ${entry.word} (${entry.langCode}):\n${entry.json}" + } + scope.launch { + val clipData = ClipData.newPlainText(allJson, allJson) + clipboard.setClipEntry(clipData.toClipEntry()) + } + Log.d("DictionaryResultDeveloperJsonSection", "Copied JSON to clipboard:\n$allJson") + }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) { + @Suppress("HardCodedStringLiteral") + Text(text = "Copy All JSON") + } + + localEntries.forEachIndexed { index, entry -> + // Log the JSON for debugging + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryResultScreen", "Local Dictionary Entry ${index + 1}: ${entry.json}") + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + Text( + text = "Entry ${index + 1} - ${entry.word} (${entry.langCode})", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + if (entry.pos != null) { + Text( + text = "POS: ${entry.pos}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(8.dp)) + SelectionContainer { + Text( + text = entry.json, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } +} + +// Keep the existing components that are already well-structured +@Composable +fun GeneratingAnimation() { + @Suppress("HardCodedStringLiteral") val infiniteTransition = rememberInfiniteTransition(label = "pulsing") + @Suppress("HardCodedStringLiteral") val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1000), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + imageVector = AppIcons.Refresh, // Or a brain icon if available + contentDescription = null, + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp) + .alpha(alpha), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.text_contacting_ai), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.alpha(alpha) + ) + } + } +} + +@Composable +fun DefinitionPart(part: EntryPart) { + + AppCard( + modifier = Modifier.fillMaxWidth(), + expandable = part.content.toString().length > 250, + initiallyExpanded = true, + title = part.title, + icon = AppIcons.AI + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + + // Safely extraction logic + val contentText = remember(part.content) { + try { + when (val contentElement = part.content) { + is JsonPrimitive -> contentElement.content + is JsonArray -> contentElement.joinToString(separator = "\n") { element -> + // Check if the element inside the array is actually a primitive + if (element is JsonPrimitive) { + "• ${element.content}" + } else { + // If it's an Object or Array, convert to string safely instead of crashing + "• $element" + } + } + // Fallback for JsonObject or other top-level types + else -> contentElement.toString() + } + } catch (e: Exception) { + // Ultimate fallback if something else goes wrong during parsing + part.content.toString() + } + } + + SelectionContainer { + Text( + text = formatMarkdownText(contentText), + style = MaterialTheme.typography.bodyLarge, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight + ) + } + } + } +} + + + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun DictionaryResultBreadcrumbsPreview() { + val mockBreadcrumbs = listOf( + BreadcrumbItem("Word1", 1), + BreadcrumbItem("Word2", 2), + BreadcrumbItem("Current", 3) + ) + DictionaryResultBreadcrumbs( + breadcrumbs = mockBreadcrumbs, + navController = rememberNavController() + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun DictionaryResultEntryHeaderPreview() { + val mockEntry = DictionaryEntry( + id = 1, + word = "Example", + languageCode = 1, + definition = emptyList(), + languageName = "English", + ) + DictionaryResultEntryHeader( + entry = mockEntry + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun DictionaryResultDeveloperJsonSectionPreview() { + val mockEntry = DictionaryWordEntry( + word = "Example", + langCode = "en", + pos = "noun", + json = """{"word": "example", "pos": "noun", "definition": "something that is typical"}""" + ) + val mockEntries = listOf(mockEntry) + + DictionaryResultDeveloperJsonSection( + localEntries = mockEntries + ) +} + +@Preview(showBackground = true) +@Composable +fun GeneratingAnimationPreview() { + GeneratingAnimation() +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun DefinitionPartPreview() { + val mockPart = EntryPart( + title = "Definition", + content = JsonPrimitive("A word or phrase used to describe something") + ) + + DefinitionPart(part = mockPart) +} + +// Data classes for the refactored components +data class EntryData( + val entry: DictionaryEntry, + val language: Language? +) + +data class BreadcrumbItem( + val word: String, + val entryId: Int +) \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryResultScreen.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryResultScreen.kt new file mode 100644 index 0000000..bcbd479 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryResultScreen.kt @@ -0,0 +1,442 @@ +package eu.gaudian.translator.view.dictionary + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.DictionaryEntry +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.repository.DictionaryWordEntry +import eu.gaudian.translator.utils.TextToSpeechHelper +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppFabMenu +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.FabMenuItem +import eu.gaudian.translator.view.composable.TabItem +import eu.gaudian.translator.view.composable.TabbedTopAppBar +import eu.gaudian.translator.view.dialogs.AddVocabularyDialog +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.SettingsViewModel +import eu.gaudian.translator.viewmodel.StatusViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.launch + +enum class DictionaryResultTab( + @param:StringRes val titleRes: Int, + val icon: ImageVector +) { + AI(R.string.tab_ai_definition, AppIcons.Robo), + Local(R.string.tab_downloaded, AppIcons.Download) +} + +data class DictionaryTabUi( + val type: DictionaryResultTab, + override val title: String, + override val icon: ImageVector +) : TabItem + +@Composable +fun DictionaryResultScreen( + navController: NavController, + entryId: Int, +) { + val activity = LocalContext.current.findActivity() + val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity) + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val vocabularyViewModel: VocabularyViewModel = viewModel(viewModelStoreOwner = activity) + val statusViewModel: StatusViewModel = viewModel() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + + // State Collection + val searchHistory by dictionaryViewModel.searchHistoryEntries.collectAsState() + val currentEntry = remember(searchHistory, entryId) { searchHistory.find { it.id == entryId } } + val isDeveloperModeEnabled by settingsViewModel.isDeveloperModeEnabled.collectAsState() + val developerEntry by dictionaryViewModel.developerCurrentEntry.collectAsState() + val allLanguages by languageViewModel.allLanguages.collectAsState() + val entryToNavigate by dictionaryViewModel.navigateToEntry.collectAsState() + val generatingEntryIds by dictionaryViewModel.generatingEntryIds.collectAsState() + val breadcrumbs by dictionaryViewModel.breadcrumbs.collectAsState() + + // Using ViewModel's StateFlows for entryData and localEntries + val entryData by dictionaryViewModel.currentEntryData.collectAsState() + val isTtsAvailable by dictionaryViewModel.isTtsAvailable.collectAsState() + val localEntries by dictionaryViewModel.localEntries.collectAsState() + + var showAddVocabularyDialog by remember { mutableStateOf(false) } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Update entryData using ViewModel's method + LaunchedEffect(currentEntry) { + if (currentEntry != null) { + val language = languageViewModel.getLanguageById(currentEntry.languageCode) + val ttsAvailable = TextToSpeechHelper.isPlayable(context, language) + dictionaryViewModel.updateCurrentEntryData(currentEntry, language, ttsAvailable) + } + } + + // Update breadcrumbs using ViewModel's method + var lastBreadcrumbEntryId by remember { mutableStateOf(null) } + LaunchedEffect(entryData) { + entryData?.let { data -> + if (data.entry.id != lastBreadcrumbEntryId) { + lastBreadcrumbEntryId = data.entry.id + dictionaryViewModel.updateBreadcrumbs(data.entry) + } + } + } + + // Handle Entry ID direct lookup using ViewModel + LaunchedEffect(entryId) { + val entry = dictionaryViewModel.getDictionaryEntryById(entryId) + if (entry != null) { + val language = languageViewModel.getLanguageById(entry.languageCode) + val ttsAvailable = TextToSpeechHelper.isPlayable(context, language) + dictionaryViewModel.updateCurrentEntryData(entry, language, ttsAvailable) + } + } + + // Developer Entry Update + LaunchedEffect(developerEntry) { + developerEntry?.let { devEntry -> + val language = languageViewModel.getLanguageById(devEntry.languageCode) + val ttsAvailable = TextToSpeechHelper.isPlayable(context, language) + dictionaryViewModel.updateCurrentEntryData(devEntry, language, ttsAvailable) + } + } + + // Navigation Effect + LaunchedEffect(entryToNavigate) { + entryToNavigate?.let { targetEntry -> + dictionaryViewModel.setInternalNavigation() + @Suppress("HardCodedStringLiteral") + navController.navigate("dictionary_result/${targetEntry.id}") + dictionaryViewModel.onNavigationDone() + } + } + + // Load local entries using ViewModel's method + LaunchedEffect(entryData) { + entryData?.let { data -> + val langCode = data.language?.code + if (langCode != null) { + dictionaryViewModel.loadLocalEntriesForEntry(data.entry, langCode) + } else { + dictionaryViewModel.clearLocalEntries() + } + } ?: run { + dictionaryViewModel.clearLocalEntries() + } + } + + DisposableEffect(entryId) { + onDispose { + dictionaryViewModel.clearCurrentEntryId() + // Only clear breadcrumbs if this is truly leaving the screen + // (not just internal navigation to a new entry) + dictionaryViewModel.onLeavingDictionaryResultScreen() + } + } + + val isGenerating = entryData?.entry?.id?.let { generatingEntryIds.contains(it) } == true + + // Prepare FAB Items + val fabItems = remember(entryData, isGenerating, isTtsAvailable, isDeveloperModeEnabled) { + createFabItems( + entryData = entryData, + isGenerating = isGenerating, + isTtsAvailable = isTtsAvailable, + isDeveloperModeEnabled = isDeveloperModeEnabled, + onCycleDev = { lang -> dictionaryViewModel.developerCycleLocalDictionaryEntries(lang) }, + onRegenerate = { data -> + scope.launch { + dictionaryViewModel.performSearch(data.entry.word, data.language!!, regenerate = true) + } + }, + onAddToVocab = { showAddVocabularyDialog = true }, + onReadAloud = { data -> + scope.launch { + val voice = settingsViewModel.getTtsVoiceForLanguage(data.language!!.code, data.language.region) + TextToSpeechHelper.speakOut(context, data.entry.word, data.language, voice) + } + }, + context = context + ) + } + + DictionaryResultContent( + entryData = entryData, + localEntries = localEntries, + breadcrumbs = breadcrumbs.map { BreadcrumbItem(it.word, it.id) }, + isGenerating = isGenerating, + isDeveloperModeEnabled = isDeveloperModeEnabled, + fabItems = fabItems, + navController = navController, + dictionaryViewModel = dictionaryViewModel, + allLanguages = allLanguages, + onNavigateBack = { navController.popBackStack() } + ) + + if (showAddVocabularyDialog) { + entryData?.let { + AddVocabularyDialog( + statusViewModel = statusViewModel, + languageViewModel = languageViewModel, + vocabularyViewModel = vocabularyViewModel, + initialWord = it.entry.word, + onDismissRequest = { + @Suppress("AssignedValueIsNeverRead") + showAddVocabularyDialog = false + } + ) + } + } +} + +// Extracted Helper for FAB creation +private fun createFabItems( + entryData: DictionaryViewModel.EntryData?, + isGenerating: Boolean, + isTtsAvailable: Boolean, + isDeveloperModeEnabled: Boolean, + onCycleDev: (Language) -> Unit, + onRegenerate: (DictionaryViewModel.EntryData) -> Unit, + onAddToVocab: () -> Unit, + onReadAloud: (DictionaryViewModel.EntryData) -> Unit, + context: Context, +): List { + if (entryData == null) return emptyList() + val items = mutableListOf() + + if (isDeveloperModeEnabled && entryData.language != null) { + items.add(FabMenuItem(context.getString(R.string.label_auto_cycle_dev), AppIcons.Settings) { onCycleDev(entryData.language) }) + } + + if (!isGenerating) { + items.add(FabMenuItem(context.getString(R.string.label_regenerate), AppIcons.Refresh) { onRegenerate(entryData) }) + } + + items.add(FabMenuItem(context.getString(R.string.label_add_to_dictionary), AppIcons.AddTo) { onAddToVocab() }) + + if (isTtsAvailable) { + items.add(FabMenuItem(context.getString(R.string.label_read_aloud), AppIcons.Speech) { onReadAloud(entryData) }) + } + return items +} + +@Composable +fun DictionaryResultContent( + entryData: DictionaryViewModel.EntryData?, + localEntries: List, + breadcrumbs: List, + isGenerating: Boolean, + isDeveloperModeEnabled: Boolean, + fabItems: List, + navController: NavController, + dictionaryViewModel: DictionaryViewModel, + allLanguages: List, + onNavigateBack: () -> Unit +) { + val hasAiResult = entryData?.entry?.definition?.isNotEmpty() == true || isGenerating + val hasLocalResult = localEntries.isNotEmpty() + val showTabs = hasAiResult && hasLocalResult + + val aiTabTitle = stringResource(DictionaryResultTab.AI.titleRes) + val localTabTitle = stringResource(DictionaryResultTab.Local.titleRes) + + val tabs = remember(aiTabTitle, localTabTitle) { + listOf( + DictionaryTabUi(DictionaryResultTab.AI, aiTabTitle, DictionaryResultTab.AI.icon), + DictionaryTabUi(DictionaryResultTab.Local, localTabTitle, DictionaryResultTab.Local.icon) + ) + } + + var selectedTab by remember { mutableStateOf(tabs.first()) } + LaunchedEffect(showTabs) { if (!showTabs) selectedTab = tabs.first() } + + AppScaffold( + topBar = { + if (showTabs) { + TabbedTopAppBar( + tabs = tabs, + selectedTab = selectedTab, + onTabSelected = { selectedTab = it }, + onNavigateBack = onNavigateBack + ) + } else { + DictionarySimpleTopBar( + word = entryData?.entry?.word, + languageName = entryData?.language?.name, + onNavigateBack = onNavigateBack + ) + } + }, + floatingActionButton = { + if (fabItems.isNotEmpty()) { + AppFabMenu(items = fabItems) + } + } + ) { paddingValues -> + entryData?.let { data -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + val showAi = !showTabs || selectedTab.type == DictionaryResultTab.AI + val showLocal = (!showTabs && hasLocalResult) || (showTabs && selectedTab.type == DictionaryResultTab.Local) + + if (showLocal) { + DictionaryResultBreadcrumbs( + breadcrumbs = breadcrumbs, + navController = navController + ) + } + + DefinitionBody( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp) + .verticalScroll(rememberScrollState()), + entry = data.entry, + localEntries = localEntries, + dictionaryViewModel = dictionaryViewModel, + allLanguages = allLanguages, + isGenerating = isGenerating, + isDeveloperModeEnabled = isDeveloperModeEnabled, + showAiContent = showAi, + showLocalContent = showLocal + ) + } + } + } +} + +@Composable +fun DictionarySimpleTopBar( + word: String?, + languageName: String?, + onNavigateBack: () -> Unit +) { + AppTopAppBar( + title = { + Column { + Text( + text = word ?: stringResource(R.string.text_loading_3d), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + languageName?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + }, + actions = {} + ) +} + +@Composable +fun DefinitionBody( + modifier: Modifier = Modifier, + entry: DictionaryEntry, + localEntries: List, + dictionaryViewModel: DictionaryViewModel, + allLanguages: List, + isGenerating: Boolean, + isDeveloperModeEnabled: Boolean, + showAiContent: Boolean = true, + showLocalContent: Boolean = true +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + if (showAiContent) { + AiDefinitionsSection( + definitionParts = entry.definition, + isGenerating = isGenerating, + hasOtherContent = showLocalContent + ) + } + + if (showLocalContent) { + DictionaryResultLocalEntriesSection( + localEntries = localEntries, + dictionaryViewModel = dictionaryViewModel, + allLanguages = allLanguages, + isDeveloperModeEnabled = isDeveloperModeEnabled + ) + } + + if (isGenerating && entry.definition.isNotEmpty() && showAiContent) { + GeneratingAnimation() + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun AiDefinitionsSection( + definitionParts: List, + isGenerating: Boolean, + hasOtherContent: Boolean +) { + if (definitionParts.isNotEmpty()) { + definitionParts.forEach { part -> + DefinitionPart(part = part) + } + } else if (isGenerating) { + GeneratingAnimation() + } else if (!hasOtherContent) { + Text( + text = stringResource(R.string.text_no_data_available), + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryScreen.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryScreen.kt new file mode 100644 index 0000000..452eeab --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryScreen.kt @@ -0,0 +1,45 @@ +package eu.gaudian.translator.view.dictionary + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel + +@Composable +fun DictionaryScreen( + navController: NavController, + onEntryClick: (eu.gaudian.translator.model.DictionaryEntry) -> Unit = {}, + dictionaryViewModel: DictionaryViewModel, + languageViewModel: LanguageViewModel, + onNavigateToOptions: () -> Unit +) { + // Use the new refactored component + DictionaryScreenContent( + navController = navController, + onEntryClick = onEntryClick, + dictionaryViewModel = dictionaryViewModel, + languageViewModel = languageViewModel, + onNavigateToOptions = onNavigateToOptions + ) +} + +@Preview +@Composable +fun DictionaryScreenPreview() { + val activity = androidx.compose.ui.platform.LocalContext.current.findActivity() + val dictionaryViewModel: DictionaryViewModel = viewModel() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + + DictionaryScreen( + navController = rememberNavController(), + onEntryClick = {}, + dictionaryViewModel = dictionaryViewModel, + languageViewModel = languageViewModel, + onNavigateToOptions = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryScreenContent.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryScreenContent.kt new file mode 100644 index 0000000..56a308d --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryScreenContent.kt @@ -0,0 +1,2 @@ +package eu.gaudian.translator.view.dictionary + diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryTableComponents.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryTableComponents.kt new file mode 100644 index 0000000..4a7f537 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/DictionaryTableComponents.kt @@ -0,0 +1,257 @@ +package eu.gaudian.translator.view.dictionary + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AutoResizeSingleLineText + +// A shared constant for consistency across the app +val UnifiedCornerShape = RoundedCornerShape(16.dp) + +@Composable +fun DictionaryTableCard( + items: List, + modifier: Modifier = Modifier, + cornerRadius: Dp = 16.dp, // Modernized radius + maxRowsBeforeCollapse: Int = 6, + headerContent: @Composable RowScope.() -> Unit, + rowContent: @Composable RowScope.(T) -> Unit +) { + val maxDataRows = maxRowsBeforeCollapse - 1 + val isCollapsible = items.size > maxDataRows + var isExpanded by remember { mutableStateOf(false) } + + val visibleItems = if (isCollapsible && !isExpanded) items.take(maxDataRows) else items + + Card( + modifier = modifier + .fillMaxWidth() + .animateContentSize(), + shape = RoundedCornerShape(cornerRadius), + // Switch to a filled surface variant for a modern "block" look, removing borders + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Column { + // Header with distinct background + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + content = headerContent + ) + + // Rows + visibleItems.forEachIndexed { index, item -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + content = { rowContent(item) } + ) + } + // Subtle divider between rows, but not after the last one unless footer exists + if (index < visibleItems.lastIndex || isCollapsible) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + + // Footer + if (isCollapsible) { + DictionaryTableFooter( + isExpanded = isExpanded, + remainingCount = items.size - maxDataRows, + onToggle = { isExpanded = !isExpanded } + ) + } + } + } +} + +@Composable +fun DictionaryTableFooter( + isExpanded: Boolean, + remainingCount: Int, + onToggle: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle() } + .padding(12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (isExpanded) stringResource(R.string.label_show_less) else stringResource( + R.string.label_show_2d_more, + remainingCount + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = if (isExpanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + } +} + +// --- Unchanged Layout Helpers (Same as previous step) --- + +@Composable +fun DictionaryTableHeaderRow( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + content = content + ) +} + +@Composable +fun DictionaryTableRow( + modifier: Modifier = Modifier, + showDivider: Boolean = true, + content: @Composable RowScope.() -> Unit +) { + if (showDivider) { + HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + Row( + modifier = modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + content = content + ) +} + +@Composable +fun RowScope.DictionaryTableHeaderCell( + text: String, + modifier: Modifier = Modifier, + weight: Float = 1f +) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier.weight(weight) + ) +} + +@Composable +fun RowScope.DictionaryTableCell( + text: String, + modifier: Modifier = Modifier, + weight: Float = 1f, + isItalic: Boolean = false +) { + val baseStyle = MaterialTheme.typography.bodyMedium + val finalStyle = if (isItalic) baseStyle.copy(fontStyle = FontStyle.Italic) else baseStyle + + AutoResizeSingleLineText( + text = text, + style = finalStyle, + modifier = modifier.weight(weight), + color = MaterialTheme.colorScheme.onSurface + ) +} + + +// Simple data class for preview purposes +data class WordEntry(val word: String, val translation: String, val type: String) + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun DictionaryTableCardCollapsiblePreview() { + // Define column weights + val w1 = .35f; val w2 = .35f; val w3 = .3f + + // Create a list with 6 items (Header + 6 = 7 lines total -> Should Collapse) + val entries = listOf( + WordEntry("hello", "hola", "noun"), + WordEntry("world", "mundo", "noun"), + WordEntry("cat", "gato", "noun"), // Item 3 (Line 4) + WordEntry("dog", "perro", "noun"), // Item 4 (Line 5) -> Hidden + WordEntry("red", "rojo", "adj"), + WordEntry("blue", "azul", "adj") + ) + + Column(modifier = Modifier.padding(16.dp)) { + Text("Collapsible Table (Limit: 4 lines)", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + + DictionaryTableCard( + items = entries, + maxRowsBeforeCollapse = 7, // Header + items + headerContent = { + DictionaryTableHeaderCell("Word", weight = w1) + DictionaryTableHeaderCell("Translation", weight = w2) + DictionaryTableHeaderCell("Type", weight = w3) + } + ) { item -> + // This block runs for every item in the list + DictionaryTableCell(item.word, weight = w1) + DictionaryTableCell(item.translation, weight = w2) + DictionaryTableCell(item.type, weight = w3, isItalic = true) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/EtymologyResultScreen.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/EtymologyResultScreen.kt new file mode 100644 index 0000000..07caac2 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/EtymologyResultScreen.kt @@ -0,0 +1,268 @@ +package eu.gaudian.translator.view.dictionary + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.EtymologyData +import eu.gaudian.translator.model.EtymologyStep +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.RelatedWord +import eu.gaudian.translator.utils.TextToSpeechHelper +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.SettingsViewModel +import kotlinx.coroutines.launch + +@Composable +fun EtymologyResultScreen( + navController: NavController, + word: String, + languageCode: Int, +) { + val activity = LocalContext.current.findActivity() + val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity) + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val uiState by dictionaryViewModel.uiState.collectAsState() + + var etymologyData by remember { mutableStateOf(null) } + var language by remember { mutableStateOf(null) } + var isTtsAvailable by remember { mutableStateOf(false) } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + LaunchedEffect(word, languageCode) { + language = languageViewModel.getLanguageById(languageCode) + isTtsAvailable = TextToSpeechHelper.isPlayable(context, language) + + // Fetch etymology data + dictionaryViewModel.fetchEtymology(word, language!!) + } + + LaunchedEffect(uiState) { + if (!uiState.isLoading) { + etymologyData = dictionaryViewModel.etymologyData.value + } + } + + DisposableEffect(Unit) { + onDispose { + // Clear etymology data when leaving the screen + } + } + + AppScaffold( + topBar = { + AppTopAppBar( + title = { + Column { + Text( + text = word, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + language?.name?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + ) + } + } + }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + }, + actions = { + etymologyData?.let { data -> + if (isTtsAvailable) { + IconButton(onClick = { + scope.launch { + val voice = settingsViewModel.getTtsVoiceForLanguage((language?.code ?: 1).toString(), language?.region ?: "") + TextToSpeechHelper.speakOut(context, data.word, language!!, voice) + } + }) { + Icon(AppIcons.Speech, contentDescription = stringResource(R.string.cd_text_to_speech)) + } + } + IconButton( + onClick = { + // Refresh etymology + scope.launch { + dictionaryViewModel.fetchEtymology(word, language!!) + } + }, + enabled = !uiState.isLoading + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.5.dp + ) + } else { + Icon(AppIcons.Refresh, contentDescription = stringResource(R.string.cd_re_generate_definition)) + } + } + } + } + ) + } + ) { paddingValues -> + etymologyData?.let { data -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 20.dp) + .verticalScroll(rememberScrollState()) + ) { + EtymologyResult(data = data) + } + } ?: run { + // Loading state + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + strokeWidth = 4.dp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.text_loading_3d), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } +} + +@Composable +fun EtymologyResult(data: EtymologyData) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.origin_of_, data.word), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + data.timeline.reversed().forEach { step -> + AppCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "${step.year} - ${step.language}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = step.description, style = MaterialTheme.typography.bodyLarge) + } + } + } + if (data.relatedWords.isNotEmpty()) { + Text( + text = stringResource(R.string.label_related_words), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp), + color = MaterialTheme.colorScheme.primary + ) + data.relatedWords.forEach { related -> + AppCard( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = related.word, fontWeight = FontWeight.SemiBold) + Text(text = related.language) + } + } + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun EtymologyResultPreview() { + val sampleEtymologyData = EtymologyData( + word = "example", + timeline = listOf( + EtymologyStep("1600s", "Latin", "From Latin 'exemplum'"), + EtymologyStep("1800s", "English", "Adopted into English") + ), + relatedWords = listOf( + RelatedWord("Latin", "exemplum"), + RelatedWord("French", "exemple") + ) + ) + EtymologyResult(data = sampleEtymologyData) +} + +@Suppress("HardCodedStringLiteral") +@Composable +@Preview +fun EtymologyResultScreenPreview() { + EtymologyResultScreen( + navController = rememberNavController(), + word = "example", + languageCode = 1 + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/EtymologyScreen.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/EtymologyScreen.kt new file mode 100644 index 0000000..a0bf8da --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/EtymologyScreen.kt @@ -0,0 +1,108 @@ +package eu.gaudian.translator.view.dictionary + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppOutlinedCard +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel + +@Composable +fun EtymologyScreen( + navController: NavController, + dictionaryViewModel: DictionaryViewModel, + languageViewModel: LanguageViewModel, +) { + var searchQuery by remember { mutableStateOf("") } + val uiState by dictionaryViewModel.uiState.collectAsState() + + Column(modifier = Modifier.padding(16.dp)) { + AppOutlinedCard { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AppTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { Text(stringResource(R.string.search_for_a_word_s_origin)) }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + DictionaryLanguageDropDown( + languageViewModel = languageViewModel, + modifier = Modifier.weight(1f) + ) + AppButton( + onClick = { + val language = languageViewModel.selectedDictionaryLanguage.value + if (searchQuery.isNotBlank() && language != null) { + @Suppress("HardCodedStringLiteral") + navController.navigate("etymology_result/${searchQuery}/${language.code}") + } + }, + enabled = searchQuery.isNotBlank() && !uiState.isLoading, + modifier = Modifier.padding(start = 8.dp) + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text(stringResource(R.string.cd_search)) + } + } + } + } + } + + + } +} + +@Preview +@Composable +fun EtymologyScreenPreview() { + val activity = LocalContext.current.findActivity() + val dictionaryViewModel: DictionaryViewModel = viewModel() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + EtymologyScreen( + navController = rememberNavController(), + dictionaryViewModel = dictionaryViewModel, + languageViewModel = languageViewModel + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/LocalWordEntryComponents.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/LocalWordEntryComponents.kt new file mode 100644 index 0000000..95b5d08 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/LocalWordEntryComponents.kt @@ -0,0 +1,1318 @@ +package eu.gaudian.translator.view.dictionary + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.grammar.DictionaryEntryData +import eu.gaudian.translator.model.grammar.EtymologyData +import eu.gaudian.translator.model.grammar.GrammaticalFeaturesData +import eu.gaudian.translator.model.grammar.Inflection +import eu.gaudian.translator.model.grammar.LanguageConfig +import eu.gaudian.translator.model.grammar.SenseData +import eu.gaudian.translator.model.grammar.UnifiedMorphology +import eu.gaudian.translator.model.grammar.UnifiedMorphologyParser +import eu.gaudian.translator.utils.dictionary.LocalDictionaryWordInfo +import eu.gaudian.translator.utils.dictionary.PartOfSpeechTranslator +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AutoResizeSingleLineText +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import eu.gaudian.translator.viewmodel.LanguageConfigViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +// Main component that orchestrates all the sections +@Composable +fun RichLocalEntryDisplay( + word: String, + langCode: String, + pos: String?, + data: DictionaryEntryData, + localInfo: LocalDictionaryWordInfo? = null, + onWordClick: ((String, String) -> Unit)? = null, + dictionaryViewModel: DictionaryViewModel, + allLanguages: List, + expandable: Boolean = true, // Enables whole-entry collapsing + initiallyExpanded: Boolean = true // Default state +) { + val languageConfigViewModel: LanguageConfigViewModel = viewModel() + val allConfigs by languageConfigViewModel.configs.collectAsState() + val languageConfig = allConfigs[langCode] + + // State for the whole entry + var isEntryExpanded by remember { mutableStateOf(initiallyExpanded) } + + // Cache to store which words are clickable (exist in DB) + val clickableWordsCache = remember { mutableStateMapOf() } + + // Asynchronously check related words/translations + LaunchedEffect(localInfo, data) { + withContext(Dispatchers.IO) { + val wordsToCheck = mutableListOf>() + // 1. Gather Translations + localInfo?.translations?.forEach { wordsToCheck.add(it.languageCode to it.word) } + ?: data.translations.forEach { wordsToCheck.add(it.languageCode to it.word) } + // 2. Gather Relations + localInfo?.relatedWords?.forEach { wordsToCheck.add(langCode to it.word) } + ?: data.allRelatedWords.forEach { wordsToCheck.add(langCode to it.word) } + // 3. Batch Check + wordsToCheck.distinct().forEach { (lCode, w) -> + val targetLang = allLanguages.firstOrNull { it.code.equals(lCode, ignoreCase = true) } + val exists = if (targetLang != null) { + dictionaryViewModel.hasWordInDictionary(w, targetLang.code.lowercase()) + } else false + clickableWordsCache["$lCode:$w"] = exists + } + } + } + + val isWordClickableAsync: (String, String) -> Boolean = { lCode, w -> + clickableWordsCache["$lCode:$w"] == true + } + + // Outer Container + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)) + ) { + + // --- PRIMARY BLOCK: Header + Definitions --- + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + + // Header is always visible. It acts as the toggle if expandable is true. + LocalEntryHeaderSection( + word = word, + pos = pos, + data = data, + isExpanded = isEntryExpanded, + onHeaderClick = if (expandable) { { isEntryExpanded = !isEntryExpanded } } else null, + // Visual connection logic: + // If Expanded AND has senses: Flat bottom to connect to senses. + // Else (Collapsed OR no senses): Rounded bottom (standalone card). + shape = if (isEntryExpanded && data.senses.isNotEmpty()) RoundedCornerShape( + topStart = 24.dp, + topEnd = 24.dp, + bottomStart = 4.dp, + bottomEnd = 4.dp + ) else RoundedCornerShape(24.dp) + ) + + // Definitions appear only when expanded + if (isEntryExpanded && data.senses.isNotEmpty()) { + LocalEntrySensesSection( + data = data + ) + } + } + + // --- SECONDARY BLOCKS (Metadata) --- + // These only appear when the entry is expanded + if (isEntryExpanded) { + // 1. Grammar / Forms + if (languageConfig != null) { + LocalEntryGrammarSection( + word = word, + langCode = langCode, + pos = pos, + data = data, + languageConfig = languageConfig + ) + } + + // 2. Etymology + LocalEntryEtymologySection(data = data) + + // 3. Translations + LocalEntryTranslationsSection( + localInfo = localInfo, + data = data, + allLanguages = allLanguages, + isWordClickableAsync = isWordClickableAsync, + onWordClick = onWordClick + ) + + // 4. Relations + LocalEntryRelationsSection( + localInfo = localInfo, + data = data, + langCode = langCode, + allLanguages = allLanguages, + isWordClickableAsync = isWordClickableAsync, + onWordClick = onWordClick + ) + } + } +} + +/** + * A uniform expandable container for sections like Grammar, Etymology, etc. + */ +@Composable +fun ExpandableSectionCard( + title: String, + icon: ImageVector? = null, + initiallyExpanded: Boolean = false, + content: @Composable () -> Unit +) { + var isExpanded by remember { mutableStateOf(initiallyExpanded) } + + Card( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = spring(stiffness = Spring.StiffnessMediumLow)), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + ), + ) { + Column { + // Header Row + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface + ) + } + + Icon( + imageVector = if (isExpanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown, + contentDescription = if (isExpanded) stringResource(R.string.label_collapse) else stringResource(R.string.label_expand), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Content + if (isExpanded) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 16.dp) + ) + Box(modifier = Modifier.padding(16.dp)) { + content() + } + } + } + } +} + +@Composable +fun LocalEntryHeaderSection( + word: String, + pos: String?, + data: DictionaryEntryData, + shape: Shape = RoundedCornerShape(24.dp), + isExpanded: Boolean = true, + onHeaderClick: (() -> Unit)? = null +) { + Card( + modifier = Modifier + .fillMaxWidth() + .then( + if (onHeaderClick != null) Modifier.clickable { onHeaderClick() } else Modifier + ), + shape = shape, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp) + ) + ) { + Column( + modifier = Modifier + .padding(20.dp) + .fillMaxWidth() + ) { + // Header Row: Word, POS, and Expand Arrow + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top // Align top to handle multiline words nicely + ) { + // Word and POS Column + Column(modifier = Modifier.weight(1f)) { + AutoResizeSingleLineText( + text = word, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = (-0.5).sp, + ) + ) + + if (!pos.isNullOrBlank()) { + Text( + text = PartOfSpeechTranslator.translatePos(pos), + style = MaterialTheme.typography.titleLarge, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f) + ) + } + } + + // Expand Arrow (only if clickable) + if (onHeaderClick != null) { + Icon( + imageVector = if (isExpanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown, + contentDescription = if (isExpanded) stringResource(R.string.label_collapse) else stringResource(R.string.label_expand), + modifier = Modifier + .padding(start = 16.dp) + .size(28.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 12.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant + ) + + // Phonetics + val phonetics = data.phonetics + if (phonetics != null && (phonetics.ipa.isNotEmpty() || phonetics.variations.isNotEmpty())) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = AppIcons.Speech, + contentDescription = stringResource(R.string.label_pronunciation), + modifier = Modifier + .padding(top = 6.dp, end = 12.dp) + .size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + phonetics.ipa.forEach { ipaString -> + PhoneticChip(ipa = ipaString, tags = emptyList()) + } + phonetics.variations.forEach { variation -> + PhoneticChip(ipa = variation.ipa, tags = variation.rawTags) + } + } + } + } + + // Hyphenation + val hyphenation = data.hyphenation + if (hyphenation.isNotEmpty()) { + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Light)) { + append(stringResource(R.string.label_grammar_hyphenation) + " ") + } + append(hyphenation.joinToString(" • ")) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp) + ) + } + + // Grammatical Tags + val tagLabels = data.allTags + if (tagLabels.isNotEmpty()) { + FlowRow( + modifier = Modifier.padding(top = 16.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + tagLabels.forEach { tag -> + SuggestionChip( + onClick = { /* No-op */ }, + label = { + Text( + tag, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium + ) + }, + shape = CircleShape, + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ), + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = Color.Transparent + ), + ) + } + } + } + } + } +} + +// 2. Senses Section +@Composable +private fun LocalEntrySensesSection( + data: DictionaryEntryData +) { + val senses = data.senses + if (senses.isEmpty()) return + + Card( + shape = RoundedCornerShape( + topStart = 4.dp, + topEnd = 4.dp, + bottomStart = 24.dp, + bottomEnd = 24.dp + ), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp)) { + Text( + text = stringResource(R.string.label_grammar_meanings), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 12.dp) + ) + + senses.forEachIndexed { index, senseData -> + SenseDisplay(index = index, senseData = senseData) + if (index < senses.lastIndex) { + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } +} + +// 3. Grammar Section +@Composable +fun GenericGridTable(grid: UnifiedMorphology.Grid) { + if (grid.rowLabels.isEmpty() || grid.colLabels.isEmpty()) return + + Column( + modifier = Modifier + .fillMaxWidth() + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + ) { + // Header Row + Row( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Title in the top-left corner + AutoResizeSingleLineText( + text = grid.title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.secondary, + // CHANGED: Increased weight from 0.35f to 0.8f to give labels more space + modifier = Modifier.weight(0.8f) + ) + + grid.colLabels.forEach { colKey -> + Text( + text = colKey.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + } + } + + HorizontalDivider(thickness = 0.5.dp, color = MaterialTheme.colorScheme.outlineVariant) + + // Data Rows + grid.rowLabels.forEachIndexed { index, rowKey -> + Row( + modifier = Modifier + .fillMaxWidth() + .background( + if (index % 2 == 1) MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.1f + ) else Color.Transparent + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Row Label (e.g. Nominative, Genitive) + AutoResizeSingleLineText( + text = rowKey.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.secondary, + // CHANGED: Must match the header weight (0.8f) to align columns + modifier = Modifier.weight(0.8f) + ) + + // Cells + val hasSingleColumn = grid.colLabels.size == 1 + grid.colLabels.forEach { colKey -> + val cellKey = if (hasSingleColumn) rowKey else "$rowKey|$colKey" + AutoResizeSingleLineText( + text = grid.cells[cellKey] ?: "—", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + } + } + } + } +} + +@Composable +private fun LocalEntryGrammarSection( + word: String, + langCode: String, + pos: String?, + data: DictionaryEntryData, + languageConfig: LanguageConfig +) { + val context = LocalContext.current + val morphology = remember(langCode, pos, data, languageConfig) { + UnifiedMorphologyParser.parse( + entry = data, + lemma = word, + pos = pos, + langCode = langCode, + config = languageConfig, + context = context + ) + } + + if (morphology != null) { + ExpandableSectionCard( + title = stringResource(R.string.label_grammar_inflections), + icon = AppIcons.MenuBook + ) { + when (morphology) { + is UnifiedMorphology.Verb -> { + val aux = morphology.auxiliary + if (aux != null) { + Text( + text = stringResource(R.string.label_grammar_auxiliary, aux), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + } + UnifiedVerbTable(verbData = morphology) + } + + is UnifiedMorphology.Grid -> { + // FIX: Removed the standalone Text(text = morphology.title) + // The title is now handled inside GenericGridTable + GenericGridTable(grid = morphology) + } + + is UnifiedMorphology.ListForms -> { + InflectionTable(inflections = morphology.forms) + } + } + } + } +} + +// 4. Etymology Section +@Composable +private fun LocalEntryEtymologySection( + data: DictionaryEntryData +) { + val etymologyList = data.etymology.texts + + if (etymologyList.isNotEmpty()) { + ExpandableSectionCard( + title = stringResource(R.string.label_etymology), + icon = AppIcons.History + ) { + SelectionContainer { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + etymologyList.forEach { rawText -> + val styledText = rememberEtymologyStyling(rawText) + + Text( + text = styledText, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Serif, + lineHeight = 24.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + } + } + } +} + +// 5. Translations Section +@Composable +private fun LocalEntryTranslationsSection( + localInfo: LocalDictionaryWordInfo?, + data: DictionaryEntryData, + allLanguages: List, + isWordClickableAsync: (String, String) -> Boolean, + onWordClick: ((String, String) -> Unit)? +) { + val hasTranslations = !localInfo?.translations.isNullOrEmpty() || data.translations.isNotEmpty() + if (!hasTranslations) return + + ExpandableSectionCard( + title = stringResource(R.string.label_translations), + icon = AppIcons.Translate + ) { + val infoTranslations = localInfo?.translations + + if (!infoTranslations.isNullOrEmpty()) { + val grouped = infoTranslations.groupBy { it.languageCode } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + grouped.forEach { (lang, list) -> + Column { + Text( + text = getLanguageName(lang, allLanguages), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp) + ) + CollapsibleFlowRow(items = list, maxLinesToShow = 4) { item -> + TranslationChip( + label = buildString { + append(item.word) + item.sense?.takeIf { it.isNotBlank() }?.let { append(" ($it)") } + }, + isClickable = onWordClick != null && isWordClickableAsync(item.languageCode, item.word), + onClick = { onWordClick?.invoke(item.languageCode, item.word) } + ) + } + } + } + } + } else { + // Fallback to data.translations + val grouped = data.translations.groupBy { it.languageCode } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + grouped.forEach { (lang, list) -> + Row(verticalAlignment = Alignment.Top) { + Text( + text = "${getLanguageName(lang, allLanguages)}: ", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(90.dp) + ) + val words = list.joinToString(", ") { item -> + val w = item.word + val sense = item.sense + if (!sense.isNullOrBlank()) "$w ($sense)" else w + } + + SelectionContainer { + Text( + text = words, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } +} + +// 6. Relations Section +@Composable +private fun LocalEntryRelationsSection( + localInfo: LocalDictionaryWordInfo?, + data: DictionaryEntryData, + langCode: String, + allLanguages: List, + isWordClickableAsync: (String, String) -> Boolean, + onWordClick: ((String, String) -> Unit)? +) { + val hasRelations = !localInfo?.relatedWords.isNullOrEmpty() || data.relations.isNotEmpty() + if (!hasRelations) return + + ExpandableSectionCard( + title = stringResource(R.string.label_related_words), + icon = AppIcons.Share + ) { + val relationsInfo = localInfo?.relatedWords + if (!relationsInfo.isNullOrEmpty()) { + val grouped = relationsInfo.groupBy { it.relationType } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + grouped.toSortedMap().forEach { (relationType, list) -> + Column { + Text( + text = relationType.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(bottom = 4.dp) + ) + CollapsibleFlowRow(items = list, maxLinesToShow = 3) { item -> + TranslationChip( + label = buildString { + append(item.word) + item.senseIndex?.takeIf { it.isNotBlank() }?.let { append(" ($it)") } + }, + isClickable = onWordClick != null && isWordClickableAsync(langCode, item.word), + onClick = { onWordClick?.invoke(langCode, item.word) } + ) + } + } + } + } + } else { + // Fallback for relations from data + val relations = data.relations + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + relations.entries.sortedBy { it.key }.forEach { (relationType, list) -> + if (list.isNotEmpty()) { + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.padding(vertical = 2.dp) + ) { + Text( + text = "${getLanguageName(relationType, allLanguages)}: ", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier + .width(96.dp) + .padding(end = 4.dp) + ) + val words = list.joinToString(", ") { item -> + val w = item.word + val senseIndex = item.senseIndex + if (!senseIndex.isNullOrBlank()) "$w ($senseIndex)" else w + } + SelectionContainer { + Text( + text = words, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } +} + + +// --- HELPERS AND TABLES --- + + + +@Composable +fun UnifiedVerbTable(verbData: UnifiedMorphology.Verb) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + verbData.tenses.forEach { (tenseLabel, forms) -> + if (forms.isNotEmpty()) { + Column { + Text( + text = tenseLabel, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Column( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f)) + ) { + forms.indices.forEach { i -> + val pronoun = verbData.pronouns.getOrNull(i) ?: "" + val form = forms[i] + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = pronoun, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.width(80.dp) + ) + Text( + text = form, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + if (i < forms.lastIndex) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + } + } + } + } + } + } + } +} + +@Composable +fun InflectionTable(inflections: List) { + val visibleRows = inflections.filter { it.form.isNotBlank() } + if (visibleRows.isEmpty()) return + + Column( + modifier = Modifier + .fillMaxWidth() + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + RoundedCornerShape(8.dp) + ) + .clip(RoundedCornerShape(8.dp)) + ) { + visibleRows.forEachIndexed { index, item -> + Row( + modifier = Modifier + .fillMaxWidth() + .background( + if (index % 2 == 1) MaterialTheme.colorScheme.surfaceVariant.copy( + alpha = 0.1f + ) else Color.Transparent + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = item.form, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Text( + text = item.tags.joinToString(", "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(2f) + ) + } + if (index < visibleRows.lastIndex) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + } + } + } +} + +@Composable +fun SenseDisplay(index: Int, senseData: SenseData) { + // New feature: Collapsible examples + // If examples exist, we show a toggle instead of flooding the screen + val hasExamples = senseData.examples.isNotEmpty() + var areExamplesExpanded by remember { mutableStateOf(false) } + + Column(modifier = Modifier + .fillMaxWidth() + .animateContentSize()) { + Row(verticalAlignment = Alignment.Top) { + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.width(28.dp) + ) + + Column { + senseData.glosses.forEach { gloss -> + SelectionContainer { + Text( + text = gloss, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + // Tags inline + if (senseData.tags.isNotEmpty()) { + FlowRow( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + senseData.tags.forEach { tag -> + Text( + text = tag.uppercase(), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), + color = MaterialTheme.colorScheme.secondary + ) + } + } + } + + // Collapsible Examples Section + if (hasExamples) { + Spacer(modifier = Modifier.height(6.dp)) + + // Toggle Button + Row( + modifier = Modifier + .clickable { areExamplesExpanded = !areExamplesExpanded } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (areExamplesExpanded) stringResource(R.string.label_hide_examples) + else stringResource(R.string.label_show_examples), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + Icon( + imageVector = if (areExamplesExpanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp) + ) + } + + // Actual Examples List + if (areExamplesExpanded) { + Column { + senseData.examples.forEach { ex -> + Row(modifier = Modifier.padding(vertical = 2.dp)) { + Box( + modifier = Modifier + .padding(top = 6.dp, end = 8.dp) + .size(4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)) + ) + Text( + text = ex, + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun TranslationChip( + label: String, + isClickable: Boolean, + onClick: () -> Unit +) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = if (isClickable) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .border( + 1.dp, + if (isClickable) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.outlineVariant, + RoundedCornerShape(8.dp) + ) + .background( + if (isClickable) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, + RoundedCornerShape(8.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + .then(if (isClickable) Modifier.clickable { onClick() } else Modifier) + ) +} + +@Composable +fun CollapsibleFlowRow( + items: List, + maxLinesToShow: Int, + modifier: Modifier = Modifier, + horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(8.dp), + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp), + itemContent: @Composable (T) -> Unit +) { + var isExpanded by remember { mutableStateOf(false) } + // Simple heuristic + val shouldShowCollapseButton = items.size > maxLinesToShow * 3 + + Column(modifier = modifier) { + FlowRow( + horizontalArrangement = horizontalArrangement, + verticalArrangement = verticalArrangement + ) { + val itemsToShow = if (shouldShowCollapseButton && !isExpanded) { + items.take(maxLinesToShow * 3) + } else { + items + } + itemsToShow.forEach { item -> itemContent(item) } + } + + if (shouldShowCollapseButton) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + .clickable { isExpanded = !isExpanded }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (isExpanded) stringResource(R.string.label_show_less) else stringResource(R.string.label_more), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +@Composable +private fun PhoneticChip( + ipa: String, + tags: List, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "[$ipa]", + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + if (tags.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + tags.forEach { tag -> + Text( + text = tag.uppercase(), + style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp, fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .border( + 1.dp, + MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 4.dp, vertical = 2.dp) + ) + } + } + } + } + } +} + +@Composable +private fun rememberEtymologyStyling(text: String): AnnotatedString { + val primaryColor = MaterialTheme.colorScheme.primary + val definitionColor = MaterialTheme.colorScheme.secondary + return remember(text) { + buildAnnotatedString { + @Suppress("HardCodedStringLiteral") val pattern = Regex("(\\^\\(→\\s*\\w+\\))|(‚[^‘]+‘)") + var lastIndex = 0 + pattern.findAll(text).forEach { match -> + append(text.substring(lastIndex, match.range.first)) + val value = match.value + if (value.startsWith("^")) { + val code = value.substringAfter("→").substringBefore(")").trim() + withStyle(SpanStyle(fontSize = 10.sp, color = primaryColor, fontWeight = FontWeight.Bold, baselineShift = BaselineShift.Superscript)) { + append(code.uppercase()) + } + } else { + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = definitionColor, fontWeight = FontWeight.Medium)) { + append(value) + } + } + lastIndex = match.range.last + 1 + } + append(text.substring(lastIndex)) + } + } +} + +private fun getLanguageName(languageCode: String, allLanguages: List): String { + return allLanguages.find { it.code.equals(languageCode, ignoreCase = true) }?.name + ?: languageCode +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +private fun LocalEntryHeaderSectionPreview() { + val testData = DictionaryEntryData( + translations = emptyList(), + relations = emptyMap(), + phonetics = null, + hyphenation = listOf("hel", "lo"), + etymology = EtymologyData(emptyList()), + senses = emptyList(), + grammaticalFeatures = GrammaticalFeaturesData(listOf("noun")), + grammaticalProperties = null, + pronunciation = emptyList(), + inflections = emptyList(), + forms = emptyList() + ) + MaterialTheme { + LocalEntryHeaderSection(word = "hello", pos = "noun", data = testData) + } +} + +// --- PREVIEWS --- + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "1. Header & Senses Combo") +@Composable +private fun HeaderAndSensesPreview() { + val testData = DictionaryEntryData( + translations = emptyList(), + relations = emptyMap(), + phonetics = null, + hyphenation = listOf("Hund"), + etymology = EtymologyData(emptyList()), + senses = listOf( + SenseData( + glosses = listOf("Haustier, dessen Vorfahre der Wolf ist"), + examples = listOf("Der Hund bellt laut.", "Vorsicht vor dem Hund!"), + tags = listOf("Zoologie"), + categories = emptyList(), + topics = emptyList(), + rawTags = emptyList(), + ), + SenseData( + glosses = listOf("gerissener, gemeiner, hinterhÀltiger Mensch"), + examples = emptyList(), + tags = listOf("pejorative", "figurative"), + categories = emptyList(), + topics = emptyList(), + rawTags = emptyList(), + ) + ), + grammaticalFeatures = GrammaticalFeaturesData(listOf("masculine", "singular")), + grammaticalProperties = null, + pronunciation = emptyList(), + inflections = emptyList(), + forms = emptyList(), + ) + + MaterialTheme { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + LocalEntryHeaderSection( + word = "Hund", + pos = "noun", + data = testData, + shape = RoundedCornerShape( + topStart = 24.dp, + topEnd = 24.dp, + bottomStart = 4.dp, + bottomEnd = 4.dp + ) + ) + LocalEntrySensesSection(data = testData) + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "2. Grammar Section (Expanded)") +@Composable +private fun GrammarSectionPreview() { + // Mocking a Grid for Noun Declension + val mockGrid = UnifiedMorphology.Grid( + title = "Declension", + rowLabels = listOf("nominative", "genitive", "dative", "accusative"), + colLabels = listOf("singular", "plural"), + cells = mapOf( + "nominative|singular" to "der Hund", "nominative|plural" to "die Hunde", + "genitive|singular" to "des Hundes", "genitive|plural" to "der Hunde", + "dative|singular" to "dem Hund", "dative|plural" to "den Hunden", + "accusative|singular" to "den Hund", "accusative|plural" to "die Hunde" + ) + ) + + MaterialTheme { + Column(modifier = Modifier.padding(16.dp)) { + ExpandableSectionCard( + title = "Inflections", + icon = AppIcons.MenuBook, + initiallyExpanded = true + ) { + GenericGridTable(grid = mockGrid) + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "3. Etymology Section") +@Composable +private fun EtymologySectionPreview() { + val rawText = "aus ^(→ gmh) hunt < ^(→ ine) *kwon-, daher auch griechisch κύωΜ (kÜōn) (siehe auch zynisch), lateinisch canis (Hund)." + + MaterialTheme { + Column(modifier = Modifier.padding(16.dp)) { + ExpandableSectionCard( + title = "Etymology", + icon = AppIcons.History, + initiallyExpanded = true + ) { + SelectionContainer { + Text( + text = rememberEtymologyStyling(rawText), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Serif, + lineHeight = 24.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "4. Translations Section") +@Composable +private fun TranslationsSectionPreview() { + MaterialTheme { + Column(modifier = Modifier.padding(16.dp)) { + ExpandableSectionCard( + title = "Translations", + icon = AppIcons.Translate, // Ensure this icon exists or use a placeholder + initiallyExpanded = true + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + // English Group + Column { + Text( + text = "English", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp) + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TranslationChip(label = "dog (1)", isClickable = true) {} + TranslationChip(label = "hound (1)", isClickable = true) {} + TranslationChip(label = "scoundrel (2)", isClickable = false) {} + } + } + + // French Group + Column { + Text( + text = "French", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp) + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TranslationChip(label = "chien (1)", isClickable = true) {} + TranslationChip(label = "canaille (2)", isClickable = true) {} + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/LocalWordEntryDisplay.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/LocalWordEntryDisplay.kt new file mode 100644 index 0000000..2b177e0 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/LocalWordEntryDisplay.kt @@ -0,0 +1,103 @@ +package eu.gaudian.translator.view.dictionary + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.repository.DictionaryWordEntry +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.dictionary.LocalDictionaryAccess +import eu.gaudian.translator.utils.formatJsonForDisplay +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import kotlinx.coroutines.launch + +@Composable +fun LocalWordEntryDisplay( + entry: DictionaryWordEntry, + dictionaryViewModel: DictionaryViewModel, + allLanguages: List, + expandable: Boolean = false +) { + // State to hold the parsed data + val structuredData by dictionaryViewModel.getStructuredDictionaryDataState(entry).collectAsState() + val isLoading by dictionaryViewModel.getStructuredDictionaryDataLoading(entry).collectAsState() + val scope = rememberCoroutineScope() + + // TODO: Move `LocalDictionaryAccess.parseWordInfo` to ViewModel. + // It creates `localInfo` which is derived data, should be part of a UI State object. + val localInfo = remember(entry.json) { + try { + LocalDictionaryAccess.parseWordInfo(entry) + } catch (e: Exception) { + @Suppress("HardCodedStringLiteral") + Log.e("LocalDisplay", "Failed to parse LocalDictionaryWordInfo for ${entry.word}: ${e.message}") + null + } + } + + val onWordClick: (String, String) -> Unit = { targetLangCode, targetWord -> + val targetLang = allLanguages.firstOrNull { it.code.equals(targetLangCode, ignoreCase = true) } + scope.launch { + if (targetLang != null && dictionaryViewModel.hasWordInDictionary(targetWord, targetLang.code.lowercase())) { + dictionaryViewModel.performSearch( + query = targetWord, + language = targetLang, + regenerate = false, + useDownloaded = true, + isDrillDown = true + ) + } else { + @Suppress("HardCodedStringLiteral") + Log.d("LocalWordEntryDisplay", "Cannot navigate to word '$targetWord' - not available") + } + } + } + + if (isLoading) { + Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } + } else { + structuredData?.let { data -> + RichLocalEntryDisplay( + word = entry.word, + langCode = entry.langCode, + pos = entry.pos, + data = data, + localInfo = localInfo, + onWordClick = onWordClick, + dictionaryViewModel = dictionaryViewModel, + allLanguages = allLanguages, + expandable = expandable + ) + } ?: run { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.label_raw_data_2d), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error + ) + SelectionContainer { + Text(text = formatJsonForDisplay(entry.json), style = MaterialTheme.typography.bodySmall) + } + } + } + } +} + diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/MainDictionaryScreen.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/MainDictionaryScreen.kt new file mode 100644 index 0000000..ba02cff --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/MainDictionaryScreen.kt @@ -0,0 +1,108 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.dictionary + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.LocalConnectionConfigured +import eu.gaudian.translator.view.NoConnectionScreen +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppTabLayout +import eu.gaudian.translator.view.composable.TabItem +import eu.gaudian.translator.view.settings.SettingsRoutes +import eu.gaudian.translator.viewmodel.CorrectionViewModel +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel + + +@Composable +private fun getDictionaryTabs(): List { + return listOf( + DictionaryTab(stringResource(R.string.label_dictionary), AppIcons.Dictionary), + DictionaryTab(stringResource(R.string.title_corrector), AppIcons.Check) + ) +} +private data class DictionaryTab(override val title: String, override val icon: ImageVector) : + TabItem + +@Composable +fun MainDictionaryScreen( + navController: NavController + ) { + val viewModelStoreOwner = if (LocalInspectionMode.current) { + null + } else { + LocalActivity.current + } + + + val activity = LocalContext.current.findActivity() + + val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity) + val correctionViewModel: CorrectionViewModel = viewModel(viewModelStoreOwner = viewModelStoreOwner as ViewModelStoreOwner) + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val dictionaryTabs = getDictionaryTabs() + + val connectionConfigured = LocalConnectionConfigured.current + + if (!connectionConfigured) { + NoConnectionScreen(onSettingsClick = {navController.navigate(SettingsRoutes.API_KEY)}) + return + } + + var selectedTab by remember { mutableStateOf(dictionaryTabs[0]) } + Column { + AppTabLayout( + tabs = dictionaryTabs, + selectedTab = selectedTab, + onTabSelected = { selectedTab = it } + ) + + when (selectedTab) { + dictionaryTabs[0] -> DictionaryScreen( + navController = navController, + dictionaryViewModel = dictionaryViewModel, + languageViewModel = languageViewModel, + onEntryClick = { entry -> + // Set flag indicating navigation is from external source (not DictionaryResultScreen) + dictionaryViewModel.setNavigatingFromDictionaryResult(false) + navController.navigate("dictionary_result/${entry.id}") + }, + onNavigateToOptions = { + navController.navigate("dictionary_options") + } + ) + dictionaryTabs[1] -> CorrectionScreen( + correctionViewModel = correctionViewModel, + languageViewModel = languageViewModel + ) + } + } +} + +@ThemePreviews +@Composable +fun DictionaryHostScreenPreview() { + val navController = rememberNavController() + + MainDictionaryScreen( + navController = navController, + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/MainDictionaryScreenComponents.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/MainDictionaryScreenComponents.kt new file mode 100644 index 0000000..97cfb7b --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/MainDictionaryScreenComponents.kt @@ -0,0 +1,746 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.dictionary + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.DictionaryEntry +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.communication.FileInfo +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown +import eu.gaudian.translator.view.composable.OptionItemSwitch +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +// Main Dictionary Screen Component +@Composable +fun DictionaryScreenContent( + navController: NavController, + onEntryClick: (DictionaryEntry) -> Unit, + dictionaryViewModel: DictionaryViewModel, + languageViewModel: LanguageViewModel, + onNavigateToOptions: () -> Unit +) { + // Navigation effect + val entryToNavigate by dictionaryViewModel.navigateToEntry.collectAsState() + LaunchedEffect(entryToNavigate) { + entryToNavigate?.let { entry -> + onEntryClick(entry) + dictionaryViewModel.onNavigationDone() + } + } + + LaunchedEffect(Unit) { + dictionaryViewModel.fetchManifest() + } + + var showHistorySheet by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() + var searchQuery by remember { mutableStateOf("") } + val searchHistory by dictionaryViewModel.searchHistoryEntries.collectAsState(initial = emptyList()) + val uiState by dictionaryViewModel.uiState.collectAsState() + val wordOfTheDay by dictionaryViewModel.wordOfTheDay.collectAsState() + val selectedLanguage by languageViewModel.selectedDictionaryLanguage.collectAsState() + var useDownloaded by remember { mutableStateOf(false) } + + // Update local dictionary availability when selected language changes + LaunchedEffect(selectedLanguage) { + selectedLanguage?.let { lang -> + dictionaryViewModel.updateLocalDictAvailability(lang.code.lowercase()) + } + } + + LazyColumn( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + DictionarySearchCard( + searchQuery = searchQuery, + onSearchQueryChange = { searchQuery = it }, + selectedLanguage = selectedLanguage, + languageViewModel = languageViewModel, + dictionaryViewModel = dictionaryViewModel, + useDownloaded = useDownloaded, + onUseDownloadedChange = { useDownloaded = it }, + uiState = uiState, + onNavigateToOptions = onNavigateToOptions, + onShowHistory = { showHistorySheet = true }, + onOriginClick = { + if (searchQuery.isNotBlank() && selectedLanguage != null) { + @Suppress("HardCodedStringLiteral") + navController.navigate("etymology_result/${searchQuery}/${selectedLanguage!!.nameResId}") + } + }, + onDefinitionClick = { + if (searchQuery.isNotBlank() && selectedLanguage != null) { + dictionaryViewModel.performSearch(searchQuery, selectedLanguage!!, useDownloaded = useDownloaded) + } + } + ) + } + + wordOfTheDay?.let { entry -> + item { + WordOfTheDayCard( + entry = entry, + isLoading = uiState.isLoading, + onEntryClick = { onEntryClick(it) }, + onRefresh = dictionaryViewModel::refreshWordOfTheDay + ) + } + } + + item { + DictionaryManagerSection( + dictionaryViewModel = dictionaryViewModel + ) + } + } + + if (showHistorySheet) { + ModalBottomSheet( + onDismissRequest = { showHistorySheet = false }, + sheetState = sheetState + ) { + DictionaryHistorySheetContent( + historyEntries = searchHistory, + onEntryClick = { + onEntryClick(it) + showHistorySheet = false + } + ) + } + } +} + +// --- Component 1: Search Card --- + +@Composable +fun DictionarySearchCard( + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + selectedLanguage: Language?, + languageViewModel: LanguageViewModel, + dictionaryViewModel: DictionaryViewModel, + useDownloaded: Boolean, + onUseDownloadedChange: (Boolean) -> Unit, + uiState: DictionaryViewModel.DictionaryUiState, + onNavigateToOptions: () -> Unit, + onShowHistory: () -> Unit, + onOriginClick: () -> Unit, + onDefinitionClick: () -> Unit +) { + AppCard { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + SearchFieldWithSuggestions( + searchQuery = searchQuery, + selectedLanguage = selectedLanguage, + onSearchQueryChange = onSearchQueryChange, + dictionaryViewModel = dictionaryViewModel + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Using ViewModel's isLocalDictAvailable StateFlow + val isLocalDictAvailable by dictionaryViewModel.isLocalDictAvailable.collectAsState() + + LanguageSelectionRow( + languageViewModel = languageViewModel, + hasDictionary = isLocalDictAvailable, + useDownloaded = useDownloaded, + onUseDownloadedChange = onUseDownloadedChange + ) + + Spacer(modifier = Modifier.height(8.dp)) + + SearchActionRow( + onNavigateToOptions = onNavigateToOptions, + onShowHistory = onShowHistory, + onOriginClick = onOriginClick, + onDefinitionClick = onDefinitionClick, + isSearchEnabled = searchQuery.isNotBlank(), + isLoading = uiState.isLoading + ) + } + } +} + +@Composable +private fun SearchFieldWithSuggestions( + searchQuery: String, + selectedLanguage: Language?, + onSearchQueryChange: (String) -> Unit, + dictionaryViewModel: DictionaryViewModel +) { + var expanded by remember { mutableStateOf(false) } + var suggestions by remember { mutableStateOf(emptyList()) } + val scope = rememberCoroutineScope() + var lastJob by remember { mutableStateOf(null) } + + // Observe suggestions from ViewModel + val vmSuggestions by dictionaryViewModel.suggestions.collectAsState() + LaunchedEffect(vmSuggestions) { + suggestions = vmSuggestions + expanded = suggestions.isNotEmpty() + } + + ExposedDropdownMenuBox( + expanded = expanded && suggestions.isNotEmpty(), + onExpandedChange = { expanded = !expanded } + ) { + AppTextField( + value = searchQuery, + onValueChange = { + onSearchQueryChange(it) + // Using ViewModel's fetchSuggestions with debounce + lastJob?.cancel() + lastJob = scope.launch { + delay(100) + if (searchQuery.length >= 3 && selectedLanguage != null) { + dictionaryViewModel.fetchSuggestions(searchQuery, selectedLanguage.code.lowercase(), 5) + } else { + dictionaryViewModel.clearSuggestions() + } + } + }, + label = { Text(stringResource(R.string.cd_search)) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable), + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) } + ) + if (suggestions.isNotEmpty()) { + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + suggestions.forEach { word -> + DropdownMenuItem( + text = { Text(word) }, + onClick = { + dictionaryViewModel.setNavigatingFromDictionaryResult(false) + if (selectedLanguage != null) { + dictionaryViewModel.performSearch(word, selectedLanguage) + } + } + ) + } + } + } + } +} + +@Composable +private fun LanguageSelectionRow( + languageViewModel: LanguageViewModel, + hasDictionary: Boolean, + useDownloaded: Boolean, + onUseDownloadedChange: (Boolean) -> Unit +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + DictionaryLanguageDropDown( + languageViewModel = languageViewModel, + modifier = Modifier.weight(1f) + ) + } + if (hasDictionary) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OptionItemSwitch( + title = stringResource(R.string.text_use_downloaded_dictionary), + checked = useDownloaded, + onCheckedChange = onUseDownloadedChange, + ) + } + } + } +} + +@Composable +private fun SearchActionRow( + onNavigateToOptions: () -> Unit, + onShowHistory: () -> Unit, + onOriginClick: () -> Unit, + onDefinitionClick: () -> Unit, + isSearchEnabled: Boolean, + isLoading: Boolean +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onNavigateToOptions, modifier = Modifier.size(35.dp)) { + Icon(AppIcons.Settings, contentDescription = stringResource(R.string.label_dictionary_options), tint = MaterialTheme.colorScheme.primary) + } + IconButton(onClick = onShowHistory, modifier = Modifier.size(35.dp)) { + Icon(AppIcons.History, contentDescription = stringResource(R.string.cd_translation_history), tint = MaterialTheme.colorScheme.primary) + } + Spacer(Modifier.weight(1f)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppButton(onClick = onOriginClick, enabled = isSearchEnabled && !isLoading) { + Text(stringResource(R.string.origin)) + } + AppButton(onClick = onDefinitionClick, enabled = isSearchEnabled && !isLoading) { + Text(text = stringResource(R.string.definition), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 2.dp + ) + } + } + } +} + +// --- Component 2: Manager Section --- + +@Composable +fun DictionaryManagerSection( + dictionaryViewModel: DictionaryViewModel, +) { + val manifest by dictionaryViewModel.manifest.collectAsState() + val downloadedDictionaries by dictionaryViewModel.downloadedDictionaries.collectAsState() + val orphanedFiles by dictionaryViewModel.orphanedFiles.collectAsState() + val downloadProgress by dictionaryViewModel.downloadProgress.collectAsState() + val isDownloading by dictionaryViewModel.isDownloading.collectAsState() + + var showDeleteAllDialog by remember { mutableStateOf(false) } + + if (manifest == null) return + + AppCard( + title = stringResource(R.string.label_dictionary_manager), + text = stringResource(R.string.text_dictionary_manager_description), + expandable = true + ) { + Column(modifier = Modifier.fillMaxWidth()) { + if (manifest!!.files.isNotEmpty()) { + DictionaryDownloadList( + files = manifest!!.files, + downloadedDictionaries = downloadedDictionaries, + downloadProgress = downloadProgress, + isDownloading = isDownloading, + dictionaryViewModel = dictionaryViewModel + ) + } + + if (orphanedFiles.isNotEmpty()) { + OrphanedFilesList( + files = orphanedFiles, + dictionaryViewModel = dictionaryViewModel + ) + } + + if ((downloadedDictionaries.isNotEmpty() || orphanedFiles.isNotEmpty())) { + AppButton( + onClick = { showDeleteAllDialog = true }, + enabled = !isDownloading, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp) + ) { + Text(stringResource(R.string.label_delete_all)) + } + } + + if (manifest!!.files.isEmpty() && orphanedFiles.isEmpty()) { + Text( + text = stringResource(R.string.text_no_dictionaries_available), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 16.dp) + ) + } + } + } + + if (showDeleteAllDialog) { + DeleteAllDialog( + onConfirm = { + dictionaryViewModel.deleteAllDictionaries() + showDeleteAllDialog = false + }, + onDismiss = { showDeleteAllDialog = false } + ) + } +} + +@Composable +private fun DictionaryDownloadList( + files: List, + downloadedDictionaries: List, + downloadProgress: Float?, + isDownloading: Boolean, + dictionaryViewModel: DictionaryViewModel +) { + // Local state to track which ID is currently being processed + var currentDownloadingId by remember { mutableStateOf(null) } + + // Reset when progress clears + LaunchedEffect(downloadProgress) { + if (downloadProgress == null) currentDownloadingId = null + } + + Text( + text = stringResource(R.string.text_available_dictionaries), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + + Column { + files.forEach { fileInfo -> + val isDownloaded = downloadedDictionaries.any { it.id == fileInfo.id } + // Using ViewModel's getDictionaryUiItems to get pre-calculated values + val uiItems = dictionaryViewModel.getDictionaryUiItems(files, downloadedDictionaries) + val uiItem = uiItems.find { it.fileInfo.id == fileInfo.id } + + DictionaryManagerItem( + fileInfo = fileInfo, + isDownloaded = uiItem?.isDownloaded ?: isDownloaded, + hasUpdate = uiItem?.hasUpdate ?: false, + size = uiItem?.size ?: fileInfo.assets.sumOf { it.sizeBytes }, + downloadProgress = if (fileInfo.id == currentDownloadingId) downloadProgress else null, + isDownloading = isDownloading, + onDownload = { + currentDownloadingId = fileInfo.id + dictionaryViewModel.downloadDictionary(fileInfo) + }, + onUpdate = { + currentDownloadingId = fileInfo.id + dictionaryViewModel.downloadDictionary(fileInfo) + }, + onDelete = { dictionaryViewModel.deleteDictionary(fileInfo) } + ) + } + } +} + +@Composable +private fun OrphanedFilesList( + files: List, + dictionaryViewModel: DictionaryViewModel +) { + Text( + text = stringResource(R.string.label_orphaned_files), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + Text( + text = stringResource(R.string.text_these_files_exist_locally), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Column { + files.forEach { fileInfo -> + OrphanedFileItem( + fileInfo = fileInfo, + size = dictionaryViewModel.getDictionarySize(fileInfo), + onDelete = { dictionaryViewModel.deleteOrphanedFile(fileInfo) } + ) + } + } +} + +@Composable +fun DeleteAllDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.delete_all_dictionaries_title)) }, + text = { Text(stringResource(R.string.delete_all_dictionaries_confirmation)) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(android.R.string.ok)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + } + ) +} + +// --- Helper Items --- + +@Composable +fun DictionaryManagerItem( + fileInfo: FileInfo, + isDownloaded: Boolean, + hasUpdate: Boolean, + size: Long, + downloadProgress: Float?, + isDownloading: Boolean, + onDownload: () -> Unit, + onUpdate: () -> Unit, + onDelete: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(fileInfo.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(fileInfo.description, style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.label_version_2d, fileInfo.version), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.label_size_2d_mb, size / 1024 / 1024), style = MaterialTheme.typography.bodySmall) + + if (downloadProgress != null) { + LinearProgressIndicator( + progress = { downloadProgress }, + modifier = Modifier.fillMaxWidth(), + color = ProgressIndicatorDefaults.linearColor, + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (!isDownloaded) { + AppButton(onClick = onDownload, enabled = !isDownloading) { + Text(stringResource(R.string.label_download)) + } + } else { + if (hasUpdate) { + AppOutlinedButton(onClick = onUpdate, enabled = !isDownloading) { + Text(stringResource(R.string.label_update)) + } + } + AppOutlinedButton(onClick = onDelete, enabled = !isDownloading, borderColor = MaterialTheme.colorScheme.error) { + Text(stringResource(R.string.label_delete)) + } + } + } + } + } + } +} + +@Composable +fun OrphanedFileItem(fileInfo: FileInfo, size: Long, onDelete: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Column(modifier = Modifier.padding(16.dp)) { + Text(fileInfo.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(fileInfo.description, style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.label_version_2d, fileInfo.version), style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.label_size_2d_mb, size / 1024 / 1024), style = MaterialTheme.typography.bodySmall) + AppButton(onClick = onDelete) { Text(stringResource(R.string.label_delete)) } + } + } +} + +@Composable +fun WordOfTheDayCard( + entry: DictionaryEntry, + isLoading: Boolean, + onEntryClick: (DictionaryEntry) -> Unit, + onRefresh: () -> Unit, + modifier: Modifier = Modifier +) { + AppCard(modifier = modifier.fillMaxWidth().clickable { onEntryClick(entry) }) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.text_word_of_the_day), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + IconButton(onClick = onRefresh, enabled = !isLoading) { + Icon(AppIcons.Refresh, contentDescription = stringResource(R.string.refresh_word_of_the_day)) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "${entry.word}: ${entry.definition.firstOrNull()?.content ?: ""}") + } + } +} + +@Composable +fun DictionaryHistorySheetContent( + historyEntries: List, + onEntryClick: (DictionaryEntry) -> Unit +) { + Column(modifier = Modifier.padding(16.dp)) { + Text(stringResource(R.string.text_search_history), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp)) + LazyColumn { + items(historyEntries) { entry -> + DictionaryHistoryItem(entry = entry, onEntryClick = onEntryClick) + } + } + } +} + +@Composable +fun DictionaryHistoryItem(entry: DictionaryEntry, onEntryClick: (DictionaryEntry) -> Unit) { + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), onClick = { onEntryClick(entry) }) { + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text(text = entry.word) + Text(text = entry.languageName, style = MaterialTheme.typography.bodySmall) + } + } +} + + +@Suppress("HardCodedStringLiteral") +@Composable +@androidx.compose.ui.tooling.preview.Preview +fun DictionarySearchCardPreview() { + // Preview with mock values - ViewModels are null for preview + Box(modifier = Modifier.padding(16.dp)) { + Text("DictionarySearchCard Preview") + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +@androidx.compose.ui.tooling.preview.Preview +fun LanguageSelectionRowPreview() { + // Preview with mock values + Box(modifier = Modifier.padding(16.dp)) { + Text("LanguageSelectionRow Preview") + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +@androidx.compose.ui.tooling.preview.Preview +fun SearchActionRowPreview() { + // Preview with mock values + Box(modifier = Modifier.padding(16.dp)) { + Text("SearchActionRow Preview") + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +@androidx.compose.ui.tooling.preview.Preview +fun DictionaryManagerItemPreview() { + val mockFileInfo = FileInfo( + id = "test_id", + name = "German Dictionary", + description = "German to English dictionary", + version = "1.0.0", + assets = emptyList() + ) + DictionaryManagerItem( + fileInfo = mockFileInfo, + isDownloaded = false, + hasUpdate = false, + size = 1024 * 1024 * 50, + downloadProgress = null, + isDownloading = false, + onDownload = {}, + onUpdate = {}, + onDelete = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Composable +@androidx.compose.ui.tooling.preview.Preview +fun WordOfTheDayCardPreview() { + val mockEntry = DictionaryEntry( + id = 1, + word = "Apfel", + definition = emptyList(), + languageCode = 1, + languageName = "German" + ) + WordOfTheDayCard( + entry = mockEntry, + isLoading = false, + onEntryClick = {}, + onRefresh = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Composable +@androidx.compose.ui.tooling.preview.Preview +fun DictionaryHistoryItemPreview() { + val mockEntry = DictionaryEntry( + id = 1, + word = "Hello", + definition = emptyList(), + languageCode = 1, + languageName = "English" + ) + DictionaryHistoryItem(entry = mockEntry, onEntryClick = {}) +} + +@Suppress("HardCodedStringLiteral") +@Composable +@androidx.compose.ui.tooling.preview.Preview +fun DictionaryHistorySheetContentPreview() { + val mockEntries = listOf( + DictionaryEntry(id = 1, word = "Hello", definition = emptyList(), languageCode = 1, languageName = "English"), + DictionaryEntry(id = 2, word = "Guten Tag", definition = emptyList(), languageCode = 1, languageName = "German") + ) + DictionaryHistorySheetContent(historyEntries = mockEntries, onEntryClick = {}) +} diff --git a/app/src/main/java/eu/gaudian/translator/view/dictionary/VerbTable.kt b/app/src/main/java/eu/gaudian/translator/view/dictionary/VerbTable.kt new file mode 100644 index 0000000..1fd9d97 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dictionary/VerbTable.kt @@ -0,0 +1,189 @@ +package eu.gaudian.translator.view.dictionary + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.grammar.VerbConjugation +import eu.gaudian.translator.view.composable.AppIcons + +@Composable +fun VerbTable( + conjugation: VerbConjugation, + modifier: Modifier = Modifier +) { + val tenses = conjugation.conjugations.keys.toList() + val expandedStates = remember { + mutableStateMapOf().apply { + tenses.firstOrNull()?.let { this[it] = true } + } + } + + Card( + modifier = modifier.fillMaxWidth(), + shape = UnifiedCornerShape, // Using the shared shape + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ), + elevation = CardDefaults.cardElevation(0.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Title + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(bottom = 12.dp) + ) { + Icon( + imageVector = AppIcons.Check, // Or a generic Verb icon + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 8.dp).width(18.dp) + ) + Text( + text = stringResource(R.string.label_conjugation, conjugation.infinitive), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + + tenses.forEach { tense -> + val forms = conjugation.conjugations[tense] ?: emptyList() + val isExpanded = expandedStates[tense] == true + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp) + .clip(RoundedCornerShape(8.dp)) + .background(if(isExpanded) MaterialTheme.colorScheme.surface.copy(alpha=0.5f) else androidx.compose.ui.graphics.Color.Transparent) + ) { + // Expandable tense header + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expandedStates[tense] = !isExpanded } + .padding(horizontal = 8.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = tense, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if(isExpanded) FontWeight.Bold else FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Icon( + imageVector = if (isExpanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + + // Conjugation forms + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + val count = maxOf(conjugation.pronouns.size, forms.size) + for (i in 0 until count) { + val person = conjugation.pronouns.getOrNull(i) ?: "" + val form = forms.getOrNull(i) ?: "" + + if (form.isNotBlank()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (person.isNotBlank()) { + Text( + text = person, + modifier = Modifier.width(100.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary + ) + } + Text( + text = form, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } + } + } + if (tense != tenses.last()) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + modifier = Modifier.padding(horizontal = 8.dp) + ) + } + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun VerbTablePreview() { + val sampleConjugation = VerbConjugation( + infinitive = "être", + conjugations = mapOf( + "Présent" to listOf("suis", "es", "est", "sommes", "êtes", "sont"), + "Imparfait" to listOf("étais", "étais", "était", "étions", "étiez", "étaient") + ) + ) + VerbTable(conjugation = sampleConjugation) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun VerbTableGermanPreview() { + val sampleConjugation = VerbConjugation( + infinitive = "laufen", + pronouns = listOf("ich", "du", "er/sie/es", "wir", "ihr", "sie"), + conjugations = mapOf( + "PrÀsens" to listOf("laufe", "lÀufst", "lÀuft", "laufen", "lauft", "laufen"), + "PrÀteritum" to listOf("lief", "liefst", "lief", "liefen", "lieft", "liefen") + ) + ) + VerbTable(conjugation = sampleConjugation) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/AiGenerationScreen.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/AiGenerationScreen.kt new file mode 100644 index 0000000..b561fe6 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/AiGenerationScreen.kt @@ -0,0 +1,144 @@ +package eu.gaudian.translator.view.exercises + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.viewmodel.AiGenerationState +import kotlin.math.cos +import kotlin.math.sin + +@Composable +fun AiGenerationScreen(state: AiGenerationState) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.8f)) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + when (state) { + is AiGenerationState.Generating -> { + GeneratingAnimation() + Text( + text = state.statusMessage, + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + } + is AiGenerationState.Success -> { + Icon( + imageVector = AppIcons.CheckCircle, + contentDescription = stringResource(R.string.cd_success), + tint = Color.Green, + modifier = Modifier.size(80.dp) + ) + Text( + text = state.generatedExerciseTitle, + color = Color.White, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + is AiGenerationState.Error -> { + Icon( + imageVector = AppIcons.Error, + contentDescription = stringResource(R.string.cd_error), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(80.dp) + ) + Text( + text = state.errorMessage, + color = Color.White, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + else -> {} + } + } + } +} + +@Composable +private fun GeneratingAnimation() { + @Suppress("HardCodedStringLiteral") val transition = rememberInfiniteTransition(label = "generating_anim") + + val angle1 by transition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable(tween(2000, easing = LinearEasing), RepeatMode.Restart), + label = "" + ) + val angle2 by transition.animateFloat( + initialValue = 180f, + targetValue = 540f, + animationSpec = infiniteRepeatable(tween(3000, easing = FastOutSlowInEasing), RepeatMode.Restart), + label = "" + ) + val scale by transition.animateFloat( + initialValue = 1f, + targetValue = 1.2f, + animationSpec = infiniteRepeatable(tween(1500), RepeatMode.Reverse), + label = "" + ) + val color = MaterialTheme.colorScheme.primary + + Canvas(modifier = Modifier.size(120.dp)) { + val radius = size.minDimension / 4 + val center = this.center + + drawCircle( + color = color, + radius = radius * scale, + style = Stroke(width = 8f) + ) + + fun getOffset(angle: Float, r: Float) = Offset( + x = center.x + r * cos(angle * (Math.PI / 180f)).toFloat(), + y = center.y + r * sin(angle * (Math.PI / 180f)).toFloat() + ) + + drawCircle( + color = color, + radius = 20f, + center = getOffset(angle1, radius * 2.5f) + ) + + drawCircle( + color = color.copy(alpha = 0.7f), + radius = 15f, + center = getOffset(angle2, radius * 2.5f) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseListScreen.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseListScreen.kt new file mode 100644 index 0000000..b48ef39 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseListScreen.kt @@ -0,0 +1,113 @@ +package eu.gaudian.translator.view.exercises + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Exercise +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton +import eu.gaudian.translator.view.composable.AppOutlinedCard +import eu.gaudian.translator.viewmodel.ExerciseViewModel + +@Composable +fun ExerciseListScreen( + onExerciseClicked: (Exercise) -> Unit, + onLongExerciseClicked: (Exercise) -> Unit, + onDeleteClicked: (Exercise) -> Unit, +) { + val exerciseViewModel: ExerciseViewModel = viewModel() + val exercises by exerciseViewModel.exercises.collectAsState() + + AppOutlinedCard{ + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp) + ) { + items(exercises) { exercise -> + ExerciseListItem( + exercise = exercise, + onClick = { onExerciseClicked(exercise) }, + onLongClick = { onLongExerciseClicked(exercise) }, + onDelete = { onDeleteClicked(exercise) } + ) + } + } +} +} + +@Composable +private fun ExerciseListItem( + exercise: Exercise, + onClick: () -> Unit, + onLongClick: () -> Unit, + onDelete: () -> Unit +) { + AppCard( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Row( + modifier = Modifier.padding(start = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = AppIcons.Quiz, + contentDescription = stringResource(R.string.label_exercise), + modifier = Modifier.size(40.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(text = exercise.title, style = MaterialTheme.typography.titleMedium) + Text( + text = stringResource(R.string.questions, exercise.questions.size), + style = MaterialTheme.typography.bodyMedium + ) + // Add the new button + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AppButton(onClick = onClick, shape = RoundedCornerShape(8.dp), + ) { + Text(stringResource(R.string.label_start)) + } + AppOutlinedButton(onClick = onLongClick, shape = RoundedCornerShape(8.dp)) { + Text(stringResource(R.string.start_long)) + } + } + } + IconButton(onClick = onDelete) { + Icon(AppIcons.Delete, contentDescription = stringResource(R.string.delete_exercise)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseMenu.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseMenu.kt new file mode 100644 index 0000000..a24c8ad --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseMenu.kt @@ -0,0 +1,38 @@ +package eu.gaudian.translator.view.exercises + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppFabMenu +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.FabMenuItem + +@Composable +fun ExerciseMenu( + modifier: Modifier = Modifier, + onCreateExercise: () -> Unit, + onCreateYouTubeExercise: () -> Unit, + onStartExercise: () -> Unit +) { + val menuItems = listOf( + FabMenuItem( + text = stringResource(R.string.label_create_exercise), + imageVector = AppIcons.Add, + onClick = onCreateExercise + ), + FabMenuItem( + text = stringResource(R.string.menu_create_youtube_exercise), + imageVector = AppIcons.Play, + onClick = onCreateYouTubeExercise + ), + FabMenuItem( + text = stringResource(R.string.label_start_exercise), + imageVector = AppIcons.Play, + onClick = onStartExercise + ) + + ) + + AppFabMenu(items = menuItems, modifier = modifier) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseSessionScreen.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseSessionScreen.kt new file mode 100644 index 0000000..8628cbe --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseSessionScreen.kt @@ -0,0 +1,569 @@ +package eu.gaudian.translator.view.exercises + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.CategorizationQuestion +import eu.gaudian.translator.model.Exercise +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.TrueFalseQuestion +import eu.gaudian.translator.model.VocabularyTestQuestion +import eu.gaudian.translator.model.WordOrderQuestion +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.ui.theme.semanticColors +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.ComponentDefaults +import eu.gaudian.translator.view.vocabulary.ExerciseProgressIndicator +import eu.gaudian.translator.viewmodel.AnswerResult +import eu.gaudian.translator.viewmodel.ExerciseSessionState +import eu.gaudian.translator.viewmodel.ExerciseViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel + + +@Composable +fun ExerciseSessionScreen( + navController: NavController, + exerciseViewModel: ExerciseViewModel, // Changed: No longer creates its own instance + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(androidx.compose.ui.platform.LocalContext.current.applicationContext as android.app.Application) +) { + val sessionState by exerciseViewModel.exerciseSessionState.collectAsState() + + var showVocabulary by remember { mutableStateOf(true) } + var vocabularyChecked by remember { mutableStateOf(false) } + + LaunchedEffect(sessionState) { + if (!vocabularyChecked && sessionState != null) { + val vocabIds = sessionState?.exercise?.associatedVocabularyIds + if (!vocabIds.isNullOrEmpty()) { + vocabularyViewModel.filterByIds(vocabIds) + showVocabulary = true + } else { + showVocabulary = false + } + vocabularyChecked = true + } + } + + DisposableEffect(Unit) { + onDispose { + exerciseViewModel.closeExercise() + vocabularyViewModel.clearFilter() + } + } + + val currentSession = sessionState + if (currentSession == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + if (currentSession.isExerciseFinished) { + GenericResultScreen( + score = currentSession.correctAnswers, + wrongAnswers = currentSession.wrongAnswers, + totalItems = currentSession.questions.size, + onFinish = { + exerciseViewModel.closeExercise() + navController.popBackStack() + } + ) + return + } + + if (showVocabulary) { + ExerciseVocabularyScreen( + onStartExercise = { + vocabularyViewModel.clearFilter() + @Suppress("AssignedValueIsNeverRead") + showVocabulary = false + }, + navController = navController + ) + } else { + ExerciseQuestionScreen( + state = currentSession, + onAnswerSelect = { answer -> exerciseViewModel.selectAnswer(answer) }, + onCheckClick = { exerciseViewModel.checkAnswer() }, + onContinueClick = { exerciseViewModel.nextQuestion() }, + onCloseClick = { + navController.popBackStack() + }, + navController =navController + ) + } +} + +@Suppress("HardCodedStringLiteral") +@ThemePreviews +@Composable +fun ExerciseQuestionScreenPreview() { + val sampleQuestion = TrueFalseQuestion(id = 1, name = "Is the sky blue?", correctAnswer = true) + val sampleState = ExerciseSessionState( + exercise = Exercise(id = "1", title = "Sample Exercise", questions = listOf(1)), + questions = listOf(sampleQuestion), + currentQuestionIndex = 0, + correctAnswers = 0, + wrongAnswers = 0, + selectedAnswer = null, + answerResult = AnswerResult.UNCHECKED + ) + ExerciseQuestionScreen( + state = sampleState, + onAnswerSelect = {}, + onCheckClick = {}, + onContinueClick = {}, + onCloseClick = {}, + navController = rememberNavController() + ) +} + +@Composable +fun GenericResultScreen( + score: Int, + wrongAnswers: Int, + totalItems: Int, + onFinish: () -> Unit +) { + val percentage = if (totalItems > 0) score.toFloat() / totalItems.toFloat() else 0f + val animatedProgress by androidx.compose.animation.core.animateFloatAsState( + targetValue = percentage, + animationSpec = tween(durationMillis = 1200), + label = "ResultProgressAnimationGeneric" + ) + Scaffold( + topBar = { + androidx.compose.material3.TopAppBar( + title = { Text(stringResource(R.string.result)) } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.exercise_complete), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold + ) + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.here_s_how_you_did), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(220.dp)) { + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxSize(), + strokeWidth = 16.dp, + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ) + Text( + text = "${(percentage * 100).toInt()}%", + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold + ) + } + androidx.compose.material3.Card( + modifier = Modifier.fillMaxWidth(), + colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + androidx.compose.material3.Icon( + imageVector = AppIcons.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(16.dp)) + Text(text = stringResource(R.string.label_correct), style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + Text(text = "$score / $totalItems", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + } + androidx.compose.material3.HorizontalDivider() + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + androidx.compose.material3.Icon( + imageVector = AppIcons.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp) + ) + androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(16.dp)) + Text(text = stringResource(R.string.label_wrong), style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + Text(text = "$wrongAnswers", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error) + } + } + } + AppButton( + onClick = onFinish, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text(stringResource(R.string.finish)) + } + } + } +} + +@Composable +fun ExerciseQuestionScreen( + state: ExerciseSessionState, + onAnswerSelect: (Any) -> Unit, + onCheckClick: () -> Unit, + onContinueClick: () -> Unit, + onCloseClick: () -> Unit, + navController: NavController +) { + val exerciseViewModel = viewModel() + + Scaffold( + topBar = { + ExerciseProgressIndicator( + correctAnswers = state.correctAnswers, + wrongAnswers = state.wrongAnswers, + totalItems = state.questions.size, + onClose = onCloseClick + ) + }, + bottomBar = { + BottomBar( + answerResult = state.answerResult, + onCheckClick = onCheckClick, + onContinueClick = onContinueClick, + isCheckEnabled = state.isAnswerSelected(), + secondaryText = (if (state.answerResult !is AnswerResult.UNCHECKED && state.currentQuestion is TrueFalseQuestion) (state.currentQuestion as TrueFalseQuestion).explanation else null) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Context panel shown at the beginning of each exercise + val ctxTitle = state.exercise.contextTitle + val ctxText = state.exercise.contextText + val youtubeUrl = state.exercise.youtubeUrl + var showContext by remember { mutableStateOf(true) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Show YouTube video button if this is a YouTube exercise + if (!youtubeUrl.isNullOrBlank()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AppButton( + onClick = { + // Navigate back to YouTube browser with the saved URL + val youtubeUrl = state.exercise.youtubeUrl + val sourceLang = state.exercise.sourceLanguage + val targetLang = state.exercise.targetLanguage + + if (youtubeUrl.isNotBlank()) { + exerciseViewModel.restartYouTubeExercise( + youtubeUrl = youtubeUrl, + sourceLanguage = sourceLang, + targetLanguage = targetLang + ) + @Suppress("HardCodedStringLiteral") + navController.navigate("youtube_exercise") + } + }, + modifier = Modifier.weight(1f) + ) { + androidx.compose.material3.Icon( + imageVector = AppIcons.Play, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp) + ) + Text(stringResource(R.string.text_watch_video_again)) + } + } + } + + // Show context if available + if (!ctxText.isNullOrBlank()) { + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(text = ctxTitle?.ifBlank { stringResource(R.string.label_context) } ?: stringResource(R.string.label_context), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + AppButton(onClick = { showContext = !showContext }) { + Text(if (showContext) stringResource(R.string.hide_context) else stringResource(R.string.show_context)) + } + } + if (showContext) { + Surface(tonalElevation = 1.dp, shape = RoundedCornerShape(8.dp)) { + Text(text = ctxText, modifier = Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium) + } + } + } + } + Text( + text = stringResource( + R.string.text_question_of, + state.currentQuestionIndex + 1, + state.questions.size + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + val headerText = when (state.currentQuestion) { + is ListeningComprehensionQuestion -> stringResource(id = R.string.type_what_you_hear) + is WordOrderQuestion -> stringResource(id = R.string.tap_the_words_below_to_form_the_sentence) + else -> state.currentQuestion.name + } + Text( + text = headerText, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + // Main router for displaying the correct question UI + when (val question = state.currentQuestion) { + is TrueFalseQuestion -> TrueFalseQuestionUi( + selectedAnswer = state.selectedAnswer as? Boolean, + onAnswerSelect = { onAnswerSelect(it) }, + isLocked = state.answerResult !is AnswerResult.UNCHECKED + ) + is MultipleChoiceQuestion -> MultipleChoiceQuestionUi( + question = question, + selectedAnswerIndex = state.selectedAnswer as? Int, + onAnswerSelect = { onAnswerSelect(it) }, + isLocked = state.answerResult !is AnswerResult.UNCHECKED + ) + is FillInTheBlankQuestion -> FillInTheBlankQuestionUi( + question = question, + selectedAnswer = state.selectedAnswer as? String ?: "", + onAnswerSelect = { onAnswerSelect(it) }, + isLocked = state.answerResult !is AnswerResult.UNCHECKED + ) + is WordOrderQuestion -> WordOrderQuestionUi( + question = question, + selectedAnswer = (state.selectedAnswer as? List<*>)?.filterIsInstance() + ?: emptyList(), + onAnswerSelect = { onAnswerSelect(it) }, + isLocked = state.answerResult !is AnswerResult.UNCHECKED + ) + is MatchingPairsQuestion -> MatchingPairsQuestionUi( + question = question, + selectedAnswer = (state.selectedAnswer as? Map<*, *>)?.mapNotNull { (k, v) -> (k as? String)?.let { key -> (v as? String)?.let { value -> key to value } } } + ?.toMap() ?: emptyMap(), + onAnswerSelect = { onAnswerSelect(it) }, + isLocked = state.answerResult !is AnswerResult.UNCHECKED + ) + is ListeningComprehensionQuestion -> ListeningComprehensionQuestionUi( + question = question, + selectedAnswer = state.selectedAnswer as? String ?: "", + onAnswerSelect = { onAnswerSelect(it) }, + isLocked = state.answerResult !is AnswerResult.UNCHECKED + ) + is CategorizationQuestion -> CategorizationQuestionUi( + question = question, + selectedAnswer = (state.selectedAnswer as? Map<*, *>)?.mapNotNull { (k, v) -> (k as? String)?.let { key -> (v as? String)?.let { value -> key to value } } } + ?.toMap() ?: emptyMap(), + onAnswerSelect = { onAnswerSelect(it) }, + isLocked = state.answerResult !is AnswerResult.UNCHECKED + ) + is VocabularyTestQuestion -> VocabularyTestQuestionUi( + question = question, + selectedAnswer = state.selectedAnswer as? String ?: "", + onAnswerSelect = { onAnswerSelect(it) }, + isLocked = state.answerResult !is AnswerResult.UNCHECKED + ) + } + } + } +} + +@Composable +fun TrueFalseQuestionUi(selectedAnswer: Boolean?, onAnswerSelect: (Boolean) -> Unit, isLocked: Boolean) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AnswerButton( + text = stringResource(R.string.text_true), + isSelected = selectedAnswer == true, + onClick = { onAnswerSelect(true) }, + enabled = !isLocked + ) + AnswerButton( + text = stringResource(R.string.text_false), + isSelected = selectedAnswer == false, + onClick = { onAnswerSelect(false) }, + enabled = !isLocked + ) + } +} + +@Composable +fun MultipleChoiceQuestionUi( + question: MultipleChoiceQuestion, + selectedAnswerIndex: Int?, + onAnswerSelect: (Int) -> Unit, + isLocked: Boolean +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + question.options.forEachIndexed { index, text -> + AnswerButton( + text = text, + isSelected = selectedAnswerIndex == index, + onClick = { onAnswerSelect(index) }, + enabled = !isLocked + ) + } + } +} + +@Composable +fun AnswerButton(text: String, isSelected: Boolean, onClick: () -> Unit, enabled: Boolean) { + AppButton( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 52.dp), + enabled = enabled, + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface + ), + border = BorderStroke(2.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline) + ) { + Text(text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +fun BottomBar(answerResult: AnswerResult, onCheckClick: () -> Unit, onContinueClick: () -> Unit, isCheckEnabled: Boolean, secondaryText: String? = null) { + val isResultVisible = answerResult !is AnswerResult.UNCHECKED + + Box(contentAlignment = Alignment.BottomCenter) { + if (!isResultVisible) { + Surface(shadowElevation = ComponentDefaults.DefaultElevation) { + AppButton( + onClick = onCheckClick, + enabled = isCheckEnabled, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .height(50.dp) + ) { + Text(stringResource(R.string.text_check), fontWeight = FontWeight.Bold) + } + } + } + + // Animated Correct/Incorrect Result View + AnimatedVisibility( + visible = isResultVisible, + enter = slideInVertically(initialOffsetY = { it }, animationSpec = tween(300)), + exit = slideOutVertically(targetOffsetY = { it }, animationSpec = tween(300)) + ) { + val backgroundColor = if (answerResult is AnswerResult.CORRECT) MaterialTheme.semanticColors.successContainer else MaterialTheme.colorScheme.errorContainer + val contentColor = if (answerResult is AnswerResult.CORRECT) MaterialTheme.semanticColors.onSuccessContainer else MaterialTheme.colorScheme.onErrorContainer + + Surface( + color = backgroundColor, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 16.dp) + .defaultMinSize(minHeight = 80.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (answerResult is AnswerResult.CORRECT) stringResource(R.string.text_correct_em) else stringResource( + R.string.text_incorrect_em + ), + color = contentColor, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + if (answerResult is AnswerResult.INCORRECT) { + Text( + text = answerResult.feedback, + color = contentColor, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } else if (!secondaryText.isNullOrBlank()) { + Text( + text = secondaryText, + color = contentColor, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + if (answerResult is AnswerResult.CORRECT) { + eu.gaudian.translator.view.composable.CorrectButton( + onClick = onContinueClick, + text = stringResource(R.string.label_continue) + ) + } else { + eu.gaudian.translator.view.composable.WrongButton( + onClick = onContinueClick, + text = stringResource(R.string.label_continue) + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseVocabularyScreen.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseVocabularyScreen.kt new file mode 100644 index 0000000..e79b065 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/ExerciseVocabularyScreen.kt @@ -0,0 +1,55 @@ +package eu.gaudian.translator.view.exercises + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.vocabulary.VocabularyListScreen + +@Composable +fun ExerciseVocabularyScreen( + onStartExercise: () -> Unit, + navController: NavController +) { + Scaffold( + topBar = { + AppTopAppBar(title = { Text(stringResource(R.string.text_new_vocabulary_for_this_exercise)) }) + }, + bottomBar = { + Surface(shadowElevation = 8.dp) { + AppButton( + onClick = onStartExercise, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text(stringResource(R.string.label_start_exercise)) + } + } + } + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + + VocabularyListScreen( + navController = navController as NavHostController?, + onNavigateToItem = { item -> + // Navigate to the detail screen for a specific vocabulary item + @Suppress("HardCodedStringLiteral") + navController.navigate("vocabulary_detail/${item.id}") + }, + onNavigateBack = { /* Not needed in this context */ }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/GenerateExerciseDialog.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/GenerateExerciseDialog.kt new file mode 100644 index 0000000..f2f19ec --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/GenerateExerciseDialog.kt @@ -0,0 +1,214 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.exercises + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Question +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppSlider +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.DialogButton +import eu.gaudian.translator.view.composable.SourceLanguageDropdown +import eu.gaudian.translator.view.composable.TargetLanguageDropdown +import eu.gaudian.translator.viewmodel.LanguageViewModel +import kotlin.math.roundToInt +import kotlin.reflect.KClass + +@SuppressLint("FrequentlyChangingValue") +@Composable +fun GenerateExerciseDialog( + onDismiss: () -> Unit, + onGenerate: (category: String, types: List>, difficulty: String, amount: Int, sourceLanguage: String?, targetLanguage: String?) -> Unit +) { + // Orchestrator for a compact 2-step flow + var step by remember { mutableIntStateOf(1) } + val activity = LocalContext.current.findActivity() + + + var category by remember { mutableStateOf("") } + var amount by remember { mutableFloatStateOf(10f) } + var difficulty by remember { mutableFloatStateOf(1f) } // 0=Easy, 1=Medium, 2=Hard + val questionTypes = Question.allTypes + var selectedTypes by remember { mutableStateOf(questionTypes.toSet()) } + + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val sourceLang = languageViewModel.selectedSourceLanguage.collectAsState().value?.name + val targetLang = languageViewModel.selectedTargetLanguage.collectAsState().value?.name + + val difficultyLabels = listOf( + stringResource(R.string.label_easy), + stringResource(R.string.label_medium), + stringResource(R.string.label_hard) + ) + + when (step) { + 1 -> { + // Step 1: Basic Info (Category + Languages) + val isNextEnabled = category.isNotBlank() && !targetLang.isNullOrBlank() + AppDialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = stringResource(R.string.text_generate_exercise_with_ai), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + AppTextField( + value = category, + onValueChange = { category = it }, + label = { Text(stringResource(R.string.text_category_prompt)) }, + placeholder = { Text(stringResource(R.string.text_e_g_irregular_verbs)) }, + modifier = Modifier.fillMaxWidth() + ) + Text(stringResource(R.string.text_select_languages), style = MaterialTheme.typography.labelLarge) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.weight(1f)) { + SourceLanguageDropdown(languageViewModel = languageViewModel, autoEnabled = true, iconEnabled = true) + } + Spacer(modifier = Modifier.width(8.dp)) + Box(modifier = Modifier.weight(1f)) { + TargetLanguageDropdown(languageViewModel = languageViewModel, iconEnabled = true) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.label_cancel)) } + DialogButton(onClick = { step = 2 }, enabled = isNextEnabled) { + Text(stringResource(R.string.next)) + } + } + } + } + } + } + else -> { + // Step 2: Options (Types + Difficulty + Amount) and Generate + val isGenerateEnabled = category.isNotBlank() && selectedTypes.isNotEmpty() && !targetLang.isNullOrBlank() + AppDialog(onDismissRequest = { step = 1 }) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = stringResource(R.string.text_question_types), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp) + ) + Box { + val scrollState = rememberScrollState() + if (scrollState.canScrollForward) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(y = 8.dp) + .fillMaxWidth() + .heightIn(max = 20.dp) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)) + .alpha(if (scrollState.value < scrollState.maxValue - 50) 1f else (scrollState.maxValue - scrollState.value) / 50f) + ) { + Text("...", modifier = Modifier.align(Alignment.Center), color = MaterialTheme.colorScheme.onSurface) + } + } + Box( + modifier = Modifier + .heightIn(max = 200.dp) + .verticalScroll(scrollState) + ) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + questionTypes.forEach { type -> + val isSelected = selectedTypes.contains(type) + FilterChip( + selected = isSelected, + onClick = { + selectedTypes = if (isSelected) selectedTypes - type else selectedTypes + type + }, + label = { Text(type.simpleName?.replace(stringResource(R.string.text_question), "") ?: "") } + ) + } + } + } + } + Text(stringResource(R.string.text_difficulty_2d, difficultyLabels[difficulty.roundToInt()]), style = MaterialTheme.typography.labelLarge) + AppSlider(value = difficulty, onValueChange = { difficulty = it }, valueRange = 0f..2f, steps = 1) + Text(stringResource(R.string.text_amount_2d_questions, amount.roundToInt()), style = MaterialTheme.typography.labelLarge) + AppSlider(value = amount, onValueChange = { amount = it }, valueRange = 1f..20f, steps = 18) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = { step = 1 }) { Text(stringResource(R.string.cd_back)) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { step = 1 }) { Text(stringResource(R.string.label_cancel)) } + DialogButton( + onClick = { + onGenerate( + category, + selectedTypes.toList(), + difficultyLabels[difficulty.roundToInt()], + amount.roundToInt(), + sourceLang, + targetLang + ) + onDismiss() + }, + enabled = isGenerateEnabled + ) { Text(stringResource(R.string.text_generate)) } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/MainExerciseScreen.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/MainExerciseScreen.kt new file mode 100644 index 0000000..e7f8b6a --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/MainExerciseScreen.kt @@ -0,0 +1,206 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.exercises + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Exercise +import eu.gaudian.translator.view.composable.AppAlertDialog +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedCard +import eu.gaudian.translator.view.composable.AppTabLayout +import eu.gaudian.translator.view.composable.DialogButton +import eu.gaudian.translator.view.composable.TabItem +import eu.gaudian.translator.viewmodel.AiGenerationState +import eu.gaudian.translator.viewmodel.ExerciseViewModel + +/** + * Defines the tabs for the main Exercise screen, replacing the enum from ExerciseFragment. + */ +enum class ExerciseTab(override val title: String, override val icon: ImageVector) : TabItem { + Dashboard("Dashboard", AppIcons.Dashboard), + AllExercises("All Exercises", AppIcons.BarChart) +} + +/** + * This screen replaces ExerciseFragment. It acts as the main entry point for the "Exercises" tab, + * showing a tabbed layout for the dashboard and the list of all exercises. + * + * @param navController The main NavController to handle navigation to an exercise session. + * @param exerciseViewModel The ViewModel for managing exercise data and state. + */ +@Composable +fun MainExerciseScreen( + navController: NavController, + exerciseViewModel: ExerciseViewModel // Changed: No longer creates its own instance +) { + val aiState by exerciseViewModel.aiGenerationState.collectAsState() + var exerciseToDelete by remember { mutableStateOf(null) } + var showGenerateDialog by remember { mutableStateOf(false) } + + // This outer 'when' handles the AI generation state, same as in the fragment. + when (val currentState = aiState) { + is AiGenerationState.Idle -> { + var selectedTab by remember { mutableStateOf(ExerciseTab.Dashboard) } + + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + AppTabLayout( + tabs = ExerciseTab.entries, + selectedTab = selectedTab, + onTabSelected = { selectedTab = it } + ) + + Box(modifier = Modifier.weight(1f)) { + when (selectedTab) { + ExerciseTab.Dashboard -> ExerciseDashboard( + onCreateExercise = { showGenerateDialog = true } + ) + ExerciseTab.AllExercises -> ExerciseListScreen( + onExerciseClicked = { exercise -> + exerciseViewModel.startExercise(exercise) + navController.navigate("exercise_session") + }, + onDeleteClicked = { exercise -> + exerciseToDelete = exercise + }, + onLongExerciseClicked = { exercise -> + exerciseToDelete = exercise // Open delete dialog on long press + }, + ) + } + + } + } + + ExerciseMenu( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + onCreateExercise = { showGenerateDialog = true }, + onCreateYouTubeExercise = { + // Navigate to YouTube browser and keep MainExerciseScreen in the back stack + navController.navigate("youtube_browse") + }, + onStartExercise = { } //TODO + ) + + } + if (showGenerateDialog) { + GenerateExerciseDialog( + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showGenerateDialog = false + }, + onGenerate = { category, types, difficulty, amount, sourceLanguage, targetLanguage -> + exerciseViewModel.generateExerciseWithAi(category, types, difficulty, amount, sourceLanguage, targetLanguage) + } + ) + } + + + exerciseToDelete?.let { exercise -> + DeleteConfirmationDialog( + exercise = exercise, + onConfirm = { + // Assuming the Exercise model has a string or numeric ID + exerciseViewModel.deleteExercise(exercise.id) + }, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + exerciseToDelete = null + } + ) + } + } + else -> { + AiGenerationScreen(state = currentState) + } + } +} + +@Composable +private fun DeleteConfirmationDialog( + exercise: Exercise, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AppAlertDialog( + onDismissRequest = onDismiss, + title = { Text("Delete Exercise") }, + text = { Text("Are you sure you want to delete the exercise \"${exercise.title}\"? This action cannot be undone.") }, + confirmButton = { + DialogButton( + onClick = { + onConfirm() + onDismiss() + }, + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun ExerciseDashboard(onCreateExercise: () -> Unit) { + AppOutlinedCard{ + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_icon_construction), + contentDescription = null, + modifier = Modifier.size(200.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Danger Zone", + style = MaterialTheme.typography.headlineMedium + ) + Text( + "This area is under construction, exercises are in a beta state and may not work as expected.", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp, bottom = 24.dp) + ) + AppButton(onClick = onCreateExercise) { + Icon(AppIcons.Add, contentDescription = null, modifier = Modifier.padding(end = 8.dp)) + Text("Generate New Exercise") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/QuestionUIs.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/QuestionUIs.kt new file mode 100644 index 0000000..6810ddb --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/QuestionUIs.kt @@ -0,0 +1,515 @@ +package eu.gaudian.translator.view.exercises + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.CategorizationQuestion +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.ListeningComprehensionQuestion +import eu.gaudian.translator.model.MatchingPairsQuestion +import eu.gaudian.translator.model.VocabularyTestQuestion +import eu.gaudian.translator.model.WordOrderQuestion +import eu.gaudian.translator.utils.TextToSpeechHelper +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.viewmodel.SettingsViewModel +import kotlinx.coroutines.launch +import java.util.Locale + +@Composable +fun WordChip(text: String, isSelected: Boolean, onClick: () -> Unit, enabled: Boolean = true) { + AppButton( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface + ), + border = BorderStroke(2.dp, if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline) + ) { + Text(text) + } +} + +@Composable +@Preview +fun WordChipPreview() { + WordChip( + text = stringResource(R.string.text_sample_word), + isSelected = false, + onClick = {} + ) +} + +@Composable +fun FillInTheBlankQuestionUi( + question: eu.gaudian.translator.model.FillInTheBlankQuestion, + selectedAnswer: String, + onAnswerSelect: (String) -> Unit, + isLocked: Boolean +) { + val correct = question.correctAnswer + val firstLetter = correct.firstOrNull()?.toString() ?: "" + val placeholder = if (firstLetter.isNotBlank()) firstLetter + "_".repeat((correct.length - 1).coerceAtLeast(1)) else "_".repeat(correct.length.coerceAtLeast(1)) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (question.hintBaseForm.isNotBlank()) { + Text(stringResource(R.string.hint, question.hintBaseForm)) + } + if (question.hintOptions.isNotEmpty()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + question.hintOptions.forEach { option -> + WordChip(text = option, isSelected = false, onClick = { if (!isLocked) onAnswerSelect(option) }, enabled = !isLocked) + } + } + } + AppTextField( + value = selectedAnswer, + onValueChange = onAnswerSelect, + placeholder = { Text(placeholder) }, + label = { Text(stringResource(R.string.label_your_answer)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLocked, + singleLine = true + ) + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +@Preview +fun FillInTheBlankQuestionUiPreview() { + val question = eu.gaudian.translator.model.FillInTheBlankQuestion( + id = 1, + name = "The capital of France is ___.", + correctAnswer = "Paris", + hintBaseForm = "City in Europe", + hintOptions = listOf("Berlin", "Madrid", "Paris", "Rome") + ) + var selectedAnswer by remember { mutableStateOf("") } + FillInTheBlankQuestionUi( + question = question, + selectedAnswer = selectedAnswer, + onAnswerSelect = { + @Suppress("AssignedValueIsNeverRead") + selectedAnswer = it + }, + isLocked = false) +} + +@Composable +fun WordOrderQuestionUi( + question: WordOrderQuestion, + selectedAnswer: List, + onAnswerSelect: (List) -> Unit, + isLocked: Boolean +) { + val baseWords = remember(question.words, question.correctOrder) { + if (question.words == question.correctOrder) question.words.shuffled() else question.words + } + val availableWords = remember(baseWords, selectedAnswer) { + val selectedCounts = selectedAnswer.groupingBy { it }.eachCount() + baseWords.filter { word -> + (selectedCounts[word] ?: 0) < baseWords.count { it == word } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Box( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 70.dp) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)) + .padding(8.dp) + ) { + if (selectedAnswer.isEmpty()) { + Text(stringResource(R.string.tap_the_words_below_to_form_the_sentence), color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + selectedAnswer.forEachIndexed { index, word -> + WordChip( + text = word, + isSelected = false, + enabled = !isLocked, + onClick = { + val newList = selectedAnswer.toMutableList() + newList.removeAt(index) + onAnswerSelect(newList) + } + ) + } + } + } + } + + // The bank of available words + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + availableWords.forEach { word -> + WordChip( + text = word, + isSelected = false, + enabled = !isLocked, + onClick = { onAnswerSelect(selectedAnswer + word) } + ) + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +@Preview +fun WordOrderQuestionUiPreview() { + val question = WordOrderQuestion( + id = 1, + name = "Form the sentence.", + words = listOf("the", "quick", "brown", "fox"), + correctOrder = listOf("the", "quick", "brown", "fox") + ) + var selectedAnswer by remember { mutableStateOf>(emptyList()) } + WordOrderQuestionUi( + question = question, + selectedAnswer = selectedAnswer, + onAnswerSelect = { + @Suppress("AssignedValueIsNeverRead") + selectedAnswer = it + }, + isLocked = false + ) +} + +@Composable +fun MatchingPairsQuestionUi( + question: MatchingPairsQuestion, + selectedAnswer: Map, + onAnswerSelect: (Map) -> Unit, + isLocked: Boolean +) { + var selectedKey by remember { mutableStateOf(null) } + val keys = remember(question.pairs) { question.pairs.keys.shuffled() } + val values = remember(question.pairs) { question.pairs.values.shuffled() } + + // When locked (after check), highlight correct vs incorrect matches + val correctnessMap = remember(selectedAnswer, question.pairs, isLocked) { + if (!isLocked) emptyMap() + else selectedAnswer.mapValues { (k, v) -> question.pairs[k] == v } + } + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + keys.forEach { key -> + val isSelected = selectedKey == key || selectedAnswer.containsKey(key) + val isCorrect = correctnessMap[key] + val label = if (isLocked && isCorrect == false) "$key ✗" else if (isLocked && isCorrect == true) "$key ✓" else key + AnswerButton( + text = label, + isSelected = isSelected, + enabled = !isLocked && !selectedAnswer.containsKey(key), + onClick = { selectedKey = key } + ) + } + } + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + values.forEach { value -> + val isSelected = selectedAnswer.containsValue(value) + AnswerButton( + text = value, + isSelected = isSelected, + enabled = !isLocked && !isSelected, + onClick = { + selectedKey?.let { key -> + onAnswerSelect(selectedAnswer + (key to value)) + selectedKey = null + } + } + ) + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +@Preview +fun MatchingPairsQuestionUiPreview() { + val question = MatchingPairsQuestion( + id = 1, + name = "Match the capitals to their countries.", + pairs = mapOf("France" to "Paris", "Germany" to "Berlin", "Spain" to "Madrid") + ) + var selectedAnswer by remember { mutableStateOf>(emptyMap()) } + MatchingPairsQuestionUi( + question = question, + selectedAnswer = selectedAnswer, + onAnswerSelect = { + @Suppress("AssignedValueIsNeverRead") + selectedAnswer = it + }, + isLocked = false + ) +} + + +@Composable +fun ListeningComprehensionQuestionUi( + question: ListeningComprehensionQuestion, + selectedAnswer: String, + onAnswerSelect: (String) -> Unit, + isLocked: Boolean +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val maskedHint = remember(question.name) { + val words = question.name.split(" ").filter { it.isNotBlank() } + if (words.size < 4) "" else { + val blankCount = (words.size * 0.4).toInt().coerceAtLeast(1) + val indicesToBlank = words.indices.shuffled().take(blankCount).toSet() + words.mapIndexed { idx, w -> if (idx in indicesToBlank) "___" else w } + .joinToString(" ") + } + } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { + IconButton( + onClick = { + scope.launch { + val locale = Locale.forLanguageTag(question.languageCode) + val language = Language( + nameResId = 0, + code = locale.language, + region = locale.country, + name = locale.displayName, + englishName = locale.displayName, + isCustom = false, + isSelected = true + ) + val voice = settingsViewModel.getTtsVoiceForLanguage(language.code, language.region) + TextToSpeechHelper.speakOut(context, question.name, language, voice) + } + }, + modifier = Modifier.size(80.dp) + ) { + Icon(AppIcons.TextToSpeech, contentDescription = stringResource(R.string.listen), modifier = Modifier.fillMaxSize()) + } + // Do NOT show the full sentence; show a masked hint instead (if available) + if (maskedHint.isNotBlank()) { + Text(maskedHint, style = MaterialTheme.typography.bodyMedium) + } + AppTextField( + value = selectedAnswer, + onValueChange = onAnswerSelect, + label = { Text(stringResource(R.string.type_what_you_hear)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLocked + ) + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +@Preview +fun ListeningComprehensionQuestionUiPreview() { + val question = ListeningComprehensionQuestion( + id = 1, + name = "This is a sample sentence to be spoken.", + languageCode = "en-US" + ) + var selectedAnswer by remember { mutableStateOf("") } + ListeningComprehensionQuestionUi( + question = question, + selectedAnswer = selectedAnswer, + onAnswerSelect = { + @Suppress("AssignedValueIsNeverRead") + selectedAnswer = it + }, + isLocked = false + ) +} + +@Composable +fun CategorizationQuestionUi( + question: CategorizationQuestion, + selectedAnswer: Map, + onAnswerSelect: (Map) -> Unit, + isLocked: Boolean +) { + val unassignedItems = remember(question.items, selectedAnswer) { + question.items.filter { !selectedAnswer.containsKey(it) } + } + + val correctnessMap = remember(selectedAnswer, isLocked) { + if (!isLocked) emptyMap() else question.items.associateWith { item -> + val chosen = selectedAnswer[item] + val correct = question.correctMapping[item] + chosen != null && chosen == correct + } + } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + question.categories.forEach { category -> + Column( + modifier = Modifier + .weight(1f) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(8.dp) + ) + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(category, fontWeight = FontWeight.Bold) + selectedAnswer.filterValues { it == category }.keys.forEach { item -> + val isCorrect = correctnessMap[item] + val suffix = if (isLocked) { + if (isCorrect == true) " ✓" else if (isCorrect == false) " ✗" else "" + } else "" + Text(item + suffix, style = MaterialTheme.typography.bodySmall) + } + } + } + } + + if (unassignedItems.isNotEmpty() && !isLocked) { + var expandedItem by remember { mutableStateOf(null) } + Text(stringResource(R.string.text_assign_these_items_2d), style = MaterialTheme.typography.labelLarge) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(unassignedItems) { item -> + Box { + WordChip( + text = item, + isSelected = false, + enabled = !isLocked, + onClick = { expandedItem = item } + ) + DropdownMenu( + expanded = expandedItem == item, + onDismissRequest = { expandedItem = null } + ) { + question.categories.forEach { category -> + DropdownMenuItem( + text = { Text(category) }, + onClick = { + onAnswerSelect(selectedAnswer + (item to category)) + expandedItem = null + } + ) + } + } + } + } + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +@Preview +fun CategorizationQuestionUiPreview() { + val question = CategorizationQuestion( + id = 1, + name = "Sort these items into categories.", + items = listOf("Apple", "Banana", "Carrot", "Broccoli"), + categories = listOf("Fruit", "Vegetable"), + correctMapping = mapOf("Apple" to "Fruit", "Banana" to "Fruit", "Carrot" to "Vegetable", "Broccoli" to "Vegetable") + ) + var selectedAnswer by remember { mutableStateOf>(emptyMap()) } + CategorizationQuestionUi( + question = question, + selectedAnswer = selectedAnswer, + onAnswerSelect = { + @Suppress("AssignedValueIsNeverRead") + selectedAnswer = it + }, + isLocked = false + ) +} + + +@Composable +fun VocabularyTestQuestionUi( + question: VocabularyTestQuestion, + selectedAnswer: String, + onAnswerSelect: (String) -> Unit, + isLocked: Boolean +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.translate_the_following_d, question.languageDirection), + style = MaterialTheme.typography.bodyMedium + ) + AppTextField( + value = selectedAnswer, + onValueChange = onAnswerSelect, + label = { Text(stringResource(R.string.label_your_translation)) }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLocked, + singleLine = true + ) + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun VocabularyTestQuestionUiPreview() { + val question = VocabularyTestQuestion( + id = 1, + name = "Hello", + correctAnswer = "Hola", + languageDirection = "English to Spanish" + ) + var selectedAnswer by remember { mutableStateOf("") } + VocabularyTestQuestionUi( + question = question, + selectedAnswer = selectedAnswer, + onAnswerSelect = { + @Suppress("AssignedValueIsNeverRead") + selectedAnswer = it + }, + isLocked = false) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/YouTubeBrowserScreen.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/YouTubeBrowserScreen.kt new file mode 100644 index 0000000..8d32325 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/YouTubeBrowserScreen.kt @@ -0,0 +1,275 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.exercises + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import android.graphics.Color +import android.webkit.CookieManager +import android.webkit.JavascriptInterface +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.viewmodel.ExerciseViewModel +import java.net.URLDecoder + +/** + * A screen that lets the user browse YouTube inside a WebView. When the user taps on a video link, + * we open the YouTubeExerciseDialog in language-only mode to pick languages, then start the exercise. + */ +@Suppress("HardCodedStringLiteral") +@Composable +@SuppressLint("SetJavaScriptEnabled") +fun YouTubeBrowserScreen( + navController: NavController, + exerciseViewModel: ExerciseViewModel +) { + var pendingVideoUrl by remember { mutableStateOf(null) } + var showLanguageDialog by remember { mutableStateOf(false) } + var showCustomDialog by remember { mutableStateOf(false) } + + val context = LocalContext.current + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text("YouTube") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, stringResource(R.string.cd_back)) + } + } + ) + } + ) { padding -> + androidx.compose.foundation.layout.Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { + WebView(context).apply { + setBackgroundColor(Color.BLACK) + CookieManager.getInstance().setAcceptCookie(true) + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.loadsImagesAutomatically = true + settings.mediaPlaybackRequiresUserGesture = false + settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + + webChromeClient = WebChromeClient() + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val url = request?.url?.toString() ?: return false + if (url.startsWith("intent://")) { + extractFallbackFromIntentUrl(url)?.let { fallback -> + return handleUrl(fallback) { + pendingVideoUrl = it + showLanguageDialog = true + } + } + return true + } + return handleUrl(url) { + pendingVideoUrl = it + showLanguageDialog = true + } + } + + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + val u = url ?: return false + if (u.startsWith("intent://")) { + extractFallbackFromIntentUrl(u)?.let { fallback -> + return handleUrl(fallback) { + pendingVideoUrl = it + showLanguageDialog = true + } + } + return true + } + return handleUrl(u) { + pendingVideoUrl = it + showLanguageDialog = true + } + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + val u = url ?: return + if (!showLanguageDialog && isYouTubeWatchUrl(u)) { + pendingVideoUrl = u + showLanguageDialog = true + view?.stopLoading() + } + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + // Inject JS hooks to catch SPA navigations and link clicks + view?.evaluateJavascript(YT_INTERCEPT_JS, null) + } + } + + // Bridge: receive URL changes from JS world + addJavascriptInterface(YouTubeJsBridge { url -> + // Ensure state changes happen on UI thread + this.post { + if (!showLanguageDialog) { + pendingVideoUrl = url + showLanguageDialog = true + try { stopLoading() } catch (_: Throwable) {} + } + } + }, "YTBridge") + + // Load mobile YouTube for better interaction in WebView + loadUrl("https://m.youtube.com/") + } + } + ) + FloatingActionButton( + onClick = { showCustomDialog = true }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + ) { + Icon(imageVector = AppIcons.Add, contentDescription = "Add YouTube link") + } + } + } + + if (showLanguageDialog && pendingVideoUrl != null) { + YouTubeExerciseDialog( + onDismiss = { + showLanguageDialog = false + pendingVideoUrl = null + }, + onCreate = { _, sourceLanguage, targetLanguage -> + val url = pendingVideoUrl ?: return@YouTubeExerciseDialog + exerciseViewModel.startYouTubeExercise(url, sourceLanguage, targetLanguage) + // Navigate to the exercise screen; keep this browser on the back stack so back returns here + navController.navigate("youtube_exercise") + showLanguageDialog = false + pendingVideoUrl = null + }, + presetUrl = pendingVideoUrl!!, + languageOnly = true + ) + } + + if (showCustomDialog) { + YouTubeExerciseDialog( + onDismiss = { + showCustomDialog = false + }, + onCreate = { youtubeUrl, sourceLanguage, targetLanguage -> + exerciseViewModel.startYouTubeExercise(youtubeUrl, sourceLanguage, targetLanguage) + // Navigate to the exercise screen; keep this browser on the back stack so back returns here + navController.navigate("youtube_exercise") + showCustomDialog = false + }, + languageOnly = false + ) + } +} + +private fun isYouTubeWatchUrl(url: String): Boolean { + @Suppress("HardCodedStringLiteral") + return url.contains("youtube.com/watch") || url.contains("youtu.be/") || url.contains("youtube.com/shorts/") +} + +private inline fun handleUrl(url: String, onVideo: (String) -> Unit): Boolean { + return if (isYouTubeWatchUrl(url)) { + onVideo(url) + true // intercept + } else { + false // let WebView load normally + } +} + +@Suppress("HardCodedStringLiteral") +private fun extractFallbackFromIntentUrl(intentUrl: String): String? { + val match = Regex("S\\.browser_fallback_url=([^;]+)").find(intentUrl) ?: return null + val encoded = match.groupValues[1] + return try { + URLDecoder.decode(encoded, "UTF-8") + } catch (_: Exception) { + null + } +} + +// DO NOT EVER TOUCH THIS AGAIN +@Suppress("HardCodedStringLiteral") +private val YT_INTERCEPT_JS: String = ( + "(function(){" + + "if(window.__ytInterceptInstalled){return;}" + + "window.__ytInterceptInstalled=true;" + + "function notify(url){try{YTBridge.onUrlChange(url||location.href);}catch(e){}}" + + // Hook history API + "var ps=history.pushState, rs=history.replaceState;" + + "history.pushState=function(){ps.apply(this,arguments);notify();};" + + "history.replaceState=function(){rs.apply(this,arguments);notify();};" + + "window.addEventListener('popstate',function(){notify();},true);" + + // Intercept link clicks + "document.addEventListener('click',function(e){" + + "try{" + + "var el=e.target;" + + "while(el && el.tagName && el.tagName.toLowerCase()!=='a'){el=el.parentElement;}" + + "if(el && el.href){" + + "var href=el.href;" + + "if(/youtube\\.com\\/watch|youtu\\.be\\/|youtube\\.com\\/shorts\\//.test(href)){" + + "e.preventDefault();e.stopPropagation();" + + "notify(href);" + + "return false;" + + "}" + + "}" + + "}catch(ex){}" + + "}, true);" + + // Initial notify on first load (in case we landed directly on a watch page) + "setTimeout(function(){notify();},0);" + + "})();" +) + +private class YouTubeJsBridge(private val onVideo: (String) -> Unit) { + @Suppress("unused", "HardCodedStringLiteral") + @JavascriptInterface + fun onUrlChange(url: String) { + if (isYouTubeWatchUrl(url)) { + onVideo(url) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/YouTubeExerciseDialog.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/YouTubeExerciseDialog.kt new file mode 100644 index 0000000..1c5b780 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/YouTubeExerciseDialog.kt @@ -0,0 +1,108 @@ +package eu.gaudian.translator.view.exercises + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.DialogButton +import eu.gaudian.translator.view.composable.SourceLanguageDropdown +import eu.gaudian.translator.view.composable.TargetLanguageDropdown +import eu.gaudian.translator.viewmodel.LanguageViewModel + +@Composable +fun YouTubeExerciseDialog( + onDismiss: () -> Unit, + onCreate: (youtubeUrl: String, sourceLanguage: Language, targetLanguage: Language) -> Unit, + presetUrl: String? = null, + languageOnly: Boolean = false +) { + val activity = LocalContext.current.findActivity() + var youtubeUrl by remember { mutableStateOf(presetUrl ?: "") } + + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val sourceLang = languageViewModel.selectedSourceLanguage.collectAsState().value + val targetLang = languageViewModel.selectedTargetLanguage.collectAsState().value + + val isCreateEnabled = (languageOnly || youtubeUrl.isNotBlank()) && sourceLang != null + + AppDialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = stringResource(R.string.menu_create_youtube_exercise), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + if (!languageOnly) { + AppTextField( + value = youtubeUrl, + onValueChange = { youtubeUrl = it }, + label = { Text(stringResource(R.string.text_youtube_link)) }, + placeholder = { Text("https://www.youtube.com/watch?v=...") }, + modifier = Modifier.fillMaxWidth() + ) + } + + Text(stringResource(R.string.text_select_languages), style = MaterialTheme.typography.labelLarge) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.weight(1f)) { + SourceLanguageDropdown(languageViewModel = languageViewModel, autoEnabled = true, iconEnabled = true) + } + Spacer(modifier = Modifier.width(8.dp)) + Box(modifier = Modifier.weight(1f)) { + TargetLanguageDropdown(languageViewModel = languageViewModel, iconEnabled = true) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.label_cancel)) } + DialogButton(onClick = { + if (sourceLang != null && targetLang != null) { + val finalUrl = (presetUrl ?: youtubeUrl).trim() + onCreate(finalUrl, sourceLang, targetLang) + } + onDismiss() + }, enabled = isCreateEnabled) { + Text(stringResource(R.string.label_confirm)) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/exercises/YouTubeExerciseScreen.kt b/app/src/main/java/eu/gaudian/translator/view/exercises/YouTubeExerciseScreen.kt new file mode 100644 index 0000000..af067a9 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/exercises/YouTubeExerciseScreen.kt @@ -0,0 +1,319 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.exercises + +import android.annotation.SuppressLint +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavController +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.loadOrCueVideo +import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.views.YouTubePlayerView +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.viewmodel.ExerciseViewModel +import eu.gaudian.translator.viewmodel.YouTubeExerciseState + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun YouTubeExerciseScreen( + navController: NavController, + exerciseViewModel: ExerciseViewModel +) { + val context = LocalContext.current + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + val sessionParams by exerciseViewModel.youTubeSessionParams.collectAsState() + val exerciseState by exerciseViewModel.youTubeExerciseState.collectAsState() + + val initialUrl = sessionParams?.url + val videoIdFromUrl = extractVideoId(initialUrl) + + var currentTimeSec by remember { mutableFloatStateOf(0f) } + val listState = rememberLazyListState() + var title by remember{ mutableStateOf(String())} + + // Keep a reference to the player to support seeking from the subtitle list + var youTubePlayerRef by remember { mutableStateOf(null) } + + // Video completion detection and manual finish + @Suppress("VariableNeverRead") var videoEnded by remember { mutableStateOf(false) } + var userWantsToFinish by remember { mutableStateOf(false) } + var isGeneratingQuestions by remember { mutableStateOf(false) } + val textErrorGeneratingQuestions = stringResource(R.string.text_error_generating_questions) + + // Create the player view once and manage its lifecycle + val playerView = remember { + YouTubePlayerView(context).apply { + enableAutomaticInitialization = false + val listener = object : AbstractYouTubePlayerListener() { + override fun onReady(youTubePlayer: YouTubePlayer) { + youTubePlayerRef = youTubePlayer + // Load the initial video when ready + videoIdFromUrl?.let { id -> + // Best practice: Use loadOrCueVideo to avoid playing in the background + // if the view is not resumed. + youTubePlayer.loadOrCueVideo(lifecycleOwner.lifecycle, id, 0f) + exerciseViewModel.fetchSubtitlesForVideoId(id) + } + } + override fun onCurrentSecond(youTubePlayer: YouTubePlayer, second: Float) { + currentTimeSec = second + } + + override fun onStateChange(youTubePlayer: YouTubePlayer, state: com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants.PlayerState) { + if (state == com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants.PlayerState.ENDED) { + videoEnded = true + } + } + } + // IFramePlayerOptions allow for more control over the player appearance and behavior + val options = IFramePlayerOptions.Builder(context) + .controls(1) // Show player controls + .rel(0) // Show related videos from the same channel + .build() + initialize(listener, options) + } + } + + // Attach the player view to the Composable's lifecycle + DisposableEffect(lifecycleOwner) { + lifecycleOwner.lifecycle.addObserver(playerView) + onDispose { + exerciseViewModel.clearYouTubeSubtitles() + // It's crucial to release the player resource when the Composable is disposed + try { playerView.release() } catch (_: Exception) {} + } + } + + // Support changing the URL during the session + LaunchedEffect(videoIdFromUrl) { + if (videoIdFromUrl != null) { + // Best practice: Use loadOrCueVideo for subsequent video loads as well. + youTubePlayerRef?.loadOrCueVideo(lifecycleOwner.lifecycle, videoIdFromUrl, 0f) + exerciseViewModel.fetchSubtitlesForVideoId(videoIdFromUrl) + } else { + exerciseViewModel.clearYouTubeSubtitles() + } + } + + fun seekTo(seconds: Float) { + youTubePlayerRef?.seekTo(seconds) + } + + // Generate questions when user wants to finish + LaunchedEffect(userWantsToFinish, exerciseState) { + if (userWantsToFinish && exerciseState is YouTubeExerciseState.Success && !isGeneratingQuestions) { + val state = exerciseState as YouTubeExerciseState.Success + val subtitles = state.subtitles + + if (subtitles.isNotEmpty()) { + isGeneratingQuestions = true + + // Use ViewModel method to avoid coroutine cancellation issues + exerciseViewModel.generateAndSaveYouTubeExercise( + subtitles = subtitles, + videoTitle = title, + videoId = videoIdFromUrl ?: "unknown", + sourceLanguage = sessionParams?.sourceLanguage, + targetLanguage = sessionParams?.targetLanguage, + onComplete = { exercise: eu.gaudian.translator.model.Exercise -> + // Exercise is saved, now get the questions for the session + exerciseViewModel.startExercise(exercise) + @Suppress("HardCodedStringLiteral") + navController.navigate("exercise_session") + }, + onError = { error -> + Toast.makeText( + context, + "$textErrorGeneratingQuestions $error", + Toast.LENGTH_LONG + ).show() + isGeneratingQuestions = false + } + ) + } + } + } + + // Navigation is now handled in the generateAndSaveYouTubeExercise onComplete callback + + fun onFinishVideo() { + userWantsToFinish = true + } + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(title, maxLines = 1) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource( + R.string.cd_back + )) + } + }, + actions = { + IconButton( + onClick = { onFinishVideo() }, + enabled = exerciseState is YouTubeExerciseState.Success && !isGeneratingQuestions + ) { + Icon( + AppIcons.Check, + contentDescription = stringResource(R.string.text_finish_video_and_start_exercise) + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + // Split mode: video player and subtitles panel + AndroidView( + factory = { playerView }, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(2f) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + when (val state = exerciseState) { + is YouTubeExerciseState.Idle -> Text(stringResource(R.string.text_paste_or_open_a_), textAlign = TextAlign.Center, modifier = Modifier.padding(16.dp)) + is YouTubeExerciseState.Loading -> CircularProgressIndicator() + is YouTubeExerciseState.Error -> Text( + stringResource( + R.string.text_error_2d, + state.message + ), color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center, modifier = Modifier.padding(16.dp)) + is YouTubeExerciseState.Success -> { + title = state.videoTitle + val subtitles = state.subtitles + val activeIndex = subtitles.indexOfLast { s -> currentTimeSec >= s.start } + + LaunchedEffect(activeIndex) { + if (activeIndex >= 1) { + // Animate scroll to the currently active subtitle + listState.animateScrollToItem(index = activeIndex) + } + } + + // Show loading indicator when generating questions + if (isGeneratingQuestions) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + Text( + text = stringResource(R.string.text_generating_questions_from_video), + modifier = Modifier.padding(top = 16.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp) + ) { + itemsIndexed(items = subtitles, key = { _, item -> item.start }) { index, subtitle -> + val isActive = index == activeIndex + val bg = if (isActive) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surfaceVariant + Column( + modifier = Modifier + .fillMaxWidth() + .background(bg) + .clickable { seekTo(subtitle.start) } + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = "${formatTimestamp(subtitle.start)} - ${formatTimestamp(subtitle.end)}", + style = MaterialTheme.typography.labelSmall, + color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = subtitle.text, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth() + ) + if (!subtitle.translatedText.isNullOrBlank()) { + Text( + text = subtitle.translatedText, + style = MaterialTheme.typography.bodyMedium, + color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) + } + } + } + } + } + } + } + } + } + } +} + +fun extractVideoId(url: String?): String? { + if (url == null) return null + @Suppress("RegExpRedundantEscape", "HardCodedStringLiteral") val pattern = "(?<=watch\\?v=|/videos/|embed\\/|youtu.be\\/|\\/v\\/|\\/e\\/|watch\\?v%3D|watch\\?feature=player_embedded&v=|%2Fvideos%2F|embed%2Fvideos%2F|youtu.be%2F)[^#&?]*".toRegex() + return pattern.find(url)?.value +} + +fun formatTimestamp(seconds: Float): String { + val total = seconds.toInt() + val mins = total / 60 + val secs = total % 60 + @Suppress("HardCodedStringLiteral") + return "%d:%02d".format(mins, secs) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/AddModelScanHint.kt b/app/src/main/java/eu/gaudian/translator/view/hints/AddModelScanHint.kt new file mode 100644 index 0000000..5e09ffb --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/AddModelScanHint.kt @@ -0,0 +1,154 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons + +/** + * Transformed AddModelScanHint using the new uniform Hint structure. + */ + +@Composable +fun getAddModelScanHint(): Hint { + return Hint( + titleRes = R.string.hint_scan_hint_title, + elements = listOf( + + HintElement.Section( + title = stringResource(R.string.scan_hint_section_how_scan_works), + content = listOf( + HintElement.Text(stringResource(R.string.scan_hint_how_scan_works_paragraph)), + HintElement.UIElement { Spacer(Modifier.height(8.dp)) }, + HintElement.UIElement { ReusedScanButtonPreview() }, + HintElement.UIElement { Spacer(Modifier.height(8.dp)) }, + HintElement.BulletList( + listOf( + stringResource(R.string.scan_hint_bullet_results_depend), + stringResource(R.string.scan_hint_bullet_public_private), + stringResource(R.string.scan_hint_bullet_try_again) + ) + ) + ) + ), + HintElement.Divider, + HintElement.Section( + title = stringResource(R.string.scan_hint_section_why_missing), + content = listOf( + HintElement.InfoBadge( + icon = AppIcons.Lock, + text = stringResource(R.string.scan_hint_badge_restricted) + ), + HintElement.InfoBadge( + icon = AppIcons.Warning, + text = stringResource(R.string.scan_hint_badge_not_suitable) + ), + HintElement.InfoBadge( + icon = AppIcons.CheckCircle, + text = stringResource(R.string.scan_hint_badge_only_text_models) + ), + HintElement.Divider, + HintElement.Text(stringResource(R.string.scan_hint_focus_text_models)), + HintElement.Divider, + HintElement.Card { + PerformanceTierChips() + }, + HintElement.Divider, + HintElement.InfoBadge( + icon = AppIcons.Info, + text = stringResource(R.string.scan_hint_most_tasks_small_models) + ) + ) + ), + HintElement.Divider, + HintElement.Section( + title = stringResource(R.string.scan_hint_section_tips), + content = listOf( + HintElement.BulletList( + listOf( + stringResource(R.string.scan_hint_tip_verify_key), + stringResource(R.string.scan_hint_tip_select_org), + stringResource(R.string.scan_hint_tip_type_manually), + stringResource(R.string.scan_hint_tip_instruct_chat_text) + ) + ) + ) + ), + HintElement.Divider, + HintElement.Section( + title = stringResource(R.string.hint_scan_hint_section_visual_guide), + content = listOf( + HintElement.VisualStep( + step = stringResource(R.string.hint_scan_hint_step_1), + title = stringResource(R.string.hint_scan_hint_step1_title), + description = stringResource(R.string.hint_scan_hint_step1_desc), + trailing = { + Icon(AppIcons.Search, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + ), + HintElement.VisualStep( + step = stringResource(R.string.hint_scan_hint_step_2), + title = stringResource(R.string.hint_scan_hint_step2_title), + description = stringResource(R.string.hint_scan_hint_step2_desc), + trailing = { + SuggestionChip( + onClick = {}, + label = { Text(stringResource(R.string.hint_scan_hint_label_text_chat)) }, + enabled = false, + icon = {}, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ), + HintElement.VisualStep( + step = stringResource(R.string.hint_scan_hint_step_3), + title = stringResource(R.string.hint_scan_hint_step3_title), + description = stringResource(R.string.hint_scan_hint_step3_desc), + trailing = { + SmallPrimaryCard(text = stringResource(R.string.hint_scan_hint_add_validate)) + } + ) + ) + ), + HintElement.Divider, + HintElement.Section( + title = stringResource(R.string.hint_scan_hint_section_cant_find), + content = listOf( + HintElement.Text(stringResource(R.string.hint_scan_hint_manual_add_paragraph)) + ) + ) + ) +) +} + + + +@Preview +@Composable +fun ReusedScanButtonPreviewPreview() { + ReusedScanButtonPreview() +} + +@Preview +@Composable +fun AddModelScanHintPreview() { + getAddModelScanHint().Render() +} + +@Composable +fun AddModelScanHint() { + getAddModelScanHint().Render() +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/ApiKeyHints.kt b/app/src/main/java/eu/gaudian/translator/view/hints/ApiKeyHints.kt new file mode 100644 index 0000000..7a5506a --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/ApiKeyHints.kt @@ -0,0 +1,125 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons + +object ApiKeyHint: LegacyHint() { + override val titleRes: Int = R.string.hint_how_to_connect_to_an_ai + + @Composable + override fun getTitle(): String = stringResource(R.string.hint_how_to_connect_to_an_ai) + + @Composable + override fun Content() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + HeaderWithIcon( + title = stringResource(R.string.hint_how_to_connect_to_an_ai) + ) + + HintSection(title = stringResource(R.string.connecting_your_ai_model)) { + Text( + text = stringResource(R.string.api_hint_intro_1), + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.api_hint_intro_2), + style = MaterialTheme.typography.bodyMedium + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + HintSection(title = stringResource(R.string.key_status_indicators_title)) { + Text( + text = stringResource(R.string.key_status_explanation), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + KeyStatus(hasKey = true) + Text( + text = stringResource(R.string.key_saved_and_active), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 20.dp, bottom = 8.dp) + ) + KeyStatus(hasKey = false) + Text( + text = stringResource(R.string.key_missing_or_cleared), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 20.dp) + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + HintSection(title = stringResource(R.string.troubleshooting_title)) { + Text( + text = stringResource(R.string.troubleshooting_intro), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = stringResource(R.string.troubleshooting_bullets), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + + + @Composable + private fun KeyStatus(hasKey: Boolean) { + val (text, color, icon) = if (hasKey) { + Triple( + stringResource(R.string.text_key_active), + MaterialTheme.colorScheme.primary, + AppIcons.Check + ) + } else { + Triple( + stringResource(R.string.text_no_key), + MaterialTheme.colorScheme.error, + AppIcons.Warning + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = text, + tint = color, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text(text, color = color, style = MaterialTheme.typography.labelLarge) + } + } +} + +@Preview(showBackground = true) +@Composable +fun ApiKeyHintPreview() { + MaterialTheme { + ApiKeyHint.Content() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/CategoryHint.kt b/app/src/main/java/eu/gaudian/translator/view/hints/CategoryHint.kt new file mode 100644 index 0000000..2d9cda9 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/CategoryHint.kt @@ -0,0 +1,116 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons + +object CategoryHint : LegacyHint() { + override val titleRes: Int = R.string.category_hint_intro + + @Composable + override fun Content() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + HeaderWithIcon( + title = stringResource(R.string.category_hint_intro) + ) + + HintSection(title = stringResource(R.string.category_hint_intro)) { + // Tag Category Explanation (formerly List) + CategoryHintItem( + icon = { + Icon( + AppIcons.FilterList, + contentDescription = stringResource(R.string.content_desc_tag_category), + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.secondary + ) + }, + title = stringResource(R.string.text_list), + description = stringResource(R.string.hint_list_category) + ) + + // Filter Category Explanation + CategoryHintItem( + icon = { + Icon( + AppIcons.FilterCategory, + contentDescription = stringResource(R.string.content_desc_filter_category), + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.secondary + ) + }, + title = stringResource(R.string.text_filter), + description = stringResource(R.string.hint_filter_category_description) + ) + } + } + } +} + +@Composable +fun CategoryHint() { + CategoryHint.Content() +} + +@Composable +private fun CategoryHintItem( + icon: @Composable () -> Unit, + title: String, + description: String +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + icon() + Column { + Text(text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = description, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Preview +@Composable +fun CategoryHintPreview() { + CategoryHint() +} + +@Preview +@Composable +fun CategoryHintItemPreview() { + CategoryHintItem( + icon = { + Icon( + AppIcons.Category, + contentDescription = stringResource(R.string.cd_tag_category), + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.secondary + ) + }, + title = stringResource(R.string.text_list), + description = stringResource(R.string.category_hint_item_preview_description) + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/CategoryHintScreen.kt b/app/src/main/java/eu/gaudian/translator/view/hints/CategoryHintScreen.kt new file mode 100644 index 0000000..53521ca --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/CategoryHintScreen.kt @@ -0,0 +1,131 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons + +object CategoryHintScreen : LegacyHint() { + override val titleRes: Int = R.string.category_hint_intro + + @Composable + override fun Content() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + HeaderWithIcon( + title = stringResource(R.string.category_hint_intro) + ) + + HintSection(title = stringResource(R.string.category_list_title)) { + Text( + text = stringResource(R.string.category_list_description), + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + // Visual representation for List Category + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + WordItem(text = stringResource(R.string.example_word_apple)) + Icon(AppIcons.Add, contentDescription = stringResource(R.string.action_add), tint = MaterialTheme.colorScheme.secondary) + CategoryBox(icon = AppIcons.FilterList, text = stringResource(R.string.example_category_my_fruit_list)) + } + } + + HorizontalDivider() + + HintSection(title = stringResource(R.string.category_filter_title)) { + Text( + text = stringResource(R.string.category_filter_description), + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + // Visual representation for Filter Category + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row { + WordItem(text = stringResource(R.string.example_word_dog), subtext = stringResource(R.string.stage_1)) + Spacer(modifier = Modifier.padding(horizontal = 4.dp)) + WordItem(text = stringResource(R.string.example_word_cat), subtext = stringResource(R.string.stage_1)) + } + CategoryBox(icon = AppIcons.FilterCategory, text = stringResource(R.string.example_filter_stage_1)) + } + } + } + } +} + +@Composable +fun CategoryHintScreen() { + CategoryHintScreen.Content() +} + +/** + * A simple visual representation of a vocabulary word. + */ +@Composable +private fun WordItem(text: String, subtext: String? = null) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = text, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + subtext?.let { + Text(text = it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +/** + * A simple visual representation of a category. + */ +@Composable +private fun CategoryBox(icon: ImageVector, text: String) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(imageVector = icon, contentDescription = text, modifier = Modifier.size(24.dp)) + Text(text = text, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + } +} + + +@Preview(showBackground = true) +@Composable +fun CategoryHintScreenPreview() { + MaterialTheme { + CategoryHintScreen() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/DictionaryOptionsHint.kt b/app/src/main/java/eu/gaudian/translator/view/hints/DictionaryOptionsHint.kt new file mode 100644 index 0000000..92023c4 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/DictionaryOptionsHint.kt @@ -0,0 +1,79 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons + +@Composable +fun getDictionaryOptionsHint(): Hint { + return Hint( + titleRes = R.string.label_dictionary_options, + elements = listOf( + HintElement.Section( + title = stringResource(R.string.hint_dictionary_desc), + content = listOf( + // VisualStep 1: + HintElement.VisualStep( + step = "1", + title = stringResource(R.string.hint_dict_options_step1_title), + description = stringResource(R.string.hint_dict_options_step1_desc) + ), + // VisualStep 2: + HintElement.VisualStep( + step = "2", + title = stringResource(R.string.hint_dict_options_step2_title), + description = stringResource(R.string.hint_dict_options_step2_desc), + trailing = { + // Custom trailing composable from the original hint + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.eg_synonyms), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Icon( + imageVector = AppIcons.SwitchOn, + contentDescription = stringResource(R.string.example_toggle), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + } + } + ) + ) + ) + ) + ) +} + +/** + * Preview for the migrated DictionaryOptionsHint. + * It now calls getDictionaryOptionsHint() and then Render(). + */ +@Preview +@Composable +fun DictionaryOptionsHintPreview() { + getDictionaryOptionsHint().Render() +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/ExampleModernHint.kt b/app/src/main/java/eu/gaudian/translator/view/hints/ExampleModernHint.kt new file mode 100644 index 0000000..bb6591c --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/ExampleModernHint.kt @@ -0,0 +1,162 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton + +/** + * Example of a modern hint using the new uniform structure with Hint and HintElements. + * This demonstrates how to migrate from the old Content() based hints to the new element-based system. + */ +val ExampleModernHint = Hint( + titleRes = R.string.hint_example_hint_scan_for_models_hint, + subtitle = "How to find and add AI models to your app", + elements = listOf( + HintElement.Section( + title = "How Scanning Works", + content = listOf( + HintElement.Text("The scan feature searches for available AI models on your device or network."), + HintElement.UIElement { + ReusedScanButtonPreview() + }, + HintElement.BulletList( + listOf( + "Results depend on your API key permissions.", + "Only public models are shown by default.", + "Try again if no models are found." + ) + ) + ) + ), + HintElement.Divider, + HintElement.Section( + title = "Why Some Models Are Missing", + content = listOf( + HintElement.InfoBadge( + icon = AppIcons.Lock, + text = "Restricted access" + ), + HintElement.InfoBadge( + icon = AppIcons.Warning, + text = "Not suitable for this app" + ), + HintElement.InfoBadge( + icon = AppIcons.CheckCircle, + text = "Only text models are supported" + ), + HintElement.Text("Focus on text-based models for best performance."), + HintElement.Card { + PerformanceTierChips() + }, + HintElement.InfoBadge( + icon = AppIcons.Info, + text = "Most tasks work well with smaller models" + ) + ) + ), + HintElement.Divider, + HintElement.Section( + title = "Tips", + content = listOf( + HintElement.BulletList( + listOf( + "Verify your API key is active.", + "Select the correct organization.", + "Type model names manually if needed.", + "Prefer instruct or chat models for text." + ) + ) + ) + ), + HintElement.Divider, + HintElement.Section( + title = "Visual Guide", + content = listOf( + HintElement.VisualStep( + step = "1", + title = "Initiate Scan", + description = "Click the scan button to search for models.", + trailing = { + Icon(AppIcons.Search, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + } + ), + HintElement.VisualStep( + step = "2", + title = "Select Model Type", + description = "Choose between text, chat, or instruct models.", + trailing = { + SuggestionChip( + onClick = {}, + label = { Text("Text Chat") }, + enabled = false, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ), + HintElement.VisualStep( + step = "3", + title = "Add and Validate", + description = "Add the selected model and validate it.", + trailing = { + SmallPrimaryCard(text = "Add & Validate") + } + ) + ) + ), + HintElement.Divider, + HintElement.Section( + title = "Can't Find Your Model?", + content = listOf( + HintElement.Text("You can manually add models by entering their details.") + ) + ) + ) +) + +/** + * Preview composable for the example modern hint. + */ +@Preview +@Composable +fun ExampleModernHintPreview() { + ExampleModernHint.Render() +} + +/** + * Reused composable for scan button preview (from original hint). + */ +@Preview +@Composable +fun ReusedScanButtonPreview() { + AppCard { + AppOutlinedButton(onClick = {}, enabled = true, modifier = Modifier.fillMaxWidth()) { + Icon( + AppIcons.Search, + contentDescription = null, + modifier = Modifier.size(FilterChipDefaults.IconSize) + ) + Spacer(Modifier.width(8.dp)) + Text(text = "Scan for Models") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/Hint.kt b/app/src/main/java/eu/gaudian/translator/view/hints/Hint.kt new file mode 100644 index 0000000..1b0a0ac --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/Hint.kt @@ -0,0 +1,324 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons + +/** + * Data class for the new uniform Hint structure. + * Each hint has a title and a list of HintElements. + */ +data class Hint( + val titleRes: Int, + val subtitle: String? = null, + val elements: List +) { + /** + * Composable to render the hint. + */ + @Composable + fun Render() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + HeaderWithIcon(title = getTitle(), subtitle) + elements.forEach { RenderHintElement(it) } + } + } + @Composable + fun getTitle(): String = stringResource(titleRes) +} + +/** + * Legacy abstract class for existing hints (to be migrated). + * New hints should use the data class above. + */ +@Deprecated("Use Hint data class instead") +abstract class LegacyHint { + abstract val titleRes: Int + + @Composable + abstract fun Content() + + @Composable + open fun getTitle(): String = stringResource(titleRes) +} +@Composable +fun HeaderWithIcon(title: String, subtitle: String? = null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = AppIcons.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary + ) + + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + if(subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + )} + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun HeaderWithIconPreview() { + HeaderWithIcon(title = "Sample Title", subtitle = "Sample Subtitle") +} + +@Composable +fun HintSection(title: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + content() + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun HintSectionPreview() { + AppCard( + modifier = Modifier.fillMaxWidth(), + ) { + HintSection(title = "Sample Section Title") { + Text("Sample section content.") + } + } +} + +@Composable +fun BulletPoints(items: List) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + items.forEach { line -> + Row(verticalAlignment = Alignment.Top) { + Text("•", modifier = Modifier.width(16.dp), textAlign = TextAlign.Center) + Text(line, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun BulletPointsPreview() { + BulletPoints(items = listOf("Point 1", "Point 2", "Point 3")) +} + +@Composable +fun InfoBadgeRow(icon: ImageVector, text: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun InfoBadgeRowPreview() { + InfoBadgeRow(icon = AppIcons.Info, text = "Sample info badge text") +} + + + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun PerformanceTierChips() { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + SuggestionChip( + onClick = {}, + label = { Text(stringResource(R.string.hint_scan_hint_chip_nano)) }, + enabled = false, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) + SuggestionChip( + onClick = {}, + label = { Text(stringResource(R.string.hint_scan_hint_chip_mini)) }, + enabled = false, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) + SuggestionChip( + onClick = {}, + label = { Text(stringResource(R.string.hint_scan_hint_chip_small)) }, + enabled = false, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) + SuggestionChip( + onClick = {}, + label = { Text(stringResource(R.string.hint_scan_hint_chip_medium)) }, + enabled = false, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + SuggestionChip( + onClick = {}, + label = { Text(stringResource(R.string.hint_scan_hint_chip_large_paid)) }, + enabled = false, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + labelColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } +} + +@Preview +@Composable +fun PerformanceTierChipsPreview() { + PerformanceTierChips() +} + + + + + +@Composable +fun VisualStep( + step: String, + title: String, + description: String, + trailing: @Composable (() -> Unit)? = null +) { + AppCard( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + StepBadge(step) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text(description, style = MaterialTheme.typography.bodySmall) + } + } + if (trailing != null) { + Spacer(Modifier.width(8.dp)) + Row( + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ){ + trailing() + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun VisualStepPreview() { + VisualStep( + step = "1", + title = "Sample Step Title", + description = "Sample step description.", + trailing = { Icon(AppIcons.Search, contentDescription = null) } + ) +} + +@Composable +private fun StepBadge(step: String) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + shape = RoundedCornerShape(8.dp) + ) { + Box(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), contentAlignment = Alignment.Center) { + Text(step, color = MaterialTheme.colorScheme.onPrimaryContainer, fontWeight = FontWeight.Bold, fontSize = 12.sp) + } + } +} + +@Preview +@Composable +fun StepBadgePreview() { + StepBadge(step = "1") +} + +@Composable +fun SmallPrimaryCard(text: String) { + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary)) { + Box(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + Text(text, color = MaterialTheme.colorScheme.onPrimary, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } + } +} + +@Composable +fun TextSection(title: String, text: String) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(text, style = MaterialTheme.typography.bodySmall) + } +} + + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun SmallPrimaryCardPreview() { + SmallPrimaryCard(text = "Sample Text") +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/HintBottomSheet.kt b/app/src/main/java/eu/gaudian/translator/view/hints/HintBottomSheet.kt new file mode 100644 index 0000000..28b02ba --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/HintBottomSheet.kt @@ -0,0 +1,73 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppButton +import kotlinx.coroutines.launch + +/** + * A shared modal bottom sheet for displaying hint content. + * The content area is scrollable if it exceeds the available space. + * + * @param onDismissRequest Called when the user requests to dismiss the sheet. + * @param sheetState The state of the bottom sheet. + * @param content The hint content to display inside the sheet. + */ +@Composable +fun HintBottomSheet( + onDismissRequest: () -> Unit, + sheetState: SheetState, + content: @Composable (() -> Unit)? +) { + val scope = rememberCoroutineScope() + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + ) { + + + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + + Column( + modifier = Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + ) { + content?.invoke() + } + + Spacer(modifier = Modifier.height(16.dp)) + AppButton( + onClick = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + if (!sheetState.isVisible) { + onDismissRequest() + } + } + }, + modifier = Modifier + .align(Alignment.End) + .padding(bottom = 8.dp) + ) { + Text(stringResource(R.string.got_it)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/HintComposable.kt b/app/src/main/java/eu/gaudian/translator/view/hints/HintComposable.kt new file mode 100644 index 0000000..c6b2e84 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/HintComposable.kt @@ -0,0 +1,95 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons + +/** + * A CompositionLocal to provide the "show hints" setting down the component tree. + * This avoids having to pass the setting as a parameter to every composable. + * The default value is `false`. + */ +val LocalShowHints = compositionLocalOf { false } + +/** + * A wrapper composable that adds an optional hint button next to its content. + * The button is only visible if the 'LocalShowHints' CompositionLocal is true. + * When clicked, it displays the provided hint content in a ModalBottomSheet. + * + * @param hintContent The composable content to display inside the ModalBottomSheet. + * @param modifier The modifier to be applied to the layout. + * @param content The main composable content that this hint is for. + */ +@Composable +fun WithHint( + hintContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val showHints = LocalShowHints.current + + val sheetState = rememberModalBottomSheetState() + var showBottomSheet by remember { mutableStateOf(false) } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + content() + + if (showHints) { + IconButton( + onClick = { showBottomSheet = true }, + modifier = Modifier.padding(start = 4.dp) + ) { + Icon( + imageVector = AppIcons.Help, + contentDescription = stringResource(R.string.show_hint), + tint = MaterialTheme.colorScheme.secondary + ) + } + } + } + + if (showBottomSheet) { + HintBottomSheet( + onDismissRequest = { + @Suppress("AssignedValueIsNeverRead") + showBottomSheet = false + }, + sheetState = sheetState, + hintContent + ) + } +} + +@Preview +@Composable +fun WithHintPreview() { + androidx.compose.runtime.CompositionLocalProvider(LocalShowHints provides true) { + WithHint( + hintContent = { + Text(stringResource(R.string.this_is_a_hint)) + } + ) { + Text(stringResource(R.string.this_is_the_main_content)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/HintElement.kt b/app/src/main/java/eu/gaudian/translator/view/hints/HintElement.kt new file mode 100644 index 0000000..a22ede9 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/HintElement.kt @@ -0,0 +1,261 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import eu.gaudian.translator.view.composable.AppIcons + +/** + * Sealed class representing different types of elements that can be used in a Hint. + * Each subclass corresponds to a specific UI component or content type. + */ +sealed class HintElement { + + /** + * A simple text element. + */ + data class Text(val text: String) : HintElement() + + /** + * A header element with an icon, title, and optional subtitle. + */ + data class Header(val title: String, val subtitle: String? = null) : HintElement() + + /** + * A section element that groups content with a title. + */ + data class Section(val title: String, val content: List) : HintElement() + + data class TextSection(val title: String, val text: String) : HintElement() + + /** + * A bullet list element. + */ + data class BulletList(val items: List) : HintElement() + + /** + * A visual step element with step number, title, description, and optional trailing composable. + */ + data class VisualStep( + val step: String, + val title: String, + val description: String, + val trailing: (@Composable () -> Unit)? = null + ) : HintElement() + + /** + * An info badge row with an icon and text. + */ + data class InfoBadge(val icon: ImageVector, val text: String) : HintElement() + + /** + * A divider element. + */ + object Divider : HintElement() + + /** + * A card element with content. + */ + data class Card(val content: @Composable () -> Unit) : HintElement() + + /** + * A chips element for performance tiers or similar. + */ + data class Chips(val chips: List) : HintElement() + + /** + * A small primary card with text. + */ + data class SmallCard(val text: String) : HintElement() + + /** + * A custom UI element with a composable. + */ + data class UIElement(val composable: @Composable () -> Unit) : HintElement() +} + +/** + * Composable to render a HintElement. + */ +@Composable +fun RenderHintElement(element: HintElement) { + when (element) { + is HintElement.Text -> { + Text( + text = element.text, + style = MaterialTheme.typography.bodyMedium + ) + } + is HintElement.Header -> { + HeaderWithIcon(title = element.title, subtitle = element.subtitle) + } + is HintElement.Section -> { + HintSection(title = element.title) { + element.content.forEach { RenderHintElement(it) } + } + } + is HintElement.BulletList -> { + BulletPoints(items = element.items) + } + is HintElement.VisualStep -> { + VisualStep( + step = element.step, + title = element.title, + description = element.description, + trailing = element.trailing + ) + } + is HintElement.InfoBadge -> { + InfoBadgeRow(icon = element.icon, text = element.text) + } + is HintElement.Divider -> { + HorizontalDivider() + } + is HintElement.Card -> { + element.content() + } + is HintElement.Chips -> { + PerformanceTierChips(chips = element.chips) + } + is HintElement.SmallCard -> { + SmallPrimaryCard(text = element.text) + } + is HintElement.UIElement -> { + element.composable() + } + is HintElement.TextSection -> { + TextSection(title = element.title, text = element.text) + } + } +} + +/** + * Helper composable for PerformanceTierChips based on list of strings. + */ +@Composable +fun PerformanceTierChips(chips: List) { + // Assuming chips are resource strings or direct strings + chips.forEach { chip -> + Text(text = chip, style = MaterialTheme.typography.bodySmall) // Simplified for template + } +} + +// Preview Composables for each HintElement type + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun TextElementPreview() { + RenderHintElement(HintElement.Text("This is a sample text element.")) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun HeaderElementPreview() { + RenderHintElement(HintElement.Header("Sample Header", "Optional subtitle")) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun SectionElementPreview() { + RenderHintElement( + HintElement.Section( + title = "Sample Section", + content = listOf( + HintElement.Text("Content 1"), + HintElement.Text("Content 2") + ) + ) + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun BulletListElementPreview() { + RenderHintElement( + HintElement.BulletList( + listOf("Item 1", "Item 2", "Item 3") + ) + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun VisualStepElementPreview() { + RenderHintElement( + HintElement.VisualStep( + step = "1", + title = "Sample Step", + description = "This is a description of the step.", + trailing = { + Icon(AppIcons.Info, contentDescription = null) + } + ) + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun InfoBadgeElementPreview() { + RenderHintElement( + HintElement.InfoBadge( + icon = AppIcons.Info, + text = "Sample info badge" + ) + ) +} + +@Preview +@Composable +fun DividerElementPreview() { + RenderHintElement(HintElement.Divider) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun CardElementPreview() { + RenderHintElement( + HintElement.Card { + Text("Content inside card") + } + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun ChipsElementPreview() { + RenderHintElement( + HintElement.Chips( + listOf("Chip 1", "Chip 2", "Chip 3") + ) + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun SmallCardElementPreview() { + RenderHintElement(HintElement.SmallCard("Sample Card")) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun UIElementPreview() { + RenderHintElement( + HintElement.UIElement { + Text("Custom UI Element") + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/HintScreen.kt b/app/src/main/java/eu/gaudian/translator/view/hints/HintScreen.kt new file mode 100644 index 0000000..aeab7d7 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/HintScreen.kt @@ -0,0 +1,51 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar + +/** + * Generic hint screen wrapper that provides a consistent layout for all hint screens + * with a top app bar and back navigation. + */ +@Composable +fun HintScreen( + navController: NavController, + title: String, + content: @Composable () -> Unit +) { + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + content() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/HintScreens.kt b/app/src/main/java/eu/gaudian/translator/view/hints/HintScreens.kt new file mode 100644 index 0000000..b6444df --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/HintScreens.kt @@ -0,0 +1,121 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.runtime.Composable +import androidx.navigation.NavController + +/** + * Wrapper for Category Hint Screen + */ +@Composable +fun CategoryHintScreenWrapper(navController: NavController) { + HintScreen( + navController = navController, + title = CategoryHint.getTitle() + ) { + CategoryHint.Content() + } +} + +/** + * Dictionary Hint Screen + */ +@Composable +fun DictionaryHintScreen(navController: NavController) { + HintScreen( + navController = navController, + title = getDictionaryOptionsHint().getTitle() + ) { + getDictionaryOptionsHint() + } +} + +/** + * Import Hint Screen + */ +@Composable +fun ImportHintScreen(navController: NavController) { + HintScreen( + navController = navController, + title = getImportVocabularyHint().getTitle() + ) { + getImportVocabularyHint() + } +} + +/** + * Sorting Hint Screen + */ +@Composable +fun SortingHintScreen(navController: NavController) { + HintScreen( + navController = navController, + title = SortingScreenHint.getTitle() + ) { + SortingScreenHint.Content() + } +} + +/** + * Stages Hint Screen + */ +@Composable +fun StagesHintScreen(navController: NavController) { + HintScreen( + navController = navController, + title = LearningStagesHint.getTitle() + ) { + LearningStagesHint.Content() + } +} + +/** + * Translation Hint Screen + */ +@Composable +fun TranslationHintScreen(navController: NavController) { + HintScreen( + navController = navController, + title = getTranslationScreenHint().getTitle() + ) { + getTranslationScreenHint() + } +} + +/** + * Scan Hint Screen + */ +@Composable +fun ScanHintScreen(navController: NavController) { + HintScreen( + navController = navController, + title = getAddModelScanHint().getTitle() + ) { + AddModelScanHint() + } +} + +/** + * API Hint Screen + */ +@Composable +fun ApiHintScreen(navController: NavController) { + HintScreen( + navController = navController, + title = ApiKeyHint.getTitle() + ) { + ApiKeyHint.Content() + } +} + +/** + * Vocabulary Progress Hint Screen + */ +@Composable +fun VocabularyProgressHintScreen(navController: NavController) { + HintScreen( + navController = navController, + title = VocabularyProgressHint.getTitle() + ) { + VocabularyProgressHint.Content() + } +} diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/HintsOverviewScreen.kt b/app/src/main/java/eu/gaudian/translator/view/hints/HintsOverviewScreen.kt new file mode 100644 index 0000000..22b2034 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/HintsOverviewScreen.kt @@ -0,0 +1,188 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.LocalShowExperimentalFeatures +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedCard +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.settings.SettingsRoutes + +private data class HintItem(val titleRes: Int, val icon: ImageVector, val route: String) + +/** + * Hints overview screen that lists all available hint screens in organized categories + */ +@Composable +fun HintsOverviewScreen( + navController: NavController, + modifier: Modifier = Modifier +) { + val showExperimental = LocalShowExperimentalFeatures.current + + val importHint = getImportVocabularyHint() + val addModelScanHint = getAddModelScanHint() + val dictionaryOptionsHint = getDictionaryOptionsHint() + val translationScreenHint = getTranslationScreenHint() + + + + + val hintGroups = remember(showExperimental, importHint) { + val allGroups = listOf( + R.string.hint_hints_header_basics to listOf( + HintItem(CategoryHint.titleRes, AppIcons.Category, SettingsRoutes.HINTS_CATEGORIES), + HintItem(LearningStagesHint.titleRes, AppIcons.Stages, SettingsRoutes.HINTS_STAGES), + HintItem(translationScreenHint.titleRes, AppIcons.Translate, SettingsRoutes.HINTS_TRANSLATION) + ), + R.string.hint_hints_header_vocabulary to listOf( + HintItem(importHint.titleRes, AppIcons.Vocabulary, SettingsRoutes.HINTS_IMPORT), + HintItem(SortingScreenHint.titleRes, AppIcons.Sort, SettingsRoutes.HINTS_SORTING), + HintItem(dictionaryOptionsHint.titleRes, AppIcons.Dictionary, SettingsRoutes.HINTS_DICTIONARY), + HintItem(VocabularyProgressHint.titleRes, AppIcons.Stages, SettingsRoutes.HINTS_VOCABULARY_PROGRESS) + ), + R.string.hint_hints_header_advanced to listOf( + HintItem(addModelScanHint.titleRes, AppIcons.AI, SettingsRoutes.HINTS_SCAN), + HintItem(ApiKeyHint.titleRes, AppIcons.ApiKey, SettingsRoutes.HINTS_API) + ) + ) + + if (showExperimental) { + allGroups + } else { + allGroups + } + } + + AppOutlinedCard { + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.hint_title_hints_overview), style = MaterialTheme.typography.titleLarge) } + ) + } + ) { paddingValues -> + LazyColumn(modifier = modifier.padding(paddingValues).padding(bottom = 0.dp)) { + // Introduction section + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = stringResource(R.string.hint_hints_overview_intro), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.hint_hints_overview_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + + // Hint categories + hintGroups.forEach { (headerRes, hints) -> + item { + HintHeader(title = stringResource(id = headerRes)) + } + item { + AppCard( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column { + hints.forEachIndexed { index, hint -> + HintListItem( + title = stringResource(id = hint.titleRes), + icon = hint.icon, + onClick = { navController.navigate(hint.route) } + ) + if (index < hints.lastIndex) { + HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) + } + } + } + } + } + } + } + } + } +} + +@ThemePreviews +@Composable +fun HintsOverviewScreenPreview() { + HintsOverviewScreen(navController = rememberNavController()) +} + +@Composable +private fun HintHeader( + title: String, + modifier: Modifier = Modifier +) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) +} + +@Composable +private fun HintListItem( + title: String, + icon: ImageVector, + onClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(title) }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + modifier = Modifier.clickable(onClick = onClick) + ) +} + +@ThemePreviews +@Composable +fun HintListItemPreview() { + HintListItem( + title = stringResource(R.string.category_hint_intro), + icon = AppIcons.Category, + onClick = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/ImportVocabularyHints.kt b/app/src/main/java/eu/gaudian/translator/view/hints/ImportVocabularyHints.kt new file mode 100644 index 0000000..656d3b4 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/ImportVocabularyHints.kt @@ -0,0 +1,173 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppCheckbox +import eu.gaudian.translator.view.composable.AppSlider +import eu.gaudian.translator.view.composable.AppTextField + +/** + * Provides the migrated Hint for ImportVocabulary. + * This function is @Composable to access stringResource. + */ +@Composable +fun getImportVocabularyHint(): Hint { + return Hint( + titleRes = R.string.hint_how_to_generate_vocabulary_with_ai, + elements = listOf( + HintElement.Section( + title = stringResource(R.string.import_ai_intro), + content = listOf( + // VisualStep 1: Search Term + HintElement.VisualStep( + step = "1", + title = stringResource(R.string.import_step1_title), + description = stringResource(R.string.text_hint_you_can_search), + trailing = { + // The AppTextField is wrapped in the VisualStep's trailing composable + AppTextField( + value = stringResource(R.string.search_term_placeholder), + onValueChange = {}, + label = { Text(stringResource(R.string.text_search_term)) }, + modifier = Modifier.fillMaxWidth(), + enabled = false + ) + } + ), + // VisualStep 2: Select Languages + HintElement.VisualStep( + step = "2", + title = stringResource(R.string.import_step2_title), + description = stringResource(R.string.import_step2_desc) + ), + // VisualStep 3: Select Amount + HintElement.VisualStep( + step = "3", + title = stringResource(R.string.import_step3_title), + description = stringResource(R.string.import_step3_desc), + trailing = { + // The Column with Slider and Text is wrapped in the trailing composable + Column { + AppSlider( + value = 10f, + onValueChange = {}, + valueRange = 1f..25f, + steps = 24, + modifier = Modifier.fillMaxWidth(), + enabled = false + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.import_after_generating), + style = MaterialTheme.typography.bodyMedium + ) + } + } + ) + ) + ) + ) + ) +} + +/** + * Preview for the migrated ImportVocabularyHint. + * It now calls getImportVocabularyHint() and then Render(). + */ +@Preview +@Composable +fun ImportVocabularyHintPreview() { + getImportVocabularyHint().Render() +} + +/** + * Provides the migrated Hint for VocabularyReview. + * This function is @Composable to access stringResource. + */ + +@Composable +fun getVocabularyReviewHint(): Hint { + return Hint( + titleRes = R.string.review_intro, + elements = listOf( + // Section 1: Select Items + HintElement.Section( + title = stringResource(R.string.review_select_items_title), + content = listOf( + HintElement.Text(stringResource(R.string.review_select_items_desc)), + // Custom Row with Checkbox is wrapped in a UIElement + HintElement.UIElement { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + AppCheckbox(checked = true, onCheckedChange = {}, enabled = false) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text(text = stringResource(R.string.example_word_der_apfel), style = MaterialTheme.typography.bodyLarge) + Text(text = stringResource(R.string.example_word_the_apple), style = MaterialTheme.typography.bodyMedium) + } + } + } + ) + ), + // Divider + HintElement.Divider, + // Section 2: Duplicate Handling + HintElement.Section( + title = stringResource(R.string.duplicate_handling_title), + content = listOf( + HintElement.Text(stringResource(R.string.duplicate_handling_desc)), + // Custom Row with Checkbox and Error Text is wrapped in a UIElement + HintElement.UIElement { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + AppCheckbox(checked = false, onCheckedChange = {}, enabled = false) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text(text = stringResource(R.string.example_word_der_hund), style = MaterialTheme.typography.bodyLarge) + Text(text = stringResource(R.string.example_word_the_dog), style = MaterialTheme.typography.bodyMedium) + } + Spacer(modifier = Modifier.weight(1f)) + Text(stringResource(R.string.duplicate), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + ) + ), + // Divider + HintElement.Divider, + // Section 3: Add to List + HintElement.Section( + title = stringResource(R.string.add_to_list_optional), + content = listOf( + HintElement.Text(stringResource(R.string.add_to_list_optional_desc)) + ) + ) + ) + ) +} + +/** + * Preview for the migrated VocabularyReviewHint. + * It now calls getVocabularyReviewHint() and then Render(). + */ +@Preview +@Composable +fun VocabularyReviewHintPreview() { + getVocabularyReviewHint().Render() +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/LearningStagesHint.kt b/app/src/main/java/eu/gaudian/translator/view/hints/LearningStagesHint.kt new file mode 100644 index 0000000..886e1db --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/LearningStagesHint.kt @@ -0,0 +1,200 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons + +private data class LearningStage( + val icon: ImageVector, + val name: String, + val interval: String? = null +) + +object LearningStagesHint : LegacyHint() { + override val titleRes: Int = R.string.learning_stages_title + + @Composable + override fun Content() { + val stages = listOf( + LearningStage(AppIcons.StageNew, stringResource(R.string.stage_new)), + LearningStage(AppIcons.Stage1, stringResource(R.string.stage_1), stringResource(R.string.interval_1_day)), + LearningStage(AppIcons.Stage2, stringResource(R.string.stage_2), stringResource(R.string.interval_3_days)), + LearningStage(AppIcons.Stage3, stringResource(R.string.stage_3), stringResource(R.string.interval_1_week)), + LearningStage(AppIcons.Stage4, stringResource(R.string.stage_4), stringResource(R.string.interval_2_weeks)), + LearningStage(AppIcons.Stage5, stringResource(R.string.stage_5), stringResource(R.string.interval_1_month)), + LearningStage(AppIcons.StageLearned, stringResource(R.string.stage_learned)) + ) + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + HeaderWithIcon( + title = stringResource(R.string.learning_stages_title) + ) + + HintSection(title = stringResource(R.string.learning_stages_title)) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Group stages into chunks of 2 to create rows. + stages.chunked(2).forEach { rowItems -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + rowItems.forEach { stage -> + StageNode(stage) + // If the stage has an interval, show the connector arrow + stage.interval?.let { interval -> + StageConnector(interval) + } + } + } + } + } + } + + HintSection(title = stringResource(R.string.hint_how_it_works)) { + RuleExplanation( + icon = AppIcons.Check, + title = stringResource(R.string.hint_answer_correctly), + description = stringResource(R.string.hint_the_word_moves), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + RuleExplanation( + icon = AppIcons.Error, + title = stringResource(R.string.hint_answer_incorrectly), + description = stringResource(R.string.hint_the_word_moves_back_another_stage_this_helps_you_focus_on_), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + RuleExplanation( + icon = AppIcons.Info, + title = stringResource(R.string.hint_customizable), + description = stringResource(R.string.hint_you_can_costumize_all_intervals_and_rules_in_the_settings), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +@Composable +fun LearningStagesHint() { + LearningStagesHint.Content() +} + +/** + * A composable that displays a single stage icon and its name. + */ +@Composable +private fun StageNode(stage: LearningStage) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = stage.icon, + contentDescription = stage.name, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stage.name, + style = MaterialTheme.typography.labelMedium + ) + } +} + +/** + * A composable that draws a dashed arrow and displays the time interval between stages. + */ +@Composable +private fun StageConnector(interval: String) { + val arrowColor = MaterialTheme.colorScheme.onSurfaceVariant + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Text( + text = interval, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.secondary + ) + Spacer(modifier = Modifier.height(4.dp)) + Canvas(modifier = Modifier.size(width = 50.dp, height = 10.dp)) { + val startY = center.y + val endX = size.width + // Draw the dashed line + drawLine( + color = arrowColor, + start = Offset(0f, startY), + end = Offset(endX - 5.dp.toPx(), startY), + strokeWidth = 1.5.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + ) + // Draw the arrowhead + drawLine(arrowColor, Offset(endX - 5.dp.toPx(), startY - 4.dp.toPx()), Offset(endX, startY), strokeWidth = 1.5.dp.toPx()) + drawLine(arrowColor, Offset(endX - 5.dp.toPx(), startY + 4.dp.toPx()), Offset(endX, startY), strokeWidth = 1.5.dp.toPx()) + } + } +} + +/** + * A helper composable to explain the success/failure rules. + */ +@Composable +private fun RuleExplanation(icon: ImageVector, title: String, description: String, tint: androidx.compose.ui.graphics.Color) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = icon, + contentDescription = title, + modifier = Modifier.size(28.dp), + tint = tint + ) + Column { + Text(text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(text = description, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Preview(showBackground = true) +@Composable +fun LearningStagesHintPreview() { + MaterialTheme { + Box(modifier = Modifier.padding(8.dp)) { + LearningStagesHint() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/SortingScreenHint.kt b/app/src/main/java/eu/gaudian/translator/view/hints/SortingScreenHint.kt new file mode 100644 index 0000000..ddede6e --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/SortingScreenHint.kt @@ -0,0 +1,239 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppTextField + +object SortingScreenHint : LegacyHint() { + override val titleRes: Int = R.string.sorting_hint_title + + @Composable + override fun getTitle(): String = stringResource(R.string.sorting_hint_title) + + @Composable + override fun Content() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + HeaderWithIcon( + title = stringResource(R.string.sorting_hint_title) + ) + + HintSection(title = stringResource(R.string.sorting_hint_intro_text)) { + @Suppress("HardCodedStringLiteral") + AppTextField( + value = "der Hund", + onValueChange = {}, + label = { Text(stringResource(R.string.label_word)) }, + modifier = Modifier.fillMaxWidth(), + enabled = false + ) + @Suppress("HardCodedStringLiteral") + AppTextField( + value = "the dog", + onValueChange = {}, + label = { Text(stringResource(R.string.label_translation)) }, + modifier = Modifier.fillMaxWidth(), + enabled = false + ) + } + + HorizontalDivider() + + HintSection(title = stringResource(R.string.sorting_hint_helper_text)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + SuggestionChip( + onClick = {}, + label = { Text(stringResource(R.string.sorting_hint_chip_duplicate)) }, + icon = { + Icon( + imageVector = AppIcons.Warning, + contentDescription = stringResource(R.string.label_warning), + modifier = Modifier.size(FilterChipDefaults.IconSize) + ) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + labelColor = MaterialTheme.colorScheme.onErrorContainer, + iconContentColor = MaterialTheme.colorScheme.onErrorContainer + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), + enabled = false + ) + + SuggestionChip( + onClick = {}, + label = { Text(stringResource(R.string.label_remove_articles)) }, + icon = { + Icon( + imageVector = AppIcons.Clean, + contentDescription = stringResource(R.string.label_remove_articles), + modifier = Modifier.size(FilterChipDefaults.IconSize) + ) + }, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface), + enabled = false + ) + } + } + + HorizontalDivider() + + HintSection(title = stringResource(R.string.sorting_hint_decide_next_action)) { + LabeledSegmentedIconButtons( + onDeleteClick = {}, + onLearnedClick = {}, + onDoneClick = {} + ) + } + } + } +} + +@Preview +@Composable +private fun SortingScreenHintContentPreview() { + SortingScreenHint.Content() +} + +/** + * A non-functional, recycled composable for visual demonstration in the hint. + */ +@Composable +private fun LabeledSegmentedIconButtons( + onDeleteClick: () -> Unit, + onLearnedClick: () -> Unit, + onDoneClick: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val buttonHeight = 48.dp + val cornerRadius = 24.dp + val secondaryButtonColor = MaterialTheme.colorScheme.surfaceVariant + + Row( + modifier = Modifier + .fillMaxWidth() + .height(buttonHeight) + .clip(RoundedCornerShape(cornerRadius)), + verticalAlignment = Alignment.CenterVertically + ) { + AppButton( + onClick = onDeleteClick, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = secondaryButtonColor), + enabled = false + ) { + Icon(AppIcons.Delete, contentDescription = stringResource(R.string.label_delete), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + + VerticalDivider() + + AppButton( + onClick = onLearnedClick, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = secondaryButtonColor), + enabled = false + ) { + Icon(AppIcons.StageLearned, contentDescription = stringResource(R.string.label_learned), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + + AppButton( + onClick = onDoneClick, + modifier = Modifier + .weight(2f) + .fillMaxHeight(), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), + enabled = false + ) { + Icon(AppIcons.Stage1, contentDescription = stringResource(R.string.label_done), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + Spacer(Modifier.height(4.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + Text(stringResource(R.string.label_delete), Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.label_learned), Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.label_move_first_stage), Modifier.weight(2f), textAlign = TextAlign.Center, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface) + } + } +} + +@Preview +@Composable +private fun LabeledSegmentedIconButtonsPreview() { + LabeledSegmentedIconButtons(onDeleteClick = {}, onLearnedClick = {}, onDoneClick = {}) +} + + +@Composable +private fun VerticalDivider( + modifier: Modifier = Modifier, + thickness: Dp = 1.dp, + color: Color = MaterialTheme.colorScheme.background.copy(alpha = 0.5f) +) { + Box( + modifier + .fillMaxHeight() + .width(thickness) + .background(color = color) + ) +} + +@Preview +@Composable +private fun VerticalDividerPreview() { + VerticalDivider() +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/TranslationScreenHint.kt b/app/src/main/java/eu/gaudian/translator/view/hints/TranslationScreenHint.kt new file mode 100644 index 0000000..8ff65c5 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/TranslationScreenHint.kt @@ -0,0 +1,104 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons + +@Composable +fun getTranslationScreenHint() = Hint( + titleRes = R.string.hint_translate_how_it_works, + elements = listOf( + HintElement.TextSection( + title = stringResource(R.string.hint_translate_alternative_translations_title), + text = stringResource(R.string.hint_translate_alternative_translations_desc) + ), + HintElement.TextSection( + title = stringResource(R.string.hint_translate_custom_prompts_title), + text = stringResource(R.string.hint_translate_custom_prompts_desc), + ), + + HintElement.TextSection( + title = stringResource(R.string.hint_translate_multiple_services_title), + text = stringResource(R.string.hint_translate_multiple_services_desc) + ), + HintElement.TextSection( + title = stringResource(R.string.hint_translate_history_title), + text = stringResource(R.string.hint_translate_history_desc) + ), + HintElement.TextSection( + title = stringResource(R.string.hint_translate_tts_title), + text = stringResource(R.string.hint_translate_tts_desc) + ), + HintElement.TextSection( + title = stringResource(R.string.hint_translate_quick_actions_title), + text = stringResource(R.string.hint_translate_quick_actions_desc) + ), + HintElement.TextSection( + title = stringResource(R.string.hint_translate_model_selection_title), + text = stringResource(R.string.hint_translate_model_selection_desc)) + ) +) + + +@Composable +fun TranslationScreenHint() { + getTranslationScreenHint().Render() +} + +@Preview +@Composable +fun TranslationScreenHintPreview() { + getTranslationScreenHint().Render() +} + +@Composable +private fun TranslationHintItem( + icon: ImageVector, + title: String, + description: String +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = icon, + contentDescription = title, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.secondary + ) + Column { + Text(text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = description, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Preview +@Composable +private fun TranslationHintItemPreview() { + TranslationHintItem( + icon = AppIcons.Info, + title = stringResource(R.string.hint_translation_context_aware_title), + description = stringResource(R.string.hint_translation_context_aware_desc) + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/VocabularyProgressHint.kt b/app/src/main/java/eu/gaudian/translator/view/hints/VocabularyProgressHint.kt new file mode 100644 index 0000000..2f3ef2d --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/hints/VocabularyProgressHint.kt @@ -0,0 +1,122 @@ +package eu.gaudian.translator.view.hints + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons + +object VocabularyProgressHint : LegacyHint() { + override val titleRes: Int = R.string.hint_vocabulary_progress_hint_title + + @Composable + override fun Content() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + HeaderWithIcon( + title = stringResource(R.string.hint_vocabulary_progress_hint_title) + ) + + HintSection(title = stringResource(R.string.hint_vocabulary_progress_tracking_title)) { + // Progress Tracking + VocabularyProgressHintItem( + icon = AppIcons.BarChart, + title = stringResource(R.string.hint_vocabulary_progress_tracking_title), + description = stringResource(R.string.hint_vocabulary_progress_tracking_desc) + ) + + // Learning Stages + VocabularyProgressHintItem( + icon = AppIcons.Stages, + title = stringResource(R.string.hint_vocabulary_learning_stages_title), + description = stringResource(R.string.hint_vocabulary_learning_stages_desc) + ) + + // Review System + VocabularyProgressHintItem( + icon = AppIcons.History, + title = stringResource(R.string.hint_vocabulary_review_system_title), + description = stringResource(R.string.hint_vocabulary_review_system_desc) + ) + + // Customization + VocabularyProgressHintItem( + icon = AppIcons.Tune, + title = stringResource(R.string.hint_vocabulary_customization_title), + description = stringResource(R.string.hint_vocabulary_customization_desc) + ) + } + } + } +} + +@Preview +@Composable +private fun ContentPreview() { + VocabularyProgressHint.Content() +} + +@Composable +fun VocabularyProgressHint() { + VocabularyProgressHint.Content() +} + +@Preview +@Composable +private fun VocabularyProgressHintPreview() { + VocabularyProgressHint() +} + + +@Composable +private fun VocabularyProgressHintItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + description: String +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = icon, + contentDescription = title, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.secondary + ) + Column { + Text(text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = description, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +private fun VocabularyProgressHintItemPreview() { + VocabularyProgressHintItem( + icon = AppIcons.BarChart, + title = "Progress Tracking", + description = "Track your learning progress with detailed statistics." + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/AboutScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/AboutScreen.kt new file mode 100644 index 0000000..244e9a4 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/AboutScreen.kt @@ -0,0 +1,508 @@ +package eu.gaudian.translator.view.settings + +import android.annotation.SuppressLint +import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import eu.gaudian.translator.BuildConfig +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppSwitch +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.SecondaryButton +import eu.gaudian.translator.viewmodel.SettingsViewModel +import eu.gaudian.translator.viewmodel.StatusViewModel +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Composable +fun AboutScreen( + navController: NavController, +) { + val context = LocalContext.current + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val isDeveloperMode by settingsViewModel.isDeveloperModeEnabled.collectAsStateWithLifecycle(initialValue = false) + var tapCount by remember { mutableIntStateOf(0) } + var showDeveloperSwitch by remember { mutableStateOf(isDeveloperMode) } + var licensesExpanded by remember { mutableStateOf(false) } + var changelogExpanded by remember { mutableStateOf(false) } + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.label_about)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AppCard { + + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Image( + painter = painterResource(id = R.mipmap.ic_launcher_foreground), + contentDescription = stringResource(R.string.cd_app_logo), + modifier = Modifier + .size(128.dp) + .clickable { + tapCount++ + if (tapCount >= 7) { + showDeveloperSwitch = true + } + } + ) + + Text( + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = getAppVersion(context), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(id = R.string.text_developed_by_jonas_gaudian), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + val uriHandler = LocalUriHandler.current + + // Group 1: Website + Contact Developer + Changelog in one AppCard + AppCard { + Column { + // Website link + val websiteUrl = stringResource(R.string.gaudian_eu_website) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { uriHandler.openUri(websiteUrl) } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = R.string.text_visit_my_website), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold + ) + Icon( + imageVector = AppIcons.ExitToApp, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + + HorizontalDivider() + + // Contact developer + val appName = stringResource(R.string.app_name) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + @Suppress("HardCodedStringLiteral") val intent = android.content.Intent(android.content.Intent.ACTION_SENDTO).apply { + data = "mailto:play@gaudian.eu".toUri() + putExtra(android.content.Intent.EXTRA_EMAIL, arrayOf("play@gaudian.eu")) + putExtra(android.content.Intent.EXTRA_SUBJECT, + "$appName Feedback" + ) + } + context.startActivity(intent) + } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.contact_developer_title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.contact_developer_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + HorizontalDivider() + + // Changelog expandable + @Suppress("HardCodedStringLiteral") val rotationAngle by animateFloatAsState( + targetValue = if (changelogExpanded) 180f else 0f, + label = "Changelog Arrow Animation" + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { changelogExpanded = !changelogExpanded } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.whats_new_changelog_title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = AppIcons.ExpandMore, + contentDescription = stringResource(R.string.toggle_licenses), + modifier = Modifier.rotate(rotationAngle) + ) + } + + AnimatedVisibility(visible = changelogExpanded) { + ChangelogList() + } + } + } + + // Group 2: Open source licenses + Legal information in one AppCard + AppCard { + Column { + @Suppress("HardCodedStringLiteral") val rotationAngle by animateFloatAsState( + targetValue = if (licensesExpanded) 180f else 0f, + label = "License Arrow Animation" + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { licensesExpanded = !licensesExpanded } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.open_source_licenses), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = AppIcons.ExpandMore, + contentDescription = stringResource(R.string.toggle_licenses), + modifier = Modifier.rotate(rotationAngle) + ) + } + + AnimatedVisibility(visible = licensesExpanded) { + OpenSourceLicensesList() + } + + HorizontalDivider() + val legalInformationUrl = stringResource(R.string.legal_information_url) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { uriHandler.openUri(legalInformationUrl) } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.legal_information), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold + ) + Icon( + imageVector = AppIcons.ExitToApp, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + if (showDeveloperSwitch) { + DeveloperOptions( + isDeveloperMode = isDeveloperMode, + onToggle = { settingsViewModel.setDeveloperMode(it) }, + navController = navController + ) + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +private fun OpenSourceLicensesList() { + val uriHandler = LocalUriHandler.current + val apacheLicenseUrl = "https://www.apache.org/licenses/LICENSE-2.0" + + data class LibraryInfo(val name: String, val author: String, val licenseUrl: String, val licenseType: String = "Apache License 2.0") + + // Updated dependencies + val libraries = remember { + listOf( + LibraryInfo("Android Jetpack", "Google", apacheLicenseUrl), + LibraryInfo("AndroidX", "Google", apacheLicenseUrl), + LibraryInfo("Core KTX", "Google", apacheLicenseUrl), + LibraryInfo("JSoup", "Jonathan Hedley", "https://jsoup.org/license"), + LibraryInfo("Kotlin", "JetBrains", "https://kotlinlang.org/LICENSE.txt"), + LibraryInfo("KotlinX", "JetBrains", apacheLicenseUrl), + LibraryInfo("Material Components for Android", "Google", apacheLicenseUrl), + LibraryInfo("OkHttp", "Square, Inc.", "https://www.apache.org/licenses/LICENSE-2.0"), + LibraryInfo("Retrofit", "Square, Inc.", "https://www.apache.org/licenses/LICENSE-2.0"), + LibraryInfo("Room Compiler", "Google", apacheLicenseUrl), + LibraryInfo("Timber", "Jake Wharton", "https://www.apache.org/licenses/LICENSE-2.0"), + LibraryInfo("YouTube Player Android Core", "Pierfrancesco Soffritti", "https://www.apache.org/licenses/LICENSE-2.0"), + LibraryInfo("Zstandard", "Meta Platforms", "https://github.com/facebook/zstd/blob/dev/LICENSE"), + ).sortedBy { it.name } // Sort alphabetically + } + + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + libraries.forEach { library -> + Column(Modifier.padding(vertical = 8.dp)) { + Text( + text = library.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = library.author, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = library.licenseType, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + modifier = Modifier.clickable { uriHandler.openUri(library.licenseUrl) } + ) + } + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) + } + } +} + +@Composable +private fun ChangelogList() { + val changelogEntries = stringArrayResource(R.array.changelog_entries) + + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + changelogEntries.forEachIndexed { index, entry -> + Text( + text = entry, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + if (index < changelogEntries.size - 1) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + } + } +} + + +@Composable +private fun DeveloperOptions( + isDeveloperMode: Boolean, + onToggle: (Boolean) -> Unit, + navController: NavController, +) { + val context = LocalContext.current + val statusViewModel: StatusViewModel = viewModel() + val activity = context.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + + + AppCard { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggle(!isDeveloperMode) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.title_developer_options), + style = MaterialTheme.typography.titleMedium + ) + AppSwitch(checked = isDeveloperMode, onCheckedChange = onToggle) + } + + if (isDeveloperMode) { + val experimentalFeatures by settingsViewModel.experimentalFeatures.collectAsStateWithLifecycle() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { settingsViewModel.setExperimentalFeatures(!experimentalFeatures) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.experimental_features), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(R.string.experimental_features_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + AppSwitch( + checked = experimentalFeatures, + onCheckedChange = { settingsViewModel.setExperimentalFeatures(it) } + ) + } + + val loadingText = stringResource(R.string.text_loading_3d) + val infoText = stringResource(R.string.text_sentence_this_is_an_info_message) + val successText = stringResource(R.string.text_success_em) + val errorText = stringResource(R.string.text_sentence_oops_something_went_wrong) + + SecondaryButton( + onClick = { statusViewModel.showLoadingMessage(loadingText) }, + text = stringResource(R.string.text_show_loading), + modifier = Modifier.fillMaxWidth() + ) + SecondaryButton( + onClick = { statusViewModel.cancelLoadingOperation() }, + text = stringResource(R.string.text_cancel_loading), + modifier = Modifier.fillMaxWidth() + ) + SecondaryButton( + onClick = { statusViewModel.showInfoMessage(infoText) }, + text = stringResource(R.string.text_show_info_message), + modifier = Modifier.fillMaxWidth() + ) + SecondaryButton( + onClick = { statusViewModel.showSuccessMessage(successText) }, + text = stringResource(R.string.title_show_success_message), + modifier = Modifier.fillMaxWidth() + ) + SecondaryButton( + onClick = { statusViewModel.showErrorMessage(errorText, 2) }, + text = stringResource(R.string.text_show_error_message), + modifier = Modifier.fillMaxWidth() + ) + SecondaryButton( + onClick = { statusViewModel.showApiKeyMissingMessage() }, + text = stringResource(R.string.show_api_key_missing_message), + modifier = Modifier.fillMaxWidth() + ) + SecondaryButton( + onClick = { settingsViewModel.setIntroCompleted(false) }, + text = stringResource(R.string.text_reset_intro), + modifier = Modifier.fillMaxWidth() + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + SecondaryButton( + onClick = { navController.navigate(SettingsRoutes.THEME_PREVIEW) }, + text = stringResource(R.string.text_theme_preview), + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@SuppressLint("DefaultLocale") +@Suppress("HardCodedStringLiteral") +private fun getAppVersion(context: Context): String { + return try { + val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) + + + val parser = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm") + + val buildDateTime = LocalDateTime.parse(BuildConfig.BUILD_TIME, parser) + + val year = buildDateTime.format(DateTimeFormatter.ofPattern("yy")) + val dayOfYear = String.format("%03d", buildDateTime.dayOfYear) + val time = buildDateTime.format(DateTimeFormatter.ofPattern("HHmm")) + val buildIdentifier = "$year.$dayOfYear.$time" + + + context.getString( + R.string.text_version_display_string_2d, + packageInfo.versionName, + buildIdentifier + ) + } catch (e: Exception) { + e.printStackTrace() + context.getString(R.string.text_sentenc_version_information_not_available) + } +} + +@ThemePreviews +@Composable +private fun AboutScreenPreview() { + MaterialTheme { + AboutScreen(navController = NavController(LocalContext.current)) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/AddModelScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/AddModelScreen.kt new file mode 100644 index 0000000..66fe226 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/AddModelScreen.kt @@ -0,0 +1,709 @@ +package eu.gaudian.translator.view.settings + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.LanguageModel +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppSwitch +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.ModelBadges +import eu.gaudian.translator.view.hints.AddModelScanHint +import eu.gaudian.translator.viewmodel.ApiViewModel + +@Composable +fun AddModelScreen(navController: NavController, providerKey: String) { + val activity = LocalContext.current.findActivity() + val apiViewModel : ApiViewModel = hiltViewModel(viewModelStoreOwner = activity) + val state by apiViewModel.apiKeyManagementState.collectAsStateWithLifecycle() + val allProviders by apiViewModel.allProviders.collectAsStateWithLifecycle() + + val provider = remember(allProviders, providerKey) { + allProviders.find { it.key == providerKey } + } + val providerName = provider?.displayName ?: stringResource(R.string.label_add_custom_model) + + var modelId by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var showScannedModelsDialog by remember { mutableStateOf(false) } + + val isLoading = state.isAddingModel + val isScanning = state.isScanningModels + val scannedModels = state.scannedModels + val errorMessage = state.addModelError + + LaunchedEffect(Unit) { + var wasAdding = state.isAddingModel + snapshotFlow { state.isAddingModel } + .collect { isCurrentlyAdding -> + if (wasAdding && !isCurrentlyAdding && state.addModelError == null) { + navController.popBackStack() + } + wasAdding = isCurrentlyAdding + } + } + + LaunchedEffect(Unit) { + var wasScanning = state.isScanningModels + snapshotFlow { state.isScanningModels } + .collect { isCurrentlyScanning -> + if (wasScanning && !isCurrentlyScanning && state.scannedModels.isNotEmpty()) { + showScannedModelsDialog = true + } + wasScanning = isCurrentlyScanning + } + } + + LaunchedEffect(providerKey) { + apiViewModel.startAddingModelForProvider(providerKey) + } + DisposableEffect(Unit) { + onDispose { apiViewModel.cancelAddModel() } + } + + if (showScannedModelsDialog) { + EnhancedScannedModelsDialog( + scannedModels = scannedModels, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showScannedModelsDialog = false + }, + onModelSelected = { selectedModel -> + modelId = selectedModel.modelId + displayName = selectedModel.displayName.ifBlank { selectedModel.modelId } + description = selectedModel.description + @Suppress("AssignedValueIsNeverRead") + showScannedModelsDialog = false + } + ) + } + + + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(providerName) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + }, + hintContent = { AddModelScanHint() } + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + // Scan Option - Primary + AppCard( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + AppIcons.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.text_scan_for_available_models), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + stringResource( + R.string.text_automatically_discover_models_from, + providerName + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + + AppButton( + onClick = { apiViewModel.scanModelsForProvider(providerKey) }, + enabled = !isLoading && !isScanning, + modifier = Modifier.fillMaxWidth() + ) { + if (isScanning) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.label_scanning)) + } else { + Icon( + AppIcons.Search, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.label_scan_for_models)) + } + } + } + errorMessage?.let { + AppCard( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + AppIcons.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + text = it, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + } + } + } + } + } + + item { + // Manual Option + AppCard( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + AppIcons.Edit, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.label_add_model_manually), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary + ) + Text( + stringResource(R.string.text_enter_model_details_yourself), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AppTextField( + value = displayName, + onValueChange = { displayName = it }, + label = { Text(stringResource(R.string.label_display_name)) }, + placeholder = { Text(stringResource(R.string.text_e_g_gpt_4_claude_3)) }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + supportingText = { + if (displayName.isBlank()) { + Text( + stringResource(R.string.text_required_enter_a_human_readable_name), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + ) + AppTextField( + value = modelId, + onValueChange = { modelId = it }, + label = { Text(stringResource(R.string.label_model_id_star)) }, + placeholder = { Text(stringResource(R.string.text_e_g_gpt_4_claude_3_sonnet)) }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + supportingText = { + if (modelId.isBlank()) { + Text( + stringResource(R.string.text_required_enter_the_exact_model_identifier), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } else { + Text( + stringResource(R.string.text_this_must_match_the_provider_s_model_name_exactly), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + style = MaterialTheme.typography.bodySmall + ) + } + } + ) + AppTextField( + value = description, + onValueChange = { description = it }, + label = { Text(stringResource(R.string.label_description)) }, + placeholder = { Text(stringResource(R.string.text_e_g_fast_and_efficient_for_simple_tasks)) }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + maxLines = 3, + supportingText = { + Text( + stringResource(R.string.text_optional_describe_what_this_model_is_good_for), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + style = MaterialTheme.typography.bodySmall + ) + } + ) + } + } + } + } + + + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = { navController.popBackStack() }, + enabled = !isLoading + ) { + Text(stringResource(R.string.label_cancel)) + } + Spacer(modifier = Modifier.width(8.dp)) + AppButton( + onClick = { + apiViewModel.addModelToProvider( + providerKey, + LanguageModel( + modelId.trim(), + displayName.trim(), + providerKey, + description.trim(), + true + ) + ) + }, + enabled = modelId.isNotBlank() && displayName.isNotBlank() && !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text(stringResource(R.string.label_add_model)) + } + } + } + } + } + } +} + + + +@Suppress("HardCodedStringLiteral") +@Composable +private fun EnhancedScannedModelsDialog( + scannedModels: List, + onDismiss: () -> Unit, + onModelSelected: (LanguageModel) -> Unit +) { + var searchQuery by remember { mutableStateOf("") } + var showOnlyFreeModels by remember { mutableStateOf(false) } + + val showSearch = remember(scannedModels) { scannedModels.size > 10 } + val hasFreeModels = remember(scannedModels) { + scannedModels.any { it.displayName.contains("free", true) || it.modelId.contains("free", true) } + } + + val filteredModels = remember(scannedModels, showOnlyFreeModels, searchQuery) { + scannedModels + .filter { model -> + if (showOnlyFreeModels) { + model.displayName.contains("free", true) || model.modelId.contains("free", true) + } else { + true + } + } + .filter { model -> + model.displayName.contains(searchQuery, true) || model.modelId.contains(searchQuery, true) + } + } + + AppDialog(title = { Row( + modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onDismiss) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + Text( + text = stringResource(R.string.select_a_model), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f) + ) + } }, onDismissRequest = onDismiss) { + + Column(modifier = Modifier.padding(0.dp)) { + + + if (showSearch || hasFreeModels) { + Column(Modifier.padding(horizontal = 8.dp)) { + if (showSearch) { + AppTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.search_models)) }, + leadingIcon = { Icon(AppIcons.Search, contentDescription = null) }, + singleLine = true + ) + Spacer(Modifier.height(8.dp)) + } + if (hasFreeModels) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { showOnlyFreeModels = !showOnlyFreeModels } + .padding(vertical = 8.dp) + ) { + Text( + stringResource(R.string.show_free_models_only), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + AppSwitch(checked = showOnlyFreeModels, onCheckedChange = null) + } + } + } + } + + HorizontalDivider(modifier = Modifier.padding(top = 16.dp)) + + LazyColumn(modifier = Modifier.heightIn(max = 1000.dp)) { + if (filteredModels.isEmpty()) { + item { + Text( + text = stringResource(R.string.no_models_found), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) + } + } else { + itemsIndexed(filteredModels, key = { index, model -> "${model.modelId}_${model.providerKey}_${model.displayName}_$index" }) { index, model -> + var expanded by remember { mutableStateOf(false) } + var canExpand by remember { mutableStateOf(false) } + val secondary = buildString { + if (model.displayName.isNotBlank()) append(model.modelId) + if (model.description.isNotBlank()) { + if (isNotEmpty()) append(" • ") + append(model.description) + } + if (model.isCustom) { + if (isNotEmpty()) append(" • ") + append("Custom") + } + } + ListItem( + overlineContent = { Text(model.providerKey.uppercase()) }, + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = model.displayName.ifBlank { model.modelId }, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + Spacer(Modifier.width(8.dp)) + ModelBadges( + modelDisplayOrId = model.displayName.ifBlank { model.modelId }, + providerKey = model.providerKey, + ) + } + }, + supportingContent = { + if (secondary.isNotBlank()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + modifier = Modifier.clickable(enabled = canExpand) { expanded = !expanded }, + text = secondary, + maxLines = if (expanded) Int.MAX_VALUE else 1, + style = MaterialTheme.typography.bodyMedium, + onTextLayout = { result -> + + if (!expanded) { + val overflow = result.hasVisualOverflow + if (canExpand != overflow) { + canExpand = overflow + } + } + } + ) + } + } + }, + trailingContent = { + if (secondary.isNotBlank() && (canExpand || expanded)) { + val rotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "expandRotate" + ) + IconButton(onClick = { expanded = !expanded }) { + Icon( + imageVector = AppIcons.ExpandMore, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + modifier = Modifier.rotate(rotation) + ) + } + } + }, + modifier = Modifier.clickable { onModelSelected(model) } + ) + } + } + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "AddModelScreen - Default") +@Composable +fun AddModelScreenPreview() { + val navController = NavController(LocalContext.current) + AddModelScreen(navController = navController, providerKey = "openai") +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "AddModelScreen - Custom Provider") +@Composable +fun AddModelScreenCustomProviderPreview() { + val navController = NavController(LocalContext.current) + AddModelScreen(navController = navController, providerKey = "custom-local") +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "AddModelScreen - Anthropic") +@Composable +fun AddModelScreenAnthropicPreview() { + val navController = NavController(LocalContext.current) + AddModelScreen(navController = navController, providerKey = "anthropic") +} + +// Preview data providers +@Suppress("HardCodedStringLiteral") +class ScannedModelsPreviewProvider : PreviewParameterProvider> { + override val values = sequenceOf( + // Small set for basic preview + listOf( + LanguageModel(modelId = "gpt-3.5-turbo", displayName = "GPT-3.5 Turbo", providerKey = "openai", description = "Fast and efficient model for most tasks", isCustom = false), + LanguageModel(modelId = "gpt-4", displayName = "GPT-4", providerKey = "openai", description = "Most capable model for complex tasks", isCustom = false), + LanguageModel(modelId = "claude-instant", displayName = "Claude Instant", providerKey = "anthropic", description = "Fast and responsive model", isCustom = false) + ), + // Large set for comprehensive preview + listOf( + LanguageModel(modelId = "gpt-3.5-turbo", displayName = "GPT-3.5 Turbo", providerKey = "openai", description = "OpenAI's most capable model", isCustom = false), + LanguageModel(modelId = "gpt-4", displayName = "GPT-4", providerKey = "openai", description = "OpenAI's newest model", isCustom = false), + LanguageModel(modelId = "gpt-4-turbo", displayName = "GPT-4 Turbo", providerKey = "openai", description = "Faster version of GPT-4", isCustom = false), + LanguageModel(modelId = "claude-1", displayName = "Claude 1", providerKey = "anthropic", description = "Anthropic's largest model", isCustom = false), + LanguageModel(modelId = "claude-instant-1", displayName = "Claude Instant 1", providerKey = "anthropic", description = "Anthropic's fastest model", isCustom = false), + LanguageModel(modelId = "claude-2", displayName = "Claude 2", providerKey = "anthropic", description = "Improved Claude model", isCustom = false), + LanguageModel(modelId = "palm-2", displayName = "PaLM 2", providerKey = "google", description = "Google's latest model", isCustom = false), + LanguageModel(modelId = "gemini-pro", displayName = "Gemini Pro", providerKey = "google", description = "Google's advanced model", isCustom = false), + LanguageModel(modelId = "custom-model-1", displayName = "My Custom Model", providerKey = "custom", description = "A custom model added by the user", isCustom = true), + LanguageModel(modelId = "free-model-1", displayName = "Free Model Alpha", providerKey = "community", description = "A free model from the community", isCustom = false), + LanguageModel(modelId = "experimental-model-x", displayName = "Experimental Model X", providerKey = "labs", description = "An experimental model with latest features", isCustom = false), + LanguageModel(modelId = "legacy-model", displayName = "Legacy Model", providerKey = "archive", description = "An older model for compatibility", isCustom = false), + LanguageModel(modelId = "specialized-model-finance", displayName = "Finance Analyzer", providerKey = "financeLLM", description = "Model specialized in financial data", isCustom = false), + LanguageModel(modelId = "code-gen-master", displayName = "CodeGen Master", providerKey = "devTools", description = "Advanced code generation model", isCustom = false), + LanguageModel(modelId = "story-writer-pro", displayName = "Story Writer Pro", providerKey = "creativeAI", description = "Model for creative writing and storytelling", isCustom = false) + ) + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "ScannedModelsDialog - Small List") +@Composable +fun EnhancedScannedModelsDialogSmallPreview() { + val scannedModels = listOf( + LanguageModel(modelId = "gpt-3.5-turbo", displayName = "GPT-3.5 Turbo", providerKey = "openai", description = "Fast and efficient model for most tasks", isCustom = false), + LanguageModel(modelId = "gpt-4", displayName = "GPT-4", providerKey = "openai", description = "Most capable model for complex tasks", isCustom = false), + LanguageModel(modelId = "claude-instant", displayName = "Claude Instant", providerKey = "anthropic", description = "Fast and responsive model", isCustom = false) + ) + EnhancedScannedModelsDialog( + scannedModels = scannedModels, + onDismiss = {}, + onModelSelected = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "ScannedModelsDialog - Large List") +@Composable +fun EnhancedScannedModelsDialogLargePreview( + @PreviewParameter(ScannedModelsPreviewProvider::class) scannedModels: List +) { + EnhancedScannedModelsDialog( + scannedModels = scannedModels, + onDismiss = {}, + onModelSelected = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "ScannedModelsDialog - Custom Models Only") +@Composable +fun EnhancedScannedModelsDialogCustomPreview() { + val scannedModels = listOf( + LanguageModel(modelId = "custom-gpt", displayName = "Custom GPT", providerKey = "custom", description = "My custom GPT model", isCustom = true), + LanguageModel(modelId = "local-llama", displayName = "Local LLaMA", providerKey = "local", description = "Local LLaMA instance", isCustom = true), + LanguageModel(modelId = "fine-tuned-model", displayName = "Fine-Tuned Model", providerKey = "custom", description = "Model fine-tuned on my data", isCustom = true) + ) + EnhancedScannedModelsDialog( + scannedModels = scannedModels, + onDismiss = {}, + onModelSelected = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "ScannedModelsDialog - Free Models Only") +@Composable +fun EnhancedScannedModelsDialogFreePreview() { + val scannedModels = listOf( + LanguageModel(modelId = "free-gpt", displayName = "Free GPT", providerKey = "free", description = "Free GPT model", isCustom = false), + LanguageModel(modelId = "free-claude", displayName = "Free Claude", providerKey = "free", description = "Free Claude model", isCustom = false), + LanguageModel(modelId = "community-model", displayName = "Community Model", providerKey = "community", description = "Community-supported model", isCustom = false) + ) + EnhancedScannedModelsDialog( + scannedModels = scannedModels, + onDismiss = {}, + onModelSelected = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "ScannedModelsDialog - Mixed Providers") +@Composable +fun EnhancedScannedModelsDialogMixedPreview() { + val scannedModels = listOf( + LanguageModel(modelId = "gpt-3.5-turbo", displayName = "GPT-3.5 Turbo", providerKey = "openai", description = "OpenAI's fast model", isCustom = false), + LanguageModel(modelId = "claude-instant", displayName = "Claude Instant", providerKey = "anthropic", description = "Anthropic's fast model", isCustom = false), + LanguageModel(modelId = "custom-local", displayName = "Local Custom", providerKey = "custom", description = "My local model", isCustom = true), + LanguageModel(modelId = "free-basic", displayName = "Free Basic", providerKey = "free", description = "Free basic model", isCustom = false) + ) + EnhancedScannedModelsDialog( + scannedModels = scannedModels, + onDismiss = {}, + onModelSelected = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "ScannedModelsDialog - Empty State") +@Composable +fun EnhancedScannedModelsDialogEmptyPreview() { + EnhancedScannedModelsDialog( + scannedModels = emptyList(), + onDismiss = {}, + onModelSelected = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "ScannedModelsDialog - Single Model") +@Composable +fun EnhancedScannedModelsDialogSinglePreview() { + val scannedModels = listOf( + LanguageModel(modelId = "gpt-4", displayName = "GPT-4", providerKey = "openai", description = "OpenAI's most advanced model", isCustom = false) + ) + EnhancedScannedModelsDialog( + scannedModels = scannedModels, + onDismiss = {}, + onModelSelected = {} + ) +} diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/ApiKeyScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/ApiKeyScreen.kt new file mode 100644 index 0000000..ca570b0 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/ApiKeyScreen.kt @@ -0,0 +1,1482 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.test.core.app.ApplicationProvider +import eu.gaudian.translator.R +import eu.gaudian.translator.model.LanguageModel +import eu.gaudian.translator.model.communication.ApiProvider +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppAlertDialog +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTabLayout +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.ClickableText +import eu.gaudian.translator.view.composable.PrimaryButton +import eu.gaudian.translator.view.composable.SecondaryButton +import eu.gaudian.translator.view.composable.TabItem +import eu.gaudian.translator.view.hints.ApiKeyHint +import eu.gaudian.translator.viewmodel.ApiKeyManagementState +import eu.gaudian.translator.viewmodel.ApiViewModel +import eu.gaudian.translator.viewmodel.ProviderState + +@Composable +private fun getApiTabs(): List { + return listOf( + ApiTab(stringResource(R.string.label_providers), AppIcons.Storage), + ApiTab(stringResource(R.string.label_tasks), AppIcons.ModelTraining) + ) +} + +private data class ApiTab(override val title: String, override val icon: ImageVector) : + TabItem + +@Composable +fun ApiKeyScreen(navController: NavController) { + val activity = LocalContext.current.findActivity() + val apiViewModel : ApiViewModel = hiltViewModel(viewModelStoreOwner = activity) + val state by apiViewModel.apiKeyManagementState.collectAsStateWithLifecycle() + + val allModels by apiViewModel.allModels.collectAsStateWithLifecycle() + val allProviders by apiViewModel.allProviders.collectAsStateWithLifecycle() + val translationModel by apiViewModel.translationModel.collectAsStateWithLifecycle() + val exerciseModel by apiViewModel.exerciseModel.collectAsStateWithLifecycle() + val vocabularyModel by apiViewModel.vocabularyModel.collectAsStateWithLifecycle() + val dictionaryModel by apiViewModel.dictionaryModel.collectAsStateWithLifecycle() + + val apiTabs = getApiTabs() + var selectedTab by remember { mutableStateOf(apiTabs[0]) } + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.label_ai_configuration)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + }, + hintContent = { ApiKeyHint.Content() } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .imePadding() + ) { + HandleApiManagementDialogs(state) + + // Tab Layout + AppTabLayout( + tabs = apiTabs, + selectedTab = selectedTab, + onTabSelected = { selectedTab = it } + ) + + // Tab Content + when (selectedTab) { + apiTabs[0] -> ProvidersTabContent( + state = state, + allProviders = allProviders, + apiViewModel = apiViewModel, + navController = navController + ) + apiTabs[1] -> TasksTabContent( + allModels = allModels.filter { model -> allProviders.any { it.key == model.providerKey && it.hasValidKey } }, + allProviders = allProviders.filter { it.isCustom || it.hasValidKey }, + translationModel = translationModel, + exerciseModel = exerciseModel, + vocabularyModel = vocabularyModel, + dictionaryModel = dictionaryModel, + apiViewModel = apiViewModel + ) + } + } + } +} + +@Composable +private fun ProvidersTabContent( + state: ApiKeyManagementState, + allProviders: List, + apiViewModel: ApiViewModel, + navController: NavController +) { + var showDeleteProviderDialog by remember { mutableStateOf(false) } + var showDeleteModelDialog by remember { mutableStateOf(false) } + var providerToDelete by remember { mutableStateOf(null) } + var modelToDelete by remember { mutableStateOf?>(null) } // providerKey to modelId + var showDeleteKeyDialog by remember { mutableStateOf(false) } + var providerToDeleteKey by remember { mutableStateOf(null) } + + val listState = rememberLazyListState() + var focusedProviderKey by remember { mutableStateOf(null) } + + // 1. Stable Sort: Remember the sorted list to avoid re-sorting on every frame + val sortedProviders = remember(state.providerStates) { + state.providerStates.sortedWith(compareBy { !it.provider.isCustom } + .thenBy { !it.hasKey } + .thenBy { it.provider.displayName.lowercase() }) + } + + // 2. Smart Scroll Logic: Scroll to the focused provider if its index changes + var lastKnownIndex by remember { mutableIntStateOf(-1) } + + LaunchedEffect(sortedProviders, focusedProviderKey) { + val key = focusedProviderKey ?: return@LaunchedEffect + val newIndex = sortedProviders.indexOfFirst { it.provider.key == key } + + if (newIndex != -1 && newIndex != lastKnownIndex) { + listState.animateScrollToItem(newIndex) + lastKnownIndex = newIndex + } else if (newIndex == -1) { + lastKnownIndex = -1 + } + } + + Column( + modifier = Modifier.fillMaxSize() + ) { + // Add Provider Button + AppButton( + onClick = { apiViewModel.startAddingProvider() }, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Icon(AppIcons.Add, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.label_add_custom_provider)) + } + + // Provider List + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items( + items = sortedProviders, + // 3. Key: Ensure Compose tracks items by ID, not position + key = { it.provider.key } + ) { providerState -> + + val focusRequester = remember { FocusRequester() } + + // If this is the focused provider, request focus + LaunchedEffect(focusedProviderKey) { + if (focusedProviderKey == providerState.provider.key) { + focusRequester.requestFocus() + } + } + + ProviderCard( + providerState = providerState, + onApiKeyChanged = { newKey -> + apiViewModel.updateApiKeyForProvider(providerState.provider.key, newKey) + // Keep focus on this provider while typing + if (newKey.isNotBlank()) { + focusedProviderKey = providerState.provider.key + } + }, + onSave = { + apiViewModel.saveApiKeyForProvider(providerState.provider.key) + // Ensure we track this provider as it moves in the list + focusedProviderKey = providerState.provider.key + }, + onToggleEdit = { + apiViewModel.toggleEditModeForProvider(providerState.provider.key) + }, + onAddModel = { + focusedProviderKey = providerState.provider.key + @Suppress("HardCodedStringLiteral") + navController.navigate(SettingsRoutes.ADD_MODEL.replace("{providerKey}", providerState.provider.key)) + }, + onEditModel = { modelId -> + focusedProviderKey = providerState.provider.key + apiViewModel.startEditingModel(providerState.provider.key, modelId) + }, + onDeleteModel = { modelId -> + modelToDelete = Pair(providerState.provider.key, modelId) + showDeleteModelDialog = true + }, + onEditProvider = { apiViewModel.startEditingProvider(providerState.provider.key) }, + onDeleteProvider = { + providerToDelete = providerState.provider + showDeleteProviderDialog = true + }, + onDeleteKey = { + providerToDeleteKey = providerState.provider.key + showDeleteKeyDialog = true + }, + modifier = Modifier.focusRequester(focusRequester) + ) + } + + // Danger zone + @Suppress("HardCodedStringLiteral") + item(key = "danger_zone") { + AppButton( + onClick = { apiViewModel.showWipeAllConfirm() }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Icon(AppIcons.Delete, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.text_delete_all_providers_and_models)) + } + } + } + } + + // Delete Provider Confirmation Dialog + if (showDeleteProviderDialog && providerToDelete != null) { + AppAlertDialog( + onDismissRequest = { + showDeleteProviderDialog = false + providerToDelete = null + }, + icon = { + Icon( + AppIcons.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + title = { Text(stringResource(R.string.label_delete_provider)) }, + text = { + Text( + stringResource( + R.string.text_dialog_delete_provider, + providerToDelete!!.displayName + )) + }, + confirmButton = { + TextButton( + onClick = { + apiViewModel.deleteProvider(providerToDelete!!.key) + showDeleteProviderDialog = false + providerToDelete = null + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.label_delete)) + } + }, + dismissButton = { + TextButton(onClick = { + showDeleteProviderDialog = false + providerToDelete = null + }) { + Text(stringResource(R.string.label_cancel)) + } + } + ) + } + + // Delete Model Confirmation Dialog + if (showDeleteModelDialog && modelToDelete != null) { + val (providerKey, modelId) = modelToDelete!! + val provider = allProviders.find { it.key == providerKey } + val model = provider?.models?.find { it.modelId == modelId } + + AppAlertDialog( + onDismissRequest = { + showDeleteModelDialog = false + modelToDelete = null + }, + icon = { + Icon( + AppIcons.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + title = { Text(stringResource(R.string.label_delete_model)) }, + text = { + Text( + stringResource( + R.string.text_dialog_delete_model, + model?.displayName ?: modelId, + provider?.displayName ?: providerKey + )) + }, + confirmButton = { + TextButton( + onClick = { + apiViewModel.deleteModelFromProvider(providerKey, modelId) + showDeleteModelDialog = false + modelToDelete = null + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.label_delete)) + } + }, + dismissButton = { + TextButton(onClick = { + showDeleteModelDialog = false + modelToDelete = null + }) { + Text(stringResource(R.string.label_cancel)) + } + } + ) + } + + // Delete Key Confirmation Dialog + if (showDeleteKeyDialog && providerToDeleteKey != null) { + val provider = allProviders.find { it.key == providerToDeleteKey } + + AppAlertDialog( + onDismissRequest = { + showDeleteKeyDialog = false + providerToDeleteKey = null + }, + icon = { + Icon( + AppIcons.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + title = { Text(stringResource(R.string.label_delete_key)) }, + text = { + Text( + stringResource( + R.string.text_dialog_delete_key, + provider?.displayName ?: providerToDeleteKey!! + )) + }, + confirmButton = { + TextButton( + onClick = { + apiViewModel.deleteApiKeyForProvider(providerToDeleteKey!!) + showDeleteKeyDialog = false + providerToDeleteKey = null + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.label_delete)) + } + }, + dismissButton = { + TextButton(onClick = { + showDeleteKeyDialog = false + providerToDeleteKey = null + }) { + Text(stringResource(R.string.label_cancel)) + } + } + ) + } +} + +@Composable +private fun TasksTabContent( + allModels: List, + allProviders: List, + translationModel: LanguageModel?, + exerciseModel: LanguageModel?, + vocabularyModel: LanguageModel?, + dictionaryModel: LanguageModel?, + apiViewModel: ApiViewModel +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + AppCard { + Column(Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.label_task_model_assignments), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 4.dp) + ) + Text( + text = stringResource(R.string.text_configure_which_ai_model_to_use_for_each_task_type), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 12.dp) + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + ModelSelectionRow( + label = stringResource(id = R.string.label_translation), + allModels = allModels, + allProviders = allProviders, + selectedModel = translationModel, + onModelSelected = { apiViewModel.setTranslationModel(it) } + ) + ModelSelectionRow( + label = stringResource(id = R.string.label_exercise), + allModels = allModels, + allProviders = allProviders, + selectedModel = exerciseModel, + onModelSelected = { apiViewModel.setExerciseModel(it) } + ) + ModelSelectionRow( + label = stringResource(id = R.string.label_vocabulary), + allModels = allModels, + allProviders = allProviders, + selectedModel = vocabularyModel, + onModelSelected = { apiViewModel.setVocabularyModel(it) } + ) + ModelSelectionRow( + label = stringResource(id = R.string.label_dictionary), + allModels = allModels, + allProviders = allProviders, + selectedModel = dictionaryModel, + onModelSelected = { apiViewModel.setDictionaryModel(it) } + ) + } + } + } + } +} + +@Composable +@Preview +fun ApiKeyScreenPreview() { + ApiKeyScreen(navController = NavController(ApplicationProvider.getApplicationContext())) +} + +@Composable +private fun ModelSelectionRow( + label: String, + allModels: List, + allProviders: List, + selectedModel: LanguageModel?, + onModelSelected: (LanguageModel?) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + Box(modifier = Modifier.weight(1.5f)) { + ApiModelDropDown( + models = allModels, + providers = allProviders, + selectedModel = selectedModel, + onModelSelected = onModelSelected + ) + } + } +} + +@Composable +@Preview +private fun HandleApiManagementDialogsPreview() { + HandleApiManagementDialogs( + state = ApiKeyManagementState() + ) +} + + +@Composable +private fun HandleApiManagementDialogs( + state: ApiKeyManagementState +) { + val activity = LocalContext.current.findActivity() + val apiViewModel: ApiViewModel = hiltViewModel(viewModelStoreOwner = activity) + if (state.isAddingProvider) { + AddProviderDialog( + onDismiss = { apiViewModel.cancelAddProvider() }, + onConfirm = { provider -> apiViewModel.addProvider(provider) } + ) + } + + state.providerKeyForAddingModel?.let { providerKey -> + AddModelDialog( + providerKey = providerKey, + isLoading = state.isAddingModel, + isScanning = state.isScanningModels, + scannedModels = state.scannedModels, + errorMessage = state.addModelError, + onDismiss = { apiViewModel.cancelAddModel() }, + onScan = { apiViewModel.scanModelsForProvider(providerKey) }, + onConfirm = { model -> apiViewModel.addModelToProvider(providerKey, model) } + ) + } + + // Edit provider dialog + state.providerKeyForEditing?.let { providerKey -> + val provider = state.providerStates.firstOrNull { it.provider.key == providerKey }?.provider + if (provider != null && state.modelIdForEditing == null) { + EditProviderDialog( + initial = provider, + onDismiss = { apiViewModel.cancelEditing() }, + onConfirm = { updated -> apiViewModel.applyEditProvider(updated) } + ) + } + // Edit model dialog + val modelId = state.modelIdForEditing + if (provider != null && modelId != null) { + val model = provider.models.firstOrNull { it.modelId == modelId } + if (model != null) { + EditModelDialog( + providerKey = provider.key, + initial = model, + onDismiss = { apiViewModel.cancelEditing() }, + onConfirm = { updated -> apiViewModel.applyEditModel(provider.key, updated) }, + errorMessage = state.addModelError + ) + } + } + } + + if (state.showWipeAllConfirm) { + AlertDialog( + onDismissRequest = { apiViewModel.hideWipeAllConfirm() }, + title = { Text(stringResource(R.string.text_delete_all_providers_and_models_qm)) }, + text = { Text(stringResource(R.string.text_this_will_remove_all)) }, + confirmButton = { + TextButton( + onClick = { apiViewModel.confirmWipeAll() }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.label_delete_all)) + } + }, + dismissButton = { + TextButton(onClick = { apiViewModel.hideWipeAllConfirm() }) { + Text(stringResource(R.string.label_cancel)) + } + } + ) + } +} + + +@Suppress("HardCodedStringLiteral") +@Composable +private fun EnhancedKeyStatus(provider: ApiProvider, hasKey: Boolean) { + 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) + ) + + val (text, color, icon) = when { + isLocalHost -> Triple(stringResource(R.string.text_key_optional), MaterialTheme.colorScheme.primary, AppIcons.CheckCircle) + hasKey -> Triple(stringResource(R.string.text_key_active), MaterialTheme.colorScheme.primary, AppIcons.CheckCircle) + provider.isCustom -> Triple(stringResource(R.string.text_key_optional), MaterialTheme.colorScheme.primary, AppIcons.CheckCircle) + else -> Triple(stringResource(R.string.text_no_key), MaterialTheme.colorScheme.error, AppIcons.Warning) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background( + color = color.copy(alpha = 0.1f), + shape = RoundedCornerShape(12.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Icon( + imageVector = icon, + contentDescription = text, + tint = color, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + text, + color = color, + style = MaterialTheme.typography.labelSmall, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium + ) + } +} + +@Composable +private fun EnhancedProviderDetails( + provider: ApiProvider, + hasKey: Boolean, + onChangeClick: () -> Unit, + onAddModel: () -> Unit, + onEditModel: (String) -> Unit, + onDeleteModel: (String) -> Unit, + onDeleteKey: () -> Unit = {} +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(R.string.text_available_models), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onAddModel) { + Icon(AppIcons.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.label_add_model)) + } + } + + if (provider.models.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + provider.models.forEach { model -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + model.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium + ) + if (model.description.isNotBlank()) { + Text( + model.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + if (model.isCustom) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(40.dp) + ) { + IconButton( + onClick = { onEditModel(model.modelId) }, + modifier = Modifier.size(36.dp) + ) { + Icon( + AppIcons.Edit, + contentDescription = stringResource(R.string.edit), + modifier = Modifier.size(20.dp) + ) + } + IconButton( + onClick = { onDeleteModel(model.modelId) }, + modifier = Modifier.size(36.dp) + ) { + Icon( + AppIcons.Delete, + contentDescription = stringResource(R.string.delete_model), + modifier = Modifier.size(20.dp) + ) + } + } + } + } + } + } else { + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.no_models_configured), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(vertical = 8.dp) + ) + } + + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (hasKey) { + SecondaryButton( + onClick = onChangeClick, + text = stringResource(R.string.text_change_key), + modifier = Modifier.weight(1f) + ) + SecondaryButton( + onClick = onDeleteKey, + text = stringResource(R.string.label_delete_key), + modifier = Modifier.weight(1f), + maxLines = 1 + ) + } else { + SecondaryButton( + onClick = onChangeClick, + text = stringResource(R.string.label_add_key), + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Composable +private fun ProviderCard( + providerState: ProviderState, + onApiKeyChanged: (String) -> Unit, + onSave: () -> Unit, + onToggleEdit: () -> Unit, + onAddModel: () -> Unit, + onEditModel: (String) -> Unit, + onDeleteModel: (String) -> Unit, + onEditProvider: () -> Unit, + onDeleteProvider: () -> Unit, + onDeleteKey: () -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + @Suppress("HardCodedStringLiteral") val rotationAngle by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + animationSpec = tween(durationMillis = 300), + label = "rotation" + ) + + val isActive = providerState.hasKey || providerState.provider.isCustom + val modelCount = providerState.provider.models.size + + AppCard( + modifier = modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(vertical = 12.dp, horizontal = 16.dp)) { + // Main header with essential info only + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = AppIcons.ExpandMore, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), + modifier = Modifier.rotate(rotationAngle), + tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Spacer(Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + providerState.provider.displayName, + style = MaterialTheme.typography.titleMedium, + color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.weight(1f) + ) + if (providerState.provider.isCustom) { + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.label_custom), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + Spacer(Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + EnhancedKeyStatus(provider = providerState.provider, hasKey = providerState.hasKey) + Text( + text = stringResource(R.string.label_amount_models, modelCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } + + // Quick actions when not expanded + if (!expanded && isActive) { + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton( + onClick = onAddModel, + modifier = Modifier.weight(1f) + ) { + Icon(AppIcons.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.label_add_model)) + } + } + } + + // Expanded content with better organization + AnimatedVisibility(visible = expanded) { + Column(modifier = Modifier.padding(top = 12.dp)) { + HorizontalDivider() + + Spacer(Modifier.height(12.dp)) + + // Status and actions section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + stringResource(R.string.label_status), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + EnhancedKeyStatus(provider = providerState.provider, hasKey = providerState.hasKey) + } + + if (providerState.provider.isCustom) { + Row { + IconButton(onClick = onEditProvider) { + Icon(AppIcons.Edit, contentDescription = stringResource(R.string.edit), tint = MaterialTheme.colorScheme.primary) + } + IconButton(onClick = onDeleteProvider) { + Icon(AppIcons.Delete, contentDescription = stringResource(R.string.delete_provider), tint = MaterialTheme.colorScheme.error) + } + } + } + } + + if (providerState.provider.websiteUrl.isNotBlank()) { + Spacer(Modifier.height(12.dp)) + val annotatedString = buildAnnotatedString { + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary, textDecoration = TextDecoration.Underline)) { + append(stringResource(R.string.text_get_api_key_at, providerState.provider.displayName)) + addStringAnnotation("URL", providerState.provider.websiteUrl, 0, length) + } + } + ClickableText( + text = annotatedString, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(Modifier.height(16.dp)) + + if (providerState.isEditing) { + ApiKeyInput( + apiKey = providerState.apiKey, + validationMessage = providerState.validationMessage, + onApiKeyChanged = onApiKeyChanged, + onSave = onSave, + onCancel = onToggleEdit + ) + } else { + EnhancedProviderDetails( + provider = providerState.provider, + hasKey = providerState.hasKey, + onChangeClick = onToggleEdit, + onAddModel = onAddModel, + onEditModel = onEditModel, + onDeleteModel = onDeleteModel, + onDeleteKey = onDeleteKey + ) + } + } + } + } + } +} + + +@Suppress("HardCodedStringLiteral") +@Composable +@Preview +private fun ApiKeyInputPreview() { + ApiKeyInput( + apiKey = "test_api_key", + validationMessage = "", + onApiKeyChanged = {}, + onSave = {} + ) +} + + +@Composable +private fun ApiKeyInput( + apiKey: String, + validationMessage: String, + onApiKeyChanged: (String) -> Unit, + onSave: () -> Unit, + onCancel: (() -> Unit)? = null +) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + val clipboard = LocalClipboardManager.current + AppTextField( + value = apiKey, + onValueChange = onApiKeyChanged, + label = { Text(stringResource(R.string.text_enter_api_key)) }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + IconButton( + onClick = { + val text = clipboard.getText()?.text + if (!text.isNullOrBlank()) onApiKeyChanged(text) + }, + enabled = true + ) { Icon(AppIcons.Paste, contentDescription = stringResource(R.string.cd_paste)) } + } + ) + if (validationMessage.isNotBlank()) { + Text(validationMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + onCancel?.let { + TextButton(onClick = it) { Text(stringResource(R.string.label_cancel)) } + Spacer(Modifier.width(8.dp)) + } + PrimaryButton( + onClick = onSave, + enabled = apiKey.isNotBlank(), + text = stringResource(R.string.text_save_key) + ) + } + } +} + + +@Composable +fun AddProviderDialog(onDismiss: () -> Unit, onConfirm: (ApiProvider) -> Unit) { + val activity = LocalContext.current.findActivity() + val apiViewModel: ApiViewModel = hiltViewModel(viewModelStoreOwner = activity) + var key by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf("") } + var baseUrl by remember { mutableStateOf("") } + var endpoint by remember { mutableStateOf("") } + var websiteUrl by remember { mutableStateOf("") } + val isFormValid = displayName.isNotBlank() && baseUrl.isNotBlank() + var isChecking by remember { mutableStateOf(false) } + var availabilityMessage by remember { mutableStateOf(null) } + var availabilityOk by remember { mutableStateOf(null) } + + AppDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.label_add_custom_provider)) }, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(0.dp), + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AppTextField( + value = displayName, + onValueChange = { newValue: String -> displayName = newValue }, + label = { Text(stringResource(R.string.display_name) + " *") }, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = baseUrl, + onValueChange = { newValue: String -> baseUrl = newValue }, + label = { Text(stringResource(R.string.text_base_url_and_example) + " *") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 2 + ) + AppTextField( + value = endpoint, + onValueChange = { newValue: String -> endpoint = newValue }, + label = { Text(buildString { + append(stringResource(R.string.endpoint_e_g_api_chat)) + append(stringResource(R.string.text_optional)) + }) }, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = websiteUrl, + onValueChange = { newValue: String -> websiteUrl = newValue }, + label = { Text(buildString { + append(stringResource(R.string.website_url)) + append(stringResource(R.string.text_optional)) + }) }, + modifier = Modifier.fillMaxWidth() + ) + + Text(stringResource(R.string.label_start_required), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)) + + Row(verticalAlignment = Alignment.CenterVertically) { + if (isChecking) { + CircularProgressIndicator(modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + @Suppress("HardCodedStringLiteral") + Text("Checking...", style = MaterialTheme.typography.bodySmall) + } else { + SecondaryButton( + onClick = { + availabilityMessage = null + availabilityOk = null + isChecking = true + val url = baseUrl.trim() + apiViewModel.checkProviderAvailability(url) { ok, msg -> + availabilityOk = ok + availabilityMessage = msg + isChecking = false + } + }, + text = stringResource(R.string.text_check_availability) + ) + } + } + availabilityMessage?.let { msg -> + val color = if (availabilityOk == true) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + Text(msg, color = color, style = MaterialTheme.typography.bodySmall) + } + + // Buttons + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.label_cancel)) + } + AppButton( + onClick = { + onConfirm(ApiProvider( + key = key.trim(), + displayName = displayName.trim(), + baseUrl = baseUrl.trim(), + endpoint = endpoint.trim(), + websiteUrl = websiteUrl.trim(), + models = emptyList(), + isCustom = true + )) + }, + enabled = isFormValid + ) { + Text(stringResource(R.string.label_add)) + } + } + } + } + } +} + + + +@Composable +fun EditProviderDialog( + initial: ApiProvider, + onDismiss: () -> Unit, + onConfirm: (ApiProvider) -> Unit +) { + var displayName by remember { mutableStateOf(initial.displayName) } + var baseUrl by remember { mutableStateOf(initial.baseUrl) } + var endpoint by remember { mutableStateOf(initial.endpoint) } + var websiteUrl by remember { mutableStateOf(initial.websiteUrl) } + val isFormValid = displayName.isNotBlank() && baseUrl.isNotBlank() + + AppDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.edit) + " " + initial.displayName) }, + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AppTextField( + value = displayName, + onValueChange = { displayName = it }, + label = { Text(stringResource(R.string.display_name) + " *") }, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = baseUrl, + onValueChange = { baseUrl = it }, + label = { Text(stringResource(R.string.text_base_url_and_example) + " *") }, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = endpoint, + onValueChange = { endpoint = it }, + label = { Text(buildString { + append(stringResource(R.string.endpoint_e_g_api_chat)) + append(stringResource(R.string.text_optional)) + }) }, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = websiteUrl, + onValueChange = { websiteUrl = it }, + label = { Text(buildString { + append(stringResource(R.string.website_url)) + append(stringResource(R.string.text_optional)) + }) }, + modifier = Modifier.fillMaxWidth() + ) + + Text(stringResource(R.string.label_star_required), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.label_cancel)) } + AppButton( + onClick = { + onConfirm( + initial.copy( + displayName = displayName.trim(), + baseUrl = baseUrl.trim(), + endpoint = endpoint.trim(), + websiteUrl = websiteUrl.trim() + ) + ) + }, + enabled = isFormValid + ) { Text(stringResource(R.string.label_save)) } + } + } + } + } +} + + +@Composable +@Preview +fun AddModelDialogPreview() { + @Suppress("HardCodedStringLiteral") + AddModelDialog( + providerKey = "test_provider", + isLoading = false, + isScanning = false, + scannedModels = emptyList(), + errorMessage = null, + onDismiss = {}, + onScan = {}, + onConfirm = {} + ) +} + +@Composable +fun AddModelDialog( + providerKey: String, + isLoading: Boolean, + isScanning: Boolean, + scannedModels: List, + errorMessage: String?, + onDismiss: () -> Unit, + onScan: () -> Unit, + onConfirm: (LanguageModel) -> Unit +) { + var modelId by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + val isFormValid = modelId.isNotBlank() && displayName.isNotBlank() && !isLoading + + AppDialog( + onDismissRequest = { if (!isLoading) onDismiss() }, + title = { + Text( + text = stringResource(R.string.label_add_custom_model), + ) + } + ) { + + Column( + modifier = Modifier + .padding(4.dp) + .heightIn(max = 1000.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Optional scan area + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onScan, enabled = !isLoading && !isScanning) { + Text(text = if (isScanning) stringResource(R.string.scanning) else stringResource(R.string.scan_models)) + } + } + if (scannedModels.isNotEmpty()) { + Text(text = stringResource(R.string.text_select_model), style = MaterialTheme.typography.titleSmall) + LazyColumn( + modifier = Modifier.heightIn(max = 150.dp) + ) { + items(scannedModels) { m -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = m.modelId, modifier = Modifier.weight(1f)) + TextButton(onClick = { + modelId = m.modelId + displayName = m.displayName.ifBlank { m.modelId } + description = m.description + }) { Text(stringResource(R.string.label_select)) } + } + } + } + } else if (!isScanning) { + Text(text = stringResource(R.string.no_models_found), style = MaterialTheme.typography.bodySmall) + } + + // Manual entry + AppTextField( + value = displayName, + onValueChange = { newValue: String -> displayName = newValue }, + label = { Text(stringResource(R.string.display_name)) }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = modelId, + onValueChange = { newValue: String -> modelId = newValue }, + label = { Text(stringResource(R.string.model_id)) }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + maxLines = 2 + ) + AppTextField( + value = description, + onValueChange = { newValue: String -> description = newValue }, + label = { Text(stringResource(R.string.description)) }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) + + if (isLoading) { + Spacer(modifier = Modifier.height(8.dp)) + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } + + errorMessage?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + // Buttons + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss, enabled = !isLoading) { + Text(stringResource(R.string.label_cancel)) + } + AppButton( + onClick = { + onConfirm(LanguageModel( + modelId = modelId.trim(), + displayName = displayName.trim(), + providerKey = providerKey, + description = description.trim(), + isCustom = true + )) + }, + enabled = isFormValid + ) { + Text(stringResource(R.string.label_add_validate)) + } + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +@Preview +fun EditModelDialogPreview() { + EditModelDialog( + providerKey = "test_provider", + initial = LanguageModel("test_model_id", "Test Model", "test_provider", "Test description"), + onDismiss = {}, + onConfirm = {}, + errorMessage = null + ) +} + + +@Composable +fun EditModelDialog( + providerKey: String, + initial: LanguageModel, + onDismiss: () -> Unit, + onConfirm: (LanguageModel) -> Unit, + errorMessage: String? = null +) { + var displayName by remember { mutableStateOf(initial.displayName) } + var description by remember { mutableStateOf(initial.description) } + var modelId by remember { mutableStateOf(initial.modelId) } + + val isFormValid = modelId.isNotBlank() && displayName.isNotBlank() + + AppDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.edit) + " " + initial.displayName) }, + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AppTextField( + value = displayName, + onValueChange = { displayName = it }, + label = { Text(stringResource(R.string.display_name)) }, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = modelId, + onValueChange = { modelId = it }, + label = { Text(stringResource(R.string.model_id)) }, + modifier = Modifier.fillMaxWidth() + ) + AppTextField( + value = description, + onValueChange = { description = it }, + label = { Text(stringResource(R.string.description)) }, + modifier = Modifier.fillMaxWidth() + ) + + errorMessage?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.label_cancel)) } + AppButton( + onClick = { + onConfirm( + initial.copy( + modelId = modelId.trim(), + displayName = displayName.trim(), + description = description.trim(), + providerKey = providerKey, + isCustom = true + ) + ) + }, + enabled = isFormValid + ) { Text(stringResource(R.string.label_save)) } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/BasePromptSettingsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/BasePromptSettingsScreen.kt new file mode 100644 index 0000000..c23a824 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/BasePromptSettingsScreen.kt @@ -0,0 +1,391 @@ +package eu.gaudian.translator.view.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.LanguageModel +import eu.gaudian.translator.model.communication.ApiProvider +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.ModelBadges + +data class PromptSettingsState( + val availableModels: List = emptyList(), + val selectedModel: LanguageModel? = null, + val customPrompt: String = "", + val examplePrompts: List = emptyList() +) + +@Composable +fun BasePromptSettingsScreen( + state: PromptSettingsState, + providers: List, // Pass the list of providers + description: String, + onPromptChanged: (String) -> Unit, + onSaveClicked: () -> Unit, + onModelSelected: (LanguageModel?) -> Unit, + onExamplePromptClicked: (String) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 0.dp) + ) { + Text( + text = description, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(vertical = 16.dp) + ) + + AppCard( + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(16.dp)) { + AppTextField( + value = state.customPrompt, + onValueChange = onPromptChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(id = R.string.text_enter_your_custom_prompt)) }, + minLines = 3 + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + + Box(modifier = Modifier.weight(1f)) { + ApiModelDropDown( + models = state.availableModels, + providers = providers, // Pass providers down + selectedModel = state.selectedModel, + onModelSelected = onModelSelected, + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + AppButton(onClick = onSaveClicked) { + Text(stringResource(id = R.string.label_save)) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.text_example_prompts), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + state.examplePrompts.forEach { prompt -> + Text( + text = prompt, + modifier = Modifier + .fillMaxWidth() + .clickable { onExamplePromptClicked(prompt) } + .padding(vertical = 8.dp) + ) + HorizontalDivider() + } + } +} + +@Composable +fun ApiModelDropDown( + models: List, + providers: List, + selectedModel: LanguageModel?, + onModelSelected: (LanguageModel?) -> Unit +) { + LocalContext.current + var expanded by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + + val activeModels = models.filter { model -> providers.any { it.key == model.providerKey && (it.hasValidKey || it.isCustom) } } + val groupedModels = activeModels.groupBy { it.providerKey } + val providerNames = remember(providers) { providers.associate { it.key to it.displayName } } + val providerStatuses = remember(providers) { providers.associate { it.key to (it.hasValidKey || it.isCustom) } } + + val filteredGroupedModels = remember(groupedModels, searchQuery) { + if (searchQuery.isBlank()) { + groupedModels + } else { + groupedModels.mapValues { (_, models) -> + models.filter { model -> + model.displayName.contains(searchQuery, ignoreCase = true) || + model.modelId.contains(searchQuery, ignoreCase = true) || + model.description.contains(searchQuery, ignoreCase = true) + } + }.filterValues { it.isNotEmpty() } + } + } + + Box { + AppOutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.align(Alignment.Center), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = selectedModel?.displayName ?: stringResource(R.string.text_select_model), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (selectedModel != null) { + Text( + text = providerNames[selectedModel.providerKey] ?: selectedModel.providerKey, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown, + contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand) + ) + } + } + + DropdownMenu( + modifier = Modifier + .fillMaxWidth(), + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + // Search bar + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + AppIcons.Search, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.width(8.dp)) + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text(stringResource(R.string.label_search_models)) }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + modifier = Modifier.weight(1f) + ) + if (searchQuery.isNotBlank()) { + IconButton( + onClick = { searchQuery = "" }, + modifier = Modifier.size(24.dp) + ) { + Icon( + AppIcons.Close, + contentDescription = stringResource(R.string.cd_clear_search), + modifier = Modifier.size(16.dp) + ) + } + } + } + HorizontalDivider() + } + + if (filteredGroupedModels.isNotEmpty()) { + filteredGroupedModels.entries.forEachIndexed { index, entry -> + val providerKey = entry.key + val providerModels = entry.value + val isActive = providerStatuses[providerKey] == true + val providerName = providerNames[providerKey] ?: providerKey + + if (index > 0) HorizontalDivider() + + // Provider header + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = if (isActive) AppIcons.CheckCircle else AppIcons.Warning, + contentDescription = null, + tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = providerName, + style = MaterialTheme.typography.labelMedium, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium, + color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + Text( + text = stringResource( + R.string.labels_1d_models, + providerModels.size + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + }, + enabled = false, + onClick = {} + ) + + // Models for this provider + providerModels.forEach { model -> + DropdownMenuItem( + text = { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = model.displayName, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + fontWeight = if (model == selectedModel) androidx.compose.ui.text.font.FontWeight.Medium else androidx.compose.ui.text.font.FontWeight.Normal + ) + Spacer(modifier = Modifier.width(8.dp)) + ModelBadges( + modelDisplayOrId = model.displayName.ifBlank { model.modelId }, + providerKey = model.providerKey, + ) + } + if (model.description.isNotBlank()) { + Text( + text = model.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + }, + onClick = { + onModelSelected(model) + expanded = false + searchQuery = "" + }, + modifier = if (model == selectedModel) { + Modifier.background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ) + } else { + Modifier + } + ) + } + } + } else if (searchQuery.isNotBlank()) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.text_no_models_found), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.fillMaxWidth() + ) + }, + enabled = false, + onClick = {} + ) + } + } + } +} + + + +@Suppress("HardCodedStringLiteral") +@ThemePreviews +@Composable +fun BasePromptSettingsScreenPreview() { + val previewProviders = listOf( + ApiProvider("openai", "OpenAI", "", "", "", emptyList()), + ApiProvider("anthropic", "Anthropic", "", "", "", emptyList()) + ) + val state = PromptSettingsState( + availableModels = listOf( + LanguageModel("gpt-4", "GPT-4", "openai", "OpenAI's most capable model."), + LanguageModel("claude-2", "Claude 2", "anthropic", "Anthropic's largest model.") + ), + selectedModel = LanguageModel("gpt-4", "GPT-4", "openai", "OpenAI's most capable model."), + customPrompt = "Translate the following English text to French:", + examplePrompts = listOf( + "Summarize this article for me.", + "Explain this concept in simple terms.", + "Write a poem about nature." + ) + ) + BasePromptSettingsScreen( + state = state, + providers = previewProviders, + description = "Configure your translation prompt settings here. You can choose a language model and set a custom prompt.", + onPromptChanged = {}, + onSaveClicked = {}, + onModelSelected = {}, + onExamplePromptClicked = {}) +} diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/CustomPromptScreens.kt b/app/src/main/java/eu/gaudian/translator/view/settings/CustomPromptScreens.kt new file mode 100644 index 0000000..3c952a4 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/CustomPromptScreens.kt @@ -0,0 +1,89 @@ +package eu.gaudian.translator.view.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.viewmodel.ApiViewModel +import eu.gaudian.translator.viewmodel.SettingsViewModel + + +/** + * A settings screen for configuring the custom prompt used for vocabulary generation. + */ +@Composable +fun CustomVocabularyPromptScreen( + navController: NavController +) { + + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val apiViewModel: ApiViewModel = hiltViewModel(viewModelStoreOwner = activity) + val customPrompt by settingsViewModel.customPromptVocabulary.collectAsStateWithLifecycle() + val availableModels by apiViewModel.allValidModels.collectAsStateWithLifecycle() + val allProviders by apiViewModel.allValidProviders.collectAsStateWithLifecycle() + val selectedModel by apiViewModel.vocabularyModel.collectAsStateWithLifecycle() + var tempPrompt by remember(customPrompt) { mutableStateOf(customPrompt) } + + val screenState = PromptSettingsState( + availableModels = availableModels, + selectedModel = selectedModel, + customPrompt = tempPrompt + ) + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.text_vocabulary_prompt)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + }, + hintContent = { + Text( + //TODO make this nicer and own file + stringResource(R.string.hint_this_screen_lets_you_customize_) + ) + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + ) { + BasePromptSettingsScreen( + state = screenState, + providers = allProviders, + description = stringResource(R.string.text_here_you_can_set_a_custom_), + onPromptChanged = { tempPrompt = it }, + onSaveClicked = { settingsViewModel.saveCustomVocabularyPrompt(tempPrompt) }, + onModelSelected = { apiViewModel.setVocabularyModel(it) }, + onExamplePromptClicked = { tempPrompt = it } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/DictionaryOptionsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/DictionaryOptionsScreen.kt new file mode 100644 index 0000000..2de8588 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/DictionaryOptionsScreen.kt @@ -0,0 +1,203 @@ +package eu.gaudian.translator.view.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.LocalShowExperimentalFeatures +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.OptionItemSwitch +import eu.gaudian.translator.view.composable.PrimaryButton +import eu.gaudian.translator.view.dictionary.DictionaryManagerContent +import eu.gaudian.translator.view.hints.getDictionaryOptionsHint +import eu.gaudian.translator.viewmodel.ApiViewModel +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import eu.gaudian.translator.viewmodel.SettingsViewModel + +/** + * A settings screen for configuring dictionary content and the related AI prompt. + */ +@Composable +fun DictionaryOptionsScreen( + navController: NavController +) { + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val apiViewModel: ApiViewModel = hiltViewModel(viewModelStoreOwner = activity) + val dictionaryViewModel : DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity) + val showExperimental = LocalShowExperimentalFeatures.current + val dictionarySwitches by settingsViewModel.dictionarySwitches.collectAsStateWithLifecycle() + val dictionaryOptionLabels = stringArrayResource(R.array.dictionary_content).toList() + val dictionaryOptionKeys = stringArrayResource(R.array.dictionary_content_keys).toList() + val customPrompt by settingsViewModel.customPromptDictionary.collectAsStateWithLifecycle() + var tempPrompt by remember(customPrompt) { mutableStateOf(customPrompt) } + val availableModels by apiViewModel.allValidModels.collectAsStateWithLifecycle() + val allProviders by apiViewModel.allValidProviders.collectAsStateWithLifecycle() + val selectedModel by apiViewModel.dictionaryModel.collectAsStateWithLifecycle() + val tryWiktionaryFirst by settingsViewModel.tryWiktionaryFirst.collectAsStateWithLifecycle() + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.label_dictionary_options)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + }, + hintContent = { getDictionaryOptionsHint() } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (showExperimental) { + item { + AppCard { + Column( + modifier = Modifier.padding(0.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + OptionItemSwitch( + title = stringResource(R.string.text_try_wiktionary_first), + description = stringResource(R.string.text_try_first_finding_the_word_on), + checked = tryWiktionaryFirst, + onCheckedChange = { settingsViewModel.setTryWiktionaryFirst(it) }, + ) + } + } + } + } + } + } + + item { + AppCard { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + stringResource(R.string.text_ai_model_custom_prompt), + style = MaterialTheme.typography.titleMedium + ) + ApiModelDropDown( + models = availableModels, + providers = allProviders, + selectedModel = selectedModel, + onModelSelected = { model -> + apiViewModel.setDictionaryModel(model) + }, + ) + AppTextField( + value = tempPrompt, + onValueChange = { tempPrompt = it }, + label = { Text(stringResource(R.string.text_custom_dictionary_prompt)) }, + modifier = Modifier.defaultMinSize(minHeight = 120.dp) + ) + PrimaryButton( + onClick = { settingsViewModel.saveCustomPromptDictionary(tempPrompt) }, + text = stringResource(R.string.text_save_prompt), + modifier = Modifier.align(Alignment.End) + ) + } + } + } + + item { + AppCard { + + AppCard ( + title = stringResource(R.string.label_dictionary_content), + text = stringResource(R.string.text_select_the_content_dictionary), + expandable = true, + initiallyExpanded = false, + + ){ + Column(Modifier.padding(0.dp)) { + + + dictionaryOptionKeys.zip(dictionaryOptionLabels).forEach { (key, label) -> + val isChecked = dictionarySwitches.contains(key) || dictionarySwitches.contains(label) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + settingsViewModel.setDictionarySwitch(key, !isChecked) + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OptionItemSwitch( + title = label, + // TODO description = "TODO()", + checked = isChecked, + onCheckedChange = { checked -> + settingsViewModel.setDictionarySwitch(key, checked) + }, + ) + } + } + } + } + } + } + + item { + // The AppCard and callbacks are now handled internally by DictionaryManagerContent + DictionaryManagerContent( + dictionaryViewModel = dictionaryViewModel + ) + } + } + } +} + +@Preview +@Composable +fun DictionaryOptionsScreenPreview() { + val navController = rememberNavController() + DictionaryOptionsScreen(navController = navController) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/ExerciseSettingsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/ExerciseSettingsScreen.kt new file mode 100644 index 0000000..82e8762 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/ExerciseSettingsScreen.kt @@ -0,0 +1,155 @@ +package eu.gaudian.translator.view.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.PrimaryButton +import eu.gaudian.translator.view.composable.SecondaryButton +import eu.gaudian.translator.viewmodel.ApiViewModel +import eu.gaudian.translator.viewmodel.SettingsViewModel + +@Composable +fun ExerciseSettingsScreen( + navController: NavController +) { + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val apiViewModel: ApiViewModel = hiltViewModel(viewModelStoreOwner = activity) + val customPrompt by settingsViewModel.customPromptExercise.collectAsStateWithLifecycle() + var tempPrompt by remember(customPrompt) { mutableStateOf(customPrompt) } + + // Get all models and all providers from the ApiViewModel + val allModels by apiViewModel.allValidModels.collectAsStateWithLifecycle() + val allProviders by apiViewModel.allValidProviders.collectAsStateWithLifecycle() + + // Get the state from the SettingsViewModel which knows which providers have keys + val apiKeyState by apiViewModel.apiKeyManagementState.collectAsStateWithLifecycle() + + // Derive the list of models that are actually available (i.e., their provider has a key) + val availableModels by remember(allModels, apiKeyState) { + derivedStateOf { + val providersWithKeys = apiKeyState.providerStates.filter { it.hasKey }.map { it.provider.key }.toSet() + allModels.filter { it.providerKey in providersWithKeys } + } + } + + val selectedModel by apiViewModel.exerciseModel.collectAsStateWithLifecycle() + val examplePrompts = stringArrayResource(R.array.exercise_example_prompts).toList() + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.exercise_settings)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + AppCard { + Text( + text = stringResource(R.string.exercise_settings_description), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(16.dp) + ) + } + } + + item { + AppCard { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + stringResource(R.string.label_ai_model_and_prompt), + style = MaterialTheme.typography.titleMedium + ) + ApiModelDropDown( + models = availableModels, // Use the filtered list + providers = allProviders, + selectedModel = selectedModel, + onModelSelected = { model -> + apiViewModel.setExerciseModel(model) + }, + ) + AppTextField( + value = tempPrompt, + onValueChange = { tempPrompt = it }, + label = { Text(stringResource(R.string.custom_exercise_prompt)) }, + modifier = Modifier.defaultMinSize(minHeight = 150.dp) + ) + PrimaryButton( + onClick = { settingsViewModel.saveCustomExercisePrompt(tempPrompt) }, + text = stringResource(R.string.text_save_prompt), + modifier = Modifier.align(Alignment.End) + ) + } + } + } + + if (examplePrompts.isNotEmpty()) { + item { + AppCard { + Column(Modifier.padding(16.dp)) { + Text( + stringResource(R.string.examples), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(examplePrompts) { prompt -> + SecondaryButton( + onClick = { tempPrompt = prompt }, + text = prompt + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/GeneralSettingsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/GeneralSettingsScreen.kt new file mode 100644 index 0000000..5c67e1a --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/GeneralSettingsScreen.kt @@ -0,0 +1,88 @@ +package eu.gaudian.translator.view.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppSwitch +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.viewmodel.SettingsViewModel + +@Composable +fun GeneralSettingsScreen( + navController: NavController, +) { + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val showHints by settingsViewModel.showHints.collectAsStateWithLifecycle() + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.label_general)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AppCard { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { settingsViewModel.setShowHints(!showHints) } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.show_contextual_hints), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(R.string.display_info_buttons_for_on_screen_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + AppSwitch( + checked = showHints, + onCheckedChange = { settingsViewModel.setShowHints(it) } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/LanguageOptionsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/LanguageOptionsScreen.kt new file mode 100644 index 0000000..9c5bc3e --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/LanguageOptionsScreen.kt @@ -0,0 +1,189 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppSwitch +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.PrimaryButton +import eu.gaudian.translator.view.dialogs.AddCustomLanguageDialog +import eu.gaudian.translator.view.dialogs.EditLanguageDialog +import eu.gaudian.translator.viewmodel.LanguageViewModel + +/** + * This screen allows users to manage which languages are available in the app and add custom languages. + */ +@Composable +fun LanguageOptionsScreen( + navController: NavController, +) { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val enabledLanguages by languageViewModel.allLanguages.collectAsState() + val masterLanguages by languageViewModel.masterLanguages.collectAsState() + val isAllSelected by languageViewModel.isAllSelected.collectAsState() + var showAddLanguageDialog by remember { mutableStateOf(false) } + var editLanguageTarget by remember { mutableStateOf(null) } + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.text_language_options)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + AppCard { + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { languageViewModel.selectAllLanguages(!isAllSelected) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource(R.string.text_select_all_languages), style = MaterialTheme.typography.titleMedium) + AppSwitch( + checked = isAllSelected, + onCheckedChange = { languageViewModel.selectAllLanguages(it) } + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) { + items(masterLanguages.sortedBy { it.name }) { language -> + val isEnabled = enabledLanguages.any { it.nameResId == language.nameResId } + LanguageItem( + language = language, + isEnabled = isEnabled, + onLanguageToggled = { lang -> languageViewModel.toggleLanguageSelection(lang) }, + onLanguageDeleted = { lang -> languageViewModel.removeCustomLanguage(lang) }, + onLanguageEdit = { lang -> editLanguageTarget = lang } + ) + } + } + } + } + + PrimaryButton( + onClick = { showAddLanguageDialog = true }, + text = stringResource(R.string.text_add_custom_language), + modifier = Modifier.fillMaxWidth() + ) + } + } + + if (showAddLanguageDialog) { + @Suppress("KotlinConstantConditions") + AddCustomLanguageDialog( + showDialog = showAddLanguageDialog, + onDismiss = { showAddLanguageDialog = false }, + onAddLanguage = { language -> + languageViewModel.addCustomLanguage(language) + } + ) + } + + editLanguageTarget?.let { lang -> + EditLanguageDialog( + language = lang, + onDismiss = { editLanguageTarget = null }, + onSave = { name, code, region -> + languageViewModel.editLanguage(lang.nameResId, name, code, region) + editLanguageTarget = null + } + ) + } +} + +/** + * A composable that displays a single language item with a switch and a delete button for custom languages. + */ +@Composable +private fun LanguageItem( + language: Language, + isEnabled: Boolean, + onLanguageToggled: (Language) -> Unit, + onLanguageDeleted: (Language) -> Unit, + onLanguageEdit: (Language) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onLanguageToggled(language) } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(language.name, style = MaterialTheme.typography.bodyLarge) + Text("${language.code} - ${language.region.uppercase()}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { onLanguageEdit(language) }) { + Icon( + imageVector = AppIcons.Edit, + contentDescription = stringResource(R.string.edit) + ) + } + if (language.isCustom == true) { + IconButton(onClick = { onLanguageDeleted(language) }) { + Icon( + imageVector = AppIcons.Delete, + contentDescription = stringResource(R.string.text_delete_custom_language), + tint = MaterialTheme.colorScheme.error + ) + } + } + AppSwitch( + checked = isEnabled, + onCheckedChange = { onLanguageToggled(language) } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/LayoutOptionsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/LayoutOptionsScreen.kt new file mode 100644 index 0000000..a8b440b --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/LayoutOptionsScreen.kt @@ -0,0 +1,410 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.settings + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.AllFonts +import eu.gaudian.translator.ui.theme.AllThemes +import eu.gaudian.translator.ui.theme.FontStyle +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTabLayout +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.TabItem +import eu.gaudian.translator.viewmodel.SettingsViewModel + +private data class DarkModeOption( + override val title: String, + override val icon: ImageVector, + val name: String +) : TabItem + +@Suppress("HardCodedStringLiteral") +private fun getDarkModeOptions(context: Context): List { + return listOf( + DarkModeOption(context.getString(R.string.text_light), AppIcons.LightMode, "Light"), + DarkModeOption(context.getString(R.string.text_dark), AppIcons.DarkMode, "Dark"), + DarkModeOption(context.getString(R.string.label_system), AppIcons.BrightnessAuto, "System") + ) +} + +@Composable +fun LayoutOptionsScreen(navController: NavController) { + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val context = LocalContext.current + val selectedThemeName by settingsViewModel.theme.collectAsStateWithLifecycle() + val selectedDarkMode by settingsViewModel.darkModePreference.collectAsStateWithLifecycle() + val selectedFontName by settingsViewModel.fontPreference.collectAsStateWithLifecycle() + val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle() + + val cdBack = stringResource(R.string.cd_back) + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.label_appearance)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = cdBack) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + AppCard(Modifier.padding(0.dp)) { + Column(Modifier.padding(0.dp)) { + Text( + text = stringResource(R.string.text_appearance_mode), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + val darkModeOptions = getDarkModeOptions(context) + val selectedMode = darkModeOptions.find { it.name == selectedDarkMode } ?: darkModeOptions.last() + AppTabLayout( + tabs = darkModeOptions, + selectedTab = selectedMode, + onTabSelected = { settingsViewModel.setDarkModePreference(it.name) } + ) + } + } + + AppCard { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { settingsViewModel.setShowBottomNavLabels(!showBottomNavLabels) } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.text_navigation_bar_labels), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(R.string.text_show_text_labels_on_the_main_navigation_bar), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(Modifier.width(16.dp)) + Switch( + checked = showBottomNavLabels, + onCheckedChange = { settingsViewModel.setShowBottomNavLabels(it) } + ) + } + } + + // --- Color Palette Section --- + AppCard { + Column(Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.text_color_palette), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + ThemePicker( + selectedThemeName = selectedThemeName, + onThemeSelected = { themeName -> + settingsViewModel.setTheme(themeName) + (context as? Activity)?.recreate() + } + ) + } + } + + AppCard { + Column(Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.text_font_style), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + FontPicker( + selectedFontName = selectedFontName, + onFontSelected = { font -> + settingsViewModel.setFontPreference(font.name) + (context as? Activity)?.recreate() + } + ) + } + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +private fun ThemePicker(selectedThemeName: String, onThemeSelected: (String) -> Unit) { + val isDynamicThemeSupported = remember { Build.VERSION.SDK_INT >= Build.VERSION_CODES.S } + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 4.dp) + ) { + // Add the System Dynamic Theme option if supported + if (isDynamicThemeSupported) { + item { + SystemThemeOption( + isSelected = selectedThemeName == "System", + onClick = { onThemeSelected("System") } + ) + } + } + items(AllThemes) { theme -> + val isSelected = theme.name == selectedThemeName + val borderColor by animateColorAsState( + targetValue = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "borderColor" + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { onThemeSelected(theme.name) } + .padding(vertical = 4.dp) + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .border( + width = 3.dp, + color = borderColor, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Column(Modifier.fillMaxSize()) { + Row(Modifier.weight(1f)) { + Box(modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(theme.lightColors.primary)) + Box(modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(theme.lightColors.secondary)) + } + Row(Modifier.weight(1f)) { + Box(modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(theme.lightColors.tertiary)) + Box(modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(theme.lightColors.surface)) + } + } + + this@Column.AnimatedVisibility( + visible = isSelected, + enter = fadeIn(animationSpec = spring()), + exit = fadeOut(animationSpec = spring()) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + ) + Icon( + imageVector = AppIcons.Check, + contentDescription = stringResource(R.string.text_2d_selected, theme.name), + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + } + + Text( + text = theme.name, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +@Composable +private fun SystemThemeOption(isSelected: Boolean, onClick: () -> Unit) { + val borderColor by animateColorAsState( + targetValue = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent, + animationSpec = spring(stiffness = Spring.StiffnessMedium), + label = "borderColor" + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(vertical = 4.dp) + ) { + Box( + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .border( + width = 3.dp, + color = borderColor, + shape = CircleShape + ) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + // Icon representing the system/wallpaper theme + Icon( + imageVector = AppIcons.Wallpaper, + contentDescription = stringResource(R.string.system_theme), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(32.dp) + ) + + this@Column.AnimatedVisibility( + visible = isSelected, + enter = fadeIn(animationSpec = spring()), + exit = fadeOut(animationSpec = spring()) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + ) + Icon( + imageVector = AppIcons.Check, + contentDescription = stringResource(R.string.text_2d_selected, stringResource(R.string.system_theme)), + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + } + } + + Text( + text = stringResource(R.string.label_system), + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } +} + + +@SuppressLint("LocalContextResourcesRead", "DiscouragedApi") +@Composable +private fun FontPicker(selectedFontName: String, onFontSelected: (FontStyle) -> Unit) { + val context = LocalContext.current + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AllFonts.forEach { font -> + val isSelected = font.name == selectedFontName + val fontFamily = if (font.name == "default") { + FontFamily.Default + } else { + val fontResId = context.resources.getIdentifier(font.fileName, "font", context.packageName) + if (fontResId != 0) FontFamily(Font(resId = fontResId)) else FontFamily.Default + } + + val displayText = if (font.name == "default") { + stringResource(R.string.system_default_font) + } else { + stringResource(R.string.d_the_quick_brown_fox_jumps_over_the_lazy_dog, font.name) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onFontSelected(font) }, + shape = RoundedCornerShape(12.dp), + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + Text( + text = displayText, + style = MaterialTheme.typography.bodyLarge.copy(fontFamily = fontFamily), + modifier = Modifier.padding(16.dp), + textAlign = TextAlign.Center + ) + } + } + } +} + +@ThemePreviews +@Composable +private fun LayoutOptionsScreenPreview() { + val mockNavController = NavController(LocalContext.current) + LayoutOptionsScreen(navController = mockNavController) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/LogsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/LogsScreen.kt new file mode 100644 index 0000000..36684ee --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/LogsScreen.kt @@ -0,0 +1,509 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.settings + +import android.content.Intent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.communication.ApiLogEntry +import eu.gaudian.translator.ui.theme.semanticColors +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.viewmodel.SettingsViewModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun LogsScreen(navController: NavController) { + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val logsState by settingsViewModel.apiLogs.collectAsStateWithLifecycle(initialValue = emptyList()) + var onlyErrors by remember { mutableStateOf(false) } + var showStatusCodesDialog by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + + val displayedLogs by remember(logsState, onlyErrors) { + derivedStateOf { + val filtered = if (onlyErrors) { + logsState.filter { it.isError() } + } else { + logsState + } + filtered.asReversed() // Show most recent first + } + } + + if (showStatusCodesDialog) { + @Suppress("AssignedValueIsNeverRead") + StatusCodesDialog(onDismiss = { showStatusCodesDialog = false }) + } + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.label_logs)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + imageVector = AppIcons.ArrowBack, + contentDescription = stringResource(R.string.cd_back) + ) + } + }, + actions = { + TextButton(onClick = { + settingsViewModel.clearApiLogs() + }) { + Text(stringResource(R.string.label_clear)) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // ## A more compact and cleaner header for filters and actions + LogScreenHeader( + onlyErrors = onlyErrors, + onFilterChange = { onlyErrors = it }, + onInfoClick = { + @Suppress("AssignedValueIsNeverRead") + showStatusCodesDialog = true + } + ) + + if (logsState.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.logs_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + items(items = displayedLogs, key = { it.id }) { entry -> + LogListItem(entry = entry) + } + } + } + } + } +} + +@Composable +private fun LogScreenHeader( + onlyErrors: Boolean, + onFilterChange: (Boolean) -> Unit, + onInfoClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.only_show_errors), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Switch(checked = onlyErrors, onCheckedChange = onFilterChange) + IconButton(onClick = onInfoClick) { + Icon( + imageVector = AppIcons.Info, + contentDescription = stringResource(R.string.title_http_status_codes) + ) + } + } + HorizontalDivider(modifier = Modifier.padding(bottom = 4.dp)) +} + + +@Suppress("HardCodedStringLiteral") +@Composable +private fun LogListItem(entry: ApiLogEntry) { + var expanded by remember { mutableStateOf(false) } + val timeFormat = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } + + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { + expanded = !expanded + }, + elevation = CardDefaults.cardElevation(defaultElevation = if (expanded) 4.dp else 2.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) { + // ## This is the collapsed, scannable view + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatusIndicator(entry = entry) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "${entry.method} ${entry.endpoint}", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = entry.providerKey, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = timeFormat.format(Date(entry.timestamp)), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // ## This is the expanded, detailed view + AnimatedVisibility( + visible = expanded, + enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn(animationSpec = tween(300)), + exit = slideOutVertically(targetOffsetY = { -it / 2 }) + fadeOut(animationSpec = tween(300)) + ) { + LogDetails(entry = entry) + } + } + } +} + +@Composable +private fun StatusIndicator(entry: ApiLogEntry) { + val isHttpSuccess = entry.responseCode != null && entry.responseCode in 200..299 + val isParseOk = entry.parseErrorMessage.isNullOrBlank() && entry.errorMessage.isNullOrBlank() + val color = when { + isHttpSuccess && isParseOk -> MaterialTheme.semanticColors.success + entry.responseCode != null && entry.responseCode in 400..499 -> Color(0xFFF57C00) // Orange 700 + else -> MaterialTheme.colorScheme.error // Red for 5xx or network errors + } + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(color) + ) +} + +@Suppress("HardCodedStringLiteral") +@Composable +private fun LogDetails(entry: ApiLogEntry) { + Log.d("LogListItem", "LogDetails: $entry") + + val fullDateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) } + var tabIndex by remember { mutableIntStateOf(0) } + val tabs = listOf(stringResource(R.string.logs_request_json), stringResource(R.string.logs_response_json)) + + Column { + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + // ## Detailed meta-information + DetailRow(label = stringResource(R.string.logs_time), value = fullDateFormat.format(Date(entry.timestamp))) + DetailRow(label = stringResource(R.string.label_status), value = "${entry.responseCode ?: stringResource(R.string.na)} ${entry.responseMessage ?: ""}".trim()) + entry.model?.let { DetailRow(label = stringResource(R.string.model), value = it) } + entry.durationMs?.let { DetailRow(label = stringResource(R.string.duration), value = "$it ${stringResource(R.string.ms)}") } + entry.exceptionType?.let { DetailRow(label = stringResource(R.string.exception), value = it, valueColor = MaterialTheme.colorScheme.error) } + entry.isTimeout?.let { if (it) DetailRow(label = stringResource(R.string.timeout), value = stringResource(R.string.label_yes), valueColor = MaterialTheme.colorScheme.error) } + entry.errorMessage?.let { + DetailRow(label = stringResource(R.string.cd_error), value = it, valueColor = MaterialTheme.colorScheme.error) + } + entry.parseErrorMessage?.let { + DetailRow(label = stringResource(R.string.parse_error), value = it, valueColor = MaterialTheme.colorScheme.error) + } + + Spacer(Modifier.height(16.dp)) + + SecondaryTabRow(selectedTabIndex = tabIndex, containerColor = Color.Transparent) { + tabs.forEachIndexed { index, title -> + Tab( + text = { Text(title) }, + selected = tabIndex == index, + onClick = { tabIndex = index } + ) + } + } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Text( + text = when (tabIndex) { + 0 -> entry.requestJson ?: stringResource(R.string.not_available) + else -> entry.responseJson ?: stringResource(R.string.not_available) + }, + modifier = Modifier.padding(12.dp), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + } + + Spacer(Modifier.height(8.dp)) + ShareLogRow(entry = entry) + } +} + +@Composable +private fun DetailRow(label: String, value: String, valueColor: Color = Color.Unspecified) { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = "$label:", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(0.3f) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = valueColor, + modifier = Modifier.weight(0.7f) + ) + } + Spacer(Modifier.height(4.dp)) +} + +@Composable +private fun StatusCodesDialog(onDismiss: () -> Unit) { + AppDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_http_status_codes)) }, + content = { StatusCodeInfo() }, + ) +} + +@Composable +private fun ShareLogRow(entry: ApiLogEntry) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + val context = LocalContext.current + val logsEmail = stringResource(R.string.logs_email_subject) + val logsLabelId = stringResource(R.string.logs_label_id) + val logsLabelTimestamp = stringResource(R.string.logs_label_timestamp) + val logsLabelProvider = stringResource(R.string.logs_label_provider) + val logsLabelEndpoint = stringResource(R.string.logs_label_endpoint) + val logsLabelModel = stringResource(R.string.logs_label_model) + val logsLabelStatus = stringResource(R.string.logs_label_status) + val logsLabelDuration = stringResource(R.string.logs_label_duration) + val logsLabelException = stringResource(R.string.logs_label_exception) + val logsLabelTimeoutYes = stringResource(R.string.logs_label_timeout_yes) + val logsLabelParseError = stringResource(R.string.logs_label_parse_error) + val logsLabelError = stringResource(R.string.logs_label_error) + val logsRequestSection = stringResource(R.string.logs_request_section) + val logsResponseSection = stringResource(R.string.logs_response_section) + val ms = stringResource(R.string.ms) + val notAvailable = stringResource(R.string.not_available) + val emailLog = stringResource(R.string.email_log) + val share = stringResource(R.string.share) + + TextButton(onClick = { + val subject = (logsEmail + " " + entry.id) + val body = buildString { + @Suppress("HardCodedStringLiteral") val ts = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(entry.timestamp)) + appendLine(logsLabelId.format(entry.id)) + appendLine(logsLabelTimestamp.format(ts)) + appendLine(logsLabelProvider.format(entry.providerKey)) + appendLine(logsLabelEndpoint.format(entry.method, entry.endpoint)) + appendLine(logsLabelModel.format(entry.model ?: "-")) + appendLine(logsLabelStatus.format((entry.responseCode ?: "-").toString(), entry.responseMessage ?: "")) + entry.durationMs?.let { appendLine(logsLabelDuration.format(it.toString(), ms)) } + entry.exceptionType?.let { appendLine(logsLabelException.format(it)) } + if (entry.isTimeout == true) appendLine(logsLabelTimeoutYes) + if (!entry.parseErrorMessage.isNullOrBlank()) appendLine(logsLabelParseError.format(entry.parseErrorMessage)) + if (!entry.errorMessage.isNullOrBlank()) appendLine(logsLabelError.format(entry.errorMessage)) + appendLine("\n$logsRequestSection\n${entry.requestJson ?: notAvailable}") + appendLine("\n$logsResponseSection\n${entry.responseJson ?: notAvailable}") + } + val intent = Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" + putExtra(Intent.EXTRA_EMAIL, arrayOf("play@gaudian.eu")) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, body) + } + try { + context.startActivity(Intent.createChooser(intent, emailLog)) + } catch (_: Exception) { + // Fallback to plain text share if no email clients + val fallbackIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, body) + } + context.startActivity(Intent.createChooser(fallbackIntent, share)) + } + }) { + Text(emailLog) + } + } +} + +// Helper extension function +private fun ApiLogEntry.isError(): Boolean { + val isHttpError = responseCode != null && responseCode !in 200..299 + val hasErrorMessage = !errorMessage.isNullOrBlank() + val hasParseError = !parseErrorMessage.isNullOrBlank() + return isHttpError || hasErrorMessage || hasParseError +} + +@Preview(showBackground = true) +@Composable +private fun LogsScreenPreview() { + LogsScreen(navController = rememberNavController()) +} + +@Preview(showBackground = true) +@Composable +private fun LogListItemPreview() { + val entry = ApiLogEntry( + id = "123", + timestamp = System.currentTimeMillis(), + providerKey = "DeepL", + endpoint = "/v2/translate", + method = "POST", + model = "deepl-pro", + requestJson = """{"text": ["Hello, world!"], "target_lang": "DE"}""", + responseCode = 200, + responseMessage = "OK", + responseJson = """{"translations": [{"detected_source_language": "EN", "text": "Hallo, Welt!"}]}""", + errorMessage = null + ) + Column(modifier = Modifier.padding(16.dp)) { + LogListItem(entry = entry) + } +} + +@Preview(showBackground = true) +@Composable +private fun LogListItemErrorPreview() { + val entry = ApiLogEntry( + id = "456", + timestamp = System.currentTimeMillis(), + providerKey = "OpenAI", + endpoint = "/v1/chat/completions", + method = "POST", + model = "gpt-4", + requestJson = """{"model": "gpt-4", "messages": [{"role": "user", "content": "Say this is a test!"}]}""", + responseCode = 401, + responseMessage = "Unauthorized", + responseJson = """{"error": {"message": "Incorrect API key provided.", "type": "invalid_request_error"}}""", + errorMessage = "HTTP 401 Unauthorized" + ) + Column(modifier = Modifier.padding(16.dp)) { + LogListItem(entry = entry) + } +} + +@Composable +fun StatusCodeInfo() { + val items = listOf( + stringResource(R.string.text_200_ok) to stringResource(R.string.the_request_was_successful), + stringResource(R.string.text_400_bad_request) to stringResource(R.string.the_server_could_not_understand_the_request), + stringResource(R.string.text_401_unauthorized) to stringResource(R.string.text_authentication_is_required_and_has_failed), + stringResource(R.string.text_403_forbidden) to stringResource(R.string.the_server_understood_the_request_but_is_refusing_to_authorize_it), + stringResource(R.string.text_404_not_found) to stringResource(R.string.the_requested_resource_could_not_be_found), + stringResource(R.string.text_429_too_many_requests) to stringResource(R.string.text_too_many_requests), + stringResource(R.string.text_500_internal_server_error) to stringResource(R.string.text_an_unexpected_condition_was_encountered_on_the_server) + ) + + LazyColumn( + modifier = Modifier + .padding(8.dp) + .heightIn(max = 500.dp) + ) { + items(items) { (code, description) -> + Row(modifier = Modifier.padding(vertical = 4.dp)) { + Text( + text = code, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(120.dp) + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/MainSettingsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/MainSettingsScreen.kt new file mode 100644 index 0000000..810d950 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/MainSettingsScreen.kt @@ -0,0 +1,179 @@ +package eu.gaudian.translator.view.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.LocalShowExperimentalFeatures +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedCard +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar + +private data class Setting(val titleRes: Int, val icon: ImageVector, val route: String) + +@Composable +fun MainSettingsScreen( + navController: NavController, + modifier: Modifier = Modifier +) { + val showExperimental = LocalShowExperimentalFeatures.current + + val settingsGroups = remember(showExperimental) { + val allGroups = listOf( + R.string.label_general to listOf( + Setting(R.string.label_about, AppIcons.Info, SettingsRoutes.ABOUT), + Setting(R.string.label_general, AppIcons.Tune, SettingsRoutes.GENERAL), + Setting(R.string.settings_title_connection, AppIcons.ApiKey, SettingsRoutes.API_KEY), + Setting(R.string.label_appearance, AppIcons.Appearance, SettingsRoutes.LAYOUT_OPTIONS), + Setting(R.string.settings_title_voice, AppIcons.TextToSpeech, SettingsRoutes.TTS_OPTIONS), + Setting(R.string.label_logs, AppIcons.Log, SettingsRoutes.LOGS), + Setting(R.string.label_languages, AppIcons.Language, SettingsRoutes.LANGUAGE_OPTIONS), + //Setting(R.string.hint_settings_title_hints, AppIcons.Info, SettingsRoutes.HINTS_OVERVIEW) + + ), + R.string.settings_header_translator to listOf( + Setting(R.string.label_translation, AppIcons.Translate, SettingsRoutes.CUSTOM_TRANSLATION_PROMPT) + ), + R.string.label_dictionary to listOf( + Setting(R.string.label_dictionary, AppIcons.Dictionary, SettingsRoutes.DICTIONARY_OPTIONS) + ), + R.string.label_vocabulary to listOf( + Setting(R.string.settings_title_vocab_progress, AppIcons.Stages, SettingsRoutes.VOCABULARY_OPTIONS), + Setting(R.string.settings_title_vocab_prompt, AppIcons.EditNote, SettingsRoutes.VOCABULARY_PROMPT_OPTIONS), + Setting(R.string.settings_title_vocab_repo, AppIcons.Repository, SettingsRoutes.VOCABULARY_REPOSITORY_OPTIONS) + ), + R.string.label_exercises to listOf( + Setting(R.string.settings_title_exercise_generation, AppIcons.Exercise, SettingsRoutes.EXERCISE_OPTIONS) + ) + ) + + if (showExperimental) { + allGroups + } else { + allGroups.map { (headerRes, settings) -> + val filteredSettings = settings.filterNot { setting -> + setting.route == SettingsRoutes.DEV_OPTIONS + } + headerRes to filteredSettings + }.filterNot { (headerRes, _) -> + headerRes == R.string.label_exercises + } + } + } + + AppOutlinedCard { + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.title_settings), style = MaterialTheme.typography.titleLarge) } + ) + } + ) { paddingValues -> + LazyColumn(modifier = modifier.padding(paddingValues).padding(bottom = 0.dp)) { + settingsGroups.forEach { (headerRes, settings) -> + item { + // The string is resolved here, in the composable context + SettingsHeader(title = stringResource(id = headerRes)) + } + item { + AppCard( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp), + ) { + Column { + settings.forEachIndexed { index, setting -> + SettingsItem( + // The string is resolved here, in the composable context + title = stringResource(id = setting.titleRes), + icon = setting.icon, + onClick = { navController.navigate(setting.route) } + ) + if (index < settings.lastIndex) { + HorizontalDivider(modifier = Modifier.padding(start = 56.dp)) + } + } + } + } + } + } + } + } + } +} + +@ThemePreviews +@Composable +fun MainSettingsScreenPreview() { + MainSettingsScreen(navController = rememberNavController()) +} + +@Composable +private fun SettingsHeader( + title: String, + modifier: Modifier = Modifier +) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) +} + +@ThemePreviews +@Composable +fun SettingsHeaderPreview() { + SettingsHeader(title = stringResource(R.string.general_settings)) +} + +@Composable +private fun SettingsItem( + title: String, + icon: ImageVector, + onClick: () -> Unit, +) { + ListItem( + headlineContent = { Text(title) }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + modifier = Modifier.clickable(onClick = onClick) + ) +} + +@ThemePreviews +@Composable +fun SettingsItemPreview() { + SettingsItem( + title = stringResource(R.string.label_about), + icon = AppIcons.Info, + onClick = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/SettingsNavGraph.kt b/app/src/main/java/eu/gaudian/translator/view/settings/SettingsNavGraph.kt new file mode 100644 index 0000000..3b03598 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/SettingsNavGraph.kt @@ -0,0 +1,144 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.settings + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import eu.gaudian.translator.view.composable.Screen +import eu.gaudian.translator.view.hints.ApiHintScreen +import eu.gaudian.translator.view.hints.CategoryHintScreenWrapper +import eu.gaudian.translator.view.hints.DictionaryHintScreen +import eu.gaudian.translator.view.hints.HintsOverviewScreen +import eu.gaudian.translator.view.hints.ImportHintScreen +import eu.gaudian.translator.view.hints.ScanHintScreen +import eu.gaudian.translator.view.hints.SortingHintScreen +import eu.gaudian.translator.view.hints.StagesHintScreen +import eu.gaudian.translator.view.hints.TranslationHintScreen +import eu.gaudian.translator.view.hints.VocabularyProgressHintScreen + +// Defines the routes for the settings graph to avoid using raw strings +object SettingsRoutes { + const val TTS_OPTIONS = "settings_tts_options" + const val LOGS = "settings_logs" + const val LIST = "settings_list" + const val ABOUT = "settings_about" + const val API_KEY = "settings_api_key" + const val LAYOUT_OPTIONS = "settings_layout_options" + const val CUSTOM_TRANSLATION_PROMPT = "settings_custom_translation_prompt" + const val LANGUAGE_OPTIONS = "settings_language_options" + const val DICTIONARY_OPTIONS = "settings_dictionary_options" + const val VOCABULARY_OPTIONS = "settings_vocabulary_options" + const val VOCABULARY_PROMPT_OPTIONS = "settings_vocabulary_prompt_options" + const val VOCABULARY_REPOSITORY_OPTIONS = "settings_vocabulary_repository_options" + const val EXERCISE_OPTIONS = "settings_exercise_options" + // ADD THIS LINE + const val GENERAL = "settings_general" + const val ADD_MODEL = "settings_add_model/{providerKey}" + const val HINTS_OVERVIEW = "settings_hints_overview" + const val HINTS_CATEGORIES = "settings_hints_categories" + const val HINTS_DICTIONARY = "settings_hints_dictionary" + const val HINTS_IMPORT = "settings_hints_import" + const val HINTS_SORTING = "settings_hints_sorting" + const val HINTS_STAGES = "settings_hints_stages" + const val HINTS_TRANSLATION = "settings_hints_translation" + const val HINTS_SCAN = "settings_hints_scan" + const val HINTS_API = "settings_hints_api" + const val HINTS_VOCABULARY_PROGRESS = "settings_hints_vocabulary_progress" + + const val DEV_OPTIONS = "settings_dev_options" + const val THEME_PREVIEW = "settings_theme_preview" +} + +// This function builds the nested navigation graph for all settings screens +fun NavGraphBuilder.settingsGraph(navController: NavController) { + navigation( + startDestination = SettingsRoutes.LIST, + route = Screen.Settings.route // The route for the entire settings flow + ) { + composable(SettingsRoutes.LIST) { + MainSettingsScreen(navController = navController) + } + composable(SettingsRoutes.ABOUT) { + AboutScreen(navController = navController) + } + composable(SettingsRoutes.GENERAL) { + GeneralSettingsScreen(navController = navController) + } + composable(SettingsRoutes.API_KEY) { + ApiKeyScreen(navController = navController) + } + composable(SettingsRoutes.ADD_MODEL) { backStackEntry -> + val providerKey = backStackEntry.arguments?.getString("providerKey") ?: "" + AddModelScreen(navController = navController, providerKey = providerKey) + } + composable(SettingsRoutes.LAYOUT_OPTIONS) { + LayoutOptionsScreen(navController = navController) + } + composable(SettingsRoutes.CUSTOM_TRANSLATION_PROMPT) { + TranslationSettingsScreen( + navController = navController + ) + } + composable(SettingsRoutes.LANGUAGE_OPTIONS) { + LanguageOptionsScreen(navController = navController) + } + composable(SettingsRoutes.DICTIONARY_OPTIONS) { + DictionaryOptionsScreen( + navController = navController + ) + } + composable(SettingsRoutes.VOCABULARY_OPTIONS) { + VocabularyProgressOptionsScreen(navController = navController) + } + composable(SettingsRoutes.VOCABULARY_PROMPT_OPTIONS) { + CustomVocabularyPromptScreen(navController = navController) + } + composable(SettingsRoutes.VOCABULARY_REPOSITORY_OPTIONS) { + VocabularyRepositoryOptionsScreen(navController = navController) + } + composable(SettingsRoutes.EXERCISE_OPTIONS) { + ExerciseSettingsScreen(navController) + } + composable(SettingsRoutes.LOGS) { + LogsScreen(navController = navController) + } + composable(SettingsRoutes.TTS_OPTIONS) { + TextToSpeechSettingsScreen(navController = navController) + } + composable(SettingsRoutes.THEME_PREVIEW) { + ThemePreviewScreen(navController = navController) + } + composable(SettingsRoutes.HINTS_OVERVIEW) { + HintsOverviewScreen(navController = navController) + } + composable(SettingsRoutes.HINTS_CATEGORIES) { + CategoryHintScreenWrapper(navController = navController) + } + composable(SettingsRoutes.HINTS_DICTIONARY) { + DictionaryHintScreen(navController = navController) + } + composable(SettingsRoutes.HINTS_IMPORT) { + ImportHintScreen(navController = navController) + } + composable(SettingsRoutes.HINTS_SORTING) { + SortingHintScreen(navController = navController) + } + composable(SettingsRoutes.HINTS_STAGES) { + StagesHintScreen(navController = navController) + } + composable(SettingsRoutes.HINTS_TRANSLATION) { + TranslationHintScreen(navController = navController) + } + composable(SettingsRoutes.HINTS_SCAN) { + ScanHintScreen(navController = navController) + } + composable(SettingsRoutes.HINTS_API) { + ApiHintScreen(navController = navController) + } + composable(SettingsRoutes.HINTS_VOCABULARY_PROGRESS) { + VocabularyProgressHintScreen(navController = navController) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/TextToSpeechSettingsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/TextToSpeechSettingsScreen.kt new file mode 100644 index 0000000..31aedad --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/TextToSpeechSettingsScreen.kt @@ -0,0 +1,227 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.settings + +import android.speech.tts.Voice +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.TextToSpeechHelper +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppSlider +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.SecondaryButton +import eu.gaudian.translator.view.composable.SingleLanguageDropDown +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.SettingsViewModel +import kotlinx.coroutines.launch + +@Composable +fun TextToSpeechSettingsScreen( + navController: NavController, +) { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val scope = rememberCoroutineScope() + val speakingSpeed by settingsViewModel.speakingSpeed.collectAsState() + val context = LocalContext.current + + // Single language selection + var selectedLanguage by remember { mutableStateOf(null) } + + // Available voices and current selection for the chosen language + var availableVoices by remember { mutableStateOf>(emptyList()) } + var selectedVoiceName by remember { mutableStateOf(null) } + + // Load stored voice whenever language changes + LaunchedEffect(selectedLanguage) { + val lang = selectedLanguage ?: run { + availableVoices = emptyList() + selectedVoiceName = null + return@LaunchedEffect + } + val voices = TextToSpeechHelper.getVoicesForLanguage(context, lang) + availableVoices = voices + val stored = settingsViewModel.getTtsVoiceForLanguage(lang.code, lang.region) + selectedVoiceName = stored + } + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.settings_title_voice)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(paddingValues) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Language selection card + AppCard { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium) + SingleLanguageDropDown( + languageViewModel = languageViewModel, + selectedLanguage = selectedLanguage, + onLanguageSelected = { selectedLanguage = it }, + showNoneOption = true, + onNoneSelected = { selectedLanguage = null } + ) + } + } + + // Voice selection for the selected language + val lang = selectedLanguage + Log.d("Selected language: $lang") + if (lang != null) { + AppCard { + Column(modifier = Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = lang.name, style = MaterialTheme.typography.titleMedium) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SecondaryButton( + onClick = { + selectedVoiceName = null + scope.launch { settingsViewModel.saveTtsVoiceForLanguage(lang.code, lang.region, null) } + }, + text = stringResource(R.string.default_value), + icon = if (selectedVoiceName == null) AppIcons.Check else null, + inverse = true + ) + Spacer(Modifier.weight(1f)) + } + // Bounded scrollable list of voices to save space + if (availableVoices.isEmpty()) { + Text( + text = "No TTS voices found for this language on your device.", + style = MaterialTheme.typography.bodyMedium + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(availableVoices) { v -> + SecondaryButton( + onClick = { + selectedVoiceName = v.name + scope.launch { settingsViewModel.saveTtsVoiceForLanguage(lang.code, lang.region, selectedVoiceName) } + }, + modifier = Modifier.fillMaxWidth(), + text = v.name, + icon = if (selectedVoiceName == v.name) AppIcons.Check else null, + inverse = true + ) + } + } + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val sample = stringResource(R.string.tts_test_phrase) + AppButton( + onClick = { + scope.launch { + val ts = eu.gaudian.translator.utils.TranslationService(context) + val translated = if(lang.code != "en") ts.simpleTranslateTo(sample, lang).getOrElse { sample } else sample + + TextToSpeechHelper.speakOut( + context, + translated, + lang, + selectedVoiceName, + ) + } + }, + modifier = Modifier.weight(1f), + content = {Text(stringResource(R.string.test))} + ) + } + } + } + } + + AppCard { + var speedLocal by remember { mutableFloatStateOf(speakingSpeed.toFloat()) } + var isDragging by remember { mutableStateOf(false) } + var saveJob by remember { mutableStateOf(null) } + + LaunchedEffect(speakingSpeed) { + if (!isDragging) { + speedLocal = speakingSpeed.toFloat() + } + } + + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(text = stringResource(R.string.label_speaking_speed), style = MaterialTheme.typography.titleMedium) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(text = speedLocal.toInt().toString(), style = MaterialTheme.typography.bodyMedium) + } + AppSlider( + value = speedLocal, + onValueChange = { value -> + isDragging = true + val coerced = value.coerceIn(50f, 200f) + speedLocal = coerced + // Debounce persistence: cancel previous pending save and schedule a new one + saveJob?.cancel() + saveJob = scope.launch { + kotlinx.coroutines.delay(350) + settingsViewModel.saveSpeakingSpeed(coerced.toInt()) + isDragging = false + } + }, + valueRange = 50f..200f, + steps = 14 + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/ThemePreviewScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/ThemePreviewScreen.kt new file mode 100644 index 0000000..d9794a9 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/ThemePreviewScreen.kt @@ -0,0 +1,213 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppCheckbox +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppSwitch +import eu.gaudian.translator.view.composable.Compass +import eu.gaudian.translator.view.composable.HintIcons +import eu.gaudian.translator.view.composable.Info +import eu.gaudian.translator.view.composable.Key +import eu.gaudian.translator.view.composable.Lightbulb +import eu.gaudian.translator.view.composable.MagicWand +import eu.gaudian.translator.view.composable.MagnifyingGlass +import eu.gaudian.translator.view.composable.PointingHand +import eu.gaudian.translator.view.composable.PrimaryButton +import eu.gaudian.translator.view.composable.Puzzle +import eu.gaudian.translator.view.composable.QuestionCircle +import eu.gaudian.translator.view.composable.Scroll +import eu.gaudian.translator.view.composable.SecondaryButton + +@Composable +fun ThemePreviewScreen(navController: NavController) { + AppScaffold( + topBar = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = "Back") + } + Text( + text = "Theme Preview", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Buttons + AppCard { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Buttons", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PrimaryButton(onClick = {}, text = "Primary") + SecondaryButton(onClick = {}, text = "Secondary") + } + } + } + + // Switch & Checkbox + AppCard { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Toggles", style = MaterialTheme.typography.titleMedium) + var sw by remember { mutableStateOf(true) } + var cb by remember { mutableStateOf(false) } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { + AppSwitch(checked = sw, onCheckedChange = { sw = it }) + AppCheckbox(checked = cb, onCheckedChange = { cb = it }) + } + } + } + + // Hint icons + AppCard { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Hint icons", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(HintIcons.Lightbulb, contentDescription = "Lightbulb", modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary) + Text("Lightbulb") + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(HintIcons.QuestionCircle, contentDescription = "QuestionCircle", modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary) + Text("QuestionCircle") + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(HintIcons.MagnifyingGlass, contentDescription = "MagnifyingGlass", modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary) + Text("MagnifyingGlass") + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(HintIcons.Key, contentDescription = "Key", modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary) + Text("Key") + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(HintIcons.Info, contentDescription = "Info", modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary) + Text("Info") + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(HintIcons.Puzzle, contentDescription = "Puzzle", modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary) + Text("Puzzle") + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(HintIcons.MagicWand, contentDescription = "MagicWand", modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary) + Text("MagicWand") + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(HintIcons.Compass, contentDescription = "Compass", modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary) + Text("Compass") + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(HintIcons.PointingHand, contentDescription = "PointingHand", modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary) + Text("PointingHand") + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(HintIcons.Scroll, contentDescription = "Scroll", modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.primary) + Text("Scroll") + } + } + } + + // Color swatches from Material color scheme (backed by ThemeColorSet) + AppCard { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Theme colors", style = MaterialTheme.typography.titleMedium) + val cs = MaterialTheme.colorScheme + val items: List> = listOf( + Triple("primary", cs.primary, cs.onPrimary), + Triple("secondary", cs.secondary, cs.onSecondary), + Triple("tertiary", cs.tertiary, cs.onTertiary), + Triple("primaryContainer", cs.primaryContainer, cs.onPrimaryContainer), + Triple("secondaryContainer", cs.secondaryContainer, cs.onSecondaryContainer), + Triple("tertiaryContainer", cs.tertiaryContainer, cs.onTertiaryContainer), + Triple("error", cs.error, cs.onError), + Triple("errorContainer", cs.errorContainer, cs.onErrorContainer), + Triple("background", cs.background, cs.onBackground), + Triple("surface", cs.surface, cs.onSurface), + Triple("surfaceVariant", cs.surfaceVariant, cs.onSurfaceVariant), + Triple("outline", cs.outline, cs.onSurface), + Triple("outlineVariant", cs.outlineVariant, cs.onSurface), + Triple("scrim", cs.scrim, cs.onSurface), + Triple("inverseSurface", cs.inverseSurface, cs.inverseOnSurface), + Triple("inversePrimary", cs.inversePrimary, cs.onPrimary), + Triple("surfaceDim", cs.surfaceDim, cs.onSurface), + Triple("surfaceBright", cs.surfaceBright, cs.onSurface), + Triple("surfaceContainerLowest", cs.surfaceContainerLowest, cs.onSurface), + Triple("surfaceContainerLow", cs.surfaceContainerLow, cs.onSurface), + Triple("surfaceContainer", cs.surfaceContainer, cs.onSurface), + Triple("surfaceContainerHigh", cs.surfaceContainerHigh, cs.onSurface), + Triple("surfaceContainerHighest", cs.surfaceContainerHighest, cs.onSurface), + ) + items.forEach { (name, bg, fg) -> + ColorRow(name = name, bg = bg, fg = fg) + } + } + } + } + } +} + +@Composable +private fun ColorRow(name: String, bg: Color, fg: Color) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(bg) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = name, color = appropriateTextColor(bg, fg), fontWeight = FontWeight.Medium) + Box( + modifier = Modifier + .size(28.dp) + .background(appropriateTextColor(bg, fg)) + ) + } +} + +private fun appropriateTextColor(bg: Color, suggested: Color): Color { + // prefer suggested, but ensure sufficient contrast by falling back to black/white heuristic + val luminance = 0.299f * bg.red + 0.587f * bg.green + 0.114f * bg.blue + val fallback = if (luminance < 0.5f) Color.White else Color.Black + return if (suggested.alpha > 0f) suggested else fallback +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/TranslationSettings.kt b/app/src/main/java/eu/gaudian/translator/view/settings/TranslationSettings.kt new file mode 100644 index 0000000..8d8ea8f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/TranslationSettings.kt @@ -0,0 +1,115 @@ +package eu.gaudian.translator.view.settings + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.OptionItemSwitch +import eu.gaudian.translator.viewmodel.ApiViewModel +import eu.gaudian.translator.viewmodel.SettingsViewModel + +@SuppressLint("LocalContextResourcesRead") +@Composable +fun TranslationSettingsScreen( + navController: NavController, +) { + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + val apiViewModel: ApiViewModel = hiltViewModel(viewModelStoreOwner = activity) + val context = LocalContext.current + val customPrompt by settingsViewModel.customPrompt.collectAsStateWithLifecycle() + val availableModels by apiViewModel.allValidModels.collectAsStateWithLifecycle() + val allProviders by apiViewModel.allValidProviders.collectAsStateWithLifecycle() + val selectedModel by apiViewModel.translationModel.collectAsStateWithLifecycle() + var tempPrompt by remember(customPrompt) { mutableStateOf(customPrompt) } + val examplePrompts = remember { + context.resources.getStringArray(R.array.example_prompts).toList() + } + + val screenState = PromptSettingsState( + availableModels = availableModels, + selectedModel = selectedModel, + customPrompt = tempPrompt, + examplePrompts = examplePrompts + ) + + val useLibre by settingsViewModel.useLibreTranslate.collectAsStateWithLifecycle() + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.label_translation_settings)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + }, + hintContent = { + Text( + //TODO make this nicer and an own file + stringResource(R.string.hint_use_this_screen_to_define) + ) + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(8.dp) + ) { + AppCard{ + Row(modifier = Modifier.fillMaxWidth().padding(0.dp), + ) { + + OptionItemSwitch( + title = stringResource(R.string.toggle_use_libretranslate), + description = stringResource(R.string.toggle_use_libretranslate_desc), + checked = useLibre, + onCheckedChange = { settingsViewModel.setUseLibreTranslate(it) }, + ) + }} + + + Spacer(modifier = Modifier.height(16.dp)) + + BasePromptSettingsScreen( + state = screenState, + providers = allProviders, + description = stringResource(R.string.text_here_you_can_set), + onPromptChanged = { tempPrompt = it }, + onSaveClicked = { settingsViewModel.saveCustomPrompt(tempPrompt) }, + onModelSelected = { apiViewModel.setTranslationModel(it) }, + onExamplePromptClicked = { tempPrompt = it } + ) + } + } + } diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyProgressOptionsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyProgressOptionsScreen.kt new file mode 100644 index 0000000..e66baa1 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyProgressOptionsScreen.kt @@ -0,0 +1,450 @@ +package eu.gaudian.translator.view.settings + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppSlider +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.hints.VocabularyProgressHint +import eu.gaudian.translator.viewmodel.SettingsViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.roundToInt + +@Composable +fun VocabularyProgressOptionsScreen( + navController: NavController, + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(LocalContext.current.applicationContext as android.app.Application) +) { + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + + + val exportFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { result -> vocabularyViewModel.handleSaveResult(result) } + ) + + LaunchedEffect(exportFileLauncher) { + vocabularyViewModel.initializeSaveFileLauncher(exportFileLauncher) + } + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.vocabulary_settings)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + }, + // Here is the new hint content + hintContent = { VocabularyProgressHint() } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Interval Settings + AppCard( + expandable = true, + initiallyExpanded = true, + title = stringResource(R.string.text_interval_settings_in_days), + text = stringResource(R.string.text_customize_the_intervals), + + + + ) { + val intervals by settingsViewModel.intervals.collectAsStateWithLifecycle() + Column( + modifier = Modifier + .padding(16.dp) + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + IntervalTimeline(intervals = intervals) + intervals.forEach { (stageKey, days) -> + val displayLabel = labelForStage(stageKey) + IntervalSlider( + label = displayLabel, + value = days, + onValueChange = { newValue -> + settingsViewModel.setInterval(stageKey, newValue) + } + ) + } + + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = { settingsViewModel.resetIntervalsToDefaults() }) { + Text(stringResource(R.string.reset_to_defaults)) + } + } + } + } + + // Criteria Settings + AppCard { + val criteriaCorrect by settingsViewModel.criteriaCorrect.collectAsStateWithLifecycle() + val criteriaWrong by settingsViewModel.criteriaWrong.collectAsStateWithLifecycle() + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.label_learning_criteria), + style = MaterialTheme.typography.titleMedium + ) + // Slider for "Min correct to advance" + SettingsSlider( + label = stringResource(R.string.min_correct_to_advance), + value = criteriaCorrect, + onValueChange = { settingsViewModel.setCriteriaCorrect(it) }, + valueRange = 1f..5f, + steps = 3 // Allows snapping to 1, 2, 3, 4, 5 + ) + // Slider for "Max wrong to demote" + SettingsSlider( + label = stringResource(R.string.max_wrong_to_demote), + value = criteriaWrong, + onValueChange = { settingsViewModel.setCriteriaWrong(it) }, + valueRange = 1f..5f, + steps = 3 + ) + } + } + + // Daily Goal Settings + AppCard { + val dailyGoal by settingsViewModel.dailyGoal.collectAsStateWithLifecycle() + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.daily_learning_goal), + style = MaterialTheme.typography.titleMedium + ) + @Suppress("USELESS_ELVIS", "HardCodedStringLiteral") + SettingsSlider( + label = stringResource(R.string.target_correct_answers_per_day), + value = dailyGoal ?: 10, + onValueChange = { settingsViewModel.setDailyGoal(it) }, + valueRange = 10f..100f, + steps = 17 // Allows snapping in steps of 5 + ) + Text( + text = stringResource(R.string.text_daily_goal_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun SettingsSlider( + label: String, + value: Int, + onValueChange: (Int) -> Unit, + valueRange: ClosedFloatingPointRange, + steps: Int +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme.primaryContainer, + RoundedCornerShape(8.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + text = value.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold + ) + } + } + Spacer(Modifier.height(4.dp)) + AppSlider( + value = value.toFloat(), + onValueChange = { onValueChange(it.roundToInt()) }, + valueRange = valueRange, + steps = steps + ) + } +} + +@Composable +private fun IntervalTimeline(intervals: Map) { + val totalDays = intervals.values.sum() + val stageColors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.secondary, + MaterialTheme.colorScheme.tertiary, + Color(0xFF3F51B5), // Indigo + Color(0xFF009688), // Teal + Color(0xFFE91E63), // Pink + Color(0xFFFF9800) // Orange for the 'Learned' stage + ) + + // Main layout for the timeline and its labels + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Build the string showing the sum of all stage days + val calculationString = intervals.values + .filter { it > 0 } // Exclude stages with 0 days from the text + .joinToString(" + ") + + val fullText = if (calculationString.isNotEmpty()) { + // Using a simple "days" string, adjust if you have a specific resource + buildString { + append(calculationString) + append(" = ") + append(totalDays) + append(stringResource(R.string.text_days)) + } + } else { + buildString { + append(totalDays) + append(stringResource(R.string.text_days)) + } + } + + // Display the full calculation string centered above the bar + Text( + text = fullText, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + + // The timeline bar + Row( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + ) { + intervals.onEachIndexed { index, (_, days) -> + val weight = if (totalDays > 0) days.toFloat() / totalDays.toFloat() else 0f + if (weight > 0) { + Box( + modifier = Modifier + .fillMaxHeight() + .weight(weight) + .background(stageColors.getOrElse(index) { Color.Gray }) + ) + } + } + } + + // Spacer before the legend + Spacer(modifier = Modifier.height(4.dp)) + + // The legend using a FlowRow to ensure all items are visible + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + intervals.keys.forEachIndexed { index, stageKey -> + LegendItem( + color = stageColors.getOrElse(index) { Color.Gray }, + icon = getIconForStage(stageKey), + stageName = labelForStage(stageKey) + ) + } + } + } +} + +@Composable +private fun getIconForStage(stageKey: String): ImageVector { + return when (stageKey) { + SettingsViewModel.STAGE_NEW -> AppIcons.StageNew + SettingsViewModel.STAGE_1 -> AppIcons.Stage1 + SettingsViewModel.STAGE_2 -> AppIcons.Stage2 + SettingsViewModel.STAGE_3 -> AppIcons.Stage3 + SettingsViewModel.STAGE_4 -> AppIcons.Stage4 + SettingsViewModel.STAGE_5 -> AppIcons.Stage5 + SettingsViewModel.STAGE_LEARNED -> AppIcons.StageLearned + else -> AppIcons.Error + } +} + +@Composable +private fun labelForStage(stageKey: String): String { + return when (stageKey) { + SettingsViewModel.STAGE_NEW -> stringResource(R.string.stage_new) + SettingsViewModel.STAGE_1 -> stringResource(R.string.stage_1) + SettingsViewModel.STAGE_2 -> stringResource(R.string.stage_2) + SettingsViewModel.STAGE_3 -> stringResource(R.string.stage_3) + SettingsViewModel.STAGE_4 -> stringResource(R.string.stage_4) + SettingsViewModel.STAGE_5 -> stringResource(R.string.stage_5) + SettingsViewModel.STAGE_LEARNED -> stringResource(R.string.stage_learned) + else -> stageKey + } +} + +@Composable +private fun IntervalSlider( + label: String, + value: Int, + onValueChange: (Int) -> Unit +) { + // Constants for the logarithmic scale + val minVal = 1f + val maxVal = 180f + + fun valueToPos(v: Int): Float { + val vv = v.toFloat().coerceAtLeast(minVal) + return (ln(vv) - ln(minVal)) / (ln(maxVal) - ln(minVal)) + } + fun posToValue(p: Float): Int { + val logValue = ln(minVal) + (ln(maxVal) - ln(minVal)) * p + return exp(logValue).roundToInt().coerceIn(minVal.toInt(), maxVal.toInt()) + } + + // Local slider position state to avoid jitter when ViewModel updates on every tick + val initialPos = valueToPos(value) + val localPosState = androidx.compose.runtime.remember(value) { androidx.compose.runtime.mutableFloatStateOf(initialPos) } + + val displayValue = posToValue(localPosState.floatValue) + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold + ) + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme.primaryContainer, + RoundedCornerShape(8.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + text = stringResource(R.string.days_2d, displayValue), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold + ) + } + } + Spacer(Modifier.height(4.dp)) + AppSlider( + value = localPosState.floatValue, + onValueChange = { newSliderPosition -> + localPosState.floatValue = newSliderPosition + }, + valueRange = 0f..1f, + onValueChangeFinished = { + // Commit only when the user releases the thumb + onValueChange(posToValue(localPosState.floatValue)) + } + ) + } +} + +@Composable +private fun LegendItem(color: Color, icon: ImageVector, stageName: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Box( + modifier = Modifier + .size(10.dp) + .background(color, CircleShape) + ) + Icon( + imageVector = icon, + contentDescription = stageName, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@ThemePreviews +@Composable +fun VocabularyOptionsScreenPreview() { + VocabularyProgressOptionsScreen(navController = NavController(LocalContext.current)) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyRepositoryOptionsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyRepositoryOptionsScreen.kt new file mode 100644 index 0000000..e499bc5 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyRepositoryOptionsScreen.kt @@ -0,0 +1,433 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.settings + +import android.app.Application +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.PrimaryButton +import eu.gaudian.translator.view.composable.SecondaryButton +import eu.gaudian.translator.view.composable.SingleLanguageDropDown +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.StatusViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import okhttp3.internal.platform.PlatformRegistry.applicationContext + +@Composable +fun VocabularyRepositoryOptionsScreen( + navController: NavController, + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application), + statusViewModel: StatusViewModel = StatusViewModel.getInstance(applicationContext as Application) +) { + + val context = LocalContext.current + val repositoryStateImportedFrom = stringResource(R.string.repository_state_imported_from) + + val importFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri -> + uri?.let { + context.contentResolver.openInputStream(it)?.use { inputStream -> + val jsonString = inputStream.bufferedReader().use { reader -> reader.readText() } + vocabularyViewModel.importVocabulary(jsonString) + statusViewModel.showInfoMessage(repositoryStateImportedFrom + " " +it.path) + } + } + } + ) + + // CSV/Excel import state + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val showTableImportDialog = remember { mutableStateOf(false) } + var parsedTable by remember { mutableStateOf>>(emptyList()) } + var selectedColFirst by remember { mutableIntStateOf(0) } + var selectedColSecond by remember { mutableIntStateOf(1) } + var skipHeader by remember { mutableStateOf(true) } + var selectedLangFirst by remember { mutableStateOf(null) } + var selectedLangSecond by remember { mutableStateOf(null) } + var parseError by remember { mutableStateOf(null) } + + fun parseCsv(text: String): List> { + if (text.isBlank()) return emptyList() + // Detect delimiter by highest occurrence among comma, semicolon, tab + val candidates = listOf(',', ';', '\t') + val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList() + val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } } + + val rows = mutableListOf>() + var current = StringBuilder() + var inQuotes = false + val currentRow = mutableListOf() + + var i = 0 + while (i < text.length) { + when (val ch = text[i]) { + '"' -> { + if (inQuotes && i + 1 < text.length && text[i + 1] == '"') { + current.append('"') + i++ // skip escaped quote + } else { + inQuotes = !inQuotes + } + } + '\r' -> { /* ignore, handle on \n */ } + '\n' -> { + // end of line + val field = current.toString() + current = StringBuilder() + currentRow.add(if (inQuotes) field else field) + rows.add(currentRow.toList()) + currentRow.clear() + inQuotes = false + } + else -> { + if (ch == delimiter && !inQuotes) { + val field = current.toString() + currentRow.add(field) + current = StringBuilder() + } else { + current.append(ch) + } + } + } + i++ + } + // flush last field/row if any + if (current.isNotEmpty() || currentRow.isNotEmpty()) { + currentRow.add(current.toString()) + rows.add(currentRow.toList()) + } + // Normalize: trim and drop trailing empty columns + return rows.map { row -> + row.map { it.trim().trim('"') } + }.filter { r -> r.any { it.isNotBlank() } } + } + val textExcelNotSupportedUseCsv = stringResource(R.string.text_excel_not_supported_use_csv) + val errorParsingTable = stringResource(R.string.error_parsing_table) + val errorParsingTableWithReason = stringResource(R.string.error_parsing_table_with_reason) + val importTableLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri -> + uri?.let { u -> + try { + context.contentResolver.takePersistableUriPermission(u, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) {} + try { + val mime = context.contentResolver.getType(u) + val isExcel = mime == "application/vnd.ms-excel" || mime == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + if (isExcel) { + statusViewModel.showInfoMessage(textExcelNotSupportedUseCsv) + return@let + } + context.contentResolver.openInputStream(u)?.use { inputStream -> + val text = inputStream.bufferedReader().use { it.readText() } + val rows = parseCsv(text) + if (rows.isNotEmpty() && rows.maxOf { it.size } >= 2) { + parsedTable = rows + selectedColFirst = 0 + selectedColSecond = 1.coerceAtMost(rows.first().size - 1) + showTableImportDialog.value = true + parseError = null + } else { + parseError = errorParsingTable + statusViewModel.showErrorMessage(parseError!!) + } + } + } catch (e: Exception) { + parseError = e.message + statusViewModel.showErrorMessage( + (errorParsingTableWithReason + " " + e.message) + ) + } + } + } + ) + + val exportFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { result -> vocabularyViewModel.handleSaveResult(result) } + ) + + LaunchedEffect(exportFileLauncher) { + vocabularyViewModel.initializeSaveFileLauncher(exportFileLauncher) + } + + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.vocabulary_repository)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + // Backup and Restore Section + AppCard { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.label_backup_and_restore), + style = MaterialTheme.typography.titleMedium + ) + PrimaryButton( + onClick = { vocabularyViewModel.saveRepositoryState() }, + text = stringResource(R.string.export_vocabulary_data), + modifier = Modifier.fillMaxWidth() + ) + SecondaryButton( + onClick = { importFileLauncher.launch(arrayOf("application/json")) }, + text = stringResource(R.string.import_vocabulary_data), + modifier = Modifier.fillMaxWidth() + ) + SecondaryButton( + onClick = { + // Allow CSV and Excel mime types, but we only support CSV parsing in-app + @Suppress("HardCodedStringLiteral") + importTableLauncher.launch( + arrayOf( + "text/csv", + "text/comma-separated-values", + "text/tab-separated-values", + "text/plain", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + ) + }, + text = stringResource(R.string.label_import_table_csv_excel), + modifier = Modifier.fillMaxWidth() + ) + } + } + } + item { + AppCard { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.danger_zone), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) + + val showConfirm = androidx.compose.runtime.remember { mutableStateOf(false) } + + AppButton( + onClick = { showConfirm.value = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Text(stringResource(R.string.wipe_repository_delete_all_data)) + } + + if (showConfirm.value) { + AlertDialog( + onDismissRequest = { showConfirm.value = false }, + title = { Text(stringResource(R.string.label_warning)) }, + text = { Text(stringResource(R.string.wipe_repository_delete_all_data)) }, + confirmButton = { + TextButton(onClick = { + showConfirm.value = false + vocabularyViewModel.wipeRepository() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + )) { + Text(stringResource(R.string.label_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { showConfirm.value = false }) { + Text(stringResource(R.string.label_cancel)) + } + } + ) + } + } + } + } + } + + + if (showTableImportDialog.value) { + AlertDialog( + onDismissRequest = { showTableImportDialog.value = false }, + title = { Text(stringResource(R.string.label_import_table_csv_excel)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0 + // Column selectors + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f)) + var menu1Expanded by remember { mutableStateOf(false) } + AppOutlinedButton(onClick = { menu1Expanded = true }) { Text(stringResource(R.string.label_column_n, selectedColFirst + 1)) } + DropdownMenu(expanded = menu1Expanded, onDismissRequest = { menu1Expanded = false }) { + (0 until columnCount).forEach { idx -> + val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty() + DropdownMenuItem( + text = { Text("#${idx + 1} • $header") }, + onClick = { selectedColFirst = idx; menu1Expanded = false } + ) + } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.label_second_column), modifier = Modifier.weight(1f)) + var menu2Expanded by remember { mutableStateOf(false) } + AppOutlinedButton(onClick = { menu2Expanded = true }) { Text(stringResource(R.string.label_column_n, selectedColSecond + 1)) } + DropdownMenu(expanded = menu2Expanded, onDismissRequest = { menu2Expanded = false }) { + (0 until columnCount).forEach { idx -> + val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty() + DropdownMenuItem( + text = { Text("#${idx + 1} • $header") }, + onClick = { selectedColSecond = idx; menu2Expanded = false } + ) + } + } + } + // Language selectors + Text(stringResource(R.string.label_languages)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.label_first_language)) + SingleLanguageDropDown( + languageViewModel = languageViewModel, + selectedLanguage = selectedLangFirst, + onLanguageSelected = { selectedLangFirst = it } + ) + } + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.label_second_language)) + SingleLanguageDropDown( + languageViewModel = languageViewModel, + selectedLanguage = selectedLangSecond, + onLanguageSelected = { selectedLangSecond = it } + ) + } + } + // Header toggle + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it }) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.label_header_row)) + } + // Previews + val startIdx = if (skipHeader) 1 else 0 + val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ") + val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ") + Text(stringResource(R.string.label_preview_first, previewA)) + Text(stringResource(R.string.label_preview_second, previewB)) + val totalRows = parsedTable.drop(startIdx).count { row -> + val a = row.getOrNull(selectedColFirst).orEmpty().isNotBlank() + val b = row.getOrNull(selectedColSecond).orEmpty().isNotBlank() + a || b + } + Text(stringResource(R.string.text_rows_to_import_1d, totalRows)) + } + }, + confirmButton = { + val errorSelectTwoColumns = stringResource(R.string.error_select_two_columns) + val errorSelectLanguages = stringResource(R.string.error_select_languages) + val errorNoRowsToImport = stringResource(R.string.error_no_rows_to_import) + val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from) + TextButton(onClick = { + if (selectedColFirst == selectedColSecond) { + statusViewModel.showErrorMessage(errorSelectTwoColumns) + return@TextButton + } + val langA = selectedLangFirst + val langB = selectedLangSecond + if (langA == null || langB == null) { + statusViewModel.showErrorMessage(errorSelectLanguages) + return@TextButton + } + val startIdx = if (skipHeader) 1 else 0 + val items = parsedTable.drop(startIdx).mapNotNull { row -> + val a = row.getOrNull(selectedColFirst)?.trim().orEmpty() + val b = row.getOrNull(selectedColSecond)?.trim().orEmpty() + if (a.isBlank() && b.isBlank()) null else eu.gaudian.translator.model.VocabularyItem( + id = 0, + languageFirstId = langA.nameResId, + languageSecondId = langB.nameResId, + wordFirst = a, + wordSecond = b + ) + } + if (items.isEmpty()) { + statusViewModel.showErrorMessage(errorNoRowsToImport) + return@TextButton + } + vocabularyViewModel.addVocabularyItems(items) + statusViewModel.showSuccessMessage(infoImportedItemsFrom + " " +items.size) + showTableImportDialog.value = false + }) { Text(stringResource(R.string.label_import)) } + }, + dismissButton = { + TextButton(onClick = { showTableImportDialog.value = false }) { Text(stringResource(R.string.label_cancel)) } + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/translation/InputCard.kt b/app/src/main/java/eu/gaudian/translator/view/translation/InputCard.kt new file mode 100644 index 0000000..c3ddfcb --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/translation/InputCard.kt @@ -0,0 +1,201 @@ +@file:Suppress("HardCodedStringLiteral") +package eu.gaudian.translator.view.translation + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons + +@Composable +fun InputCard( + modifier: Modifier = Modifier, + text: String, + isTranslating: Boolean, + onTextChange: (String) -> Unit, + onPasteClick: () -> Unit, + onClearClick: () -> Unit, + onTranslateClick: () -> Unit, + onHistoryClick: () -> Unit, + showBackButton: Boolean, + showForwardButton: Boolean, + onBackClick: () -> Unit, + onForwardClick: () -> Unit +) { + val showClearButton = text.isNotEmpty() + + AppCard( + modifier = modifier + .fillMaxWidth().padding(0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + ) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + textStyle = MaterialTheme.typography.titleLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + placeholder = { + Text( + stringResource(R.string.text_enter_text_to_translate), + style = MaterialTheme.typography.titleLarge, + ) + }, + modifier = Modifier + .weight(1f) + .fillMaxWidth().padding(top = 0.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(0.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + ActionBar( + contentHorizontalArrangement = Arrangement.Start, + modifier = Modifier.padding(0.dp) + ) { + if (showClearButton) { + IconButton(onClick = onClearClick) { + Icon( + imageVector = AppIcons.Clear, + contentDescription = stringResource(R.string.cd_clear_text), + tint = MaterialTheme.colorScheme.primary + ) + } + } + if (!showClearButton) { + IconButton(onClick = onPasteClick) { + Icon( + imageVector = AppIcons.Paste, + contentDescription = stringResource(R.string.cd_paste), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + Spacer(modifier = Modifier.width(4.dp)) + + ExpandableActionBar(modifier = Modifier.padding(0.dp)) { + IconButton(onClick = onHistoryClick) { + Icon( + imageVector = AppIcons.History, + contentDescription = stringResource(R.string.cd_translation_history), + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton(onClick = onBackClick, enabled = showBackButton) { + Icon( + imageVector = AppIcons.Undo, + contentDescription = "Back", + tint = if (showBackButton) MaterialTheme.colorScheme.primary else Color.Gray + ) + } + IconButton(onClick = onForwardClick, enabled = showForwardButton) { + Icon( + imageVector = AppIcons.Redo, + contentDescription = "Forward", + tint = if (showForwardButton) MaterialTheme.colorScheme.primary else Color.Gray + ) + } + } + } + + // SAFETY SPACER: Ensures the menu never touches the FAB + Spacer(Modifier.width(8.dp)) + + // Primary Translate FAB stays prominent and anchored + FloatingActionButton( + onClick = { if (!isTranslating) onTranslateClick() }, + modifier = Modifier.size(56.dp), + shape = RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) { + if (isTranslating) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Icon( + AppIcons.Translate, + contentDescription = stringResource(R.string.label_translate) + ) + } + } + } + } + } +} + +@Preview +@Composable +fun InputCardPreview() { + var text by remember { mutableStateOf("") } + InputCard( + text = text, + isTranslating = false, + onTextChange = { }, + onPasteClick = { }, + onClearClick = { }, + onTranslateClick = { }, + onHistoryClick = { }, + showBackButton = true, + showForwardButton = false, + onBackClick = { }, + onForwardClick = { } + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/translation/LanguageSelectorBar.kt b/app/src/main/java/eu/gaudian/translator/view/translation/LanguageSelectorBar.kt new file mode 100644 index 0000000..4086ab8 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/translation/LanguageSelectorBar.kt @@ -0,0 +1,178 @@ +package eu.gaudian.translator.view.translation + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.SourceLanguageDropdown +import eu.gaudian.translator.view.composable.TargetLanguageDropdown +import eu.gaudian.translator.view.hints.WithHint +import eu.gaudian.translator.viewmodel.LanguageViewModel + +/** + * A shared composable for a modern, pill-shaped action bar. + */ +@Composable +fun ActionBar( + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.surfaceVariant, + contentHorizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceEvenly, + content: @Composable RowScope.() -> Unit +) { + Surface( + // FIXED: Enforce standard height (48.dp) to align with ExpandableActionBar + modifier = modifier.height(48.dp), + shape = RoundedCornerShape(percent = 50), + color = containerColor, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 0.dp), //0 here is okay since every icon has enpough padding + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = contentHorizontalArrangement, + content = content + ) + } +} + +@Composable +fun TopBarActions(languageViewModel: LanguageViewModel, onSettingsClick: () -> Unit, hintContent: (@Composable () -> Unit)? = null) { + + ActionBar(modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)) { + if (hintContent != null) { + WithHint(hintContent = hintContent) { + } + } + + LanguageSelectorBar( + languageViewModel = languageViewModel, + modifier = Modifier.weight(1f) + ) + + IconButton(onClick = onSettingsClick) { + Icon(imageVector = AppIcons.Settings, contentDescription = stringResource(R.string.translation_prompt_settings)) + } + } +} + +@Composable +fun LanguageSelectorBar(languageViewModel: LanguageViewModel, modifier: Modifier = Modifier) { + var isSwapped by remember { mutableStateOf(false) } + @Suppress("HardCodedStringLiteral") val rotationAngle by animateFloatAsState( + targetValue = if (isSwapped) 180f else 0f, + animationSpec = tween(durationMillis = 300), + label = "rotation" + ) + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Box(modifier = Modifier.weight(1f)) { + SourceLanguageDropdown(languageViewModel = languageViewModel, autoEnabled = true, iconEnabled = false, noBorder = true) + } + + IconButton(onClick = { + isSwapped = !isSwapped + languageViewModel.switchLanguages() + }) { + Icon( + imageVector = AppIcons.SwapHoriz, + contentDescription = stringResource(R.string.cd_switch_languages), + modifier = Modifier.rotate(rotationAngle) + ) + } + + Box(modifier = Modifier.weight(1f)) { + TargetLanguageDropdown(languageViewModel = languageViewModel, iconEnabled = false,noBorder = true) + } + } +} + +/** + * A modern, expandable action bar that shows a single button when collapsed + * and reveals a row of actions when expanded. + */ +@Composable +fun ExpandableActionBar( + modifier: Modifier = Modifier, + expandIcon: ImageVector = AppIcons.ArrowRight, + content: @Composable RowScope.() -> Unit +) { + var isExpanded by remember { mutableStateOf(false) } + + Surface( + // FIXED: Enforce standard height (48.dp) + modifier = modifier.wrapContentWidth().height(48.dp), + shape = RoundedCornerShape(percent = 50), + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 2.dp + ) { + + Row( + modifier = Modifier + .animateContentSize(animationSpec = tween(50, easing = FastOutSlowInEasing)) + .padding(horizontal = 0.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + + IconButton( + onClick = { isExpanded = !isExpanded }, + // FIXED: Default size matches other buttons + ) { + val rotation by animateFloatAsState(targetValue = if (isExpanded) 180f else 0f) + Icon( + imageVector = expandIcon, + contentDescription = stringResource(R.string.label_show_more_actions), + modifier = Modifier.rotate(rotation), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandHorizontally(expandFrom = Alignment.Start) + fadeIn(), + exit = shrinkHorizontally(shrinkTowards = Alignment.Start) + fadeOut() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(0.dp), + content = content + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/translation/MainTranslationScreen.kt b/app/src/main/java/eu/gaudian/translator/view/translation/MainTranslationScreen.kt new file mode 100644 index 0000000..5c45ae6 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/translation/MainTranslationScreen.kt @@ -0,0 +1,401 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.translation + +import android.app.Application +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.TextToSpeechHelper +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.LocalConnectionConfigured +import eu.gaudian.translator.view.NoConnectionScreen +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppOutlinedCard +import eu.gaudian.translator.view.dialogs.AddVocabularyDialog +import eu.gaudian.translator.view.hints.TranslationScreenHint +import eu.gaudian.translator.view.settings.SettingsRoutes +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.SettingsViewModel +import eu.gaudian.translator.viewmodel.StatusViewModel +import eu.gaudian.translator.viewmodel.TranslationViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.launch + +@Composable +fun TranslationScreen( + translationViewModel: TranslationViewModel, + languageViewModel: LanguageViewModel, + statusViewModel: StatusViewModel, + onHistoryClick: () -> Unit, + onSettingsClick: () -> Unit, + navController: NavHostController +) { + val context = LocalContext.current + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + + val vocabularyViewModel = remember(context) { + VocabularyViewModel.getInstance(context.applicationContext as Application) + } + + + val isInitializationComplete by settingsViewModel.isInitialized.collectAsStateWithLifecycle( + initialValue = true, + lifecycleOwner = LocalLifecycleOwner.current + ) + + val connectionConfigured = LocalConnectionConfigured.current + + + if (isInitializationComplete && !connectionConfigured) { + NoConnectionScreen(onSettingsClick = { navController.navigate(SettingsRoutes.API_KEY) }) + return + } + + val isUiReady = isInitializationComplete + + @Suppress("HardCodedStringLiteral") + Crossfade(targetState = isUiReady, label = "loading_transition") { ready -> + if (!ready) { + TranslationScreenSkeleton() + } else { + // Real Content + LoadedTranslationContent( + translationViewModel = translationViewModel, + languageViewModel = languageViewModel, + statusViewModel = statusViewModel, + settingsViewModel = settingsViewModel, + vocabularyViewModel = vocabularyViewModel, + onHistoryClick = onHistoryClick, + onSettingsClick = onSettingsClick, + context = context + ) + } + } +} + +@Composable +private fun LoadedTranslationContent( + translationViewModel: TranslationViewModel, + languageViewModel: LanguageViewModel, + statusViewModel: StatusViewModel, + settingsViewModel: SettingsViewModel, + vocabularyViewModel: VocabularyViewModel, + onHistoryClick: () -> Unit, + onSettingsClick: () -> Unit, + context: Context +) { + val inputText by translationViewModel.inputText.collectAsState() + val translatedText by translationViewModel.translatedText.collectAsState() + val targetLanguage by languageViewModel.selectedTargetLanguage.collectAsState() + val isTranslating by translationViewModel.isTranslating.collectAsState() + val isExplanationOn by translationViewModel.showExplanation.collectAsState() + val explanationText by translationViewModel.explanationText.collectAsState() + val canGoBack by translationViewModel.canGoBack.collectAsState() + val canGoForward by translationViewModel.canGoForward.collectAsState() + + // Defer history logic + val translationHistory by translationViewModel.translationHistory.collectAsState() + val historyCursor by translationViewModel.historyCursor.collectAsState() + + val clipboard = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() + var isPlayable by remember { mutableStateOf(false) } + var showAddVocabularyDialog by remember { mutableStateOf(false) } + + val currentHistoryItem = remember(historyCursor, translationHistory) { + if (historyCursor >= 0 && historyCursor < translationHistory.size) { + translationHistory[historyCursor] + } else { + null + } + } + val translationSource = currentHistoryItem?.translationSource + val translationModel = currentHistoryItem?.translationModel + + if (showAddVocabularyDialog) { + AddVocabularyDialog( + statusViewModel = statusViewModel, + languageViewModel = languageViewModel, + vocabularyViewModel = vocabularyViewModel, + initialWord = inputText, + translation = translatedText, + onDismissRequest = { showAddVocabularyDialog = false }, + showMultiple = false + ) + } + + AppOutlinedCard { + Column( + modifier = Modifier + .fillMaxSize() + .padding(0.dp) + ) { + TopBarActions( + languageViewModel = languageViewModel, + onSettingsClick = onSettingsClick, + hintContent = { TranslationScreenHint() } + ) + + AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) { + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE + + if (isLandscape) { + Row(modifier = Modifier.fillMaxSize()) { + InputCard( + modifier = Modifier.weight(1f), + text = inputText, + onTextChange = { translationViewModel.setInputText(it) }, + onClearClick = { translationViewModel.clearInputAndOutputText() }, + onPasteClick = { + coroutineScope.launch { + val clipEntry = clipboard.getClipEntry() + val text = clipEntry?.clipData?.getItemAt(0)?.coerceToText(context)?.toString() + if (!text.isNullOrEmpty()) translationViewModel.setInputText(text) + } + }, + onTranslateClick = { if (inputText.isNotBlank()) translationViewModel.translateSentence(inputText) }, + isTranslating = isTranslating, + onHistoryClick = onHistoryClick, + showBackButton = canGoBack, + showForwardButton = canGoForward, + onBackClick = { translationViewModel.goBackInHistory() }, + onForwardClick = { translationViewModel.goForwardInHistory() } + ) + + VerticalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + + OutputCard( + modifier = Modifier.weight(1f), + text = translatedText ?: "", + explanationText = explanationText, + isExplanationOn = isExplanationOn, + translationSource = translationSource, + translationModel = translationModel, + onCopyClick = { translatedText?.let { context.copyToClipboard(it) } }, + onPlayClick = { + translatedText?.let { text -> + targetLanguage?.let { lang -> + coroutineScope.launch { + val voice = settingsViewModel.getTtsVoiceForLanguage(lang.code, lang.region) + TextToSpeechHelper.speakOut(context, text, lang, voice) + } + } + } + }, + isPlayEnabled = isPlayable, + onShareClick = { translatedText?.let { shareText(context, it) } }, + onAddToDictionaryClick = { if (!translatedText.isNullOrBlank()) showAddVocabularyDialog = true }, + onExplanationToggle = { translationViewModel.toggleExplanation() }, + onRequestAlternatives = { word, cb -> + coroutineScope.launch { + val ctx = translatedText ?: "" + cb(translationViewModel.getMultipleTranslations(word, ctx.ifBlank { null })) + } + }, + onApplyAlternative = { original, chosen -> translationViewModel.applyAlternative(original, chosen) } + ) + } + } else { + InputCard( + modifier = Modifier.weight(1f), + text = inputText, + onTextChange = { translationViewModel.setInputText(it) }, + onClearClick = { translationViewModel.clearInputAndOutputText() }, + onPasteClick = { + coroutineScope.launch { + val clipEntry = clipboard.getClipEntry() + val text = clipEntry?.clipData?.getItemAt(0)?.coerceToText(context)?.toString() + if (!text.isNullOrEmpty()) translationViewModel.setInputText(text) + } + }, + onTranslateClick = { if (inputText.isNotBlank()) translationViewModel.translateSentence(inputText) }, + isTranslating = isTranslating, + onHistoryClick = onHistoryClick, + showBackButton = canGoBack, + showForwardButton = canGoForward, + onBackClick = { translationViewModel.goBackInHistory() }, + onForwardClick = { translationViewModel.goForwardInHistory() } + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 4.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + + OutputCard( + modifier = Modifier.weight(1f), + text = translatedText ?: "", + explanationText = explanationText, + isExplanationOn = isExplanationOn, + translationSource = translationSource, + translationModel = translationModel, + onCopyClick = { translatedText?.let { context.copyToClipboard(it) } }, + onPlayClick = { + translatedText?.let { text -> + targetLanguage?.let { lang -> + coroutineScope.launch { + val voice = settingsViewModel.getTtsVoiceForLanguage(lang.code, lang.region) + TextToSpeechHelper.speakOut(context, text, lang, voice) + } + } + } + }, + isPlayEnabled = isPlayable, + onShareClick = { translatedText?.let { shareText(context, it) } }, + onAddToDictionaryClick = { if (!translatedText.isNullOrBlank()) showAddVocabularyDialog = true }, + onExplanationToggle = { translationViewModel.toggleExplanation() }, + onRequestAlternatives = { word, cb -> + coroutineScope.launch { + val ctx = translatedText ?: "" + cb(translationViewModel.getMultipleTranslations(word, ctx.ifBlank { null })) + } + }, + onApplyAlternative = { original, chosen -> translationViewModel.applyAlternative(original, chosen) } + ) + } + } + } + } + + LaunchedEffect(targetLanguage) { + isPlayable = TextToSpeechHelper.isPlayable(context, targetLanguage) + } +} +@Suppress("HardCodedStringLiteral") +@Composable +fun TranslationScreenSkeleton() { + Log.d("TranslationScreenSkeleton", "Skeleton started") + + val infiniteTransition = rememberInfiniteTransition(label = "shimmer") + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 0.6f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "alpha" + ) + + AppOutlinedCard { + Column(modifier = Modifier.fillMaxSize()) { + // Header Skeleton + AppCard(Modifier.padding(horizontal = 8.dp, vertical = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth().height(48.dp).padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.weight(1f).fillMaxHeight().clip(RoundedCornerShape(8.dp)).background(Color.Gray.copy(alpha = alpha))) + Spacer(Modifier.width(8.dp)) + Box(Modifier.size(24.dp).clip(CircleShape).background(Color.Gray.copy(alpha = alpha))) + Spacer(Modifier.width(8.dp)) + Box(Modifier.size(24.dp).clip(CircleShape).background(Color.Gray.copy(alpha = alpha))) + } + } + + // Body Skeleton + AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) { + Column(Modifier.fillMaxSize()) { + // Input Area + Box( + Modifier + .weight(1f) + .fillMaxWidth() + .padding(16.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Gray.copy(alpha = alpha / 2)) + ) + + // Divider + Box(Modifier.fillMaxWidth().height(1.dp).background(Color.LightGray.copy(alpha = 0.2f))) + + // Output Area + Box( + Modifier + .weight(1f) + .fillMaxWidth() + .padding(16.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Gray.copy(alpha = alpha / 2)) + ) + + // Bottom Buttons Row Skeleton + Row(Modifier.fillMaxWidth().padding(8.dp)) { + Box(Modifier.size(48.dp).clip(CircleShape).background(Color.Gray.copy(alpha = alpha))) + Spacer(Modifier.width(8.dp)) + Box(Modifier.size(48.dp).clip(CircleShape).background(Color.Gray.copy(alpha = alpha))) + } + } + } + } + } +} + +fun Context.copyToClipboard(text: String, label: String = getString(R.string.copied_text)) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, text) + clipboard.setPrimaryClip(clip) +} + +private fun shareText(context: Context, text: String) { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, text) + type = "text/plain" + } + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/translation/OutputCard.kt b/app/src/main/java/eu/gaudian/translator/view/translation/OutputCard.kt new file mode 100644 index 0000000..6b31afb --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/translation/OutputCard.kt @@ -0,0 +1,389 @@ +package eu.gaudian.translator.view.translation + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons + +@Composable +fun OutputCard( + modifier: Modifier = Modifier, + text: String, + explanationText: String? = null, + isExplanationOn: Boolean = false, + isPlayEnabled: Boolean = false, + translationSource: String? = null, + translationModel: String? = null, + onCopyClick: () -> Unit, + onPlayClick: () -> Unit, + onShareClick: () -> Unit, + onAddToDictionaryClick: () -> Unit, + onExplanationToggle: () -> Unit, + onRequestAlternatives: (String, (Result>) -> Unit) -> Unit, + onApplyAlternative: (originalWord: String, chosen: String) -> Unit +) { + AppCard( + modifier = modifier + .fillMaxWidth() + .padding(0.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceBetween + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(8.dp), + contentAlignment = if (text.isEmpty()) Alignment.Center else Alignment.TopStart + ) { + if (text.isEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.alpha(0.3f) + ) { + Icon( + imageVector = AppIcons.Translate, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.text_translation_will_appear_here), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } else { + Column { + if (!translationSource.isNullOrBlank()) { + val sourceInfo = if (!translationModel.isNullOrBlank()) { + translationModel + } else { + translationSource + } + Text( + text = sourceInfo, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(bottom = 8.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.16f), + shape = RoundedCornerShape(999.dp) + ) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) + } + + ClickableTranslatedText( + text = text, + onWordClick = { word -> + onRequestAlternatives(word) { _ -> } + }, + onRequestAlternatives = onRequestAlternatives, + onApplyAlternative = onApplyAlternative + ) + + if (isExplanationOn && !explanationText.isNullOrBlank()) { + Text( + text = stringResource(R.string.text_explanation) + ":\n" + explanationText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } + } + + val hasText = text.isNotEmpty() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(0.dp, bottom = 0.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + + ActionBar( + contentHorizontalArrangement = Arrangement.Start + ) { + IconButton(onClick = onPlayClick, enabled = isPlayEnabled) { + Icon( + imageVector = AppIcons.TextToSpeech, + contentDescription = stringResource(R.string.play_audio), + tint = if (isPlayEnabled && hasText) MaterialTheme.colorScheme.primary else Color.Gray + ) + } + IconButton(onClick = onCopyClick, enabled = hasText) { + Icon( + imageVector = AppIcons.Copy, + contentDescription = stringResource(R.string.copy_text), + tint = if (hasText) MaterialTheme.colorScheme.primary else Color.Gray + ) + } + } + + Spacer(modifier = Modifier.width(4.dp)) + + ExpandableActionBar { + IconButton(onClick = onShareClick, enabled = hasText) { + Icon( + imageVector = AppIcons.Share, + contentDescription = stringResource(R.string.share_text), + tint = if (hasText) MaterialTheme.colorScheme.primary else Color.Gray + ) + } + IconButton(onClick = onAddToDictionaryClick, enabled = hasText) { + Icon( + imageVector = AppIcons.AddToDictionary, + contentDescription = stringResource(R.string.label_add_to_dictionary), + tint = if (hasText) MaterialTheme.colorScheme.primary else Color.Gray + ) + } + IconButton(onClick = onExplanationToggle, enabled = hasText) { + Icon( + imageVector = AppIcons.Info, + contentDescription = stringResource(R.string.text_explanation), + tint = if (hasText) MaterialTheme.colorScheme.primary else Color.Gray + ) + } + } + } + } + } +} + +@Suppress("LocalVariableName") +@Composable +private fun ClickableTranslatedText( + text: String, + onWordClick: (word: String) -> Unit, + onRequestAlternatives: (String, (Result>) -> Unit) -> Unit, + onApplyAlternative: (originalWord: String, chosen: String) -> Unit +) { + val tokens = remember(text) { + val builder = mutableListOf() + if (text.isEmpty()) builder + else { + val sb = StringBuilder() + val KIND_SPACE = 0 + val KIND_WORD = 1 + val KIND_OTHER = 2 + fun Char.isJoinerOrMark(): Boolean = + this == '\u200D' || this == '\u200C' || + this == '\u09CD' || this == '\u094D' || + this == '\u0BCD' || + run { + val t = Character.getType(this) + t == Character.NON_SPACING_MARK.toInt() || t == Character.COMBINING_SPACING_MARK.toInt() || t == Character.ENCLOSING_MARK.toInt() + } + fun kindOf(ch: Char): Int = when { + ch.isWhitespace() -> KIND_SPACE + ch.isLetterOrDigit() || ch.isJoinerOrMark() -> KIND_WORD + else -> KIND_OTHER + } + var currentKind: Int? = null + for (ch in text) { + val k = kindOf(ch) + if (currentKind == null) { + currentKind = k + sb.append(ch) + } else if (k == currentKind || (currentKind == KIND_WORD && (ch.isJoinerOrMark() || ch.isLetterOrDigit()))) { + sb.append(ch) + } else { + builder.add(sb.toString()) + sb.clear() + sb.append(ch) + currentKind = k + } + } + if (sb.isNotEmpty()) builder.add(sb.toString()) + builder + } + } + + var popupOpen by remember { mutableStateOf(false) } + var selectedWord by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var alternatives by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(popupOpen) { + if (!popupOpen) selectedWord = null + } + + Box { + FlowRow(horizontalArrangement = Arrangement.Start) { + tokens.forEach { token -> + val isWord = token.any { it.isLetterOrDigit() } + val isSelected = isWord && token == selectedWord && popupOpen + + var wordHeight by remember { mutableIntStateOf(0) } + + Box( + modifier = Modifier.onSizeChanged { wordHeight = it.height } + ) { + if (isWord) { + Text( + text = token, + style = MaterialTheme.typography.titleLarge, + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 0.dp) + .clip(RoundedCornerShape(4.dp)) + .background(if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent) + .clickable { + selectedWord = token + popupOpen = true + isLoading = true + onRequestAlternatives(token) { result -> + isLoading = false + result.onSuccess { alternatives = it } + .onFailure { alternatives = emptyList() } + } + onWordClick(token) + } + .padding(horizontal = 2.dp) + ) + + if (isSelected) { + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(0, wordHeight + 10), // +10 for small visual gap + onDismissRequest = { popupOpen = false }, + properties = PopupProperties(focusable = true) + ) { + Card( + elevation = CardDefaults.cardElevation(4.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHighest), + modifier = Modifier + .widthIn(min = 150.dp, max = 250.dp) + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource(R.string.label_alternatives_for, token), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp, start = 8.dp) + ) + + if (isLoading) { + Box(Modifier + .fillMaxWidth() + .padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } else if (alternatives.isEmpty()) { + Text( + text = stringResource(id = R.string.not_available), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp) + ) + } else { + alternatives.take(5).forEach { alt -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + onApplyAlternative(token, alt) + popupOpen = false + } + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = alt, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } + } + } + } else { + Text( + text = token, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@ThemePreviews +@Composable +fun OutputCardPreview() { + OutputCard( + text = stringResource(R.string.this_is_a_sample_output_text), + explanationText = "This is an explanation.", + isExplanationOn = true, + translationSource = "AI Model", + translationModel = "GPT-4", + onCopyClick = {}, + onPlayClick = {}, + onShareClick = {}, + onAddToDictionaryClick = {}, + onExplanationToggle = {}, + onRequestAlternatives = { _, cb -> cb(Result.success(listOf("option1", "option2"))) }, + onApplyAlternative = { _, _ -> } + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/translation/TranlsationScreen.kt b/app/src/main/java/eu/gaudian/translator/view/translation/TranlsationScreen.kt new file mode 100644 index 0000000..73da6e3 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/translation/TranlsationScreen.kt @@ -0,0 +1,98 @@ +package eu.gaudian.translator.view.translation + +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import eu.gaudian.translator.utils.TextToSpeechHelper +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.SettingsViewModel +import eu.gaudian.translator.viewmodel.StatusViewModel +import eu.gaudian.translator.viewmodel.TranslationViewModel +import kotlinx.coroutines.launch + + +@Composable +fun TranslationScreen( + navController: NavController, + statusViewModel: StatusViewModel = viewModel(), + translationViewModel: TranslationViewModel = viewModel(), +) { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + var showHistorySheet by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() + + val scope = rememberCoroutineScope() + val context = LocalContext.current + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + + + TranslationScreen( + translationViewModel = translationViewModel, + languageViewModel = languageViewModel, + statusViewModel = statusViewModel, + onHistoryClick = { showHistorySheet = true }, + onSettingsClick = { + @Suppress("HardCodedStringLiteral") + navController.navigate("custom_translation_prompt") + }, + navController = navController as NavHostController + ) + + if (showHistorySheet) { + ModalBottomSheet( + onDismissRequest = { + @Suppress("AssignedValueIsNeverRead") + showHistorySheet = false + }, + sheetState = sheetState + ) { + + HistorySheetContent( + translationViewModel = translationViewModel, + onPlayAudio = { text, language -> + scope.launch { + val voice = + settingsViewModel.getTtsVoiceForLanguage(language.code, language.region) + TextToSpeechHelper.speakOut( + context = context, + text = text, + language = language, + voiceName = voice, + ) + } + }, + onItemClick = { item -> + translationViewModel.applyHistoryItem(item) + @Suppress("AssignedValueIsNeverRead") + showHistorySheet = false + }, + languageViewModel = languageViewModel + ) + } + } + +} + +@Preview +@Composable +fun TranslationScreenPreview() { + val navController = NavController(LocalContext.current) + TranslationScreen( + navController = navController, + statusViewModel = viewModel(), + translationViewModel = viewModel(), + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/translation/TranslationHistory.kt b/app/src/main/java/eu/gaudian/translator/view/translation/TranslationHistory.kt new file mode 100644 index 0000000..92c3837 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/translation/TranslationHistory.kt @@ -0,0 +1,382 @@ +package eu.gaudian.translator.view.translation + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.TranslationHistoryItem +import eu.gaudian.translator.model.generateRandomLanguage +import eu.gaudian.translator.model.generateSimpleLanguage +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.TranslationViewModel + +@Composable +fun HistorySheetContent( + translationViewModel: TranslationViewModel, + languageViewModel: LanguageViewModel, + onPlayAudio: (text: String, language: Language) -> Unit, + onItemClick: (TranslationHistoryItem) -> Unit +) { + + val history by translationViewModel.translationHistory.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) // Clean background + .padding(bottom = 16.dp) + ) { + // --- Custom Header --- + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp, horizontal = 16.dp) // Added outer padding + ) { + // 1. Calculate a safe padding for the title so it never touches the delete button + // "Delete All" usually takes about 60-80dp. We use 80dp to be safe. + val titlePadding = if (history.isNotEmpty()) 80.dp else 0.dp + + Text( + text = stringResource(R.string.cd_translation_history), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + // FIX: This padding ensures the title wraps before hitting the button + .padding(horizontal = titlePadding) + .fillMaxWidth() + ) + + if (history.isNotEmpty()) { + Text( + text = stringResource(R.string.label_delete_all), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { translationViewModel.clearTranslationHistory() } + .padding(8.dp) // Touch target padding + ) + } + } + + if (history.isEmpty()) { + EmptyHistoryState() + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + itemsIndexed( + items = history, + key = { _, item -> item.id } + ) { _, item -> + val sourceLanguage by languageViewModel.getLanguageByIdFlow(item.sourceLanguageCode).collectAsState(initial = null) + val targetLanguage by languageViewModel.getLanguageByIdFlow(item.targetLanguageCode).collectAsState(initial = null) + + + + sourceLanguage?.let { + targetLanguage?.let { onClick -> + SwipeableHistoryItem( + item = item, + onDelete = { translationViewModel.deleteTranslationHistoryItem(item) }, + onPlay = onPlayAudio, + onClick = onItemClick, + sourceLanguage = it, + targetLanguage = onClick, + + ) + } + } + + + } + } + } + } +} + +@Composable +fun EmptyHistoryState() { + Column( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 64.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = AppIcons.History, + contentDescription = null, + tint = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.size(80.dp) + ) + Spacer(modifier = Modifier.size(16.dp)) + Text( + text = stringResource(R.string.label_no_history_yet), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun SwipeableHistoryItem( + item: TranslationHistoryItem, + sourceLanguage: Language, + targetLanguage: Language, + onDelete: () -> Unit, + onPlay: (text: String, language: Language) -> Unit, + onClick: (TranslationHistoryItem) -> Unit +) { + + val dismissState = rememberSwipeToDismissBoxState( + + confirmValueChange = { + if (it == SwipeToDismissBoxValue.EndToStart || it == SwipeToDismissBoxValue.StartToEnd) { + onDelete() + true + } + else { + false + } + } + ) + + SwipeToDismissBox( + state = dismissState, + enableDismissFromStartToEnd = false, + backgroundContent = { + val color by animateColorAsState( + if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) MaterialTheme.colorScheme.errorContainer else Color.Transparent, + label = "bgColor" + ) + val iconScale by animateFloatAsState( + if (dismissState.targetValue == SwipeToDismissBoxValue.EndToStart) 1.2f else 0.8f, + label = "iconScale" + ) + + Box( + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(16.dp)) + .background(color) + .padding(horizontal = 24.dp), + contentAlignment = Alignment.CenterEnd + ) { + Icon( + AppIcons.Delete, + contentDescription = stringResource(R.string.label_delete), + modifier = Modifier.scale(iconScale), + tint = MaterialTheme.colorScheme.onErrorContainer + ) + } + }, + content = { + HistoryCard(item, sourceLanguage, targetLanguage, onPlay, onClick) + } + ) +} + +@Composable +fun HistoryCard( + item: TranslationHistoryItem, + sourceLanguage: Language, + targetLanguage: Language, + onPlay: (text: String, language: Language) -> Unit, + onClick: (TranslationHistoryItem) -> Unit +) { + ElevatedCard( + onClick = { onClick(item) }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 0.5.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + // --- Top Row: Language Indicators & Time --- + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + LanguageBadge(text = sourceLanguage.name) + Icon( + imageVector = AppIcons.ArrowRight, + contentDescription = null, + modifier = Modifier + .size(14.dp) + .padding(horizontal = 4.dp), + tint = MaterialTheme.colorScheme.outline + ) + LanguageBadge(text = targetLanguage.name) + } + + Text( + text = item.getRelativeTimeSpan(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + + Spacer(modifier = Modifier.size(12.dp)) + + // --- Source Text (Subtle) --- + Text( + text = item.sourceText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.size(4.dp)) + + // --- Translated Text (Prominent) --- + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = item.text, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold + ), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) + + if (item.playable == true) { + IconButton( + onClick = { item.targetLanguageCode?.let { onPlay(item.text, targetLanguage) } }, + modifier = Modifier + .size(32.dp) + .background( + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), + CircleShape + ) + ) { + Icon( + imageVector = AppIcons.TextToSpeech, + contentDescription = stringResource(R.string.cd_play), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } + } +} + +@Composable +fun LanguageBadge(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) +} + +// --- Preview --- + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +fun HistoryItemPreview() { + val mockItem = TranslationHistoryItem( + text = "Guten Morgen", + sourceText = "Good Morning", + sourceLanguageCode = 134, + targetLanguageCode = 221, + playable = true, + timestamp = System.currentTimeMillis() + ) + + MaterialTheme { + Column(Modifier.padding(16.dp)) { + HistoryCard( + item = mockItem, onClick = {}, + sourceLanguage = generateRandomLanguage(), + targetLanguage = generateRandomLanguage(), + onPlay = { _: String, _: Language -> }, + ) + Spacer(Modifier.size(16.dp)) + // Swipe visual mock + Box(Modifier.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(16.dp))) { + Row(Modifier + .fillMaxWidth() + .padding(16.dp), horizontalArrangement = Arrangement.End) { + Icon(AppIcons.Delete, null, tint = MaterialTheme.colorScheme.onErrorContainer) + } + HistoryCard( + item = mockItem.copy(text = "Example Swipe"), + onPlay = { _: String, _: Language -> }, + onClick = {}, + sourceLanguage = generateSimpleLanguage("German"), + targetLanguage = generateSimpleLanguage("English"), + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/CategoryDetailScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/CategoryDetailScreen.kt new file mode 100644 index 0000000..a944ddf --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/CategoryDetailScreen.kt @@ -0,0 +1,270 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.vocabulary + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.TagCategory +import eu.gaudian.translator.model.VocabularyFilter +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.PrimaryButton +import eu.gaudian.translator.view.dialogs.DeleteCategoryDialog +import eu.gaudian.translator.view.dialogs.DeleteItemsDialog +import eu.gaudian.translator.view.dialogs.EditCategoryDialog +import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.ProgressViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel + +@SuppressLint("ContextCastToActivity") +@Composable +fun CategoryDetailScreen( + categoryId: Int, + onBackClick: () -> Unit, + onNavigateToItem: (VocabularyItem) -> Unit, + navController: NavHostController, + modifier: Modifier = Modifier +) { + val activity = LocalContext.current.findActivity() + val categoryViewModel: CategoryViewModel = viewModel(viewModelStoreOwner = activity) + val progressViewModel: ProgressViewModel = ProgressViewModel.getInstance(LocalContext.current.applicationContext as android.app.Application) + val vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(activity.application as android.app.Application) + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + + val category by categoryViewModel.getCategoryById(categoryId).collectAsState(initial = null) + val categoryProgressList by progressViewModel.categoryProgressList.collectAsState() + val categoryProgress = categoryProgressList.find { it.vocabularyCategory.id == categoryId } + + val context = LocalContext.current + val title = when (val cat = category) { + is TagCategory -> cat.name + is VocabularyFilter -> cat.name + else -> stringResource(R.string.text_loading_3d) + } + + val languages = languageViewModel.allLanguages.collectAsState(initial = emptyList()) + + val subtitle = when (val cat = category) { + is TagCategory -> stringResource(R.string.text_manual_vocabulary_list) + is VocabularyFilter -> buildString { + val hasLangList = !cat.languages.isNullOrEmpty() + val hasPair = cat.languagePairs != null + val hasStages = !cat.stages.isNullOrEmpty() + if (!hasLangList && !hasPair && !hasStages) { + append(stringResource(R.string.text_filter_all_items)) + } else { + //append(stringResource(R.string.filter)) + append(" ") + if (hasPair) { + val (a,b) = cat.languagePairs + append("[${languages.value.find{ it.nameResId == a }?.name} - ${languages.value.find{ it.nameResId == b }?.name}]") + } else if (hasLangList) { + append(cat.languages.joinToString(", ") { langId -> languages.value.find { it.nameResId == langId }?.name.toString() }) + } else { + append(stringResource(R.string.text_all_languages)) + } + append(" | ") + if (hasStages) append(cat.stages.joinToString(", ") { it.toString(context) }) else append(stringResource(R.string.label_all_stages)) + } + } + else -> "" + } + + var showMenu by remember { mutableStateOf(false) } + val showDeleteCategoryDialog by categoryViewModel.showDeleteCategoryDialog.collectAsState(initial = false) + val showDeleteItemsDialog by categoryViewModel.showDeleteItemsDialog.collectAsState(initial = false) + val showEditCategoryDialog by categoryViewModel.showEditCategoryDialog.collectAsState(initial = false) + + AppScaffold( + modifier = modifier, + topBar = { + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.surface) + ) { + AppTopAppBar( + title = { + Column { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + AppIcons.ArrowBack, + contentDescription = stringResource(R.string.cd_back) + ) + } + }, + actions = { + IconButton(onClick = { showMenu = !showMenu }) { + Icon( + imageVector = AppIcons.MoreVert, + contentDescription = stringResource(R.string.text_more_options) + ) + } + DropdownMenu( + + expanded = showMenu, + onDismissRequest = { showMenu = false }, + modifier = Modifier.width(220.dp) + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.text_edit_category)) }, + onClick = { + categoryViewModel.setShowEditCategoryDialog(true, categoryId) + showMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.text_export_category)) }, + onClick = { + vocabularyViewModel.saveCategory(categoryId) + showMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.delete_items_category)) }, + onClick = { + categoryViewModel.setShowDeleteItemsDialog(true, categoryId) + showMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.text_delete_category)) }, + onClick = { + categoryViewModel.setShowDeleteCategoryDialog(true, categoryId) + showMenu = false + } + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + // TODO: Review this + containerColor = MaterialTheme.colorScheme.surface + ) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + if (categoryProgress != null) { + Box(modifier = Modifier.weight(1f)) { + CategoryProgressCircle( + totalItems = categoryProgress.totalItems, + itemsCompleted = categoryProgress.itemsCompleted, + itemsInStages = categoryProgress.itemsInStages, + newItems = categoryProgress.newItems, + circleSize = 80.dp, + ) + } + } else { + Spacer(modifier = Modifier.weight(1f)) + } + + Box( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) { + PrimaryButton( + text = stringResource(R.string.label_start), + icon = AppIcons.Play, + onClick = { + val categories = listOf(category) + val categoryIds = categories.joinToString(",") { it?.id.toString() } + navController.navigate("vocabulary_exercise/false?categories=$categoryIds") + }, + modifier = Modifier.heightIn(max = 80.dp) + ) + } + } + } + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + VocabularyListScreen( + categoryId = categoryId, + showDueTodayOnly = false, + onNavigateToItem = onNavigateToItem, + navController = navController, // Pass the received navController here + isRemoveFromCategoryEnabled = category is TagCategory, + showTopBar = false, + enableNavigationButtons = true + ) + + // Dialogs + if (showDeleteCategoryDialog) { + DeleteCategoryDialog( + onDismiss = { categoryViewModel.setShowDeleteCategoryDialog(false, categoryId) }, + viewModel = categoryViewModel, + ) + } + if (showDeleteItemsDialog) { + DeleteItemsDialog( + onDismiss = { categoryViewModel.setShowDeleteItemsDialog(false, categoryId) }, + viewModel = vocabularyViewModel, + categoryId = categoryId + ) + } + if (showEditCategoryDialog) { + EditCategoryDialog( + onDismiss = { categoryViewModel.setShowEditCategoryDialog(false, categoryId) }, + languageViewModel = languageViewModel, + categoryViewModel = categoryViewModel, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/CategoryListScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/CategoryListScreen.kt new file mode 100644 index 0000000..f65c38c --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/CategoryListScreen.kt @@ -0,0 +1,336 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.vocabulary + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Badge +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.TagCategory +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.dialogs.AddCategoryDialog +import eu.gaudian.translator.view.dialogs.DeleteMultipleCategoriesDialog +import eu.gaudian.translator.view.vocabulary.widgets.CategoryCircleType +import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.ProgressViewModel + +enum class SortOption { + NAME, + SIZE, + COMPLETION_PERCENTAGE, + NEW_ITEMS, + IN_PROGRESS +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun CategoryListScreen( + categoryViewModel: CategoryViewModel, + progressViewModel: ProgressViewModel, + onNavigateBack: () -> Unit, + onCategoryClicked: (Int) -> Unit, +) { + + var sortOption by remember { mutableStateOf(SortOption.NAME) } + var sortMenuExpanded by remember { mutableStateOf(false) } + val categoryProgressList by progressViewModel.categoryProgressList.collectAsState(initial = emptyList()) + var showAddCategoryDialog by remember { mutableStateOf(false) } + var showDeleteCategoryDialog by remember { mutableStateOf(false) } + var selectedCategories by remember { mutableStateOf(emptySet()) } + var isSelectionMode by remember { mutableStateOf(false) } + val haptic = LocalHapticFeedback.current + + if (showAddCategoryDialog) { + AddCategoryDialog( + onDismiss = { showAddCategoryDialog = false }, + categoryViewModel = categoryViewModel, + ) + } + + val sortedCategories = remember(categoryProgressList, sortOption) { + when (sortOption) { + SortOption.NAME -> categoryProgressList.sortedBy { it.vocabularyCategory.name } + SortOption.SIZE -> categoryProgressList.sortedByDescending { it.totalItems } + SortOption.COMPLETION_PERCENTAGE -> categoryProgressList.sortedByDescending { + if (it.totalItems > 0) it.itemsCompleted.toFloat() / it.totalItems else 0f + } + SortOption.NEW_ITEMS -> categoryProgressList.sortedByDescending { it.newItems } + SortOption.IN_PROGRESS -> categoryProgressList.sortedByDescending { it.itemsInStages } + } + } + + AppScaffold( + topBar = { + AppTopAppBar( + title = { + if (isSelectionMode && selectedCategories.isNotEmpty()) { + Text(stringResource(R.string.text_2d_categories_selected, selectedCategories.size)) + } else { + Text(stringResource(R.string.label_categories)) + } + }, + navigationIcon = { + if (isSelectionMode) { + IconButton(onClick = { + isSelectionMode = false + selectedCategories = emptySet() + }) { + Icon(AppIcons.CheckCircle, contentDescription = stringResource(R.string.label_close)) + } + } else { + IconButton(onClick = onNavigateBack) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + } + }, + actions = { + if (isSelectionMode) { + if (selectedCategories.isNotEmpty()) { + IconButton( + onClick = { + showDeleteCategoryDialog = true + }, + enabled = true + ) { + Icon(AppIcons.Delete, contentDescription = stringResource(R.string.label_delete)) + } + } + IconButton( + onClick = { + selectedCategories = if (selectedCategories.size == sortedCategories.size) { + emptySet() + } else { + sortedCategories.map { it.vocabularyCategory.id }.toSet() + } + } + ) { + if (selectedCategories.size == sortedCategories.size) { + Icon(AppIcons.Deselect, contentDescription = stringResource(R.string.deselect_all)) + } else { + Icon(AppIcons.SelectAll, contentDescription = stringResource(R.string.select_all)) + } + } + } else { + IconButton(onClick = { sortMenuExpanded = true }) { + Icon(AppIcons.Sort, contentDescription = stringResource(R.string.sort)) + } + } + DropdownMenu( + expanded = sortMenuExpanded, + onDismissRequest = { sortMenuExpanded = false } + ) { + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.sort_by_name), + color = if (sortOption == SortOption.NAME) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { + sortOption = SortOption.NAME + sortMenuExpanded = false + } + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.sort_by_size), + color = if (sortOption == SortOption.SIZE) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { + sortOption = SortOption.SIZE + sortMenuExpanded = false + } + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.sort_by_completion_percentage), + color = if (sortOption == SortOption.COMPLETION_PERCENTAGE) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { + sortOption = SortOption.COMPLETION_PERCENTAGE + sortMenuExpanded = false + } + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.sort_by_new_items), + color = if (sortOption == SortOption.NEW_ITEMS) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { + sortOption = SortOption.NEW_ITEMS + sortMenuExpanded = false + } + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.sort_by_in_progress), + color = if (sortOption == SortOption.IN_PROGRESS) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + }, + onClick = { + sortOption = SortOption.IN_PROGRESS + sortMenuExpanded = false + } + ) + } + } + ) + }, + floatingActionButton = { + if (!isSelectionMode) { + FloatingActionButton( + onClick = { showAddCategoryDialog = true }, + shape = RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 8.dp) + ) { + Icon(AppIcons.Add, contentDescription = stringResource(R.string.label_add_category)) + } + } + } + ) { paddingValues -> + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 128.dp), + contentPadding = paddingValues, + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(16.dp) + ) { + items(sortedCategories.size) { index -> + val data = sortedCategories[index] + val categoryId = data.vocabularyCategory.id + val isSelected = selectedCategories.contains(categoryId) + + Box( + modifier = Modifier + .combinedClickable( + onClick = { + if (isSelectionMode) { + selectedCategories = if (isSelected) { + selectedCategories - categoryId + } else { + selectedCategories + categoryId + } + if (selectedCategories.isEmpty()) { + isSelectionMode = false + } + } else { + onCategoryClicked(categoryId) + } + }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + isSelectionMode = true + selectedCategories = selectedCategories + categoryId + } + ) + ) { + Box( + modifier = if (isSelected) Modifier.background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + shape = MaterialTheme.shapes.medium + ) else Modifier + ) { + CategoryProgressCircle( + category = data.vocabularyCategory.name, + totalItems = data.totalItems, + itemsCompleted = data.itemsCompleted, + itemsInStages = data.itemsInStages, + newItems = data.newItems, + onClick = null, + showPercentage = true, + type = if(data.vocabularyCategory is TagCategory) CategoryCircleType.List else CategoryCircleType.Filter + ) + } + + if (isSelectionMode) { + Checkbox( + checked = isSelected, + onCheckedChange = { checked -> + selectedCategories = if (checked) { + selectedCategories + categoryId + } else { + selectedCategories - categoryId + } + if (selectedCategories.isEmpty()) { + isSelectionMode = false + } + }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + .size(24.dp) + ) + } + + if (isSelected && !isSelectionMode) { + Badge( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + ) { + Icon( + imageVector = AppIcons.CheckCircle, + contentDescription = stringResource(R.string.text_selected), + modifier = Modifier.size(16.dp) + ) + } + } + } + } + } + } + + if (showDeleteCategoryDialog && selectedCategories.isNotEmpty()) { + DeleteMultipleCategoriesDialog( + categoryIds = selectedCategories.toList(), + categoryViewModel = categoryViewModel, + onDismiss = { showDeleteCategoryDialog = false }, + onConfirm = { + selectedCategories = emptySet() + isSelectionMode = false + showDeleteCategoryDialog = false + } + ) + } +} diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/DashboardContent.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/DashboardContent.kt new file mode 100644 index 0000000..9b2f68f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/DashboardContent.kt @@ -0,0 +1,678 @@ +@file:Suppress("HardCodedStringLiteral", "AssignedValueIsNeverRead", "UnusedMaterial3ScaffoldPaddingParameter") + +package eu.gaudian.translator.view.vocabulary + +import android.annotation.SuppressLint +import android.app.Application +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.model.WidgetType +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedCard +import eu.gaudian.translator.view.dialogs.MissingLanguageDialog +import eu.gaudian.translator.view.vocabulary.widgets.AllVocabularyWidget +import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressWidget +import eu.gaudian.translator.view.vocabulary.widgets.DueTodayWidget +import eu.gaudian.translator.view.vocabulary.widgets.LevelWidget +import eu.gaudian.translator.view.vocabulary.widgets.ModernStartButtons +import eu.gaudian.translator.view.vocabulary.widgets.StatusWidget +import eu.gaudian.translator.view.vocabulary.widgets.StreakWidget +import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.ProgressViewModel +import eu.gaudian.translator.viewmodel.SettingsViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch + +@SuppressLint("FrequentlyChangingValue") +@Composable +fun DashboardContent( + navController: NavController, + onShowCustomExerciseDialog: () -> Unit, + startDailyExercise: (Boolean) -> Unit, + onNavigateToCategoryDetail: (Int) -> Unit, + onNavigateToCategoryList: () -> Unit, + onShowWordPairExerciseDialog: () -> Unit, + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(LocalContext.current.applicationContext as Application), + progressViewModel: ProgressViewModel = ProgressViewModel.getInstance(LocalContext.current.applicationContext as Application), +) { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + var showMissingLanguageDialog by remember { mutableStateOf(false) } + var selectedMissingLanguageId by remember { mutableStateOf(null) } + + val affectedItems by remember(selectedMissingLanguageId) { + selectedMissingLanguageId?.let { + vocabularyViewModel.getItemsForLanguage(it) + } ?: flowOf(emptyList()) + }.collectAsState(initial = emptyList()) + + if (showMissingLanguageDialog && selectedMissingLanguageId != null) { + MissingLanguageDialog( + showDialog = true, + missingLanguageId = selectedMissingLanguageId!!, + affectedItems = affectedItems, + onDismiss = { showMissingLanguageDialog = false }, + onDelete = { items -> + vocabularyViewModel.deleteVocabularyItemsById(items.map { it.id }) + showMissingLanguageDialog = false + }, + onReplace = { oldId, newId -> + vocabularyViewModel.replaceLanguageId(oldId, newId) + showMissingLanguageDialog = false + }, + onCreate = { newLanguage -> + languageViewModel.addCustomLanguage(newLanguage) + }, + languageViewModel = languageViewModel + ) + } + + AppOutlinedCard { + // We collect the order from DB initially + val initialWidgetOrder by settingsViewModel.widgetOrder.collectAsState(initial = null) + val collapsedWidgetIds by settingsViewModel.collapsedWidgetIds.collectAsState(initial = emptySet()) + val dashboardScrollState by settingsViewModel.dashboardScrollState.collectAsState() + val scope = rememberCoroutineScope() + + if (initialWidgetOrder == null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(vertical = 64.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + // BEST PRACTICE: Use a SnapshotStateList for immediate UI updates. + // We only initialize this once, so DB updates don't reset the list while dragging. + val orderedWidgets = remember { mutableStateListOf() } + + // Sync with DB only on first load + LaunchedEffect(initialWidgetOrder) { + if (orderedWidgets.isEmpty() && !initialWidgetOrder.isNullOrEmpty()) { + orderedWidgets.addAll(initialWidgetOrder!!) + } else if (orderedWidgets.isEmpty()) { + orderedWidgets.addAll(WidgetType.DEFAULT_ORDER) + } + } + + val lazyListState = rememberLazyListState( + initialFirstVisibleItemIndex = dashboardScrollState.first, + initialFirstVisibleItemScrollOffset = dashboardScrollState.second + ) + + // Save scroll state + LaunchedEffect(lazyListState.firstVisibleItemIndex, lazyListState.firstVisibleItemScrollOffset) { + settingsViewModel.saveDashboardScrollState( + lazyListState.firstVisibleItemIndex, + lazyListState.firstVisibleItemScrollOffset + ) + } + DisposableEffect(Unit) { + onDispose { + settingsViewModel.saveDashboardScrollState( + lazyListState.firstVisibleItemIndex, + lazyListState.firstVisibleItemScrollOffset + ) + } + } + + // --- Robust Drag and Drop State --- + val dragDropState = rememberDragDropState( + lazyListState = lazyListState, + onSwap = { fromIndex, toIndex -> + // Swap data immediately for responsiveness + orderedWidgets.apply { + add(toIndex, removeAt(fromIndex)) + } + }, + onDragEnd = { + // Persist to DB only when user drops + settingsViewModel.saveWidgetOrder(orderedWidgets.toList()) + } + ) + + LazyColumn( + state = lazyListState, + modifier = Modifier + .fillMaxSize() + .dragContainer(dragDropState), + contentPadding = PaddingValues(bottom = 160.dp) + ) { + itemsIndexed( + items = orderedWidgets, + key = { _, widget -> widget.id } + ) { index, widgetType -> + + val isDragging = index == dragDropState.draggingItemIndex + + // Calculate translation: distinct logic for dragged vs. stationary items + val translationY = if (isDragging) { + dragDropState.draggingItemOffset + } else { + 0f + } + + Box( + modifier = Modifier + .zIndex(if (isDragging) 1f else 0f) + .graphicsLayer { + this.translationY = translationY + this.shadowElevation = if (isDragging) 8.dp.toPx() else 0f + this.scaleX = if (isDragging) 1.02f else 1f + this.scaleY = if (isDragging) 1.02f else 1f + } + // CRITICAL FIX: Only apply animation to items NOT being dragged. + // This prevents the "flicker" by stopping the layout animation + // from fighting your manual drag offset. + .then( + if (!isDragging) { + Modifier.animateItem( + placementSpec = spring( + stiffness = Spring.StiffnessLow, + visibilityThreshold = IntOffset.VisibilityThreshold + ) + ) + } else { + Modifier + } + ) + ) { + WidgetContainer( + widgetType = widgetType, + isExpanded = widgetType.id !in collapsedWidgetIds, + onExpandedChange = { newExpandedState -> + scope.launch { + settingsViewModel.setWidgetExpandedState(widgetType.id, newExpandedState) + } + }, + onDragStart = { dragDropState.onDragStart(index) }, + onDrag = { dragAmount -> dragDropState.onDrag(dragAmount) }, + onDragEnd = { dragDropState.onDragEnd() }, + onDragCancel = { dragDropState.onDragInterrupted() }, + modifier = Modifier.fillMaxWidth() + ) { + LazyWidget( + widgetType = widgetType, + navController = navController, + vocabularyViewModel = vocabularyViewModel, + progressViewModel = progressViewModel, + onShowCustomExerciseDialog = onShowCustomExerciseDialog, + startDailyExercise = startDailyExercise, + onNavigateToCategoryDetail = onNavigateToCategoryDetail, + onNavigateToCategoryList = onNavigateToCategoryList, + onShowWordPairExerciseDialog = onShowWordPairExerciseDialog, + onMissingLanguage = { missingId -> + selectedMissingLanguageId = missingId + showMissingLanguageDialog = true + } + ) + } + } + } + } + } + } +} + +@Composable +private fun WidgetContainer( + widgetType: WidgetType, + isExpanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + onDragStart: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, + content: @Composable () -> Unit +) { + AppCard( + modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(animationSpec = tween(300, easing = FastOutSlowInEasing)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(widgetType.titleRes), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + + IconButton(onClick = { onExpandedChange(!isExpanded) }) { + Icon( + imageVector = if (isExpanded) AppIcons.ArrowDropUp + else AppIcons.ArrowDropDown, + contentDescription = if (isExpanded) stringResource(R.string.text_collapse_widget) + else stringResource(R.string.text_expand_widget) + ) + } + + // Drag Handle with specific pointer input + Icon( + imageVector = AppIcons.DragHandle, + contentDescription = stringResource(R.string.text_drag_to_reorder), + tint = if (isExpanded) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(end = 8.dp, start = 8.dp) + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { _ -> onDragStart() }, + onDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount.y) + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragCancel() } + ) + } + ) + } + if (isExpanded) { + content() + } + } + } +} + +// -------------------------------------------------------------------------------- +// Fixed Drag and Drop Logic +// -------------------------------------------------------------------------------- + +@Composable +fun rememberDragDropState( + lazyListState: LazyListState, + onSwap: (Int, Int) -> Unit, + onDragEnd: () -> Unit +): DragDropState { + val scope = rememberCoroutineScope() + return remember(lazyListState, scope) { + DragDropState( + state = lazyListState, + onSwap = onSwap, + onDragFinished = onDragEnd, + scope = scope + ) + } +} + +fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { + return this.pointerInput(dragDropState) { + // Just allows the modifier to exist in the chain, logic is in the handle + } +} + +class DragDropState( + private val state: LazyListState, + private val onSwap: (Int, Int) -> Unit, + private val onDragFinished: () -> Unit, + private val scope: CoroutineScope +) { + var draggingItemIndex by mutableIntStateOf(-1) + private set + + private val _draggingItemOffset = Animatable(0f) + val draggingItemOffset: Float + get() = _draggingItemOffset.value + + private val scrollChannel = Channel(Channel.CONFLATED) + + init { + scope.launch { + for (scrollAmount in scrollChannel) { + if (scrollAmount != 0f) { + state.scrollBy(scrollAmount) + checkSwap() + } + } + } + } + + fun onDragStart(index: Int) { + draggingItemIndex = index + scope.launch { _draggingItemOffset.snapTo(0f) } + } + + fun onDrag(dragAmount: Float) { + if (draggingItemIndex == -1) return + + scope.launch { + _draggingItemOffset.snapTo(_draggingItemOffset.value + dragAmount) + checkSwap() + checkOverscroll() + } + } + + private fun checkSwap() { + val draggedIndex = draggingItemIndex + if (draggedIndex == -1) return + + val visibleItems = state.layoutInfo.visibleItemsInfo + val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return + + // Calculate the visual center of the dragged item + val draggedCenter = draggedItemInfo.offset + (draggedItemInfo.size / 2) + _draggingItemOffset.value + + // Find a target to swap with + // FIX: We strictly check if we have crossed the CENTER of the target item. + // This acts as a hysteresis buffer to prevent flickering at the edges. + val targetItem = visibleItems.find { item -> + item.index != draggedIndex && + draggedCenter > item.offset && + draggedCenter < (item.offset + item.size) + } + + if (targetItem != null) { + // Extra Check: Ensure we have actually crossed the midpoint of the target + val targetCenter = itemCenter(targetItem.offset, targetItem.size) + val isAboveAndMovingDown = draggedIndex < targetItem.index && draggedCenter > targetCenter + val isBelowAndMovingUp = draggedIndex > targetItem.index && draggedCenter < targetCenter + + if (isAboveAndMovingDown || isBelowAndMovingUp) { + val targetIndex = targetItem.index + + // 1. Swap Data + onSwap(draggedIndex, targetIndex) + + // 2. Adjust Offset + // We calculate the physical distance the item moved in the layout (e.g. 150px). + // We subtract this from the current drag offset to keep the item visually stationary under the finger. + val layoutJumpDistance = (targetItem.offset - draggedItemInfo.offset).toFloat() + + scope.launch { + _draggingItemOffset.snapTo(_draggingItemOffset.value - layoutJumpDistance) + } + + // 3. Update Index + draggingItemIndex = targetIndex + } + } + } + + private fun itemCenter(offset: Int, size: Int): Float { + return offset + (size / 2f) + } + + private fun checkOverscroll() { + val draggedIndex = draggingItemIndex + if (draggedIndex == -1) { + scrollChannel.trySend(0f) + return + } + + val layoutInfo = state.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return + + val viewportStart = layoutInfo.viewportStartOffset + val viewportEnd = layoutInfo.viewportEndOffset + // Increased threshold slightly for smoother top-edge scrolling + val boundsStart = viewportStart + (viewportEnd * 0.15f) + val boundsEnd = viewportEnd - (viewportEnd * 0.15f) + + val itemTop = draggedItemInfo.offset + _draggingItemOffset.value + val itemBottom = itemTop + draggedItemInfo.size + + val scrollAmount = when { + itemTop < boundsStart -> -10f // Slower, more controlled scroll speed + itemBottom > boundsEnd -> 10f + else -> 0f + } + + scrollChannel.trySend(scrollAmount) + } + + fun onDragEnd() { + resetDrag() + onDragFinished() + } + + fun onDragInterrupted() { + resetDrag() + } + + private fun resetDrag() { + draggingItemIndex = -1 + scrollChannel.trySend(0f) + scope.launch { _draggingItemOffset.snapTo(0f) } + } +} + +// -------------------------------------------------------------------------------- +// Remainder of your existing components +// -------------------------------------------------------------------------------- + +@Composable +private fun LazyWidget( + widgetType: WidgetType, + navController: NavController, + vocabularyViewModel: VocabularyViewModel, + progressViewModel: ProgressViewModel, + onShowCustomExerciseDialog: () -> Unit, + startDailyExercise: (Boolean) -> Unit, + onNavigateToCategoryDetail: (Int) -> Unit, + onNavigateToCategoryList: () -> Unit, + onShowWordPairExerciseDialog: () -> Unit, + onMissingLanguage: (Int) -> Unit +) { + when (widgetType) { + WidgetType.StartButtons -> ModernStartButtons( + onCustomClick = onShowCustomExerciseDialog, + onDailyClick = { isSpelling -> + if (isSpelling) { + onShowWordPairExerciseDialog() + } else { + startDailyExercise(true) + Log.d("DailyExercise", "Starting daily exercise") + } + } + ) + + WidgetType.Status -> LazyStatusWidget( + vocabularyViewModel = vocabularyViewModel, + onNavigateToNew = { navController.navigate("vocabulary_sorting?mode=NEW") }, + onNavigateToDuplicates = { navController.navigate("vocabulary_sorting?mode=DUPLICATES") }, + onNavigateToFaulty = { navController.navigate("vocabulary_sorting?mode=FAULTY") }, + onNavigateToNoGrammar = { navController.navigate("no_grammar_items") }, + onNavigateToMissingLanguage = onMissingLanguage + ) + + else -> { + // Regular widgets that load immediately + when (widgetType) { + WidgetType.Streak -> StreakWidget( + streak = progressViewModel.streak.collectAsState(initial = 0).value, + lastSevenDays = progressViewModel.lastSevenDays.collectAsState().value, + dueTodayCount = progressViewModel.dueTodayCount.collectAsState().value, + wordsCompleted = progressViewModel.totalWordsCompleted.collectAsState().value, + onStatisticsClicked = { navController.navigate("vocabulary_heatmap") } + ) + + WidgetType.WeeklyActivityChart -> WeeklyActivityChartWidget( + weeklyStats = progressViewModel.weeklyActivityStats.collectAsState().value + ) + + WidgetType.AllVocabulary -> AllVocabularyWidget( + vocabularyViewModel = vocabularyViewModel, + onOpenAllVocabulary = { navController.navigate("vocabulary_list/false/null") }, + onStageClicked = { stage -> navController.navigate("vocabulary_list/false/$stage") } + ) + + WidgetType.DueToday -> DueTodayWidget( + vocabularyViewModel = vocabularyViewModel, + onStageClicked = { stage -> navController.navigate("vocabulary_list/false/$stage") } + ) + + WidgetType.CategoryProgress -> CategoryProgressWidget( + onCategoryClicked = { category -> + category?.let { onNavigateToCategoryDetail(it.id) } + }, + onViewAllClicked = onNavigateToCategoryList + ) + + WidgetType.Levels -> LevelWidget( + totalWords = vocabularyViewModel.vocabularyItems.collectAsState().value.size, + learnedWords = vocabularyViewModel.stageStats.collectAsState().value + .firstOrNull { it.stage == VocabularyStage.LEARNED }?.itemCount ?: 0, + onNavigateToProgress = { navController.navigate("language_progress") } + ) + + } + } + } +} + +@Composable +private fun LazyStatusWidget( + vocabularyViewModel: VocabularyViewModel, + onNavigateToNew: () -> Unit, + onNavigateToDuplicates: () -> Unit, + onNavigateToFaulty: () -> Unit, + onNavigateToNoGrammar: () -> Unit, + onNavigateToMissingLanguage: (Int) -> Unit +) { + var isLoading by remember { mutableStateOf(true) } + + // Collect all flows asynchronously + val newItemsCount by vocabularyViewModel.newItemsCount.collectAsState() + val duplicateCount by vocabularyViewModel.duplicateItemsCount.collectAsState() + val faultyItemsCount by vocabularyViewModel.faultyItemsCount.collectAsState() + val itemsWithoutGrammarCount by vocabularyViewModel.itemsWithoutGrammarCount.collectAsState() + val missingLanguageInfo by vocabularyViewModel.missingLanguageInfo.collectAsState() + + LaunchedEffect( + newItemsCount, + duplicateCount, + faultyItemsCount, + itemsWithoutGrammarCount, + missingLanguageInfo + ) { + delay(100) + isLoading = false + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.padding(16.dp)) + } + } else { + StatusWidget( + vocabularyViewModel = vocabularyViewModel, + onNavigateToNew = onNavigateToNew, + onNavigateToDuplicates = onNavigateToDuplicates, + onNavigateToFaulty = onNavigateToFaulty, + onNavigateToNoGrammar = onNavigateToNoGrammar, + onNavigateToMissingLanguage = onNavigateToMissingLanguage + ) + } +} + +@Preview +@Composable +fun DashboardContentPreview() { + val navController = rememberNavController() + DashboardContent( + navController = navController, + onShowCustomExerciseDialog = {}, + onNavigateToCategoryDetail = {}, + startDailyExercise = {}, + onNavigateToCategoryList = {}, + onShowWordPairExerciseDialog = {}, + ) +} + +@Preview +@Composable +fun WidgetContainerPreview() { + WidgetContainer( + widgetType = WidgetType.Streak, + isExpanded = true, + onExpandedChange = {}, + onDragStart = { } , + onDrag = { }, + onDragEnd = { }, + onDragCancel = { } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text("Preview Content") + } + } +} diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExerciseControls.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExerciseControls.kt new file mode 100644 index 0000000..eeb85d5 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExerciseControls.kt @@ -0,0 +1,119 @@ +package eu.gaudian.translator.view.vocabulary +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.CorrectButton +import eu.gaudian.translator.view.composable.WrongButton + +@Composable +fun ExerciseControls( + state: VocabularyExerciseState?, + onAction: (VocabularyExerciseAction) -> Unit +) { + if (state == null) return // Don't show controls if exercise is finished + + var spellingAnswer by remember { mutableStateOf("") } + + LaunchedEffect(state.item.id) { + spellingAnswer = "" + } + + val isRevealed = state.isRevealed + val isAnswered = state.isCorrect != null + + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + // Input field for spelling, only shown before the answer is revealed. + if (!isRevealed && state is VocabularyExerciseState.Spelling) { + AppTextField( + value = spellingAnswer, + onValueChange = { spellingAnswer = it }, + label = { Text(stringResource(R.string.type_the_translation)) }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + if (!isRevealed) { + when (state) { + is VocabularyExerciseState.Spelling -> { + AppOutlinedButton( + onClick = { onAction(VocabularyExerciseAction.Submit(spellingAnswer)) }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(AppIcons.Check, contentDescription = stringResource(R.string.text_check)) + Spacer(modifier = Modifier.padding(4.dp)) + Text(stringResource(R.string.text_check)) + } + } + is VocabularyExerciseState.Guessing -> { + AppOutlinedButton( + onClick = { onAction(VocabularyExerciseAction.Reveal) }, + modifier = Modifier.fillMaxWidth() + ) { + Icon(AppIcons.Sync, contentDescription = stringResource(R.string.flip_card)) + Spacer(modifier = Modifier.padding(4.dp)) + Text(stringResource(R.string.flip_card)) + } + } + + else -> { + } + } + } + else { + if (isAnswered) { + + AppOutlinedButton( + onClick = { onAction(VocabularyExerciseAction.Next) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.next_card)) + } + } else { + + if (state is VocabularyExerciseState.Guessing) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + WrongButton( + onClick = { onAction(VocabularyExerciseAction.Submit(false)) }, + modifier = Modifier.weight(1f) + ) { + Icon(AppIcons.Close, contentDescription = stringResource(R.string.incorrect)) + Text(stringResource(R.string.label_wrong)) + } + CorrectButton( + onClick = { onAction(VocabularyExerciseAction.Submit(true)) }, + modifier = Modifier.weight(1f) + ) { + Icon(AppIcons.Check, contentDescription = stringResource(R.string.label_correct)) + Text(stringResource(R.string.right)) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExerciseProgressIndicator.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExerciseProgressIndicator.kt new file mode 100644 index 0000000..8f06a25 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExerciseProgressIndicator.kt @@ -0,0 +1,179 @@ +package eu.gaudian.translator.view.vocabulary + + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.composable.AppIcons + +@Composable +fun ExerciseProgressIndicator( + correctAnswers: Int, + wrongAnswers: Int, + totalItems: Int, + onClose: () -> Unit, + modifier: Modifier = Modifier, + correctColor: Color = MaterialTheme.colorScheme.primary, + wrongColor: Color = MaterialTheme.colorScheme.error, + trackColor: Color = MaterialTheme.colorScheme.outline +) { + val targetCorrectFraction = if (totalItems > 0) correctAnswers.toFloat() / totalItems else 0f + val targetWrongFraction = if (totalItems > 0) wrongAnswers.toFloat() / totalItems else 0f + + val animatedCorrectFraction by animateFloatAsState(targetValue = targetCorrectFraction, label = "correctAnimation") + val animatedWrongFraction by animateFloatAsState(targetValue = targetWrongFraction, label = "wrongAnimation") + val totalProgressFraction = animatedCorrectFraction + animatedWrongFraction + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + IconButton(onClick = onClose, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = AppIcons.Close, + contentDescription = stringResource(R.string.label_close_exercise), + modifier = Modifier.size(36.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + } + + Box( + modifier = Modifier + .weight(1f) + .height(32.dp), + contentAlignment = Alignment.Center + ) { + Canvas( + modifier = Modifier + .matchParentSize() + .clip(RoundedCornerShape(8.dp)) + ) { + drawRect(color = trackColor) + drawRect( + color = wrongColor, + size = Size(width = size.width * totalProgressFraction, height = size.height) + ) + drawRect( + color = correctColor, + size = Size(width = size.width * animatedCorrectFraction, height = size.height) + ) + } + + ProgressContent( + correctAnswers = correctAnswers, + wrongAnswers = wrongAnswers, + color = MaterialTheme.colorScheme.onPrimary + ) + + + } + + Text( + text = "/ $totalItems", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +private fun ProgressContent(correctAnswers: Int, wrongAnswers: Int, color: Color) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = AppIcons.Check, + contentDescription = stringResource(R.string.correct_answers), + tint = color, + modifier = Modifier.size(16.dp, 16.dp) + ) + Text( + text = "$correctAnswers", + color = color, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "$wrongAnswers", + color = color, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + Icon( + imageVector = AppIcons.Cancel, + contentDescription = stringResource(R.string.label_wrong_answers), + tint = color, + modifier = Modifier.size(16.dp, 16.dp) + ) + } + } +} + + +@ThemePreviews +@Composable +private fun ExerciseProgressIndicatorPreview() { + Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp)) { + ExerciseProgressIndicator( + correctAnswers = 0, + wrongAnswers = 0, + totalItems = 20, + onClose = {} + ) + ExerciseProgressIndicator( + correctAnswers = 5, + wrongAnswers = 2, + totalItems = 20, + onClose = {} + ) + ExerciseProgressIndicator( + correctAnswers = 10, + wrongAnswers = 8, + totalItems = 20, + onClose = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/LanguageProgressScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/LanguageProgressScreen.kt new file mode 100644 index 0000000..5615bd6 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/LanguageProgressScreen.kt @@ -0,0 +1,372 @@ +package eu.gaudian.translator.view.vocabulary + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.LanguageLevels +import eu.gaudian.translator.model.MyAppLanguageLevel +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar + +@Composable +fun LanguageProgressScreen(wordsLearned: Int, navController: NavController) { + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.your_language_journey)) }, + onNavigateBack = { navController.popBackStack() } + ) + }, + content = { innerPadding -> + LevelsRoadmap( + wordsLearned = wordsLearned, + modifier = Modifier.padding(innerPadding) + ) + } + ) +} + +@Composable +private fun LevelsRoadmap(wordsLearned: Int, modifier: Modifier = Modifier) { + val allLevels = LanguageLevels.all + val currentLevel = LanguageLevels.getLevelForWords(wordsLearned) + val nextLevel = LanguageLevels.getNextLevel(wordsLearned) + var selectedLevelForDialog by remember { mutableStateOf(null) } + + Box(modifier = modifier.fillMaxSize()) { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 24.dp) + ) { + item { + JourneyHeader(currentLevel, nextLevel, wordsLearned) + Spacer(modifier = Modifier.height(32.dp)) + } + + items(allLevels.size) { index -> + val level = allLevels[index] + RoadmapNode( + level = level, + isUnlocked = wordsLearned >= level.wordsKnown, + isCurrent = level == currentLevel, + isFirst = index == 0, + isLast = index == allLevels.lastIndex, + onClick = { + if (wordsLearned >= level.wordsKnown) { + selectedLevelForDialog = level + } + } + ) + } + } + + // Show the dialog when a level is selected + selectedLevelForDialog?.let { level -> + LevelDetailDialog( + level = level, + onDismiss = { selectedLevelForDialog = null } + ) + } + } +} + +@Composable +private fun JourneyHeader( + currentLevel: MyAppLanguageLevel, + nextLevel: MyAppLanguageLevel?, + wordsLearned: Int +) { + val progress = if (nextLevel != null && nextLevel.wordsKnown > currentLevel.wordsKnown) { + (wordsLearned - currentLevel.wordsKnown).toFloat() / (nextLevel.wordsKnown - currentLevel.wordsKnown).toFloat() + } else { + 1f + } + val animatedProgress by animateFloatAsState( + targetValue = progress.coerceIn(0f, 1f), + animationSpec = tween(1000, easing = FastOutSlowInEasing), + label = "ProgressAnimation" + ) + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.current_level), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = currentLevel.nameResId), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(16.dp)) + + if (nextLevel != null) { + Text( + text = stringResource(R.string.next_, stringResource(id = nextLevel.nameResId)), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape), + strokeCap = StrokeCap.Round + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + text = stringResource(R.string.words_2d, currentLevel.wordsKnown), + style = MaterialTheme.typography.labelSmall + ) + Text( + text = stringResource(R.string.words, nextLevel.wordsKnown), + style = MaterialTheme.typography.labelSmall + ) + } + } else { + Text( + text = stringResource(R.string.text_mastered_final_level), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +@Composable +private fun RoadmapNode( + level: MyAppLanguageLevel, + isUnlocked: Boolean, + isCurrent: Boolean, + isFirst: Boolean, + isLast: Boolean, + onClick: () -> Unit +) { + val pathColor = if (isUnlocked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant + @Suppress("HardCodedStringLiteral") val infiniteTransition = rememberInfiniteTransition(label = "pulse_transition") + @Suppress("HardCodedStringLiteral") val pulseScale by if (isCurrent) { + infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "pulse_scale" + ) + } else { + animateFloatAsState(targetValue = 1f, label = "static_scale") + } + + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + // Path and Icon Column + Column( + modifier = Modifier.width(48.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Canvas(modifier = Modifier + .weight(0.4f) + .fillMaxWidth()) { + if (!isFirst) { + drawLine( + color = pathColor, + start = Offset(center.x, 0f), + end = Offset(center.x, size.height), + strokeWidth = 3.dp.toPx(), + cap = StrokeCap.Round + ) + } + } + Box( + modifier = Modifier + .size(48.dp) + .scale(pulseScale) + .background( + if (isCurrent) MaterialTheme.colorScheme.primaryContainer else Color.Transparent, + CircleShape + ) + .border(width = 3.dp, color = pathColor, shape = CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = level.iconResId), + contentDescription = stringResource(id = level.nameResId), + modifier = Modifier.size(28.dp), + tint = if (isUnlocked) Color.Unspecified else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + + // Line below the icon + Canvas(modifier = Modifier + .weight(0.6f) + .fillMaxWidth()) { + if (!isLast) { + drawLine( + color = pathColor, + start = Offset(center.x, 0f), + end = Offset(center.x, size.height), + strokeWidth = 3.dp.toPx(), + cap = StrokeCap.Round + ) + } + } + } + + // Card with level details + Card( + modifier = Modifier + .padding(start = 16.dp, bottom = 24.dp) + .align(Alignment.CenterVertically) + .clickable(enabled = isUnlocked, onClick = onClick), + elevation = CardDefaults.cardElevation(if (isUnlocked) 4.dp else 1.dp), + colors = CardDefaults.cardColors( + containerColor = if (isUnlocked) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.surface.copy(alpha = 0.5f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(id = level.nameResId), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(R.string.words_2d, level.wordsKnown), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + } + if (isUnlocked) { + Icon( + imageVector = AppIcons.CheckCircle, + contentDescription = stringResource(R.string.cd_achieved), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } + } + if (isUnlocked) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = level.descriptionResId), + style = MaterialTheme.typography.bodySmall, + lineHeight = 16.sp + ) + } + } + } + } +} + +@Composable +private fun LevelDetailDialog(level: MyAppLanguageLevel, onDismiss: () -> Unit) { + AppDialog(onDismissRequest = onDismiss) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = level.iconResId), + contentDescription = null, + modifier = Modifier.size(120.dp), + tint = Color.Unspecified + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = level.nameResId), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.words_required, level.wordsKnown), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = level.descriptionResId), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun LanguageProgressScreenPreview() { + LanguageProgressScreen(wordsLearned = 50000, navController = NavController(androidx.compose.ui.platform.LocalContext.current)) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/MainVocabularyScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/MainVocabularyScreen.kt new file mode 100644 index 0000000..a8cea1c --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/MainVocabularyScreen.kt @@ -0,0 +1,452 @@ +@file:Suppress("HardCodedStringLiteral", "AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.vocabulary + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Exercise +import eu.gaudian.translator.model.MatchingPairsQuestion +import eu.gaudian.translator.model.Question +import eu.gaudian.translator.model.VocabularyCategory +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedCard +import eu.gaudian.translator.view.composable.AppSlider +import eu.gaudian.translator.view.composable.AppTabLayout +import eu.gaudian.translator.view.composable.OptionItemSwitch +import eu.gaudian.translator.view.composable.TabItem +import eu.gaudian.translator.view.dialogs.StartExerciseDialog +import eu.gaudian.translator.view.dialogs.VocabularyMenu +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.ExerciseViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + + +@Suppress("HardCodedStringLiteral") +enum class VocabularyTab( + override val title: String, + override val icon: ImageVector, + val route: String +) : TabItem { + Dashboard(title = "title_dashboard", icon = AppIcons.Dashboard, route = "dashboard"), + Statistics(title = "label_all_vocabulary", icon = AppIcons.BarChart, route = "statistics") +} + +//Used to avoid the warning of unused variables in strings.xml + +@Suppress("unused", "HardCodedStringLiteral", "UnusedVariable") +@Composable +fun Dummy() { + + val dummy = listOf( + stringResource(id = R.string.title_dashboard), + stringResource(id = R.string.label_all_vocabulary), + ) +} + +@Composable +fun MainVocabularyScreen( + navController: NavController +) { + + val activity = LocalActivity.current as ComponentActivity + val vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(activity.application as Application) + val categoryViewModel: CategoryViewModel = viewModel(viewModelStoreOwner = activity) + val exerciseViewModel: ExerciseViewModel = viewModel(viewModelStoreOwner = activity) + val vocabularyNavController = rememberNavController() + val coroutineScope = rememberCoroutineScope() + var showCustomExerciseDialog by remember { mutableStateOf(false) } + var startDailyExercise by remember { mutableStateOf(false) } + var showWordPairExerciseDialog by remember { mutableStateOf(false) } + + // Word Pair settings and temporary selections + var showWordPairSettingsDialog by remember { mutableStateOf(false) } + var tempWpCategories by remember { mutableStateOf>(emptyList()) } + var tempWpStages by remember { mutableStateOf>(emptyList()) } + var tempWpLanguageIds by remember { mutableStateOf>(emptyList()) } + + var wpQuestionCount by remember { mutableIntStateOf(5) } + var wpShuffleQuestions by remember { mutableStateOf(true) } + var wpShuffleWordOrder by remember { mutableStateOf(true) } + var wpTrainingMode by remember { mutableStateOf(false) } + var wpDueTodayOnly by remember { mutableStateOf(false) } + + if (showCustomExerciseDialog) { + StartExerciseDialog( + vocabularyViewModel = vocabularyViewModel, + categoryViewModel = categoryViewModel, + onDismiss = { showCustomExerciseDialog = false }, + onConfirm = { categories, stages, languageIds -> + showCustomExerciseDialog = false + val categoryIds = categories.joinToString(",") { it.id.toString() } + val stageNames = stages.joinToString(",") { it.name } + val languageIdsStr = languageIds.joinToString(",") { it.toString() } + @Suppress("HardCodedStringLiteral") + navController.navigate("vocabulary_exercise/false?categories=$categoryIds&stages=$stageNames&languages=$languageIdsStr") + } + ) + } + + if (showWordPairExerciseDialog) { + StartExerciseDialog( + vocabularyViewModel = vocabularyViewModel, + categoryViewModel = categoryViewModel, + onDismiss = { showWordPairExerciseDialog = false }, + onConfirm = { categories, stages, languageIds -> + // Store selections and open settings dialog instead of starting immediately + tempWpCategories = categories + tempWpStages = stages + tempWpLanguageIds = languageIds + showWordPairExerciseDialog = false + showWordPairSettingsDialog = true + } + ) + } + + val textWordPairSettings = stringResource(R.string.text_word_pair_settings) + + // Settings dialog for Word Pair Exercise + if (showWordPairSettingsDialog) { + AppDialog( + onDismissRequest = { showWordPairSettingsDialog = false }, + title = { Text(textWordPairSettings) } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + // Amount of questions + Text( + stringResource( + R.string.text_amount_of_questions_2d, + wpQuestionCount + )) + AppSlider( + value = wpQuestionCount.toFloat(), + onValueChange = { wpQuestionCount = it.toInt().coerceIn(1, 20) }, + valueRange = 1f..20f, + steps = 18 + ) + + // Toggles + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OptionItemSwitch( + title = stringResource(R.string.text_shuffle_questions), + checked = wpShuffleQuestions, + onCheckedChange = { wpShuffleQuestions = it }, + ) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OptionItemSwitch( + title = stringResource(R.string.text_shuffle_card_order), + description = stringResource(R.string.text_swap_sides), + checked = wpShuffleWordOrder, + onCheckedChange = { wpShuffleWordOrder = it }, + ) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OptionItemSwitch( + title = stringResource(R.string.tetx_training_mode), + description = stringResource(R.string.text_no_progress), + checked = wpTrainingMode, + onCheckedChange = { wpTrainingMode = it }, + ) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OptionItemSwitch( + title = stringResource(R.string.text_due_today_only), + checked = wpDueTodayOnly, + onCheckedChange = { wpDueTodayOnly = it }, + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = { showWordPairSettingsDialog = false }) { + Text(stringResource(id = R.string.label_cancel)) + } + val textMatchThePairs = stringResource(R.string.text_match_the_pairs) + val textWordPairExercise = stringResource(R.string.text_word_pair_exercise) + val textTrainingModeDescription = stringResource(R.string.text_training_mode_description) + val labelTrainingMode = stringResource(R.string.label_training_mode) + TextButton(onClick = { + showWordPairSettingsDialog = false + // Build a Word Pair Exercise using matching pairs from selected vocabulary with options + coroutineScope.launch { + val items = vocabularyViewModel.filterVocabularyItems( + languages = tempWpLanguageIds, + query = null, + categoryIds = tempWpCategories.map { it.id }, + stage = tempWpStages.firstOrNull(), + sortOrder = VocabularyViewModel.SortOrder.NEWEST_FIRST, + dueTodayOnly = wpDueTodayOnly + ).first() + + val maxPairsPerQuestion = 5 + var pairsList = items.mapNotNull { item -> + val k = item.wordFirst.trim() + val v = item.wordSecond.trim() + if (k.isNotBlank() && v.isNotBlank()) k to v else null + } + if (wpShuffleWordOrder) { + pairsList = pairsList.map { (a, b) -> if ((0..1).random() == 0) a to b else b to a } + } + if (pairsList.isEmpty()) return@launch + + + val shuffledPairs = if (wpShuffleQuestions) pairsList.shuffled() else pairsList + + val chunked = shuffledPairs.chunked(maxPairsPerQuestion) + val limitedChunks = chunked.take(wpQuestionCount) + val questions = mutableListOf() + var qId = 1 + limitedChunks.forEach { chunk -> + if (chunk.size >= 2) { + questions.add( + MatchingPairsQuestion( + id = qId++, + name = textMatchThePairs, + pairs = chunk.toMap() + ) + ) + } + } + if (questions.isEmpty()) return@launch + + @Suppress("HardCodedStringLiteral") val exercise = Exercise( + id = "wordpair-" + System.currentTimeMillis().toString(), + title = textWordPairExercise, + questions = questions.map { it.id }, + contextTitle = if (wpTrainingMode) labelTrainingMode else null, + contextText = if (wpTrainingMode) textTrainingModeDescription else null + ) + exerciseViewModel.startAdHocExercise(exercise, questions) + @Suppress("HardCodedStringLiteral") + navController.navigate("exercise_session") + } + }) { + Text(stringResource(id = R.string.label_start_exercise)) + } + } + } + } + } + + // Use LaunchedEffect to handle the navigation side effect + LaunchedEffect(startDailyExercise) { + if (startDailyExercise) { + @Suppress("HardCodedStringLiteral") + Log.d("DailyExercise", "Starting daily exercise") + @Suppress("HardCodedStringLiteral") + navController.navigate("vocabulary_exercise/false?categories=&stages=&languages=&dailyOnly=true") + startDailyExercise = false + } + } + + val navBackStackEntry by vocabularyNavController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + val selectedTab = remember(currentRoute) { + VocabularyTab.entries.find { it.route == currentRoute } ?: VocabularyTab.Dashboard + } + + val repoEmpty = + vocabularyViewModel.vocabularyItems.collectAsState(initial = emptyList()).value.isEmpty() + + if (repoEmpty) { + NoVocabularyScreen() + return + } + + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + AppTabLayout( + tabs = VocabularyTab.entries, + selectedTab = selectedTab, + onTabSelected = { tab -> + vocabularyNavController.navigate(tab.route) { + popUpTo(vocabularyNavController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + + NavHost( + navController = vocabularyNavController, + startDestination = VocabularyTab.Dashboard.route, + modifier = Modifier.weight(1f) + ) { + composable(VocabularyTab.Dashboard.route) { + DashboardContent( + navController = navController, + onShowCustomExerciseDialog = { showCustomExerciseDialog = true }, + onNavigateToCategoryDetail = { categoryId -> + navController.navigate("category_detail/$categoryId") + }, + startDailyExercise = { startDailyExercise = true }, + onNavigateToCategoryList = { + navController.navigate("category_list_screen") + }, + onShowWordPairExerciseDialog = { showWordPairExerciseDialog = true } + ) + } + composable(VocabularyTab.Statistics.route) { + StatisticsContent(navController = navController) + } + composable("category_detail/{categoryId}") { backStackEntry -> + + val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull() + + if (categoryId != null) { + CategoryDetailScreen( + categoryId = categoryId, + onBackClick = { vocabularyNavController.popBackStack() }, + onNavigateToItem = { item -> + navController.navigate("vocabulary_detail/${item.id}") + }, + navController = navController as NavHostController + ) + } + } + composable("vocabulary_exercise/{isSpelling}") { backStackEntry -> + backStackEntry.arguments?.getString("isSpelling")?.toBooleanStrict() ?: false + VocabularyExerciseHostScreen( + categoryIdsAsJson = null, + stageNamesAsJson = null, + languageIdsAsJson = null, + onClose = { navController.popBackStack() }, + navController = navController, + dailyOnlyAsJson = null + ) + } + composable("vocabulary_exercise/{dailyOnly}") { backStackEntry -> + backStackEntry.arguments?.getString("dailyOnly")?.toBooleanStrict() ?: false + VocabularyExerciseHostScreen( + categoryIdsAsJson = null, + stageNamesAsJson = null, + languageIdsAsJson = null, + onClose = { navController.popBackStack() }, + navController = navController, + dailyOnlyAsJson = "{\"dailyOnly\": true}" + ) + } + } + } + + var menuHeightPx by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val menuHeightDp = (menuHeightPx / density.density).dp + val animatedBottomPadding by animateDpAsState(targetValue = 16.dp + 8.dp + menuHeightDp, label = "fabBottomPadding") + + Column( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + horizontalAlignment = Alignment.End + ) { + VocabularyMenu(modifier = Modifier.onSizeChanged { menuHeightPx = it.height }) + } + + // Place the FAB separately and animate its bottom padding based on the menu height + FloatingActionButton( + onClick = { showCustomExerciseDialog = true }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = animatedBottomPadding) + ) { + Icon( + imageVector = AppIcons.Quiz, + contentDescription = stringResource(R.string.cd_start_exercise) + ) + } + } +} + +@Composable +fun StatisticsContent( + navController: NavController +) { + + AppOutlinedCard { + VocabularyListScreen( + categoryId = null, + showDueTodayOnly = false, + onNavigateToItem = { item -> + navController.navigate("vocabulary_detail/${item.id}") + }, + onNavigateBack = null, + navController = navController as NavHostController, + enableNavigationButtons = true + ) + } +} + +@ThemePreviews +@Composable +fun VocabularyDashboardScreenPreview() { + val navController = rememberNavController() + MainVocabularyScreen(navController = navController) +} + + +@ThemePreviews +@Composable +fun StatisticsContentPreview() { + val navController = rememberNavController() + StatisticsContent(navController = navController) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/NoGrammarItemsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/NoGrammarItemsScreen.kt new file mode 100644 index 0000000..bbaa3c3 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/NoGrammarItemsScreen.kt @@ -0,0 +1,213 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.vocabulary + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppSlider +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.launch + +@Composable +fun NoGrammarItemsScreen( + navController: NavController, + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(androidx.compose.ui.platform.LocalContext.current.applicationContext as android.app.Application) +) { + val itemsWithoutGrammar by vocabularyViewModel.allItemsWithoutGrammar.collectAsState() + val isGenerating by vocabularyViewModel.isGenerating.collectAsState() + + var showFetchGrammarDialog by remember { mutableStateOf(false) } + + @Suppress("UnusedVariable") val onClose = { navController.popBackStack() } + + if (itemsWithoutGrammar.isEmpty() && !isGenerating) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + AppTopAppBar( + title = { Text(stringResource(R.string.title_items_without_grammar)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + }, + ) + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.no_items_without_grammar), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } else { + // Use the generic VocabularyListScreen to display the items + VocabularyListScreen( + itemsToShow = itemsWithoutGrammar, + onNavigateToItem = { item -> + @Suppress("HardCodedStringLiteral") + navController.navigate("vocabulary_detail/${item.id}") + }, + onNavigateBack = { navController.popBackStack() } + ) + } + + Box(modifier = Modifier + .fillMaxSize() + .padding(bottom = 80.dp), contentAlignment = Alignment.BottomCenter) { + if (itemsWithoutGrammar.isNotEmpty() && !isGenerating) { + AppButton(onClick = { showFetchGrammarDialog = true }) { + Text(text = stringResource(R.string.fetch_all_grammar_infos)) + } + } + } + + if (showFetchGrammarDialog) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = { showFetchGrammarDialog = false }, + sheetState = sheetState + ) { + if (itemsWithoutGrammar.size <= 2) { + // Directly fetch all items without slider for 1 or 2 items + val itemsToFetch = itemsWithoutGrammar + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.fetching_for_d_items, itemsToFetch.size), + style = MaterialTheme.typography.titleMedium + ) + Spacer(Modifier.height(24.dp)) + AppButton( + onClick = { + scope.launch { + showFetchGrammarDialog = false + vocabularyViewModel.fetchAndApplyGrammaticalDetailsForList(itemsToFetch) + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isGenerating + ) { + if (isGenerating) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + Text(stringResource(R.string.start_fetching)) + } + } + } + } else { + // Use slider for more than 2 items + var sliderValue by remember { mutableFloatStateOf(1f) } + val itemsToFetch = itemsWithoutGrammar.take(sliderValue.toInt()) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.select_number_of_items_to_fetch), + style = MaterialTheme.typography.titleMedium + ) + Spacer(Modifier.height(16.dp)) + AppSlider( + value = sliderValue, + onValueChange = { sliderValue = it }, + valueRange = 1f..minOf(50f, itemsWithoutGrammar.size.toFloat()), + steps = minOf(50, itemsWithoutGrammar.size) - 2, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.fetching_for_d_items, sliderValue.toInt()), + style = MaterialTheme.typography.bodyMedium + ) + Spacer(Modifier.height(24.dp)) + AppButton( + onClick = { + scope.launch { + showFetchGrammarDialog = false + vocabularyViewModel.fetchAndApplyGrammaticalDetailsForList(itemsToFetch) + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isGenerating + ) { + if (isGenerating) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + Text(stringResource(R.string.start_fetching)) + } + } + } + } + } + } + + // A small indicator to show that the process is ongoing + if (isGenerating) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator() + Text(stringResource(R.string.fetching_grammar_details)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/NoVocabularyScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/NoVocabularyScreen.kt new file mode 100644 index 0000000..1100ac4 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/NoVocabularyScreen.kt @@ -0,0 +1,107 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.vocabulary + +import android.app.Application +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.LocalConnectionConfigured +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.dialogs.AddVocabularyDialog +import eu.gaudian.translator.view.dialogs.ImportVocabularyDialog +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.StatusViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel + +@Composable +fun NoVocabularyScreen(){ + + val application = LocalContext.current.applicationContext as Application + val statusViewModel = StatusViewModel.getInstance(application) + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val vocabularyViewModel = VocabularyViewModel.getInstance(application) + val categoryViewModel = CategoryViewModel.getInstance(application) + + + var showAddVocabularyDialog by remember { mutableStateOf(false) } + var showImportVocabularyDialog by remember { mutableStateOf(false) } + + val connectionConfigured = LocalConnectionConfigured.current + + + if (showAddVocabularyDialog) { + AddVocabularyDialog( + statusViewModel = statusViewModel, + languageViewModel = languageViewModel, + vocabularyViewModel = vocabularyViewModel, + onDismissRequest = { showAddVocabularyDialog = false } + ) + } + + if (showImportVocabularyDialog) { + ImportVocabularyDialog( + languageViewModel = languageViewModel, + categoryViewModel = categoryViewModel, + vocabularyViewModel = vocabularyViewModel, + onDismiss = { showImportVocabularyDialog = false } + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Image( + modifier = Modifier.size(200.dp), + painter = painterResource(id = R.drawable.ic_empty), + contentDescription = stringResource(id = R.string.text_vocab_empty) + ) + Spacer(modifier = Modifier.size(16.dp)) + Text( + text = stringResource(id = R.string.text_vocab_empty), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(16.dp)) + + AppButton(modifier = Modifier.fillMaxWidth(), onClick = { showAddVocabularyDialog = true }) { + Text(stringResource(R.string.label_add_vocabulary)) + } + if(connectionConfigured) { + AppButton(modifier = Modifier.fillMaxWidth(), onClick = { showImportVocabularyDialog = true }) { + Text(stringResource(R.string.label_create_vocabulary_with_ai)) + } + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/ResultScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ResultScreen.kt new file mode 100644 index 0000000..25b26da --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ResultScreen.kt @@ -0,0 +1,267 @@ +package eu.gaudian.translator.view.vocabulary + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton + +@Composable +fun ResultScreen( + score: Int, + wrongAnswers: Int, + totalItems: Int, + onRestart: () -> Unit, + onRetryWrong: (List) -> Unit, + onClose: () -> Unit +) { + val percentage = if (totalItems > 0) score.toFloat() / totalItems.toFloat() else 0f + + val animatedProgress by animateFloatAsState( + targetValue = percentage, + animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + label = "ResultProgressAnimation" + ) + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + if (wrongAnswers > 0) { + stringResource(R.string.text_repeat_wrong_guesses) + } else { + stringResource(R.string.result) + } + ) + }, + actions = { + IconButton(onClick = onClose) { + Icon(AppIcons.Close, contentDescription = stringResource(R.string.label_close)) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ) + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceAround + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.exercise_complete), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.here_s_how_you_did), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(220.dp)) { + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxSize(), + strokeWidth = 16.dp, + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + ) + Text( + text = "${(percentage * 100).toInt()}%", + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold + ) + } + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + ResultStatRow( + icon = AppIcons.Done, + label = stringResource(R.string.label_correct), + value = "$score / $totalItems", + color = MaterialTheme.colorScheme.primary + ) + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + ResultStatRow( + icon = AppIcons.Clear, + label = stringResource(R.string.label_wrong), + value = "$wrongAnswers", + color = MaterialTheme.colorScheme.error + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (wrongAnswers > 0) { + // Show retry button for wrong answers + AppOutlinedButton( + onClick = { + onRetryWrong(emptyList()) // ViewModel handles filtering + }, + modifier = Modifier + .weight(1f) + .height(56.dp) + ) { + Icon(AppIcons.Refresh, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.text_repeat_wrong)) + } + // Show restart button as secondary option + AppButton( + onClick = onRestart, + modifier = Modifier + .weight(1f) + .height(56.dp) + ) { + Text(stringResource(R.string.text_start_over)) + } + } else { + // Show regular try again and finish buttons when no wrong answers + AppOutlinedButton( + onClick = onRestart, + modifier = Modifier + .weight(1f) + .height(56.dp) + ) { + Icon(AppIcons.Refresh, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.try_again)) + } + AppButton( + onClick = onClose, + modifier = Modifier + .weight(1f) + .height(56.dp) + ) { + Text(stringResource(R.string.finish)) + } + } + } + } + } +} + +@Composable +private fun ResultStatRow(icon: ImageVector, label: String, value: String, color: Color) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp)) { + Icon( + imageVector = icon, + contentDescription = label, + tint = color, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = label, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f) + ) + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = color + ) + } +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "Result Screen - Perfect Score") +@Composable +fun ResultScreenPerfectPreview() { + + ResultScreen( + score = 3, + wrongAnswers = 0, + totalItems = 3, + onRestart = {}, + onRetryWrong = {}, + onClose = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "Result Screen - Some Wrong Answers") +@Composable +fun ResultScreenSomeWrongPreview() { + + + ResultScreen( + score = 3, + wrongAnswers = 2, + totalItems = 5, + onRestart = {}, + onRetryWrong = {}, + onClose = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "Result Screen - All Wrong") +@Composable +fun ResultScreenAllWrongPreview() { + + + ResultScreen( + score = 0, + wrongAnswers = 2, + totalItems = 2, + onRestart = {}, + onRetryWrong = {}, + onClose = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/StageDetailScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/StageDetailScreen.kt new file mode 100644 index 0000000..fb3db6f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/StageDetailScreen.kt @@ -0,0 +1,76 @@ +package eu.gaudian.translator.view.vocabulary + +import android.app.Application +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.vocabulary.widgets.DetailedStageProgressBar +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import okhttp3.internal.platform.PlatformRegistry.applicationContext + +@Composable +fun StageDetailScreen( + navController: NavController, + stage: VocabularyStage, + viewModel: VocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application), +) { + val stageMapping by viewModel.stageMapping.collectAsState() + val dueTodayItems by viewModel.dueTodayItems.collectAsState() + + val itemsInStage = stageMapping.filter { it.value == stage }.keys + val dueTodayInStage = dueTodayItems.filter { stageMapping[it.id] == stage } + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(text = stringResource(R.string.due_today_, stage.toString())) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + AppIcons.ArrowBack, + contentDescription =stringResource(R.string.cd_back) + ) + } + } + ) + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + DetailedStageProgressBar( + stage = stage, + itemCount = dueTodayInStage.size, + totalItems = itemsInStage.size, + onStageTapped = {}, + ) + + VocabularyListScreen( + categoryId = null, + showDueTodayOnly = true, + stage = stage, + onNavigateToItem = { item -> + @Suppress("HardCodedStringLiteral") + navController.navigate( + "vocabulary_card/${item.id}/false/false/false" + ) + }, + onNavigateBack = { navController.popBackStack() }, + navController = navController as NavHostController, + enableNavigationButtons = true + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/StartScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/StartScreen.kt new file mode 100644 index 0000000..a3d7f40 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/StartScreen.kt @@ -0,0 +1,470 @@ +package eu.gaudian.translator.view.vocabulary + +import android.app.Application +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.CardSet +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppOutlinedButton +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppSlider +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.OptionItemSwitch +import eu.gaudian.translator.view.composable.SingleLanguageDropDown +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel + +@Composable +fun StartScreen( + cardSet: CardSet?, + onStartClicked: (List) -> Unit, + onClose: () -> Unit, + shuffleCards: Boolean, + onShuffleCardsChanged: (Boolean) -> Unit, + shuffleLanguages: Boolean, + onShuffleLanguagesChanged: (Boolean) -> Unit, + trainingMode: Boolean, + onTrainingModeChanged: (Boolean) -> Unit, + dueTodayOnly: Boolean, + onDueTodayOnlyChanged: (Boolean) -> Unit, + selectedExerciseTypes: Set, + onExerciseTypeSelected: (VocabularyExerciseType) -> Unit, + hideTodayOnlySwitch: Boolean = false, + selectedOriginLanguage: Language?, + onOriginLanguageChanged: (Language?) -> Unit, + selectedTargetLanguage: Language?, + onTargetLanguageChanged: (Language?) -> Unit, +) { + val vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance( + LocalContext.current.applicationContext as Application + ) + val dueTodayItems by vocabularyViewModel.dueTodayItems.collectAsState(initial = emptyList()) + val allItems = cardSet?.cards ?: emptyList() + + var amount by remember(allItems) { mutableIntStateOf(allItems.size) } + + val itemsToShow = if (dueTodayOnly) { + allItems.filter { card -> dueTodayItems.any { it.id == card.id } } + } else { + allItems + } + + if (amount > itemsToShow.size) { + amount = itemsToShow.size + } + + StartScreenContent( + vocabularyItemsCount = itemsToShow.size, + shuffleCards = shuffleCards, + onShuffleCardsChanged = onShuffleCardsChanged, + shuffleLanguages = shuffleLanguages, + onShuffleLanguagesChanged = onShuffleLanguagesChanged, + trainingMode = trainingMode, + onTrainingModeChanged = onTrainingModeChanged, + dueTodayOnly = dueTodayOnly, + onDueTodayOnlyChanged = onDueTodayOnlyChanged, + amount = amount, + onAmountChanged = { + @Suppress("AssignedValueIsNeverRead") + amount = it + }, + onStartClicked = { + val finalItems = if (shuffleCards) { + itemsToShow.shuffled().take(amount) + } else { + itemsToShow.take(amount) + } + onStartClicked(finalItems) + }, + onClose = onClose, + selectedExerciseTypes = selectedExerciseTypes, + onExerciseTypeSelected = onExerciseTypeSelected, + hideTodayOnlySwitch = hideTodayOnlySwitch, + selectedOriginLanguage = selectedOriginLanguage, + onOriginLanguageChanged = onOriginLanguageChanged, + selectedTargetLanguage = selectedTargetLanguage, + onTargetLanguageChanged = onTargetLanguageChanged, + allItems = allItems + ) +} + +@Composable +private fun StartScreenContent( + vocabularyItemsCount: Int, + shuffleCards: Boolean, + onShuffleCardsChanged: (Boolean) -> Unit, + shuffleLanguages: Boolean, + onShuffleLanguagesChanged: (Boolean) -> Unit, + trainingMode: Boolean, + onTrainingModeChanged: (Boolean) -> Unit, + dueTodayOnly: Boolean, + onDueTodayOnlyChanged: (Boolean) -> Unit, + amount: Int, + onAmountChanged: (Int) -> Unit, + onStartClicked: () -> Unit, + onClose: () -> Unit, + selectedExerciseTypes: Set, + onExerciseTypeSelected: (VocabularyExerciseType) -> Unit, + hideTodayOnlySwitch: Boolean = false, + selectedOriginLanguage: Language?, + onOriginLanguageChanged: (Language?) -> Unit, + selectedTargetLanguage: Language?, + onTargetLanguageChanged: (Language?) -> Unit, + allItems: List, +) { + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.prepare_exercise)) }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + AppIcons.Close, + contentDescription = stringResource(R.string.label_close) + ) + } + } + ) + }, + + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Box( + modifier = Modifier.weight(1f) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(16.dp)) + if (vocabularyItemsCount > 0) { + Text(stringResource(R.string.number_of_cards, amount, vocabularyItemsCount)) + AppSlider( + value = amount.toFloat(), + onValueChange = { onAmountChanged(it.toInt()) }, + valueRange = 1f..vocabularyItemsCount.toFloat(), + steps = if (vocabularyItemsCount > 1) vocabularyItemsCount - 2 else 0 + ) + + // Quick selection buttons + val quickSelectValues = listOf(10, 25, 50, 100) + val availableValues = + quickSelectValues.filter { it <= vocabularyItemsCount } + + if (availableValues.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + 8.dp, + Alignment.CenterHorizontally + ) + ) { + availableValues.forEach { value -> + AppOutlinedButton( + onClick = { onAmountChanged(value) }, + modifier = Modifier.weight(1f), + enabled = value <= vocabularyItemsCount + ) { + Text(value.toString()) + } + } + } + } + } else { + Text( + stringResource(R.string.no_cards_found_for_the_selected_filters), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 24.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) + + +// Language Selection Section + Text( + stringResource(R.string.label_language_direction), + style = MaterialTheme.typography.titleLarge + ) + + Text( + stringResource(R.string.text_language_direction_explanation), + style = MaterialTheme.typography.bodyMedium + ) + Spacer(Modifier.height(16.dp)) + + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + +// Get available languages from the card set + val availableLanguages = remember(allItems) { + allItems.flatMap { listOfNotNull(it.languageFirstId, it.languageSecondId) } + .distinct() + .mapNotNull { languageId -> + languageViewModel.allLanguages.value.find { it.nameResId == languageId } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Origin Language Dropdown + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.label_origin_language), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + SingleLanguageDropDown( + modifier = Modifier.fillMaxWidth(), + languageViewModel = languageViewModel, + selectedLanguage = selectedOriginLanguage, + onLanguageSelected = { language -> + onOriginLanguageChanged(language) + // Clear target language if it's the same as origin + if (selectedTargetLanguage?.nameResId == language.nameResId) { + onTargetLanguageChanged(null) + } + }, + showNoneOption = true, + alternateLanguages = availableLanguages + ) + } + + // Target Language Dropdown + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.label_target_language), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + SingleLanguageDropDown( + modifier = Modifier.fillMaxWidth(), + languageViewModel = languageViewModel, + selectedLanguage = selectedTargetLanguage, + onLanguageSelected = { language -> + onTargetLanguageChanged(language) + // Clear origin language if it's the same as target + if (selectedOriginLanguage?.nameResId == language.nameResId) { + onOriginLanguageChanged(null) + } + }, + alternateLanguages = availableLanguages, + showNoneOption = true, + ) + } + } + Spacer(Modifier.height(16.dp)) + HorizontalDivider( + modifier = Modifier.padding(vertical = 24.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) + + Text( + stringResource(R.string.label_choose_exercise_types), + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.height(16.dp)) + ExerciseTypeSelector( + selectedTypes = selectedExerciseTypes, + onTypeSelected = onExerciseTypeSelected + ) + HorizontalDivider( + modifier = Modifier.padding(vertical = 24.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) + + Text( + stringResource(R.string.options), + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.height(16.dp)) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OptionItemSwitch( + title = stringResource(R.string.shuffle_cards), + description = stringResource(R.string.text_shuffle_card_order_description), + checked = shuffleCards, + onCheckedChange = onShuffleCardsChanged + ) + OptionItemSwitch( + title = stringResource(R.string.text_shuffle_languages), + description = stringResource(R.string.text_shuffle_languages_description), + checked = shuffleLanguages, + onCheckedChange = onShuffleLanguagesChanged + ) + OptionItemSwitch( + title = stringResource(R.string.label_training_mode), + description = stringResource(R.string.text_training_mode_description), + checked = trainingMode, + onCheckedChange = onTrainingModeChanged + ) + if (!hideTodayOnlySwitch) { + OptionItemSwitch( + title = stringResource(R.string.text_due_today_only), + description = stringResource(R.string.text_due_today_only_description), + checked = dueTodayOnly, + onCheckedChange = onDueTodayOnlyChanged + ) + } + } + Spacer(Modifier.height(16.dp)) + } + } + AppButton( + onClick = onStartClicked, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .height(50.dp), + enabled = vocabularyItemsCount > 0 && amount > 0 + ) { + Text(stringResource(R.string.label_start_exercise_2d, amount)) + } + } + + } + + +} + +@Composable +private fun ExerciseTypeSelector( + selectedTypes: Set, + onTypeSelected: (VocabularyExerciseType) -> Unit, +) { + // Using FlowRow for a more flexible layout that wraps to the next line if needed + @OptIn(ExperimentalLayoutApi::class) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + ExerciseTypeCard( + icon = AppIcons.Guessing, + isSelected = VocabularyExerciseType.GUESSING in selectedTypes, + onClick = { onTypeSelected(VocabularyExerciseType.GUESSING) }, + text = stringResource(R.string.label_guessing_exercise), + ) + ExerciseTypeCard( + icon = AppIcons.SpellCheck, + isSelected = VocabularyExerciseType.SPELLING in selectedTypes, + onClick = { onTypeSelected(VocabularyExerciseType.SPELLING) }, + text = stringResource(R.string.label_spelling_exercise), + ) + ExerciseTypeCard( + icon = AppIcons.CheckList, + isSelected = VocabularyExerciseType.MULTIPLE_CHOICE in selectedTypes, + onClick = { onTypeSelected(VocabularyExerciseType.MULTIPLE_CHOICE) }, + text = stringResource(R.string.label_multiple_choice_exercise), + ) + ExerciseTypeCard( + icon = AppIcons.Extension, + isSelected = VocabularyExerciseType.WORD_JUMBLE in selectedTypes, + onClick = { onTypeSelected(VocabularyExerciseType.WORD_JUMBLE) }, + text = stringResource(R.string.label_word_jumble_exercise), + ) + } +} + +@Composable +private fun ExerciseTypeCard( + text: String, + icon: ImageVector, + isSelected: Boolean, + onClick: () -> Unit, +) { + val borderColor by animateColorAsState( + targetValue = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy( + alpha = 0.5f + ), + label = "borderColorAnimation", + animationSpec = tween(300) + ) + val containerColor by animateColorAsState( + targetValue = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + animationSpec = tween(300) + ) + + Card( + onClick = onClick, + modifier = Modifier.size(width = 120.dp, height = 100.dp), // Made the cards smaller + shape = RoundedCornerShape(12.dp), + border = BorderStroke(2.dp, borderColor), + colors = CardDefaults.cardColors(containerColor = containerColor), + elevation = CardDefaults.cardElevation(if (isSelected) 4.dp else 1.dp) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp)) // Smaller icon + Spacer(Modifier.height(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, // Smaller text + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/StartScreenContent.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/StartScreenContent.kt new file mode 100644 index 0000000..a629929 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/StartScreenContent.kt @@ -0,0 +1,148 @@ +package eu.gaudian.translator.view.vocabulary + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppSlider +import eu.gaudian.translator.view.composable.OptionItemSwitch + +@Composable +fun StartScreenContent( + vocabularyItemsCount: Int, + shuffleCards: Boolean, + shuffleLanguages: Boolean, + trainingMode: Boolean, + dueTodayOnly: Boolean, + onShuffleCardsChanged: (Boolean) -> Unit, + onShuffleLanguagesChanged: (Boolean) -> Unit, + onTrainingModeChanged: (Boolean) -> Unit, + onDueTodayOnlyChanged: (Boolean) -> Unit, + onAmountChanged: (Int) -> Unit, + onStartClicked: () -> Unit, + onClose: () -> Unit +) { + var amount by remember { mutableIntStateOf(vocabularyItemsCount) } + + LaunchedEffect(vocabularyItemsCount) { + amount = vocabularyItemsCount + onAmountChanged(amount) + } + + Column(modifier = Modifier.padding(16.dp)) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp) + ) { + IconButton( + onClick = onClose, + modifier = Modifier + .size(28.dp) + .align(Alignment.CenterStart) + ) { + Icon( + imageVector = AppIcons.Close, + contentDescription = stringResource(R.string.label_close_exercise), + modifier = Modifier.size(36.dp) + ) + } + Text( + text = stringResource(R.string.label_start_exercise), + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Text("${stringResource(id = R.string.text_amount_of_cards)}: $amount", color = MaterialTheme.colorScheme.onSurface, + ) + AppSlider( + value = amount.toFloat(), + onValueChange = { + amount = it.toInt() + onAmountChanged(amount) + }, + valueRange = 1f..vocabularyItemsCount.toFloat(), + steps = if (vocabularyItemsCount > 1) vocabularyItemsCount - 2 else 0, + ) + Spacer(Modifier.height(16.dp)) + OptionItemSwitch( + title = stringResource(id = R.string.text_shuffle_card_order), + checked = shuffleCards, + onCheckedChange = onShuffleCardsChanged + ) + Spacer(Modifier.height(16.dp)) + + OptionItemSwitch( + title = stringResource(id = R.string.text_shuffle_languages), + checked = shuffleLanguages, + onCheckedChange = onShuffleLanguagesChanged + ) + Spacer(Modifier.height(16.dp)) + + OptionItemSwitch( + title = stringResource(id = R.string.text_training_mode), + description = stringResource(R.string.this_mode_will_not_affect_your_progress_in_stages), + checked = trainingMode, + onCheckedChange = onTrainingModeChanged + ) + Spacer(Modifier.height(16.dp)) + OptionItemSwitch( + title = stringResource(id = R.string.text_due_today_only), + checked = dueTodayOnly, + onCheckedChange = onDueTodayOnlyChanged + ) + Spacer(Modifier.height(16.dp)) + + AppButton ( + onClick = onStartClicked, + modifier = Modifier + .padding(top = 16.dp) + .align(Alignment.CenterHorizontally) + .defaultMinSize(minHeight = 48.dp), + ) { + Text(stringResource(id = R.string.label_start)) + } + } +} + +@ThemePreviews +@Composable +fun StartScreenContentPreview() { + StartScreenContent( + vocabularyItemsCount = 10, + shuffleCards = false, + shuffleLanguages = false, + trainingMode = false, + dueTodayOnly = false, + onShuffleCardsChanged = {}, + onShuffleLanguagesChanged = {}, + onTrainingModeChanged = {}, + onDueTodayOnlyChanged = {}, + onAmountChanged = {}, + onStartClicked = {}, + onClose = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyCardHost.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyCardHost.kt new file mode 100644 index 0000000..b7fa644 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyCardHost.kt @@ -0,0 +1,370 @@ +package eu.gaudian.translator.view.vocabulary + +import android.app.Application +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppAlertDialog +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.dialogs.CategorySelectionDialog +import eu.gaudian.translator.view.dialogs.ImportVocabularyDialog +import eu.gaudian.translator.view.dialogs.StageSelectionDialog +import eu.gaudian.translator.view.vocabulary.card.VocabularyCard +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.launch +import kotlin.time.ExperimentalTime + +@Composable +fun VocabularyCardHost( + navController: NavController, + itemId: Int, + exerciseMode: Boolean = false, + spellingMode: Boolean = false, + switchOrder: Boolean? = false, + onAnswerSelected: ((Boolean) -> Unit)? = null, + onBackPressed: (() -> Unit)? = null +) { + val activity = LocalContext.current.findActivity() + val vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(LocalContext.current.applicationContext as Application) + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val categoryViewModel: CategoryViewModel = viewModel() + val scope = rememberCoroutineScope() + + val navigationItems by vocabularyViewModel.currentNavigationItems.collectAsState() + val navigationPosition by vocabularyViewModel.currentNavigationPosition.collectAsState() + + var vocabularyItem by remember { mutableStateOf(null) } + + LaunchedEffect(itemId) { + vocabularyItem = vocabularyViewModel.getVocabularyItemById(itemId) + } + + AppScaffold( + topBar = { + AppTopAppBar( + modifier = Modifier.height(56.dp), + title = { + if (navigationItems.isNotEmpty()) { + Text(stringResource(R.string.label_card_with_position, navigationPosition + 1, navigationItems.size)) + } else { + Text(stringResource(R.string.item_details)) + } + }, + navigationIcon = { + IconButton(onClick = { onBackPressed?.invoke() }) { + Icon( + AppIcons.ArrowBack, + contentDescription = stringResource(R.string.cd_back) + ) + } + }, + actions = { + // Previous button + if (navigationPosition > 0) { + IconButton(onClick = { + if (vocabularyViewModel.navigateToPreviousItem()) { + val prevItem = navigationItems[navigationPosition - 1] + scope.launch { + + @Suppress("AssignedValueIsNeverRead") + vocabularyItem = vocabularyViewModel.getVocabularyItemById(prevItem.id) + } + } + }) { + Icon( + AppIcons.ArrowLeft, + contentDescription = stringResource(R.string.previous_item), + tint = MaterialTheme.colorScheme.primary + ) + } + } + + // Next button + if (navigationPosition < navigationItems.size - 1) { + IconButton(onClick = { + if (vocabularyViewModel.navigateToNextItem()) { + val nextItem = navigationItems[navigationPosition + 1] + scope.launch { + @Suppress("AssignedValueIsNeverRead") + vocabularyItem = vocabularyViewModel.getVocabularyItemById(nextItem.id) + } + } + }) { + Icon( + AppIcons.ArrowRight, + contentDescription = stringResource(R.string.next_item), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + ) + } + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + if (vocabularyItem == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + val currentVocabularyItem = vocabularyItem!! + + var isFlipped by remember { mutableStateOf(false) } + var showQuitDialog by remember { mutableStateOf(false) } + var showStatisticsDialog by remember { mutableStateOf(false) } + var showCategoryDialog by remember { mutableStateOf(false) } + var showStageDialog by remember { mutableStateOf(false) } + var showImportDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + + val lifecycleOwner = LocalLifecycleOwner.current + val stats by vocabularyViewModel + .getVocabularyItemDetailsFlow(currentVocabularyItem.id) + .collectAsStateWithLifecycle( + initialValue = null, + lifecycle = lifecycleOwner.lifecycle + ) + + if (exerciseMode && onBackPressed != null) { + //TODO consider BackPressHandler(onBackPressed = { showQuitDialog = true }) + } + + if(showDeleteDialog){ + AppAlertDialog( + title = { + Text(stringResource(R.string.text_delete_vocabulary_item)) + }, + text = { + Text(stringResource(R.string.text_are_you_sure_delete_vocabulary_item)) + }, + confirmButton = { + TextButton( + onClick = { + vocabularyViewModel.deleteData( + VocabularyViewModel.DeleteType.VOCABULARY_ITEM_BY_ID, + currentVocabularyItem.id + ) + if (exerciseMode) { + onAnswerSelected?.invoke(false) + } else { + navController.popBackStack() + } + } + ) { + Text(text = stringResource(R.string.label_delete)) + } + }, + dismissButton = @Composable { + TextButton( + onClick = { showDeleteDialog = false }, + ) { + Text(text = stringResource(R.string.label_cancel)) + } + }, + onDismissRequest = { showDeleteDialog = false }, + ) + } + + // Main content + VocabularyCard( + vocabularyItem = currentVocabularyItem, + exerciseMode = exerciseMode, + switchOrder = switchOrder == true, + isFlipped = isFlipped, + onStatisticsClick = { showStatisticsDialog = true }, + onMoveToCategoryClick = { showCategoryDialog = true }, + onMoveToStageClick = { showStageDialog = true }, + onDeleteClick = { showDeleteDialog = true }, + navController = navController, + isUserSpellingCorrect = false, + vocabularyViewModel = vocabularyViewModel + ) + + // Dialogs are unaffected by the layout change + if (showQuitDialog) { + QuitDialog( + onConfirm = { + onBackPressed?.invoke() + showQuitDialog = false + }, + onDismiss = { showQuitDialog = false } + ) + } + + if (showStatisticsDialog) { + StatisticsDialog( + vocabularyItem = currentVocabularyItem, + stats = stats, + onDismiss = { showStatisticsDialog = false } + ) + } + + if (showCategoryDialog) { + CategorySelectionDialog( + viewModel = categoryViewModel, + onCategorySelected = { + vocabularyViewModel.addVocabularyItemToCategories( + listOf(currentVocabularyItem), + it.mapNotNull { category -> category?.id } + ) + showCategoryDialog = false + }, + onDismissRequest = { showCategoryDialog = false } + ) + } + + if (showStageDialog) { + StageSelectionDialog( + onStageSelected = { stage -> + stage?.let { + vocabularyViewModel.addVocabularyItemToStage( + listOf(currentVocabularyItem), + it + ) + } + showStageDialog = false + }, + onDismissRequest = { showStageDialog = false } + ) + } + + if (showImportDialog) { + ImportVocabularyDialog( + onDismiss = { showImportDialog = false }, + languageViewModel = languageViewModel, + optionalDescription = stringResource(R.string.generate_related_vocabulary_items), + optionalSearchTerm = currentVocabularyItem.wordFirst, + categoryViewModel = categoryViewModel, + vocabularyViewModel = vocabularyViewModel + ) + } + + LaunchedEffect(spellingMode) { + if (spellingMode) { + //TODO implement // Setup spelling mode logic if needed + } + } + } + } + } +} + + +@Composable +private fun QuitDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AppAlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.quit)) }, + text = { Text(text = stringResource(R.string.text_are_you_sure_you_want_to_quit)) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(R.string.label_yes)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.no)) } } + ) +} + +@OptIn(ExperimentalTime::class) +@Composable +private fun StatisticsDialog( + vocabularyItem: VocabularyItem, + stats: VocabularyViewModel.VocabularyItemDetails?, + onDismiss: () -> Unit +) { + AppDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.label_statistics)) }, + content = { + if (stats != null) { + Column { + Text(stringResource(R.string.stage__, stats.stage.toString(context = LocalContext.current))) + Text(stringResource(R.string.last_correct, stats.lastCorrectAnswer ?: "-")) + Text(stringResource(R.string.last_incorrect, stats.lastIncorrectAnswer ?: "-")) + Text(stringResource(R.string.correct_answers_, stats.correctAnswerCount)) + Text(stringResource(R.string.incorrect_answers, stats.incorrectAnswerCount)) + Text(stringResource(R.string.item_id, vocabularyItem.id)) + vocabularyItem.languageFirstId?.let { Text(stringResource(R.string.languagefirst_id, it)) } + vocabularyItem.languageSecondId?.let { Text(stringResource(R.string.languagesecond_id, it)) } + } + } else { + Text(stringResource(R.string.statistics_are_loading)) + } + }, + ) +} + +@ThemePreviews +@Composable +fun VocabularyCardHostPreview() { + val navController = NavController(LocalContext.current) + VocabularyCardHost(navController = navController, itemId = 1) +} + +@ThemePreviews +@Composable +fun QuitDialogPreview() { + QuitDialog(onConfirm = {}, onDismiss = {}) +} + +@ThemePreviews +@Composable +fun VocabularyCardHostWithNavigationPreview() { + val navController = NavController(LocalContext.current) + VocabularyCardHost( + navController = navController, + itemId = 1, + onBackPressed = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@OptIn(ExperimentalTime::class) +@ThemePreviews +@Composable +fun StatisticsDialogPreview() { + val vocabularyItem = VocabularyItem(id = 1, languageFirstId = 101, languageSecondId = 102, wordFirst = "Hello", wordSecond = "Hola") + val stats = VocabularyViewModel.VocabularyItemDetails( + stage = VocabularyStage.NEW, + lastCorrectAnswer = null, + lastIncorrectAnswer = null, + correctAnswerCount = 5, + incorrectAnswerCount = 2 + ) + StatisticsDialog(vocabularyItem = vocabularyItem, stats = stats, onDismiss = {}) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyExercise.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyExercise.kt new file mode 100644 index 0000000..2c52196 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyExercise.kt @@ -0,0 +1,516 @@ +package eu.gaudian.translator.view.vocabulary + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.ui.theme.semanticColors +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.ComponentDefaults +import eu.gaudian.translator.view.vocabulary.card.VocabularyCard +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel + +/** + * Represents the different types of exercises available. + * This enum can be expanded with new exercise types. + */ +enum class VocabularyExerciseType { + GUESSING, + SPELLING, + MULTIPLE_CHOICE, + WORD_JUMBLE +} + +/** + * A sealed interface to represent the state of an exercise. + * Each exercise type will have its own state implementation. + */ +sealed interface VocabularyExerciseState { + val item: VocabularyItem + val isRevealed: Boolean + val isCorrect: Boolean? + val isSwitched: Boolean + + data class Guessing( + override val item: VocabularyItem, + override val isRevealed: Boolean = false, + override val isCorrect: Boolean? = null, + override val isSwitched: Boolean = false, + ) : VocabularyExerciseState + + data class Spelling( + override val item: VocabularyItem, + val userAnswer: String = "", + override val isRevealed: Boolean = false, + override val isCorrect: Boolean? = null, + override val isSwitched: Boolean = false, + ) : VocabularyExerciseState + + data class MultipleChoice( + override val item: VocabularyItem, + val options: List, + val selectedAnswer: String? = null, + override val isRevealed: Boolean = false, + override val isCorrect: Boolean? = null, + override val isSwitched: Boolean = false, + ) : VocabularyExerciseState + + data class WordJumble( + override val item: VocabularyItem, + val jumbledLetters: List>, // Pair of Char and a unique ID for stability + val assembledWord: List> = emptyList(), + override val isRevealed: Boolean = false, + override val isCorrect: Boolean? = null, + override val isSwitched: Boolean = false, + ) : VocabularyExerciseState +} + +/** + * A generic renderer for exercises. It takes an ExerciseState + * and renders the appropriate UI for that state. + */ +@Composable +fun VocabularyExerciseRenderer( + state: VocabularyExerciseState, + onAction: (VocabularyExerciseAction) -> Unit, + navController: NavController +) { + when (state) { + is VocabularyExerciseState.Guessing -> GuessingExercise(state, navController) + is VocabularyExerciseState.Spelling -> SpellingExercise(state, navController) + is VocabularyExerciseState.MultipleChoice -> MultipleChoiceExercise(state, onAction) + is VocabularyExerciseState.WordJumble -> WordJumbleExercise(state, onAction) + } +} + + + + + +sealed interface VocabularyExerciseAction { + data object Reveal : VocabularyExerciseAction + data class Submit(val answer: Any) : VocabularyExerciseAction + data object Next : VocabularyExerciseAction + data class UpdateWordJumble(val assembledWord: List>) : VocabularyExerciseAction +} + +@Composable +fun GuessingExercise( + state: VocabularyExerciseState.Guessing, + navController: NavController, + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(LocalContext.current.applicationContext as android.app.Application) +) { + VocabularyCard( + vocabularyItem = state.item, + isFlipped = state.isRevealed, + navController = navController, + exerciseMode = true, + switchOrder = state.isSwitched, + vocabularyViewModel = vocabularyViewModel + ) +} + + + +@Composable +fun SpellingExercise( + state: VocabularyExerciseState.Spelling, + navController: NavController, + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(LocalContext.current.applicationContext as android.app.Application) +) { + VocabularyCard( + vocabularyItem = state.item, + isFlipped = state.isRevealed, + userSpellingAnswer = state.userAnswer, + isUserSpellingCorrect = state.isCorrect, + navController = navController, + exerciseMode = true, + switchOrder = state.isSwitched, + vocabularyViewModel = vocabularyViewModel + ) +} + + + +@Composable +fun MultipleChoiceExercise( + state: VocabularyExerciseState.MultipleChoice, + onAction: (VocabularyExerciseAction) -> Unit +) { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + var languageFirst by remember { mutableStateOf(null) } + var languageSecond by remember { mutableStateOf(null) } + + LaunchedEffect(state.item.languageFirstId, state.item.languageSecondId) { + languageFirst = languageViewModel.getLanguageById(state.item.languageFirstId ?: 0) + languageSecond = languageViewModel.getLanguageById(state.item.languageSecondId ?: 0) + } + + val correctAnswer = if (state.isSwitched) state.item.wordFirst else state.item.wordSecond + val promptLanguage = if (state.isSwitched) languageSecond else languageFirst + val targetLanguage = if (state.isSwitched) languageFirst else languageSecond + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.label_translate_from_2d, promptLanguage?.name ?: ""), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = if (state.isSwitched) state.item.wordSecond else state.item.wordFirst, + style = MaterialTheme.typography.displaySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.to_d, targetLanguage?.name ?: ""), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + state.options.forEach { option -> + val isSelected = state.selectedAnswer == option + val isCorrectOption = option == correctAnswer + + val buttonColors = when { + // After reveal: show correct as filled CorrectButton, wrong selected as filled WrongButton + state.isRevealed && isCorrectOption -> ButtonDefaults.buttonColors( + containerColor = MaterialTheme.semanticColors.success, + contentColor = MaterialTheme.semanticColors.onSuccess + ) + state.isRevealed && isSelected && !isCorrectOption -> ButtonDefaults.buttonColors( + containerColor = MaterialTheme.semanticColors.wrong, + contentColor = MaterialTheme.semanticColors.onWrong + ) + // Before reveal: keep neutral outlined look until selection + !state.isRevealed && isSelected && isCorrectOption -> ButtonDefaults.buttonColors( + containerColor = MaterialTheme.semanticColors.success.copy(alpha = 0.85f), + contentColor = MaterialTheme.semanticColors.onSuccess + ) + !state.isRevealed && isSelected && !isCorrectOption -> ButtonDefaults.buttonColors( + containerColor = MaterialTheme.semanticColors.wrong.copy(alpha = 0.85f), + contentColor = MaterialTheme.semanticColors.onWrong + ) + else -> ButtonDefaults.outlinedButtonColors() + } + + val borderColor = when { + state.isRevealed -> Color.Transparent + isSelected && !isCorrectOption -> MaterialTheme.colorScheme.error + isSelected && isCorrectOption -> MaterialTheme.semanticColors.success + else -> MaterialTheme.colorScheme.outline + } + + AppButton( + onClick = { if (!state.isRevealed) onAction(VocabularyExerciseAction.Submit(option)) }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = buttonColors, + border = BorderStroke(1.dp, borderColor) + ) { + val textColor = when { + state.isRevealed && isCorrectOption -> MaterialTheme.semanticColors.onSuccess + state.isRevealed && isSelected && !isCorrectOption -> MaterialTheme.semanticColors.onWrong + !state.isRevealed && isSelected && isCorrectOption -> MaterialTheme.semanticColors.onSuccess + !state.isRevealed && isSelected && !isCorrectOption -> MaterialTheme.semanticColors.onWrong + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Text(option, color = textColor) + } + } + + // Optional textual feedback when an option was selected + if (state.selectedAnswer != null && !state.isRevealed) { + val wasCorrect = state.selectedAnswer == correctAnswer + val msg = if (wasCorrect) stringResource(R.string.text_correct_em) else stringResource(R.string.label_wrong) + val color = if (wasCorrect) MaterialTheme.semanticColors.success else MaterialTheme.colorScheme.error + Text(text = msg, color = color, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun MultipleChoiceExercisePreview() { + val vocabularyItem = VocabularyItem( + id = 1, + languageFirstId = 1, + languageSecondId = 2, + wordFirst = "Hello", + wordSecond = "Hola" + ) + val state = VocabularyExerciseState.MultipleChoice( + item = vocabularyItem, + options = listOf("Hola", "Bonjour", "Hallo", "Ciao"), + selectedAnswer = "Hola", + isRevealed = true, + isCorrect = true, + isSwitched = false + ) + MultipleChoiceExercise(state = state, onAction = {}) +} + + + +@Composable +fun WordJumbleExercise( + state: VocabularyExerciseState.WordJumble, + onAction: (VocabularyExerciseAction) -> Unit +) { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + var languageFirst by remember { mutableStateOf(null) } + var languageSecond by remember { mutableStateOf(null) } + + LaunchedEffect(state.item.languageFirstId, state.item.languageSecondId) { + languageFirst = languageViewModel.getLanguageById(state.item.languageFirstId ?: 0) + languageSecond = languageViewModel.getLanguageById(state.item.languageSecondId ?: 0) + } + + val availableLetters = remember(state.jumbledLetters, state.assembledWord) { + state.jumbledLetters.filterNot { it in state.assembledWord } + } + val correctAnswer = if (state.isSwitched) state.item.wordFirst else state.item.wordSecond + + // NEW: Group available letters by character to get counts + val groupedAvailableLetters = remember(availableLetters) { + availableLetters + .groupBy { it.first } // Groups into a Map>> + .map { (char, instances) -> + // Triple stores: 1. The character, 2. Its count, 3. The ID of the first instance for a stable key + Triple(char, instances.size, instances.first().second) + } + } + + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val promptLanguage = if (state.isSwitched) languageSecond else languageFirst + val targetLanguage = if (state.isSwitched) languageFirst else languageSecond + + Text( + text = stringResource(R.string.label_translate_from_2d, promptLanguage?.name ?: ""), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = if (state.isSwitched) state.item.wordSecond else state.item.wordFirst, + style = MaterialTheme.typography.displaySmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = stringResource(R.string.to_d, targetLanguage?.name ?: ""), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 60.dp) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)) + .padding(8.dp), + horizontalArrangement = if (state.assembledWord.isEmpty()) Arrangement.Center else Arrangement.spacedBy(8.dp), + verticalArrangement = if (state.assembledWord.isEmpty()) Arrangement.Center else Arrangement.spacedBy(8.dp) + ) { + if (state.assembledWord.isEmpty()) { + Text(stringResource(R.string.text_assemble_the_word_here), color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + state.assembledWord.forEach { (char, id) -> + LetterPill(char = char, isDraggable = true, onClick = { + val newAssembled = state.assembledWord.toMutableList().apply { remove(Pair(char, id)) } + onAction(VocabularyExerciseAction.UpdateWordJumble(newAssembled)) + }) + } + } + } + + Spacer(Modifier.height(8.dp)) + if (state.isRevealed) { + val color = if (state.isCorrect == true) Color(0xFF4CAF50) else MaterialTheme.colorScheme.error + Text(if (state.isCorrect == true) stringResource(R.string.text_correct_em) else stringResource( + R.string.correct_answer, correctAnswer + ), color = color) + } else { + Surface(shadowElevation = ComponentDefaults.DefaultElevation) { + AppButton( + onClick = { + val assembledString = state.assembledWord.map { it.first }.joinToString("") + onAction(VocabularyExerciseAction.Submit(assembledString)) + }, + enabled = state.assembledWord.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .height(50.dp) + ) { + Text(stringResource(R.string.text_check), fontWeight = FontWeight.Bold) + } + } + } + + Spacer(Modifier.height(24.dp)) + + // Available letters - UPDATED LOGIC + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + items(groupedAvailableLetters, key = { it.third }) { (char, count, _) -> + // This is the action to perform when a letter is clicked + val onClickAction = { + if (!state.isRevealed) { + // Find the first available instance of the clicked character + val instanceToMove = availableLetters.first { it.first == char } + val newAssembled = state.assembledWord + instanceToMove + onAction(VocabularyExerciseAction.UpdateWordJumble(newAssembled)) + } + } + + // If count is > 1, show the grouped pill. Otherwise, show the original single pill. + if (count > 1) { + GroupedLetterPill( + char = char, + count = count, + isDraggable = !state.isRevealed, + onClick = onClickAction + ) + } else { + LetterPill( + char = char, + isDraggable = !state.isRevealed, + onClick = onClickAction + ) + } + } + } + } + } +} + + + +@Composable +private fun LetterPill(char: Char, isDraggable: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .then(if (isDraggable) Modifier.clickable(onClick = onClick) else Modifier) + .padding(4.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = char.uppercase(), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } +} + +@Composable +private fun GroupedLetterPill( + char: Char, + count: Int, + isDraggable: Boolean, + onClick: () -> Unit +) { + Box { // Use a Box to stack the pill and the count badge + // The base is the standard round letter pill + LetterPill(char = char, isDraggable = isDraggable, onClick = onClick) + + // This is the count badge, styled and aligned on top + Box( + modifier = Modifier + .align(Alignment.TopStart) // Position in the top-left corner + .size(20.dp) // Give the badge a fixed circular size + .background( + color = MaterialTheme.colorScheme.secondary, + shape = CircleShape + ) + // Add a small border to make it pop + .border(1.dp, MaterialTheme.colorScheme.background, CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + text = count.toString(), + color = MaterialTheme.colorScheme.onSecondary, + style = MaterialTheme.typography.labelSmall, // Use a small font style + textAlign = TextAlign.Center + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyExerciseHostScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyExerciseHostScreen.kt new file mode 100644 index 0000000..eb012e5 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyExerciseHostScreen.kt @@ -0,0 +1,288 @@ +package eu.gaudian.translator.view.vocabulary + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.view.composable.AppAlertDialog +import eu.gaudian.translator.viewmodel.ExerciseConfig +import eu.gaudian.translator.viewmodel.ScreenState +import eu.gaudian.translator.viewmodel.VocabularyExerciseViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + + +@Serializable +private data class DailyOnly(val dailyOnly: Boolean) + +@Composable +fun VocabularyExerciseHostScreen( + categoryIdsAsJson: String?, + stageNamesAsJson: String?, + languageIdsAsJson: String?, + dailyOnlyAsJson: String?, + onClose: () -> Unit, + navController: NavController +) { + val vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(androidx.compose.ui.platform.LocalContext.current.applicationContext as android.app.Application) + val exerciseViewModel: VocabularyExerciseViewModel = viewModel() + + val cardSet by vocabularyViewModel.cardSet.collectAsState() + val screenState by exerciseViewModel.screenState.collectAsState() + + var shuffleCards by rememberSaveable { mutableStateOf(false) } + var shuffleLanguages by remember { mutableStateOf(false) } + var trainingMode by remember { mutableStateOf(false) } + var dueTodayOnly by remember { mutableStateOf(false) } + var selectedExerciseTypes by remember { mutableStateOf(setOf(VocabularyExerciseType.GUESSING)) } + var selectedOriginLanguage by remember { mutableStateOf(null) } + var selectedTargetLanguage by remember { mutableStateOf(null) } + + var finalScore by remember { mutableIntStateOf(0) } + var finalWrongAnswers by remember { mutableIntStateOf(0) } + + + val dailyOnly = try { + dailyOnlyAsJson?.let { Json.decodeFromString(it).dailyOnly } ?: false + } catch (_: Exception) { + false + } + + LaunchedEffect(Unit) { + + + vocabularyViewModel.prepareExercise( + categoryIdsAsJson, + stageNamesAsJson, + languageIdsAsJson, + dailyOnly = dailyOnly, + ) + } + + if (cardSet == null && screenState != ScreenState.START) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + when (screenState) { + ScreenState.START -> { + StartScreen( + cardSet = cardSet, + onStartClicked = { finalItems -> + exerciseViewModel.startExerciseWithConfig( + finalItems, + ExerciseConfig( + shuffleCards = shuffleCards, + shuffleLanguages = shuffleLanguages, + trainingMode = trainingMode, + dueTodayOnly = dueTodayOnly, + selectedExerciseTypes = selectedExerciseTypes, + exerciseItemCount = finalItems.size, + originalExerciseItems = finalItems, + originLanguageId = selectedOriginLanguage?.nameResId, + targetLanguageId = selectedTargetLanguage?.nameResId + ) + ) + }, + onClose = onClose, + shuffleCards = shuffleCards, + onShuffleCardsChanged = { + @Suppress("AssignedValueIsNeverRead") + shuffleCards = it + }, + shuffleLanguages = shuffleLanguages, + onShuffleLanguagesChanged = { + @Suppress("AssignedValueIsNeverRead") + shuffleLanguages = it + }, + trainingMode = trainingMode, + onTrainingModeChanged = { + @Suppress("AssignedValueIsNeverRead") + trainingMode = it + exerciseViewModel.onTrainingModeChanged(it) + }, + hideTodayOnlySwitch = dailyOnly, + dueTodayOnly = dueTodayOnly, + onDueTodayOnlyChanged = { + @Suppress("AssignedValueIsNeverRead") + dueTodayOnly = it + }, + selectedExerciseTypes = selectedExerciseTypes, + onExerciseTypeSelected = { type -> + val currentTypes = selectedExerciseTypes.toMutableSet() + if (type in currentTypes) { + if (currentTypes.size > 1) currentTypes.remove(type) + } else { + currentTypes.add(type) + } + selectedExerciseTypes = currentTypes + }, + selectedOriginLanguage = selectedOriginLanguage, + onOriginLanguageChanged = { + @Suppress("AssignedValueIsNeverRead") + selectedOriginLanguage = it + }, + selectedTargetLanguage = selectedTargetLanguage, + onTargetLanguageChanged = { + @Suppress("AssignedValueIsNeverRead") + selectedTargetLanguage = it + } + ) + } + ScreenState.EXERCISE -> { + ExerciseScreen( + viewModel = exerciseViewModel, + onClose = onClose, + onFinish = { score, wrong -> + @Suppress("AssignedValueIsNeverRead") + finalScore = score + @Suppress("AssignedValueIsNeverRead") + finalWrongAnswers = wrong + exerciseViewModel.finishExercise(score, wrong) + }, + navController = navController + ) + } + ScreenState.RESULT -> { + val totalItems by exerciseViewModel.totalItems.collectAsState() + val originalItems by exerciseViewModel.originalItems.collectAsState() + ResultScreen( + score = finalScore, + wrongAnswers = finalWrongAnswers, + totalItems = totalItems, + onRestart = { + vocabularyViewModel.clearCardSet() + exerciseViewModel.resetExercise() + }, + onRetryWrong = { _ -> + exerciseViewModel.retryWrongAnswers(originalItems) + }, + onClose = onClose + ) + } + } + } +} + + + + + + + +@OptIn(ExperimentalAnimationApi::class) +@Composable +private fun ExerciseScreen( + viewModel: VocabularyExerciseViewModel, + onClose: () -> Unit, + onFinish: (score: Int, wrongAnswers: Int) -> Unit, + navController: NavController +) { + val exerciseState by viewModel.exerciseState.collectAsState() + val totalItems by viewModel.totalItems.collectAsState() + val score by viewModel.correctAnswers.collectAsState() + val wrongAnswers by viewModel.wrongAnswers.collectAsState() + + val currentExerciseState = exerciseState + + var showExitDialog by remember { mutableStateOf(false) } + + val onExitConfirmed = { + showExitDialog = false + onClose() + } + + BackHandler(enabled = true) { + showExitDialog = true + } + + if (showExitDialog) { + AppAlertDialog( + onDismissRequest = { + @Suppress("AssignedValueIsNeverRead") + showExitDialog = false + }, + title = { Text(stringResource(R.string.label_quit_exercise_qm)) }, + text = { Text(stringResource(R.string.text_are_you_sure_you_want_to_quit_your_progress_in_this_session_will_be_lost)) }, + confirmButton = { + TextButton(onClick = onExitConfirmed) { + Text(stringResource(R.string.quit)) + } + }, + dismissButton = { + TextButton(onClick = { + @Suppress("AssignedValueIsNeverRead") + showExitDialog = false + }) { + Text(stringResource(R.string.label_cancel)) + } + } + ) + } + + LaunchedEffect(currentExerciseState, score, wrongAnswers) { + if (currentExerciseState == null && (score + wrongAnswers) >= totalItems && totalItems > 0) { + onFinish(score, wrongAnswers) + } + } + + Column(modifier = Modifier.fillMaxSize()) { + ExerciseProgressIndicator( + correctAnswers = score, + wrongAnswers = wrongAnswers, + totalItems = totalItems, + onClose = { + @Suppress("AssignedValueIsNeverRead") + showExitDialog = true + } + ) + + Box(modifier = Modifier.weight(1f)) { + AnimatedContent( + targetState = currentExerciseState?.item?.id, + transitionSpec = { + slideInHorizontally { height -> height }.togetherWith(slideOutHorizontally { height -> -height }) + }, + label = "VocabularyCardAnimation" + ) { targetState -> + if (currentExerciseState != null && targetState == currentExerciseState.item.id) { + VocabularyExerciseRenderer( + state = currentExerciseState, + onAction = viewModel::onAction, + navController = navController + ) + } + } + } + + ExerciseControls( + state = currentExerciseState, + onAction = viewModel::onAction + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyHeatMapScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyHeatMapScreen.kt new file mode 100644 index 0000000..30fbdfc --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyHeatMapScreen.kt @@ -0,0 +1,554 @@ +package eu.gaudian.translator.view.vocabulary + +import android.annotation.SuppressLint +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.viewmodel.CategoryProgress +import eu.gaudian.translator.viewmodel.ProgressViewModel +import kotlinx.coroutines.launch +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.number +import kotlinx.datetime.plus +import kotlinx.datetime.todayIn +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import kotlin.math.log2 +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +// Data class to hold the data for a single month's grid +private data class HeatmapMonth(val yearMonth: YearMonth, val weeks: List>) + +@OptIn(ExperimentalTime::class) +@Composable +fun VocabularyHeatmapScreen( + viewModel: ProgressViewModel = ProgressViewModel.getInstance(androidx.compose.ui.platform.LocalContext.current.applicationContext as android.app.Application), navController: NavController +) { + // State and derived data + val dailyStats by viewModel.dailyVocabularyStats.collectAsState() + val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) } + val categoryProgressList by viewModel.categoryProgressList.collectAsState() + val streak by viewModel.streak.collectAsState() + val totalWordsCompleted by viewModel.totalWordsCompleted.collectAsState() + val totalWordsInProgress by viewModel.totalWordsInProgress.collectAsState() + val startDate = remember { today.minus(12, DateTimeUnit.MONTH) } + + AppScaffold( + topBar = { + AppTopAppBar( + title = { Text(stringResource(R.string.label_vocabulary_activity)) }, + onNavigateBack = { navController.popBackStack() }, + ) + }, + modifier = Modifier.fillMaxSize() + ) { paddingValues -> + Column( + // Allow content to scroll if it exceeds the screen height + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Heatmap + HeatmapCalendar( + dailyStats = dailyStats, + startDate = startDate, + endDate = today, + ) + + // Spacer and Legend + Spacer(modifier = Modifier.height(24.dp)) + Legend( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + CategoryProgressSection( + categoryProgress = categoryProgressList, + ) + Spacer(modifier = Modifier.height(24.dp)) + StatsOverview( + streak = streak, + wordsCompleted = totalWordsCompleted, + wordsInProgress = totalWordsInProgress, + ) + } + } + } +} + +/** + * Generates a list of HeatmapMonth objects, each containing the data for one month's grid. + */ +private fun generateHeatmapMonths(startDate: LocalDate, endDate: LocalDate): List { + val months = mutableListOf() + val startMonth = YearMonth.of(startDate.year, startDate.month.number) + val endMonth = YearMonth.of(endDate.year, endDate.month.number) + + var currentMonth = startMonth + while (!currentMonth.isAfter(endMonth)) { + months.add(HeatmapMonth( + yearMonth = currentMonth, + weeks = generateWeeksForMonth(currentMonth) + )) + currentMonth = currentMonth.plusMonths(1) + } + return months +} + +/** + * Generates the list of weeks for a single month, with padding for alignment. + */ +private fun generateWeeksForMonth(yearMonth: YearMonth): List> { + val firstDayOfMonth = yearMonth.atDay(1) + val lastDayOfMonth = yearMonth.atEndOfMonth() + val days = mutableListOf() + + val startDayOfWeek = firstDayOfMonth.dayOfWeek.value + repeat(startDayOfWeek - 1) { days.add(null) } + + for (day in 1..lastDayOfMonth.dayOfMonth) { + val date = yearMonth.atDay(day) + days.add(LocalDate(date.year, date.monthValue, date.dayOfMonth)) + } + + return days.chunked(7) +} + + +@SuppressLint("FrequentlyChangingValue") +@Composable +private fun HeatmapCalendar( + dailyStats: Map, + startDate: LocalDate, + endDate: LocalDate, + modifier: Modifier = Modifier +) { + val months = remember(startDate, endDate) { generateHeatmapMonths(startDate, endDate) } + val maxCount = remember(dailyStats) { dailyStats.values.maxOrNull() ?: 1 } + val coroutineScope = rememberCoroutineScope() + + val lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = months.size - 1) + var currentMonth by remember { mutableStateOf(months.last().yearMonth) } + + LaunchedEffect(lazyListState.firstVisibleItemIndex) { + if (months.isNotEmpty() && lazyListState.firstVisibleItemIndex < months.size) { + currentMonth = months[lazyListState.firstVisibleItemIndex].yearMonth + } + } + + Column(modifier = modifier) { + MonthHeader( + month = currentMonth, + onPrev = { + coroutineScope.launch { + if (lazyListState.firstVisibleItemIndex > 0) { + lazyListState.animateScrollToItem(lazyListState.firstVisibleItemIndex - 1) + } + } + }, + onNext = { + coroutineScope.launch { + if (lazyListState.firstVisibleItemIndex < months.size - 1) { + lazyListState.animateScrollToItem(lazyListState.firstVisibleItemIndex + 1) + } + } + } + ) + Spacer(modifier = Modifier.height(8.dp)) + + // This BoxWithConstraints ensures each month grid gets the full width of the container. + BoxWithConstraints { + val itemWidth = this.maxWidth + LazyRow( + state = lazyListState, + // The fling behavior makes the list snap to the start of each item. + flingBehavior = rememberSnapFlingBehavior(lazyListState = lazyListState), + horizontalArrangement = Arrangement.spacedBy(16.dp) // Space between months + ) { + items(months.size) { index -> + MonthGrid( + modifier = Modifier.width(itemWidth), + month = months[index], + dailyStats = dailyStats, + maxCount = maxCount + ) + } + } + } + } +} + +@Composable +private fun MonthHeader( + month: YearMonth, + onPrev: () -> Unit, + onNext: () -> Unit, + modifier: Modifier = Modifier +) { + val formatter = remember { + @Suppress("HardCodedStringLiteral") + DateTimeFormatter.ofPattern("MMMM yyyy") + } + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + IconButton(onClick = onPrev) { + Icon(AppIcons.ArrowLeft, contentDescription = stringResource(R.string.previous_month)) + } + Text( + text = month.format(formatter), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp) + ) + IconButton(onClick = onNext) { + Icon(AppIcons.ArrowRight, contentDescription = stringResource(R.string.next_month)) + } + } +} + +@Composable +private fun MonthGrid( + month: HeatmapMonth, + dailyStats: Map, + maxCount: Int, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Row(modifier = Modifier.fillMaxWidth()) { + val locale = java.util.Locale.getDefault() + // Generate localized short weekday labels for Monday to Sunday. + val dayFormatter = remember(locale) { + DateTimeFormatter.ofPattern("EEEEE", locale) + } + val baseMonday = java.time.LocalDate.of(2024, 9, 2) // a known Monday + val days = (0..6).map { offset -> + val d = baseMonday.plusDays(offset.toLong()) + // Use 1-letter if available; otherwise fallback to short name and take first char + val label = d.format(dayFormatter) + if (label.length <= 2) label else label.first().toString() + } + days.forEach { day -> + Text( + text = day, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center + ) + } + } + Spacer(modifier = Modifier.height(4.dp)) + + month.weeks.forEach { week -> + Row(modifier = Modifier.fillMaxWidth()) { + for (i in 0 until 7) { + val date = week.getOrNull(i) + if (date != null) { + val count = dailyStats[date] ?: 0 + val colorIntensity = if (count > 0) { + (log2(count.toFloat() + 1) / log2(maxCount.toFloat() + 1)) + } else { + 0f + }.coerceIn(0f, 1f) + + DaySquare( + date = date, + count = count, + colorIntensity = colorIntensity, + modifier = Modifier + .weight(1f) + .aspectRatio(1f) + ) + } else { + Spacer(modifier = Modifier + .weight(1f) + .aspectRatio(1f)) + } + } + } + } + } +} + + +@OptIn(ExperimentalTime::class) +@Composable +private fun DaySquare( + date: LocalDate, + count: Int, + colorIntensity: Float, + modifier: Modifier = Modifier +) { + val today = remember { Clock.System.todayIn(TimeZone.currentSystemDefault()) } + val isToday = date == today + val isFuture = date > today + val baseColor = MaterialTheme.colorScheme.primary + + val backgroundColor = when { + isFuture -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.1f) // Faint color for future dates + colorIntensity > 0 -> baseColor.copy(alpha = (0.15f + (colorIntensity * 0.85f)).coerceIn(0.15f, 1f)) + else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) // For past/today with no activity + } + + Box( + modifier = modifier + .padding(2.dp) + .clip(MaterialTheme.shapes.extraSmall) + .background(backgroundColor) + .border( + width = 1.dp, + color = if (isToday) MaterialTheme.colorScheme.outline else Color.Transparent, + shape = MaterialTheme.shapes.extraSmall + ), + contentAlignment = Alignment.Center + ) { + // Only show the count for past and present days + if (count > 0 && !isFuture) { + Text( + text = count.toString(), + fontSize = 8.sp, + color = if (colorIntensity > 0.6f) Color.White else MaterialTheme.colorScheme.onSurface + ) + } + } +} + +@Composable +private fun Legend(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + Text( + text = stringResource(R.string.less), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(end = 6.dp) + ) + + val baseColor = MaterialTheme.colorScheme.primary + val shades = listOf(0.15f, 0.4f, 0.7f, 1.0f) + shades.forEach { alpha -> + Box( + modifier = Modifier + .size(14.dp) + .clip(MaterialTheme.shapes.extraSmall) + .background(baseColor.copy(alpha = alpha)) + .padding(end = 2.dp) // Add spacing between color boxes + ) + } + + Text( + text = stringResource(R.string.more), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 6.dp) // Increased spacing + ) + } +} + +// --- Previews for easy testing in Android Studio --- + +@OptIn(ExperimentalTime::class) +@Preview(showBackground = true, widthDp = 380) +@Composable +fun VocabularyHeatmapScreenPreview() { + val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) + val startDate = today.minus(12, DateTimeUnit.MONTH) + val sampleStats = buildMap { + var currentDate = startDate + while (currentDate <= today) { + if ((1..10).random() > 2) { + put(currentDate, (1..25).random()) + } + currentDate = currentDate.plus(1, DateTimeUnit.DAY) + } + } + + Column(modifier = Modifier + .fillMaxSize() + .padding(16.dp)) { + Text( + text = stringResource(R.string.label_vocabulary_activity), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 24.dp) + ) + HeatmapCalendar(dailyStats = sampleStats, startDate = startDate, endDate = today) + Spacer(modifier = Modifier.height(24.dp)) + Legend(modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +fun StatsOverview( + streak: Int, + wordsCompleted: Int, + wordsInProgress: Int, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + StatCard(title = stringResource(R.string.current_streak), value = stringResource( + R.string.days_2d, + streak + ), modifier = Modifier.weight(1f)) + StatCard(title = stringResource(R.string.words_completed), value = wordsCompleted.toString(), modifier = Modifier.weight(1f)) + StatCard(title = stringResource(R.string.text_in_progress), value = wordsInProgress.toString(), modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun StatCard(title: String, value: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + } +} + + +/** + * A Composable to show a detailed list of progress per vocabulary category. + * + * How to use: + * 1. Collect the category progress list from your ViewModel: + * val categoryProgress by viewModel.categoryProgressList.collectAsState() + * + * 2. Call this composable in the main Column of your screen, likely below the StatsOverview: + * CategoryProgressSection(categoryProgress = categoryProgress) + */ +@Composable +fun CategoryProgressSection( + categoryProgress: List, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = stringResource(R.string.progress_by_category), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp, top = 24.dp) + ) + categoryProgress.forEach { progress -> + CategoryProgressItem(progress = progress) + Spacer(modifier = Modifier.height(12.dp)) + } + } +} + +@Composable +private fun CategoryProgressItem(progress: CategoryProgress) { + val progressPercentage = if (progress.totalItems > 0) { + progress.itemsCompleted.toFloat() / progress.totalItems.toFloat() + } else { + 0f + } + // Animate the progress bar's movement + val animatedProgress by animateFloatAsState( + targetValue = progressPercentage, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "CategoryProgressAnimation" + ) + + Column { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = progress.vocabularyCategory.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${progress.itemsCompleted} / ${progress.totalItems}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(MaterialTheme.shapes.small), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyListScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyListScreen.kt new file mode 100644 index 0000000..e4764dd --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyListScreen.kt @@ -0,0 +1,973 @@ +@file:Suppress("HardCodedStringLiteral", "AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.vocabulary + +import android.os.Parcelable +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppSwitch +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.MultipleLanguageDropdown +import eu.gaudian.translator.view.composable.insertBreakOpportunities +import eu.gaudian.translator.view.dialogs.CategoryDropdown +import eu.gaudian.translator.view.dialogs.CategorySelectionDialog +import eu.gaudian.translator.view.dialogs.StageSelectionDialog +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageConfigViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel.SortOrder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + + +@Parcelize +private data class VocabularyFilterState( + val searchQuery: String = "", + val selectedStage: VocabularyStage? = null, + val sortOrder: SortOrder = SortOrder.NEWEST_FIRST, + val categoryId: Int? = null, + val dueTodayOnly: Boolean = false, + val selectedLanguageIds: List = emptyList(), + val selectedWordClass: String? = null +) : Parcelable + +@Composable +fun VocabularyListScreen( + categoryId: Int? = null, + showDueTodayOnly: Boolean? = null, + stage: VocabularyStage? = null, + onNavigateToItem: (VocabularyItem) -> Unit?, + onNavigateBack: (() -> Unit)? = null, + navController: NavHostController? = null, + itemsToShow: List = emptyList(), + isRemoveFromCategoryEnabled: Boolean = false, + showTopBar: Boolean = true, + enableNavigationButtons: Boolean = false +) { + val scope = rememberCoroutineScope() + val activity = LocalContext.current.findActivity() + val lazyListState = rememberLazyListState() + val vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(LocalContext.current.applicationContext as android.app.Application) + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val categoryViewModel: CategoryViewModel = viewModel() + val context = LocalContext.current + + var filterState by rememberSaveable { + mutableStateOf( + VocabularyFilterState( + categoryId = categoryId, + dueTodayOnly = showDueTodayOnly == true, + selectedStage = stage + ) + ) + } + val isFilterActive by remember(filterState) { + derivedStateOf { + filterState.selectedStage != null || + (filterState.categoryId != null && filterState.categoryId != 0) || + filterState.dueTodayOnly || + filterState.selectedLanguageIds.isNotEmpty() || + !filterState.selectedWordClass.isNullOrBlank() + } + } + var showFilterSheet by remember { mutableStateOf(false) } + + var selection by remember { mutableStateOf>(emptySet()) } + val isInSelectionMode = selection.isNotEmpty() + + var showCategoryDialog by remember { mutableStateOf(false) } + var showStageDialog by remember { mutableStateOf(false) } + var isSearchActive by rememberSaveable { mutableStateOf(false) } + + val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList()) + val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet()) + val categoryNameFlow = remember(categoryId) { categoryId?.let { categoryViewModel.getCategoryById(it) } } + + val vocabularyItemsFlow: Flow> = remember(filterState) { + vocabularyViewModel.filterVocabularyItems( + languages = filterState.selectedLanguageIds, + query = filterState.searchQuery.takeIf { it.isNotBlank() }, + categoryId = filterState.categoryId, + stage = filterState.selectedStage, + wordClass = filterState.selectedWordClass, + dueTodayOnly = filterState.dueTodayOnly, + sortOrder = filterState.sortOrder + ) + } + + val vocabularyItems: List = itemsToShow.ifEmpty { + vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList()).value + } + + LaunchedEffect(categoryId, showDueTodayOnly, stage) { + filterState = filterState.copy( + categoryId = categoryId, + dueTodayOnly = showDueTodayOnly == true, + selectedStage = stage + ) + } + + // Set navigation context when navigation buttons are enabled + LaunchedEffect(vocabularyItems, enableNavigationButtons) { + if (enableNavigationButtons && vocabularyItems.isNotEmpty()) { + // Set the first item as the current navigation context + vocabularyViewModel.setNavigationContext(vocabularyItems, vocabularyItems.first().id) + } + } + + + val allItemsSelected = remember(vocabularyItems, selection) { + vocabularyItems.isNotEmpty() && selection.size == vocabularyItems.size + } + + + AppScaffold( + modifier = Modifier + .padding(0.dp) + .background(Color.Magenta), + topBar = { + val topBarMode = when { + isInSelectionMode -> "selection" + isSearchActive -> "search" + showTopBar -> "default" + else -> "none" + } + Crossfade( + modifier = Modifier.padding(0.dp), + targetState = topBarMode, + label = "top-bar-anim" + ) { state -> + when (state) { + "selection" -> ContextualTopAppBar( + modifier = Modifier.padding(0.dp), + selectionCount = selection.size, + allItemsSelected = allItemsSelected, + isRemoveEnabled = isRemoveFromCategoryEnabled, + onSelectAllClick = { + selection = if (allItemsSelected) { + emptySet() + } else { + vocabularyItems.map { it.id.toLong() }.toSet() + } + }, + onCloseClick = { selection = emptySet() }, + onDeleteClick = { + vocabularyViewModel.deleteVocabularyItemsById(selection.map { it.toInt() }) + selection = emptySet() + }, + onMoveToCategoryClick = { showCategoryDialog = true }, + onMoveToStageClick = { showStageDialog = true }, + onRemoveFromCategoryClick = { + if (categoryId != null) { + val itemsToRemove = vocabularyItems.filter { selection.contains(it.id.toLong()) } + vocabularyViewModel.removeVocabularyItemsFromCategory(itemsToRemove, categoryId) + selection = emptySet() + } + } + ) + + "search" -> SearchTopAppBar( + searchQuery = filterState.searchQuery, + onQueryChanged = { newQuery -> + filterState = filterState.copy(searchQuery = newQuery) + }, + onCloseSearch = { + isSearchActive = false + filterState = filterState.copy(searchQuery = "") + } + ) + + "default" -> DefaultTopAppBar( + title = when { + filterState.searchQuery.isNotBlank() -> "Results for: \"${filterState.searchQuery}\"" // Consider using a string resource + + stage != null -> stringResource(R.string.text_stage_2d, stage.toString(context)) + categoryId != null && categoryId != 0 -> stringResource(R.string.label_category_2d, + categoryNameFlow?.collectAsStateWithLifecycle(initialValue = null)?.value?.name + ?: "" + ) + showDueTodayOnly == true -> stringResource(R.string.text_due_today) + else -> stringResource(R.string.label_all_vocabulary) + }, + currentSortOrder = filterState.sortOrder, + isFilterActive = isFilterActive, + onSortOrderChanged = { newOrder -> + filterState = filterState.copy(sortOrder = newOrder) + }, + onFilterClick = { showFilterSheet = true }, + onSearchClick = { isSearchActive = true }, + onNavigateBack = onNavigateBack + ) + } + } + }, + floatingActionButton = { + val showFab by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 5 && !isInSelectionMode } } + AnimatedVisibility(visible = showFab) { + FloatingActionButton( + onClick = { scope.launch { lazyListState.animateScrollToItem(0) } }, + shape = CircleShape, + modifier = Modifier.size(50.dp), + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) { + Icon(AppIcons.ArrowCircleUp, contentDescription = "Scroll to top") + } + } + }, + floatingActionButtonPosition = FabPosition.Center + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues).padding(bottom = 0.dp)) { + if (vocabularyItems.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + modifier = Modifier.size(200.dp), + painter = painterResource(id = R.drawable.ic_nothing_found), + contentDescription = stringResource(id = R.string.no_vocabulary_items_found_perhaps_try_changing_the_filters) + ) + Spacer(modifier = Modifier.size(16.dp)) + + Box(modifier = Modifier + .fillMaxSize() + .padding(8.dp), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.no_vocabulary_items_found_perhaps_try_changing_the_filters), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } + } else { + LazyColumn( + state = lazyListState, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 0.dp) + ) { + items( + items = vocabularyItems, + key = { it.id } + ) { item -> + val isSelected = selection.contains(item.id.toLong()) + VocabularyListItem( + item = item, + allLanguages = allLanguages, + isSelected = isSelected, + onItemClick = { + if (isInSelectionMode) { + selection = if (isSelected) { + selection - item.id.toLong() + } else { + selection + item.id.toLong() + } + } else { + if (navController != null && enableNavigationButtons) { + vocabularyViewModel.setNavigationContext(vocabularyItems, item.id) + navController.navigate("vocabulary_detail/${item.id}") + } else { + onNavigateToItem(item) + } + } + }, + onItemLongClick = { + if (!isInSelectionMode) { + selection = setOf(item.id.toLong()) + } + }, + onDeleteClick = { + vocabularyViewModel.deleteData(VocabularyViewModel.DeleteType.VOCABULARY_ITEM, item = item) + }, + modifier = Modifier.animateItem() + ) + } + } + } + } + + + if (showFilterSheet) { + FilterSortBottomSheet( + currentFilterState = filterState, + onDismiss = { showFilterSheet = false }, + onApplyFilters = { newState -> + filterState = newState + showFilterSheet = false + scope.launch { lazyListState.scrollToItem(0) } + }, + categoryViewModel = categoryViewModel, + languageViewModel = languageViewModel, + languagesPresent = allLanguages.filter { it.nameResId in languagesPresent }, + hideCategory = categoryId != null && categoryId != 0, + hideStage = stage != null + ) + } + + if (showCategoryDialog) { + val selectedItems = vocabularyItems.filter { selection.contains(it.id.toLong()) } + CategorySelectionDialog( + viewModel = categoryViewModel, + onCategorySelected = { + vocabularyViewModel.addVocabularyItemToCategories( + selectedItems, + it.mapNotNull { category -> category?.id } + ) + showCategoryDialog = false + }, + onDismissRequest = { showCategoryDialog = false } + ) + } + + if (showStageDialog) { + val selectedItems = vocabularyItems.filter { selection.contains(it.id.toLong()) } + StageSelectionDialog( + onStageSelected = { selectedStage -> + selectedStage?.let { + vocabularyViewModel.addVocabularyItemToStage(selectedItems, it) + } + showStageDialog = false + selection = emptySet() + }, + onDismissRequest = { showStageDialog = false } + ) + } + } +} + +@ThemePreviews +@Composable +fun VocabularyListScreenPreview() { + val navController = rememberNavController() + VocabularyListScreen( + categoryId = 1, + showDueTodayOnly = false, + stage = VocabularyStage.NEW, + onNavigateToItem = {}, + onNavigateBack = {}, + navController = navController + ) +} + + +@Composable +private fun DefaultTopAppBar( + title: String, + onFilterClick: () -> Unit, + onNavigateBack: (() -> Unit)?, + currentSortOrder: SortOrder, + isFilterActive: Boolean, // <-- Add new parameter + onSortOrderChanged: (SortOrder) -> Unit, + onSearchClick: () -> Unit +) { + + var showSortMenu by remember { mutableStateOf(false) } + AppTopAppBar( + modifier = Modifier.height(56.dp), + title = { + + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + Text(title) + } + }, + navigationIcon = { + onNavigateBack?.let { + IconButton(onClick = it) { + Icon( + AppIcons.ArrowBack, + contentDescription = "stringResource(R.string.navigate_back)" + ) + } + } + }, + actions = { + IconButton(onClick = onSearchClick) { + Icon( + imageVector = AppIcons.Search, + contentDescription = stringResource(R.string.cd_search)) + } + + Box { + IconButton(onClick = { showSortMenu = true }) { + Icon( + imageVector = AppIcons.Sort, + contentDescription = stringResource(R.string.sort) + ) + } + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false } + ) { + SortOrder.entries.forEach { order -> + DropdownMenuItem( + text = { Text(order.name.replace('_', ' ').lowercase().replaceFirstChar { it.titlecase() }) }, + onClick = { + onSortOrderChanged(order) + showSortMenu = false + }, + leadingIcon = { + if (currentSortOrder == order) { + Icon( + imageVector = AppIcons.Check, + contentDescription = stringResource(R.string.text_selected) + ) + } + } + ) + } + } + } + IconButton(onClick = onFilterClick) { + Icon( + imageVector = if (isFilterActive) { + AppIcons.FilterFilled + } else { + AppIcons.FilterOutlined + }, + contentDescription = stringResource(R.string.filter_and_sort), + tint = if (isFilterActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } + } + + ) +} + + + +@Composable +private fun SearchTopAppBar( + searchQuery: String, + onQueryChanged: (String) -> Unit, + onCloseSearch: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + AppTopAppBar( + modifier = Modifier.height(56.dp), + title = { + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + BasicTextField( + value = searchQuery, + onValueChange = onQueryChanged, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (searchQuery.isEmpty()) { + Text( + text = stringResource(R.string.search_vocabulary), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + innerTextField() + } + } + ) + } + }, + navigationIcon = { + IconButton(onClick = onCloseSearch) { + Icon( + imageVector = AppIcons.Close, + contentDescription = stringResource(R.string.label_close_search) + ) + } + }, + actions = { + } + ) +} + +@ThemePreviews +@Composable +fun SearchTopAppBarPreview() { + SearchTopAppBar( + searchQuery = stringResource(R.string.search_query), + onQueryChanged = {}, + onCloseSearch = {} + ) +} + +@Composable +private fun ContextualTopAppBar( + modifier: Modifier = Modifier, + selectionCount: Int, + allItemsSelected: Boolean, + isRemoveEnabled: Boolean, + onCloseClick: () -> Unit, + onSelectAllClick: () -> Unit, + onDeleteClick: () -> Unit, + onMoveToCategoryClick: () -> Unit, + onMoveToStageClick: () -> Unit, + onRemoveFromCategoryClick: () -> Unit +) { + var showOverflowMenu by remember { mutableStateOf(false) } + + AppTopAppBar( + modifier = modifier.height(56.dp), + title = { + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center + ) { + Text(stringResource(R.string.d_selected, selectionCount)) + } + }, + navigationIcon = { + IconButton(onClick = onCloseClick) { + Icon(imageVector = AppIcons.Close, contentDescription = stringResource(R.string.label_close_selection_mode)) + } + }, + actions = { + IconButton(onClick = onSelectAllClick) { + Icon( + imageVector = AppIcons.SelectAll, + contentDescription = if (allItemsSelected) stringResource(R.string.deselect_all) else stringResource(R.string.select_all) + ) + } + + IconButton(onClick = onDeleteClick) { + Icon(AppIcons.Delete, contentDescription = stringResource(R.string.label_delete)) + } + + Box { + IconButton(onClick = { showOverflowMenu = true }) { + Icon(imageVector = AppIcons.More, contentDescription = stringResource(R.string.more_actions)) + } + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { showOverflowMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.move_to_category)) }, + onClick = { + onMoveToCategoryClick() + showOverflowMenu = false + }, + leadingIcon = { Icon(AppIcons.Category, contentDescription = null) } + ) + if (isRemoveEnabled) { + DropdownMenuItem( + text = { Text(stringResource(R.string.remove_from_category)) }, + onClick = { + onRemoveFromCategoryClick() + showOverflowMenu = false + }, + leadingIcon = { Icon(AppIcons.Remove, contentDescription = null) } + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.move_to_stage)) }, + onClick = { + onMoveToStageClick() + showOverflowMenu = false + }, + leadingIcon = { Icon(AppIcons.Stages, contentDescription = null) } + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) +} + + +@ThemePreviews +@Composable +fun ContextualTopAppBarPreview() { + ContextualTopAppBar( + selectionCount = 5, + allItemsSelected = false, + isRemoveEnabled = true, + onCloseClick = {}, + onSelectAllClick = {}, + onDeleteClick = {}, + onMoveToCategoryClick = {}, + onMoveToStageClick = {}, + onRemoveFromCategoryClick = {} + ) +} + +@Composable +private fun VocabularyListItem( + item: VocabularyItem, + allLanguages: List, + isSelected: Boolean, + onItemClick: () -> Unit, + onItemLongClick: () -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier +) { + val languageMap = remember(allLanguages) { allLanguages.associateBy { it.nameResId } } + val langFirst = item.languageFirstId?.let { languageMap[it]?.name } ?: "" + val langSecond = item.languageSecondId?.let { languageMap[it]?.name } ?: "" + + OutlinedCard( + modifier = modifier + .fillMaxWidth() + .clip(CardDefaults.shape) + .combinedClickable( + onClick = onItemClick, + onLongClick = onItemLongClick + ) + .animateContentSize(), + elevation = CardDefaults.cardElevation(defaultElevation = if (isSelected) 4.dp else 0.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surface + ), + border = BorderStroke( + width = if (isSelected) 1.5.dp else 1.dp, + color = if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) { + Row( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + LanguageRow(word = item.wordFirst, language = langFirst) + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) + LanguageRow(word = item.wordSecond, language = langSecond) + } + + Box( + modifier = Modifier.padding(4.dp), + contentAlignment = Alignment.Center + ) { + Crossfade(targetState = isSelected, label = "action-icon-fade") { selected -> + if (selected) { + Icon( + imageVector = AppIcons.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } else { + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = AppIcons.Delete, + contentDescription = stringResource(id = R.string.label_delete), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + } + } + } + } +} + + + +@Composable +private fun LanguageRow(word: String, language: String) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 2.dp)) { + Text( + text = insertBreakOpportunities(word), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(0.7f) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = language, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier.weight(0.3f) + ) + } +} + +@ThemePreviews +@Composable +fun LanguageRowPreview() { + LanguageRow( + word = "Hello", + language = "English" + ) +} + + +@Composable +private fun FilterSortBottomSheet( + currentFilterState: VocabularyFilterState, + categoryViewModel: CategoryViewModel, + languageViewModel: LanguageViewModel, + languagesPresent: List, + onDismiss: () -> Unit, + onApplyFilters: (VocabularyFilterState) -> Unit, + hideCategory: Boolean = false, + hideStage: Boolean = false +) { + var selectedStage by rememberSaveable { mutableStateOf(currentFilterState.selectedStage) } + var dueTodayOnly by rememberSaveable { mutableStateOf(currentFilterState.dueTodayOnly) } + var selectedCategoryId by rememberSaveable { mutableStateOf(currentFilterState.categoryId) } + var selectedLanguageIds by rememberSaveable { mutableStateOf(currentFilterState.selectedLanguageIds) } + var selectedWordClass by rememberSaveable { mutableStateOf(currentFilterState.selectedWordClass) } + + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val languageConfigViewModel: LanguageConfigViewModel = viewModel() + val allWordClasses by languageConfigViewModel.allWordClasses.collectAsStateWithLifecycle() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .navigationBarsPadding() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.text_filter), style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.weight(1f)) + TextButton(onClick = { + if (!hideStage) selectedStage = null + dueTodayOnly = false + if (!hideCategory) selectedCategoryId = null + selectedLanguageIds = emptyList() + selectedWordClass = null + }) { + Text(stringResource(R.string.label_clear_all), color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.width(8.dp)) + TextButton( + onClick = { + onApplyFilters( + currentFilterState.copy( + selectedStage = selectedStage, + dueTodayOnly = dueTodayOnly, + categoryId = selectedCategoryId, + selectedLanguageIds = selectedLanguageIds, + selectedWordClass = selectedWordClass + ) + ) + } + ) { + Text(stringResource(R.string.label_apply_filters), fontWeight = FontWeight.Bold) + } + } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.text_due_today_only), style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.width(8.dp)) + AppSwitch(checked = dueTodayOnly, onCheckedChange = { dueTodayOnly = it }) + } + Spacer(Modifier.height(16.dp)) + + Text(stringResource(R.string.language), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + MultipleLanguageDropdown( + languageViewModel = languageViewModel, + onLanguagesSelected = { languages -> + selectedLanguageIds = languages.map { it.nameResId } + }, + alternateLanguages = languagesPresent + ) + Spacer(Modifier.height(16.dp)) + + if (!hideCategory) { + Text(stringResource(R.string.label_category), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + CategoryDropdown( + categoryViewModel = categoryViewModel, + initialCategoryId = selectedCategoryId, + onCategorySelected = { categories -> + selectedCategoryId = categories.firstOrNull()?.id + } + ) + Spacer(Modifier.height(16.dp)) + } + + if (!hideStage) { + Text(stringResource(R.string.filter_by_stage), style = MaterialTheme.typography.titleMedium) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedStage == null, + onClick = { selectedStage = null }, + label = { Text(stringResource(R.string.label_all_stages)) } + ) + VocabularyStage.entries.forEach { stage -> + FilterChip( + selected = selectedStage == stage, + onClick = { selectedStage = stage }, + label = { Text(stage.toString(context)) } + ) + } + } + } + + if (allWordClasses.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + Text(stringResource(R.string.filter_by_word_type), style = MaterialTheme.typography.titleMedium) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = selectedWordClass == null, + onClick = { selectedWordClass = null }, + label = { Text(stringResource(R.string.label_all_types)) } + ) + allWordClasses.forEach { wordClass -> + FilterChip( + selected = selectedWordClass == wordClass, + onClick = { selectedWordClass = wordClass }, + label = { Text(wordClass.replaceFirstChar { it.titlecase() }) } + ) + } + } + } + // Add padding at the bottom for better scrolling experience + Spacer(Modifier.height(16.dp)) + } + } + } +} + +@ThemePreviews +@Composable +fun FilterSortBottomSheetPreview() { + val activity = LocalContext.current.findActivity() + val categoryViewModel: CategoryViewModel = viewModel() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + FilterSortBottomSheet( + currentFilterState = VocabularyFilterState(), + categoryViewModel = categoryViewModel, + languageViewModel = languageViewModel, + languagesPresent = emptyList(), + onDismiss = {}, + onApplyFilters = {}, + hideCategory = false, + hideStage = false + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularySortingScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularySortingScreen.kt new file mode 100644 index 0000000..fa5260f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularySortingScreen.kt @@ -0,0 +1,861 @@ +@file:Suppress("AssignedValueIsNeverRead") + +package eu.gaudian.translator.view.vocabulary + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.TagCategory +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.utils.StringHelper +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppButton +import eu.gaudian.translator.view.composable.AppCard +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AppScaffold +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.composable.SingleLanguageDropDown +import eu.gaudian.translator.view.dialogs.CategoryDropdown +import eu.gaudian.translator.view.dialogs.CreateCategoryListDialog +import eu.gaudian.translator.view.hints.SortingScreenHint +import eu.gaudian.translator.viewmodel.CategoryViewModel +import eu.gaudian.translator.viewmodel.LanguageConfigViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import okhttp3.internal.platform.PlatformRegistry.applicationContext + +enum class FilterMode { + NEW, DUPLICATES, FAULTY +} + +@Composable +fun VocabularySortingScreen( + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as android.app.Application), + categoryViewModel: CategoryViewModel = CategoryViewModel.getInstance(applicationContext as android.app.Application), + navController: NavHostController, + initialFilterMode: String? = null +) { + var sortOrder by remember { mutableStateOf(VocabularyViewModel.SortOrder.NEWEST_FIRST) } + val allDuplicateItems by vocabularyViewModel.allDuplicateItems.collectAsState() + val allFaultyItems by vocabularyViewModel.allFaultyItems.collectAsState() + val startingMode = remember { + try { + initialFilterMode?.let { FilterMode.valueOf(it.uppercase()) } + } catch (_: IllegalArgumentException) { + FilterMode.NEW + } + } + var filterMode by remember { mutableStateOf(startingMode) } + + LaunchedEffect(filterMode, allDuplicateItems, allFaultyItems) { + when (filterMode) { + FilterMode.DUPLICATES -> vocabularyViewModel.filterByIds(allDuplicateItems.map { it.id }) + FilterMode.FAULTY -> vocabularyViewModel.filterByIds(allFaultyItems.map { it.id }) + else -> vocabularyViewModel.clearFilter() // For NEW or null mode + } + } + + DisposableEffect(Unit) { + onDispose { + vocabularyViewModel.clearFilter() + } + } + + val itemsToDisplay + by vocabularyViewModel.filterVocabularyItems( + languages = null, + query = null, + categoryIds = null, + stage = if (filterMode == FilterMode.NEW) VocabularyStage.NEW else null, // Only filter by NEW stage in that mode + sortOrder = sortOrder, + ).collectAsState(initial = emptyList()) + + var showCreateCategoryDialog by remember { mutableStateOf(false) } + + if (showCreateCategoryDialog) { + CreateCategoryListDialog( + onDismiss = { showCreateCategoryDialog = false }, + onConfirm = { categoryName -> + categoryViewModel.createCategory( + TagCategory( + id = 0, + name = categoryName + ) + ) + showCreateCategoryDialog = false + } + ) + } + + AppScaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + var showSortMenu by remember { mutableStateOf(false) } + var showFilterMenu by remember { mutableStateOf(false) } + + AppTopAppBar( + title = { Text(stringResource(R.string.sort_new_vocabulary)) }, + actions = { + Box { + IconButton(onClick = { showFilterMenu = true }) { + Icon( + imageVector = AppIcons.Filter, + contentDescription = stringResource(R.string.text_filter) + ) + } + DropdownMenu( + expanded = showFilterMenu, + onDismissRequest = { showFilterMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.new_items_only)) }, + onClick = { + filterMode = if (filterMode == FilterMode.NEW) null else FilterMode.NEW + showFilterMenu = false + }, + trailingIcon = { if (filterMode == FilterMode.NEW) Icon(AppIcons.Check, stringResource(R.string.enabled)) } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.duplicates_only)) }, + onClick = { + filterMode = if (filterMode == FilterMode.DUPLICATES) null else FilterMode.DUPLICATES + showFilterMenu = false + }, + trailingIcon = { if (filterMode == FilterMode.DUPLICATES) Icon(AppIcons.Check, stringResource(R.string.enabled)) } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.faulty_items_only)) }, + onClick = { + filterMode = if (filterMode == FilterMode.FAULTY) null else FilterMode.FAULTY + showFilterMenu = false + }, + trailingIcon = { if (filterMode == FilterMode.FAULTY) Icon(AppIcons.Check, stringResource(R.string.enabled)) } + ) + } + } + + // Sort Button and Menu + Box { + IconButton(onClick = { showSortMenu = true }) { + Icon( + imageVector = AppIcons.Sort, + contentDescription = stringResource(R.string.sort) + ) + } + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.newest_first)) }, + onClick = { + sortOrder = VocabularyViewModel.SortOrder.NEWEST_FIRST + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.oldest_first)) }, + onClick = { + sortOrder = VocabularyViewModel.SortOrder.OLDEST_FIRST + showSortMenu = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.label_by_language)) }, + onClick = { + sortOrder = VocabularyViewModel.SortOrder.LANGUAGE + showSortMenu = false + } + ) + } + } + }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) + } + }, + hintContent = {SortingScreenHint.Content()} + ) + }, + + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (itemsToDisplay.isEmpty()) { + Text( + text = stringResource(R.string.no_new_vocabulary_to_sort), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp) + ) + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = paddingValues + ) { + items(itemsToDisplay, key = { it.id }) { item -> + VocabularySortingItem( + item = item, + filterMode = filterMode, // NEW: Pass the mode to the item + vocabularyViewModel = vocabularyViewModel, + categoryViewModel = categoryViewModel, + languageConfigViewModel = viewModel() + ) + } + } + AppButton( + onClick = { showCreateCategoryDialog = true }, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text(stringResource(R.string.create_new_category)) + } + } + } + } +} + +@Preview +@Composable +fun VocabularySortingScreenPreview() { + VocabularySortingScreen( + vocabularyViewModel = viewModel(), + categoryViewModel = viewModel(), + navController = NavHostController(LocalContext.current), + initialFilterMode = "NEW" + ) +} + +@Composable +fun VocabularySortingItem( + item: VocabularyItem, + filterMode: FilterMode?, // NEW: Receive the current filter mode + vocabularyViewModel: VocabularyViewModel, + categoryViewModel: CategoryViewModel, + languageConfigViewModel: LanguageConfigViewModel = viewModel() +) { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + var wordFirst by remember { mutableStateOf(item.wordFirst) } + var wordSecond by remember { mutableStateOf(item.wordSecond) } + var selectedCategories by remember { mutableStateOf>(emptyList()) } + var langFirst by remember { mutableStateOf(null) } + var langSecond by remember { mutableStateOf(null) } + val isDuplicate by vocabularyViewModel.isDuplicateFlow(item.id).collectAsState(initial = false) + val duplicates by vocabularyViewModel.getDuplicatesOf(item).collectAsState(initial = emptyList()) + var visible by remember { mutableStateOf(true) } + val scope = rememberCoroutineScope() + + var articlesLangFirst by remember { mutableStateOf(emptySet()) } + var articlesLangSecond by remember { mutableStateOf(emptySet()) } + + var showDuplicateDialog by remember { mutableStateOf(false) } + + // NEW: Calculate if the item is valid for the "Done" button in faulty mode + val isItemNowValid by remember(wordFirst, wordSecond, langFirst, langSecond) { + mutableStateOf( + wordFirst.isNotBlank() && + wordSecond.isNotBlank() && + langFirst != null && + langSecond != null && + langFirst?.nameResId != langSecond?.nameResId && + langFirst?.nameResId != 0 && + langSecond?.nameResId != 0 + ) + } + + if (showDuplicateDialog && duplicates.isNotEmpty()) { + EnhancedDuplicateDialog( + newItem = item, + existingItem = duplicates.first(), + onDismiss = { showDuplicateDialog = false }, + onDelete = { + scope.launch { + showDuplicateDialog = false + visible = false + delay(350) + vocabularyViewModel.deleteVocabularyItemsById(listOf(item.id)) + } + }, + onMerge = { + scope.launch { + showDuplicateDialog = false + visible = false + delay(350) + vocabularyViewModel.mergeDuplicateItems(item, duplicates.first()) + } + } + ) + } + + LaunchedEffect(item) { + wordFirst = item.wordFirst + wordSecond = item.wordSecond + } + + LaunchedEffect(item.languageFirstId, item.languageSecondId) { + langFirst = languageViewModel.getLanguageById(item.languageFirstId ?: 0) + langSecond = languageViewModel.getLanguageById(item.languageSecondId ?: 0) + } + + + LaunchedEffect(langFirst, langSecond) { + langFirst?.code?.let { code -> + articlesLangFirst = languageConfigViewModel.getArticlesForLanguage(code) + } + langSecond?.code?.let { code -> + articlesLangSecond = languageConfigViewModel.getArticlesForLanguage(code) + } + } + + val canRemoveArticles = remember(wordFirst, wordSecond, articlesLangFirst, articlesLangSecond) { + val wordFirstHasArticle = articlesLangFirst.any { wordFirst.startsWith("$it ", ignoreCase = true) } + val wordSecondHasArticle = articlesLangSecond.any { wordSecond.startsWith("$it ", ignoreCase = true) } + + val isAnyWordASentence = StringHelper.isSentenceStrict(wordFirst) || StringHelper.isSentenceStrict(wordSecond) + + (wordFirstHasArticle || wordSecondHasArticle) && !isAnyWordASentence + } + + + fun processAndHide(action: suspend () -> Unit) { + scope.launch { + visible = false + delay(350) + action() + } + } + + AnimatedVisibility( + visible = visible, + exit = slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(durationMillis = 300) + ) + fadeOut(animationSpec = tween(durationMillis = 200)) + ) { + AppCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (isDuplicate) { + SuggestionChip( + onClick = { + if (duplicates.isNotEmpty()) { + showDuplicateDialog = true + } + }, + label = { Text(stringResource(R.string.duplicate)) }, + icon = { + Icon( + imageVector = AppIcons.Warning, + contentDescription = stringResource(R.string.label_warning), + modifier = Modifier.size(FilterChipDefaults.IconSize) + ) + }, + colors = SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + labelColor = MaterialTheme.colorScheme.onErrorContainer, + iconContentColor = MaterialTheme.colorScheme.onErrorContainer + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error) + ) + } else { + Spacer(modifier = Modifier.height(1.dp)) + } + + AnimatedVisibility(visible = canRemoveArticles) { + SuggestionChip( + onClick = { + vocabularyViewModel.removeArticles(item, articlesLangFirst, articlesLangSecond) + }, + label = { Text(stringResource(R.string.label_remove_articles)) }, + icon = { + Icon( + imageVector = AppIcons.Clean, + contentDescription = stringResource(R.string.label_remove_articles), + modifier = Modifier.size(FilterChipDefaults.IconSize) + ) + }, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface) + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + AppTextField( + value = wordFirst, + onValueChange = { wordFirst = it }, + label = { Text(stringResource(R.string.label_word)) }, + modifier = Modifier.fillMaxWidth() + ) + SingleLanguageDropDown( + languageViewModel = languageViewModel, + selectedLanguage = langFirst, + onLanguageSelected = { langFirst = it } + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + AppTextField( + value = wordSecond, + onValueChange = { wordSecond = it }, + label = { Text(stringResource(R.string.label_translation)) }, + modifier = Modifier.fillMaxWidth() + ) + SingleLanguageDropDown( + languageViewModel = languageViewModel, + selectedLanguage = langSecond, + onLanguageSelected = { langSecond = it } + ) + } + + CategoryDropdown( + categoryViewModel = categoryViewModel, + onCategorySelected = { categories -> + selectedCategories = categories.mapNotNull { it?.id } + }, + multipleSelectable = true, + onlyLists = true + ) + + + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + + // UPDATED: Conditionally display action buttons based on the filter mode + when (filterMode) { + FilterMode.FAULTY -> { + FaultyItemActions( + isDoneEnabled = isItemNowValid, + onDeleteClick = { + processAndHide { + vocabularyViewModel.deleteVocabularyItemsById(listOf(item.id)) + } + }, + onDoneClick = { + processAndHide { + val updatedItem = item.copy( + wordFirst = wordFirst, wordSecond = wordSecond, + languageFirstId = langFirst?.nameResId, languageSecondId = langSecond?.nameResId + ) + vocabularyViewModel.editVocabularyItem(updatedItem) + + vocabularyViewModel.addVocabularyItemToStage(listOf(updatedItem), VocabularyStage.NEW) + } + } + ) + } + else -> { + LabeledSegmentedIconButtons( + onDeleteClick = { + processAndHide { + vocabularyViewModel.deleteVocabularyItemsById(listOf(item.id)) + } + }, + onLearnedClick = { + processAndHide { + val updatedItem = item.copy(wordFirst = wordFirst, wordSecond = wordSecond, languageFirstId = langFirst?.nameResId, languageSecondId = langSecond?.nameResId) + vocabularyViewModel.editVocabularyItem(updatedItem) + selectedCategories.forEach { catId -> vocabularyViewModel.addVocabularyItemToCategory(listOf(updatedItem), catId) } + vocabularyViewModel.addVocabularyItemToStage(listOf(updatedItem), VocabularyStage.LEARNED) + } + }, + onDoneClick = { + processAndHide { + val updatedItem = item.copy(wordFirst = wordFirst, wordSecond = wordSecond, languageFirstId = langFirst?.nameResId, languageSecondId = langSecond?.nameResId) + vocabularyViewModel.editVocabularyItem(updatedItem) + selectedCategories.forEach { catId -> vocabularyViewModel.addVocabularyItemToCategory(listOf(updatedItem), catId) } + vocabularyViewModel.addVocabularyItemToStage(listOf(updatedItem), VocabularyStage.STAGE_1) + } + } + ) + } + } + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun VocabularySortingItemPreview() { + val item = VocabularyItem(id = 1, wordFirst = "Hello", wordSecond = "Hola", languageFirstId = 1, languageSecondId = 2) + VocabularySortingItem( + item = item, + filterMode = FilterMode.NEW, + vocabularyViewModel = viewModel(), + categoryViewModel = viewModel(), + languageConfigViewModel = viewModel() + ) +} + +@Preview +@Composable +fun FaultyItemActionsPreview() { + FaultyItemActions(isDoneEnabled = true, onDeleteClick = {}, onDoneClick = {}) +} + +@Composable +fun FaultyItemActions( + isDoneEnabled: Boolean, + onDeleteClick: () -> Unit, + onDoneClick: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val buttonHeight = 48.dp + val cornerRadius = 24.dp + + Row( + modifier = Modifier + .fillMaxWidth() + .height(buttonHeight) + .clip(RoundedCornerShape(cornerRadius)), + verticalAlignment = Alignment.CenterVertically + ) { + // Delete Button + AppButton( + onClick = onDeleteClick, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer) + ) { + Icon(AppIcons.Delete, contentDescription = stringResource(R.string.label_delete), tint = MaterialTheme.colorScheme.onErrorContainer) + } + + AppButton( + onClick = onDoneClick, + enabled = isDoneEnabled, // Button is enabled only when the item is valid + modifier = Modifier + .weight(2f) + .fillMaxHeight(), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + ) { + Icon(AppIcons.Check, contentDescription = stringResource(R.string.label_done), tint = MaterialTheme.colorScheme.onPrimary) + } + } + + Spacer(Modifier.height(4.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + Text(stringResource(R.string.label_delete), Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 12.sp, color = MaterialTheme.colorScheme.error) + Text(stringResource(R.string.label_done), Modifier.weight(2f), textAlign = TextAlign.Center, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface) + } + } +} + +@Preview +@Composable +fun LabeledSegmentedIconButtonsPreview() { + LabeledSegmentedIconButtons(onDeleteClick = {}, onLearnedClick = {}, onDoneClick = {}) +} +@Composable +fun LabeledSegmentedIconButtons( + onDeleteClick: () -> Unit, + onLearnedClick: () -> Unit, + onDoneClick: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val buttonHeight = 48.dp + val cornerRadius = 24.dp + val secondaryButtonColor = MaterialTheme.colorScheme.surfaceVariant + + Row( + modifier = Modifier + .fillMaxWidth() + .height(buttonHeight) + .clip(RoundedCornerShape(cornerRadius)), + verticalAlignment = Alignment.CenterVertically + ) { + AppButton( + onClick = onDeleteClick, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = secondaryButtonColor) + ) { + Icon(AppIcons.Delete, contentDescription = stringResource(R.string.label_delete), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + + VerticalDivider() + + AppButton( + onClick = onLearnedClick, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = secondaryButtonColor) + ) { + Icon(AppIcons.StageLearned, contentDescription = stringResource(R.string.label_learned), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + + AppButton( + onClick = onDoneClick, + modifier = Modifier + .weight(2f) + .fillMaxHeight(), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + ) { + Icon(AppIcons.Stage1, contentDescription = stringResource(R.string.label_done), tint = MaterialTheme.colorScheme.onPrimary) + } + } + + Spacer(Modifier.height(4.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + ) { + Text(stringResource(R.string.label_delete), Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.label_learned), Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.label_move_first_stage), Modifier.weight(2f), textAlign = TextAlign.Center, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface) + } + } +} + +@Composable +fun VerticalDivider( + modifier: Modifier = Modifier, + thickness: Dp = 1.dp, + color: Color = MaterialTheme.colorScheme.background.copy(alpha = 0.5f) +) { + Box( + modifier + .fillMaxHeight() + .width(thickness) + .background(color = color) + ) +} + +@Preview +@Composable +fun VerticalDividerPreview() { + VerticalDivider() +} + +@Composable +fun EnhancedDuplicateDialog( + newItem: VocabularyItem, + existingItem: VocabularyItem, + onDismiss: () -> Unit, + onDelete: () -> Unit, + onMerge: () -> Unit +) { + AppDialog(onDismissRequest = onDismiss, title = {Text(stringResource(R.string.duplicate_detected), style = MaterialTheme.typography.headlineSmall)}) { + Column(Modifier.padding(24.dp)) { + Text( + stringResource(R.string.text_a_similar_item_already_exists), + style = MaterialTheme.typography.bodyMedium + ) + Spacer(Modifier.height(16.dp)) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.new_item), style = MaterialTheme.typography.titleSmall) + Text(newItem.wordFirst) + Text(newItem.wordSecond) + } + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.existing_item_id_d, existingItem.id), style = MaterialTheme.typography.titleSmall) + Text(existingItem.wordFirst) + Text(existingItem.wordSecond) + } + } + Spacer(Modifier.height(24.dp)) + DialogSegmentedButtons( + onDeleteClick = onDelete, + onKeepBothClick = onDismiss, + onMergeClick = onMerge + ) + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun EnhancedDuplicateDialogPreview() { + val newItem = VocabularyItem(id = 1, wordFirst = "Test New", wordSecond = "Test Neu", languageFirstId = 1, languageSecondId = 2) + val existingItem = VocabularyItem(id = 2, wordFirst = "Test Old", wordSecond = "Test Alt", languageFirstId = 1, languageSecondId = 2) + EnhancedDuplicateDialog( + newItem = newItem, + existingItem = existingItem, + onDismiss = {}, + onDelete = {}, + onMerge = {} + ) +} + +@Composable +fun DialogSegmentedButtons( + onDeleteClick: () -> Unit, + onKeepBothClick: () -> Unit, + onMergeClick: () -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val buttonHeight = 48.dp + val cornerRadius = 24.dp + val secondaryButtonColor = MaterialTheme.colorScheme.surfaceVariant + val onSecondaryTextColor = MaterialTheme.colorScheme.onSurfaceVariant + + Row( + modifier = Modifier + .fillMaxWidth() + .height(buttonHeight) + .clip(RoundedCornerShape(cornerRadius)), + verticalAlignment = Alignment.CenterVertically + ) { + + AppButton( + onClick = onDeleteClick, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer) + ) { + Icon(AppIcons.Delete, contentDescription = stringResource(R.string.delete_new), tint = MaterialTheme.colorScheme.onErrorContainer) + } + + VerticalDivider() + + + AppButton( + onClick = onKeepBothClick, + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = secondaryButtonColor) + ) { + Icon(AppIcons.Copy, contentDescription = stringResource(R.string.keep_both), tint = onSecondaryTextColor) + } + + // Merge Button + AppButton( + onClick = onMergeClick, + modifier = Modifier + .weight(2f) + .fillMaxHeight(), + shape = RoundedCornerShape(0.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + ) { + Icon(AppIcons.Merge, contentDescription = stringResource(R.string.merge), tint = MaterialTheme.colorScheme.onPrimary) + } + } + + Spacer(Modifier.height(4.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) { + Text(stringResource(R.string.delete_new), Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 12.sp, color = MaterialTheme.colorScheme.error) + Text(stringResource(R.string.keep_both), Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 12.sp, color = onSecondaryTextColor) + Text(stringResource(R.string.merge_items), Modifier.weight(2f), textAlign = TextAlign.Center, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface) + } + } +} + +@Preview +@Composable +fun DialogSegmentedButtonsPreview() { + DialogSegmentedButtons(onDeleteClick = {}, onKeepBothClick = {}, onMergeClick = {}) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/AdditionalContentBottomSheet.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/AdditionalContentBottomSheet.kt new file mode 100644 index 0000000..8356cb8 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/AdditionalContentBottomSheet.kt @@ -0,0 +1,556 @@ +package eu.gaudian.translator.view.vocabulary.card + +import android.app.Application +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.SecondaryButton +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.launch +import okhttp3.internal.platform.PlatformRegistry.applicationContext +import kotlin.system.measureTimeMillis + + +data class SynonymDisplayState( + val synonym: VocabularyItem, + val language: Language, + val alreadyInRepository: Boolean, + val proximity: Int? +) + +@Composable +fun AdditionalContentBottomSheet( + vocabularyItemId: Int, + term: String, // "first" or "second" + navController: NavController, +) { + val activity = LocalContext.current.findActivity() + + val vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application) + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity) + val coroutineScope = rememberCoroutineScope() + + // Get the vocabulary item + val vocabularyItem by vocabularyViewModel.getVocabularyItemFlow(vocabularyItemId) + .collectAsState(initial = null) + + val item = vocabularyItem ?: return + + // Get languages + val languageFirst by languageViewModel.getLanguageByIdFlow(item.languageFirstId ?: 0) + .collectAsState(initial = null) + val languageSecond by languageViewModel.getLanguageByIdFlow(item.languageSecondId ?: 0) + .collectAsState(initial = null) + + // State for synonyms and examples + var synonyms by remember(vocabularyItemId, term) { mutableStateOf?>(null) } + var example by remember(vocabularyItemId, term) { mutableStateOf?>(null) } + + // Loading states for refresh operations + var synonymsLoading by remember { mutableStateOf(false) } + var exampleLoading by remember { mutableStateOf(false) } + + // Determine which word and language to use + val isFirst = term == "first" + val word = if (isFirst) item.wordFirst else item.wordSecond + val language = if (isFirst) languageFirst else languageSecond + + // Handle dictionary navigation + val handleMoveToDictionary: () -> Unit = { + if (language != null) { + coroutineScope.launch { + @Suppress("HardCodedStringLiteral") + Log.i("handleMoveToDictionary","handleMoveToDictionary performed") + dictionaryViewModel.performSearch( + query = word, + language = language, + regenerate = false + ) + } + } + } + + val entryToNavigate by dictionaryViewModel.navigateToEntry.collectAsState() + LaunchedEffect(key1 = entryToNavigate) { + entryToNavigate?.let { entry -> + // Set flag indicating navigation is from external source (not DictionaryResultScreen) + dictionaryViewModel.setNavigatingFromDictionaryResult(false) + // Clear the navigation event immediately to prevent DictionaryResultScreen from handling it + dictionaryViewModel.onNavigationDone() + @Suppress("HardCodedStringLiteral") + navController.navigate("dictionary_result/${entry.id}") { + // Clear the back stack up to the dictionary_result destination to prevent double navigation + popUpTo("dictionary_result") { inclusive = false } + launchSingleTop = true + } + } + } + + val refreshSynonyms: () -> Unit = { + if (language != null) { + coroutineScope.launch { + synonymsLoading = true + measureTimeMillis { + val rawSynonymData = vocabularyViewModel.getSynonymsForItem( + vocabularyItemId = item.id, + isForFirstWord = isFirst + ) + val sortedSynonymData = rawSynonymData.sortedByDescending { it.proximity ?: 101 } + + if (sortedSynonymData.isNotEmpty()) { + val rawItems = sortedSynonymData.map { it.item } + val existenceFlags = vocabularyViewModel.findDuplicates(rawItems) + synonyms = sortedSynonymData.mapIndexed { index, synonymData -> + SynonymDisplayState( + synonym = synonymData.item, + language = language, + alreadyInRepository = existenceFlags[index], + proximity = synonymData.proximity + ) + } + } else { + synonyms = emptyList() + } + } + synonymsLoading = false + } + } + } + + val refreshExample: () -> Unit = { + coroutineScope.launch { + exampleLoading = true + measureTimeMillis { + example = vocabularyViewModel.getExampleForItem( + item.id, + isFirstWord = isFirst, + languageFirst = languageFirst, + languageSecond = languageSecond + ) + } + exampleLoading = false + } + } + + // Load data when component is first displayed + LaunchedEffect(vocabularyItemId, term) { + refreshExample() + } + + // Load synonyms when language becomes available + LaunchedEffect(language) { + if (language != null) { + refreshSynonyms() + } + } + + // Handle synonym addition + val handleSynonymAdded: () -> Unit = { + refreshSynonyms() + } + + // Handle example reload + val handleReloadExample: () -> Unit = { + refreshExample() + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = word, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp) + ) + + if (language != null) { + Text( + text = language.name, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + + ExampleSentencesDisplay(exampleSentence = example, loading = exampleLoading, onReload = handleReloadExample) + + Spacer(modifier = Modifier.height(16.dp)) + + SynonymsDisplay( + synonyms = synonyms, + loading = synonymsLoading, + vocabularyViewModel = vocabularyViewModel, + onSynonymAdded = handleSynonymAdded, + onReload = refreshSynonyms + ) + + Spacer(modifier = Modifier.height(16.dp)) + + MoveToDictionaryButton(onClick = handleMoveToDictionary) + } +} + +@Preview +@Composable +fun AdditionalContentBottomSheetPreview() { + + AdditionalContentBottomSheet( + vocabularyItemId = 1, + term = "first", + navController = NavController(applicationContext as Application) + ) +} + +@Composable +private fun ExampleSentencesDisplay( + exampleSentence: Pair?, + loading: Boolean, + onReload: () -> Unit, +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.example), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 0.dp) + ) + IconButton(onClick = onReload) { + Icon( + modifier = Modifier.padding(0.dp), + imageVector = AppIcons.Refresh, + contentDescription = stringResource(R.string.label_reload) + ) + } + } + + if (loading || exampleSentence == null) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + Text( + text = "\"${exampleSentence.first.trim()}\"", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "\"${exampleSentence.second.trim()}\"", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExampleSentencesDisplayLoadingPreview() { + ExampleSentencesDisplay(exampleSentence = null, loading = false, onReload = {}) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +private fun ExampleSentencesDisplayLoadedPreview() { + ExampleSentencesDisplay( + exampleSentence = Pair("This is an example sentence.", "Dies ist ein Beispielsatz."), + loading = false, + onReload = {} + ) +} + + + +@Composable +private fun SynonymsDisplay( + synonyms: List?, + loading: Boolean, + vocabularyViewModel: VocabularyViewModel, + onSynonymAdded: () -> Unit, + onReload: () -> Unit, +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.synonyms), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + IconButton(onClick = onReload) { + Icon( + imageVector = AppIcons.Refresh, + contentDescription = stringResource(R.string.label_reload) + ) + } + } + Spacer(Modifier.height(4.dp)) + + if (loading || synonyms == null) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + return + } + + if (synonyms.isNotEmpty()) { + val coroutineScope = rememberCoroutineScope() + var locallyAddedSynonyms by remember(synonyms) { mutableStateOf(emptySet()) } + + FlowRow( + modifier = Modifier.fillMaxWidth(), + ) { + synonyms.forEach { state -> + val isDisplayedAsAdded = state.alreadyInRepository || locallyAddedSynonyms.contains(state.synonym) + + SynonymChip( + state = state, + isDisplayedAsAdded = isDisplayedAsAdded, + onAddClick = { itemToAdd -> + coroutineScope.launch { + vocabularyViewModel.addVocabularyItems(listOf(itemToAdd)) + locallyAddedSynonyms = locallyAddedSynonyms + itemToAdd + onSynonymAdded() + } + } + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SynonymsDisplayLoadingPreview() { + // This preview doesn't have a real VocabularyViewModel, so actions will not work. + SynonymsDisplay(synonyms = null, loading = false, vocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application), onSynonymAdded = {}, onReload = {}) +} + +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true) +@Composable +private fun SynonymsDisplayLoadedPreview() { + val language = Language(code = "en", region = "US", nameResId = 1, name = "English", englishName = "English") + val synonyms = listOf( + SynonymDisplayState( + synonym = VocabularyItem(1, 1, 2, "similar", "Àhnlich", null, null, null, null), + language = language, + alreadyInRepository = false, + proximity = 95 + ), + SynonymDisplayState( + synonym = VocabularyItem(2, 1, 2, "alike", "gleich", null, null, null, null), + language = language, + alreadyInRepository = true, + proximity = 80 + ), + SynonymDisplayState( + synonym = VocabularyItem(3, 1, 2, "comparable", "vergleichbar", null, null, null, null), + language = language, + alreadyInRepository = false, + proximity = null + ) + ) + SynonymsDisplay(synonyms = synonyms, loading = false, vocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application), onSynonymAdded = {}, onReload = {}) +} + +@Composable +private fun MoveToDictionaryButton(onClick: () -> Unit) { + + SecondaryButton( + modifier = Modifier.fillMaxWidth(), + onClick = onClick, + text = stringResource(R.string.label_show_dictionary_entry), + icon = AppIcons.ArrowForward + ) + + + +} + +@Preview +@Composable +private fun MoveToDictionaryButtonPreview() { + + MoveToDictionaryButton(onClick = {}) + +} + + + +@Composable +private fun SynonymChip( + state: SynonymDisplayState, + isDisplayedAsAdded: Boolean, + onAddClick: (VocabularyItem) -> Unit, +) { + val text = if (state.language.nameResId == state.synonym.languageFirstId) { + state.synonym.wordFirst + } else { + state.synonym.wordSecond + } + + val displayText = if (state.proximity != null) { + "$text (${state.proximity}%)" + } else { + text + } + + val icon = if (isDisplayedAsAdded) AppIcons.Check else AppIcons.Add + + val backgroundColor = when { + isDisplayedAsAdded -> MaterialTheme.colorScheme.secondaryContainer + state.proximity != null -> { + val fraction = (state.proximity / 100f).coerceIn(0f, 1f) + lerp( + start = MaterialTheme.colorScheme.surfaceVariant, + stop = MaterialTheme.colorScheme.primaryContainer, + fraction = fraction + ) + } + else -> MaterialTheme.colorScheme.primaryContainer + } + + val contentColor = when { + isDisplayedAsAdded -> MaterialTheme.colorScheme.onSecondaryContainer + state.proximity != null -> { + val fraction = (state.proximity / 100f).coerceIn(0f, 1f) + lerp( + start = MaterialTheme.colorScheme.onSurfaceVariant, + stop = MaterialTheme.colorScheme.onPrimaryContainer, + fraction = fraction + ) + } + else -> MaterialTheme.colorScheme.onPrimaryContainer + } + + val clickModifier = if (isDisplayedAsAdded) { + Modifier + } else { + Modifier.clickable { onAddClick(state.synonym) } + } + + Surface( + modifier = Modifier + .padding(4.dp) + .then(clickModifier), + shape = RoundedCornerShape(16.dp), + color = backgroundColor, + contentColor = contentColor, + tonalElevation = 2.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = if (isDisplayedAsAdded) stringResource(R.string.synonym_exists) else stringResource( + R.string.label_add_synonym + ) + ) + Text(text = displayText, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +private fun SynonymChipNotAddedPreview() { + val language = Language(code = "en", region = "US", nameResId = 1, name = "English", englishName = "English") + val state = SynonymDisplayState( + synonym = VocabularyItem(1, 1, 2, "example", "beispiel", null, null, null, null), + language = language, + alreadyInRepository = false, + proximity = 85 + ) + SynonymChip( + state = state, + isDisplayedAsAdded = false, + onAddClick = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +private fun SynonymChipAddedPreview() { + val language = Language(code = "en", region = "US", nameResId = 1, name = "English", englishName = "English") + val state = SynonymDisplayState( + synonym = VocabularyItem(1, 1, 2, "example", "beispiel", null, null, null, null), + language = language, + alreadyInRepository = true, + proximity = 90 + ) + SynonymChip( + state = state, + isDisplayedAsAdded = true, + onAddClick = {} + ) +} diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/DraggableActionPanel.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/DraggableActionPanel.kt new file mode 100644 index 0000000..69b3538 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/DraggableActionPanel.kt @@ -0,0 +1,244 @@ +package eu.gaudian.translator.view.vocabulary.card + +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.AnchoredDraggableDefaults +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.animateTo +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.view.composable.AppIcons +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +private enum class DragState { Minimized, Extended } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun DraggableActionPanel( + modifier: Modifier = Modifier, + isEditing: Boolean, + onEditClick: () -> Unit, + onSaveClick: () -> Unit, + onCancelClick: () -> Unit, + onStatisticsClick: () -> Unit, + onMoveToCategoryClick: () -> Unit, + onMoveToStageClick: () -> Unit, + onDeleteClick: () -> Unit, + showAnalyzeGrammarButton: Boolean, + onAnalyzeGrammarClick: () -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val density = LocalDensity.current + + val positionalThreshold = { totalDistance: Float -> totalDistance * 0.5f } + val velocityThreshold = { with(density) { 125.dp.toPx() } } + + @Suppress("DEPRECATION") val state: AnchoredDraggableState = remember { + AnchoredDraggableState( + initialValue = DragState.Minimized, + anchors = DraggableAnchors { + DragState.Extended at 0f + DragState.Minimized at 1000f // Initial off-screen position + }, + positionalThreshold = positionalThreshold, + velocityThreshold = velocityThreshold, + snapAnimationSpec = tween(), + decayAnimationSpec = exponentialDecay() + ) + } + + var panelWidth by remember { mutableFloatStateOf(0f) } + val minimizedWidth = with(density) { 64.dp.toPx() } + val isExtended = state.targetValue == DragState.Extended + + LaunchedEffect(panelWidth) { + if (panelWidth > 0) { + val anchors = DraggableAnchors { + DragState.Extended at 0f + DragState.Minimized at panelWidth - minimizedWidth + } + if (state.anchors != anchors) { + state.updateAnchors(anchors) + } + } + } + + Card( + shape = RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + modifier = modifier + .padding(vertical = 16.dp) + .widthIn(min = 64.dp, max = 250.dp) + .onSizeChanged { + panelWidth = it.width.toFloat() + } + .offset { + val xOffset = if (state.anchors.size > 0) state.requireOffset() else 0f + IntOffset(x = xOffset.roundToInt(), y = 0) + } + .anchoredDraggable( + state = state, + orientation = Orientation.Horizontal, + flingBehavior = AnchoredDraggableDefaults.flingBehavior( + state = state, + positionalThreshold = positionalThreshold, + ) + ) + ) { + Row(modifier.padding(8.dp)) { + // Thumb indicator + Surface( + modifier = Modifier + .height(75.dp) + .width(4.dp) + .align(Alignment.CenterVertically), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f), + shape = RoundedCornerShape(2.dp) + ) {} + Column( + modifier = Modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 8.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Center + ) { + + val actionClickHandler: (() -> Unit) -> () -> Unit = { action -> + { + action() + coroutineScope.launch { + state.animateTo(DragState.Minimized) + } + } + } + + if (isEditing) { + ActionItem(icon = AppIcons.Check, label = stringResource(R.string.label_save), isExtended = isExtended, onClick = actionClickHandler(onSaveClick)) + ActionItem(icon = AppIcons.Close, stringResource(R.string.label_cancel), isExtended = isExtended, onClick = actionClickHandler(onCancelClick)) + } else { + ActionItem(icon = AppIcons.Edit, label = stringResource(R.string.edit), isExtended = isExtended, onClick = actionClickHandler(onEditClick)) + } + if (!isEditing) { + + if (showAnalyzeGrammarButton) { + ActionItem( + icon = AppIcons.AI, + label = stringResource(R.string.label_analyze_grammar), + isExtended = isExtended, + onClick = actionClickHandler(onAnalyzeGrammarClick) + ) + } + + ActionItem(icon = AppIcons.Category, label = stringResource(R.string.move_to_category), isExtended = isExtended, onClick = actionClickHandler(onMoveToCategoryClick)) + ActionItem(icon = AppIcons.Stages, label = stringResource(R.string.move_to_stage), isExtended = isExtended, onClick = actionClickHandler(onMoveToStageClick)) + ActionItem(icon = AppIcons.Statistics, label = stringResource(R.string.label_statistics), isExtended = isExtended, onClick = actionClickHandler(onStatisticsClick)) + + + ActionItem(icon = AppIcons.Delete, stringResource(R.string.label_delete), isExtended = isExtended, onClick = actionClickHandler(onDeleteClick)) + } + } + } + } +} + +@Composable +private fun ActionItem( + icon: ImageVector, + label: String, + isExtended: Boolean, + onClick: () -> Unit, + isLoading: Boolean = false, + isActive: Boolean = false +) { + val shape = RoundedCornerShape(12.dp) + val backgroundColor = if (isActive) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) + } else { + Color.Transparent + } + + Row( + modifier = Modifier + .clip(shape) + .background(backgroundColor) + .clickable(onClick = onClick, enabled = !isLoading) + .padding(vertical = 8.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 2.5.dp + ) + } else { + Icon(icon, contentDescription = label) + } + if (isExtended) { + Text(text = label, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Preview +@Composable +fun DraggableActionPanelPreview() { + DraggableActionPanel( + isEditing = false, + onEditClick = {}, + onSaveClick = {}, + onCancelClick = {}, + + onStatisticsClick = {}, + onMoveToCategoryClick = {}, + onMoveToStageClick = {}, + onDeleteClick = {}, + showAnalyzeGrammarButton = true, + onAnalyzeGrammarClick = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/GrammarComponents.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/GrammarComponents.kt new file mode 100644 index 0000000..023a9b3 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/GrammarComponents.kt @@ -0,0 +1,515 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.vocabulary.card + +import android.annotation.SuppressLint +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.grammar.CategoryConfig +import eu.gaudian.translator.model.grammar.FieldConfig +import eu.gaudian.translator.model.grammar.GrammaticalFeature +import eu.gaudian.translator.model.grammar.LanguageConfig +import eu.gaudian.translator.model.grammar.formatGrammarDetails +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppTextField +import eu.gaudian.translator.view.composable.DialogButton +import eu.gaudian.translator.viewmodel.LanguageConfigViewModel + +@Composable +internal fun GrammarDetailsText( + details: GrammaticalFeature?, + language: Language?, + isRevealed: Boolean +) { + Log.d("GrammarComponents", "GrammarDetailsText called with details: $details, language: $language, isRevealed: $isRevealed") + + val configViewModel: LanguageConfigViewModel = viewModel() + val allConfigs by configViewModel.configs.collectAsState() + val languageConfig = language?.code?.let { allConfigs[it] } + val categoryConfig = details?.category?.let { languageConfig?.categories?.get(it) } + + val propertiesString = remember(details, categoryConfig) { + if (details == null) { + "" + } else { + formatGrammarDetails(details, categoryConfig) + } + } + + Log.d("GrammarComponents", "Properties string: '$propertiesString'") + + if (propertiesString.isNotEmpty()) { + Text( + text = propertiesString, + style = MaterialTheme.typography.bodyLarge.copy(fontStyle = FontStyle.Italic), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.blur(if (!isRevealed) 10.dp else 0.dp) + ) + } +} + +@Composable +internal fun GrammarEditDialog( + feature: GrammaticalFeature?, + language: Language, + onDismiss: () -> Unit, + onSave: (GrammaticalFeature?) -> Unit +) { + Log.d("GrammarComponents", "GrammarEditDialog called with feature: $feature, language: $language") + + val configViewModel: LanguageConfigViewModel = viewModel() + val allConfigs by configViewModel.configs.collectAsState() + val languageConfig = allConfigs[language.code] + + Log.d("GrammarComponents", "Language config found: ${languageConfig != null}") + + var selectedCategoryKey by remember { mutableStateOf(feature?.category) } + var properties by remember { mutableStateOf(feature?.properties ?: emptyMap()) } + + Log.d("GrammarComponents", "Initial selectedCategoryKey: $selectedCategoryKey, properties: $properties") + + AppDialog(onDismissRequest = onDismiss, title = {Text( + text = stringResource(R.string.edit_features_for, language.name), + )}) { + Column(modifier = Modifier.padding(16.dp)) { + + if (languageConfig == null) { + Text(stringResource(R.string.no_grammar_configuration_found_for_this_language)) + } else { + GuidedEditTabContent( + languageConfig = languageConfig, + selectedCategoryKey = selectedCategoryKey, + onCategoryChange = { newCategoryKey -> + selectedCategoryKey = newCategoryKey + properties = emptyMap() + }, + selectedProperties = properties, + onPropertiesChange = { newProperties -> + properties = newProperties + } + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(0.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = { onSave(null) }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text(stringResource(R.string.label_delete)) + } + } + + Spacer(Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth().padding(0.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.label_cancel)) + } + DialogButton( + onClick = { + selectedCategoryKey?.let { + onSave(GrammaticalFeature(category = it, properties = properties)) + } + }, + enabled = selectedCategoryKey != null, + text = stringResource(R.string.label_save) + + ) + } + } + } +} + +@Composable +private fun GuidedEditTabContent( + languageConfig: LanguageConfig, + selectedCategoryKey: String?, + onCategoryChange: (String) -> Unit, + selectedProperties: Map, + onPropertiesChange: (Map) -> Unit +) { + Log.d("GrammarComponents", "GuidedEditTabContent called with selectedCategoryKey: $selectedCategoryKey, selectedProperties: $selectedProperties") + + var isCategoryDropdownExpanded by remember { mutableStateOf(false) } + val categories = languageConfig.categories.entries.toList() + val selectedCategoryConfig = selectedCategoryKey?.let { languageConfig.categories[it] } + + Log.d("GrammarComponents", "Categories count: ${categories.size}, selectedCategoryConfig: $selectedCategoryConfig") + + Column { + Log.d("GrammarComponents", "Column with verticalScroll modifier created") + ExposedDropdownMenuBox( + expanded = isCategoryDropdownExpanded, + onExpandedChange = { isCategoryDropdownExpanded = !isCategoryDropdownExpanded } + ) { + AppTextField( + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled = true) + .fillMaxWidth(), + readOnly = true, // value is from a dropdown + value = selectedCategoryConfig?.display_key?.let { resolveStringResource(it) } ?: stringResource(R.string.text_select_category), + onValueChange = {}, // readOnly, so no-op + label = { Text(stringResource(R.string.word_type)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isCategoryDropdownExpanded) }, + ) + ExposedDropdownMenu( + expanded = isCategoryDropdownExpanded, + onDismissRequest = { isCategoryDropdownExpanded = false } + ) { + categories.forEach { (key, config) -> + DropdownMenuItem( + text = { Text(resolveStringResource(config.display_key)) }, + onClick = { + onCategoryChange(key) + isCategoryDropdownExpanded = false + } + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + selectedCategoryConfig?.fields?.forEach { fieldConfig -> + DynamicField( + fieldConfig = fieldConfig, + currentValue = selectedProperties[fieldConfig.key], + onValueChange = { newValue -> + val newProps = selectedProperties.toMutableMap() + if (newValue == null) { + newProps.remove(fieldConfig.key) + } else { + newProps[fieldConfig.key] = newValue + } + onPropertiesChange(newProps) + } + ) + Spacer(Modifier.height(8.dp)) + } + } +} + +@Suppress("HardCodedStringLiteral") +@Composable +private fun DynamicField( + fieldConfig: FieldConfig, + currentValue: String?, + onValueChange: (String?) -> Unit +) { + Log.d("GrammarComponents", "DynamicField called with fieldConfig: ${fieldConfig.key}, type: ${fieldConfig.type}, currentValue: $currentValue") + + val label = resolveStringResource(fieldConfig.display_key) + + Log.d("GrammarComponents", "Resolved label: $label") + + when (fieldConfig.type) { + "text" -> { + AppTextField( + value = currentValue ?: "", + onValueChange = { onValueChange(it.ifEmpty { null }) }, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth() + ) + } + "enum" -> { + var isExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = isExpanded, + onExpandedChange = { + isExpanded = !isExpanded + } + ) { + AppTextField( + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled = true) + .fillMaxWidth(), + readOnly = true, + value = currentValue?.let { resolveStringResource(it) } ?: "", + onValueChange = {}, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded) }, + ) + ExposedDropdownMenu( + expanded = isExpanded, + onDismissRequest = { isExpanded = false } + ) { + fieldConfig.options?.forEach { optionKey -> + DropdownMenuItem( + text = { Text(resolveStringResource(optionKey)) }, + onClick = { + onValueChange(optionKey) + isExpanded = false + } + ) + } + } + } + } + "boolean" -> { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onValueChange((!(currentValue?.toBoolean() ?: false)).toString()) } + ) { + Checkbox( + checked = currentValue?.toBoolean() ?: false, + onCheckedChange = { onValueChange(it.toString()) } + ) + Spacer(Modifier.width(8.dp)) + Text(label) + } + } + } +} + +/** + * A map to associate string keys from the grammar configuration with their corresponding + * Android string resource IDs. This avoids the use of `getIdentifier` which is inefficient + * and discouraged. This approach is much faster and allows for compile-time verification. + */ +@Suppress("HardCodedStringLiteral") +private val stringResourceMap = mapOf( + "noun" to R.string.label_noun, + "verb" to R.string.label_verb, + "adjective" to R.string.label_adjective, + "adverb" to R.string.label_adverb, + "pronoun" to R.string.label_pronoun, + "preposition" to R.string.label_preposition, + "conjunction" to R.string.label_conjunction, + "interjection" to R.string.label_interjection, + "article" to R.string.label_article, + "gender" to R.string.label_gender, + "masculine" to R.string.label_masculine, + "feminine" to R.string.label_feminine, + "neuter" to R.string.label_neuter, + "common" to R.string.label_common, + "plural" to R.string.label_plural, + "tense" to R.string.label_tense, + "present" to R.string.present, + "past" to R.string.past, + "future" to R.string.future, + "mood" to R.string.mood, + "indicative" to R.string.indicative, + "subjunctive" to R.string.subjunctive, + "imperative" to R.string.imperative, + "word_type" to R.string.word_type +) + +@SuppressLint("LocalContextResourcesRead") +@Composable +private fun resolveStringResource(key: String): String { + // Using a map is more efficient than reflection with getIdentifier, + // as it avoids runtime lookups and allows for better build optimizations. + val resourceId = stringResourceMap[key] + return if (resourceId != null) { + stringResource(id = resourceId) + } else { + // Fallback for keys not in the map, useful for development or dynamic keys. + // The remember call here prevents recalculating the formatted string on every recomposition. + remember(key) { + key.replace('_', ' ').replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun GrammarDetailsTextPreview() { + val details = GrammaticalFeature( + category = "noun", + properties = mapOf("gender" to "masculine", "plural" to "die MÀnner") + ) + val language = Language( + code = "de", + region = "DE", + nameResId = 0, + name = "German", + englishName = "German" + ) + GrammarDetailsText(details = details, language = language, isRevealed = true) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun GrammarDetailsTextBlurredPreview() { + val details = GrammaticalFeature( + category = "noun", + properties = mapOf("gender" to "masculine", "plural" to "die MÀnner") + ) + val language = Language( + code = "de", + region = "DE", + nameResId = 0, + name = "German", + englishName = "German" + ) + GrammarDetailsText(details = details, language = language, isRevealed = false) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun GrammarEditDialogPreview() { + val language = Language( + code = "de", + region = "DE", + nameResId = 0, + name = "German", + englishName = "German" + ) + val feature = GrammaticalFeature( + category = "noun", + properties = mapOf("gender" to "masculine") + ) + GrammarEditDialog( + feature = feature, + language = language, + onDismiss = {}, + onSave = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun GuidedEditTabContentPreview() { + val languageConfig = LanguageConfig( + language_code = "de", + categories = mapOf( + "noun" to CategoryConfig( + display_key = "noun", + fields = listOf( + FieldConfig( + "gender", + "gender", + "enum", + listOf("masculine", "feminine", "neuter") + ), + FieldConfig("plural", "plural", "text") + ) + ), + "verb" to CategoryConfig( + display_key = "verb", + fields = listOf( + FieldConfig("tense", "tense", "enum", listOf("present", "past")) + ) + ) + ) + ) + GuidedEditTabContent( + languageConfig = languageConfig, + selectedCategoryKey = "noun", + onCategoryChange = {}, + selectedProperties = mapOf("gender" to "masculine"), + onPropertiesChange = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun DynamicFieldTextPreview() { + val fieldConfig = FieldConfig( + key = "plural", + display_key = "Plural form", + type = "text" + ) + DynamicField( + fieldConfig = fieldConfig, + currentValue = "die Wörter", + onValueChange = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun DynamicFieldEnumPreview() { + val fieldConfig = FieldConfig( + key = "gender", + display_key = "Gender", + type = "enum", + options = listOf("masculine", "feminine", "neuter") + ) + DynamicField( + fieldConfig = fieldConfig, + currentValue = "masculine", + onValueChange = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun DynamicFieldBooleanPreview() { + val fieldConfig = FieldConfig( + key = "is_strong", + display_key = "Is strong verb?", + type = "boolean" + ) + DynamicField( + fieldConfig = fieldConfig, + currentValue = "true", + onValueChange = {} + ) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun ResolveStringResourceWithResourceIdPreview() { + // This preview depends on having a real resource. It might not render correctly in all cases. + // Let's assume R.string.word_type exists and its key is "word_type". + // In a real app, this would resolve to "Word type". + Text(resolveStringResource("word_type")) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun ResolveStringResourceWithKeyFallbackPreview() { + // This tests the fallback mechanism when a resource ID is not found. + Text(resolveStringResource("a_made_up_key")) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/VocabularyCard.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/VocabularyCard.kt new file mode 100644 index 0000000..dd71dd0 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/card/VocabularyCard.kt @@ -0,0 +1,782 @@ +package eu.gaudian.translator.view.vocabulary.card + +import android.app.Application +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import eu.gaudian.translator.R +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.model.grammar.GrammaticalFeature +import eu.gaudian.translator.model.grammar.VocabularyFeatures +import eu.gaudian.translator.model.grammar.formatGrammarDetails +import eu.gaudian.translator.model.jsonParser +import eu.gaudian.translator.ui.theme.semanticColors +import eu.gaudian.translator.utils.StatusAction +import eu.gaudian.translator.utils.StatusMessageService +import eu.gaudian.translator.utils.TextToSpeechHelper +import eu.gaudian.translator.utils.findActivity +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.AutoResizeSingleLineText +import eu.gaudian.translator.view.composable.SingleLanguageDropDown +import eu.gaudian.translator.view.composable.insertBreakOpportunities +import eu.gaudian.translator.viewmodel.DictionaryViewModel +import eu.gaudian.translator.viewmodel.LanguageConfigViewModel +import eu.gaudian.translator.viewmodel.LanguageViewModel +import eu.gaudian.translator.viewmodel.MessageDisplayType +import eu.gaudian.translator.viewmodel.SettingsViewModel +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import kotlinx.coroutines.launch +import okhttp3.internal.platform.PlatformRegistry.applicationContext + + +@Composable +fun VocabularyCard( + vocabularyItem: VocabularyItem, + navController: NavController, + exerciseMode: Boolean, + isFlipped: Boolean, + switchOrder: Boolean, + onStatisticsClick: () -> Unit = {}, + onMoveToCategoryClick: () -> Unit = {}, + onMoveToStageClick: () -> Unit = {}, + onDeleteClick: () -> Unit = {}, + userSpellingAnswer: String? = null, + isUserSpellingCorrect: Boolean? = null, + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application), +) { + + + + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity) + + val coroutineScope = rememberCoroutineScope() + val itemState by vocabularyViewModel.getVocabularyItemFlow(vocabularyItem.id) + .collectAsState(initial = vocabularyItem) + val item = itemState ?: vocabularyItem + + + var editedFeatures by remember(item.id) { + mutableStateOf( + item.features?.let { + try { + jsonParser.decodeFromString(it) + } catch (e: Exception) { + StatusMessageService.triggerNonSuspend( + StatusAction.ShowMessage( + text = e.toString(), + type = MessageDisplayType.ERROR, + timeoutInSeconds = 3 + ) + ) + VocabularyFeatures() + } + } ?: VocabularyFeatures() + ) + } + + val languageFirst by languageViewModel.getLanguageByIdFlow(item.languageFirstId ?: 0) + .collectAsState(initial = null) + val languageSecond by languageViewModel.getLanguageByIdFlow(item.languageSecondId ?: 0) + .collectAsState(initial = null) + + var isEditing by remember(item.id) { mutableStateOf(false) } + + LaunchedEffect(key1 = item.features, key2 = isEditing) { + if (!isEditing) { + editedFeatures = item.features?.let { + try { + jsonParser.decodeFromString(it) + } catch (e: Exception) { + StatusMessageService.triggerNonSuspend( + StatusAction.ShowMessage( + text = e.toString(), + type = MessageDisplayType.ERROR, + timeoutInSeconds = 3 + ) + ) + VocabularyFeatures() + } + } ?: VocabularyFeatures() + } + } + + var editedWordFirst by remember(item.id) { + mutableStateOf(item.wordFirst) + } + var editedWordSecond by remember(item.id) { + mutableStateOf(item.wordSecond) + } + var editedLangFirstId by remember(item.id) { + mutableStateOf(item.languageFirstId) + } + var editedLangSecondId by remember(item.id) { + mutableStateOf(item.languageSecondId) + } + + + var showGrammarDialogFor by remember(item.id) { mutableStateOf(null) } + var showBottomSheet by remember(item.id) { mutableStateOf(false) } + var bottomSheetTerm by remember(item.id) { mutableStateOf(null) } + + + + val entryToNavigate by dictionaryViewModel.navigateToEntry.collectAsState() + LaunchedEffect(key1 = entryToNavigate) { + entryToNavigate?.let { entry -> + // Set flag indicating navigation is from external source (not DictionaryResultScreen) + dictionaryViewModel.setNavigatingFromDictionaryResult(false) + @Suppress("HardCodedStringLiteral") + navController.navigate("dictionary_result/${entry.id}") + dictionaryViewModel.onNavigationDone() + } + } + + val handleSave = remember(editedWordFirst, editedWordSecond, editedLangFirstId, editedLangSecondId, editedFeatures) { + { + coroutineScope.launch { + val updatedItem = item.copy( + wordFirst = editedWordFirst, + wordSecond = editedWordSecond, + languageFirstId = editedLangFirstId!!, + languageSecondId = editedLangSecondId, + features = jsonParser.encodeToString(editedFeatures) + ) + vocabularyViewModel.editVocabularyItem(updatedItem) + isEditing = false + } + } + } + + val handleCancel = remember { + { + editedWordFirst = item.wordFirst + editedWordSecond = item.wordSecond + editedLangFirstId = item.languageFirstId + editedLangSecondId = item.languageSecondId + editedFeatures = item.features?.let { jsonParser.decodeFromString(it) } ?: VocabularyFeatures() + isEditing = false + } + } + + LaunchedEffect(key1 = true) { + if(vocabularyItem.zipfFrequencyFirst == null || vocabularyItem.zipfFrequencySecond == null){ + vocabularyViewModel.editVocabularyItem(vocabularyItem) + } + } + + val rotationY by animateFloatAsState( + targetValue = if (isFlipped) 180f else 0f, + label = "rotationY" + ) + + if (languageFirst == null || languageSecond == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + OutlinedCard( + modifier = Modifier + .fillMaxSize() + .padding(2.dp), + shape = RoundedCornerShape(24.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)) + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + val panelVisible = isFlipped || !exerciseMode + val panelMargin = if (panelVisible) 64.dp else 0.dp + + Card( + modifier = Modifier + .fillMaxSize() + .padding(start = 0.dp, top = 0.dp, bottom = 0.dp, end = panelMargin) + .graphicsLayer { this.rotationY = rotationY }, + shape = RoundedCornerShape(24.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + colors = CardDefaults.cardColors(containerColor = Color.Transparent) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(start = 0.dp, top = 0.dp, bottom = 0.dp, end = 0.dp) + .verticalScroll(rememberScrollState()) + ) { + val isFrontFace = rotationY < 90f + val baseModifier = Modifier.weight(1f, fill = false) + + val frontFaceModifier = if (isFrontFace) { + baseModifier + } else { + baseModifier.graphicsLayer { this.rotationY = 180f } + } + + val backFaceModifier = if (isFrontFace) { + baseModifier + } else { + baseModifier.graphicsLayer { this.rotationY = 180f } + } + + CardFace( + modifier = frontFaceModifier, + isFront = true, + isFirst = !switchOrder, + languageViewModel = languageViewModel, + isEditing = isEditing, + word = if (isEditing) (if (!switchOrder) editedWordFirst else editedWordSecond) else (if (!switchOrder) item.wordFirst else item.wordSecond), + onWordChange = { if (!switchOrder) editedWordFirst = it else editedWordSecond = it }, + language = if (!switchOrder) languageFirst else languageSecond, + onLanguageIdChange = { if (!switchOrder) editedLangFirstId = it else editedLangSecondId = it }, + isRevealed = isFrontFace || exerciseMode, + userSpellingAnswer = userSpellingAnswer, + isUserSpellingCorrect = isUserSpellingCorrect, + correctWord = if (switchOrder) item.wordFirst else item.wordSecond, + wordDetails = if (!switchOrder) editedFeatures.first else editedFeatures.second, + onEditGrammarClick = { showGrammarDialogFor = "first" }, + isExerciseMode = exerciseMode, + vocabularyItem = item, + onMoreClick = { + @Suppress("HardCodedStringLiteral") + bottomSheetTerm = if (!switchOrder) "first" else "second" + showBottomSheet = true + } + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 4.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + + CardFace( + modifier = backFaceModifier, + isFront = false, + isFirst = switchOrder, + languageViewModel = languageViewModel, + isEditing = isEditing, + word = if (isEditing) (if (switchOrder) editedWordFirst else editedWordSecond) else (if (switchOrder) item.wordFirst else item.wordSecond), + onWordChange = { if (switchOrder) editedWordFirst = it else editedWordSecond = it }, + language = if (switchOrder) languageFirst else languageSecond, + onLanguageIdChange = { if (switchOrder) editedLangFirstId = it else editedLangSecondId = it }, + isRevealed = !(!isFlipped && exerciseMode), + userSpellingAnswer = userSpellingAnswer, + isUserSpellingCorrect = isUserSpellingCorrect, + correctWord = if (switchOrder) item.wordFirst else item.wordSecond, + wordDetails = if (switchOrder) editedFeatures.first else editedFeatures.second, + onEditGrammarClick = { + @Suppress("HardCodedStringLiteral") + showGrammarDialogFor = "second" + }, + isExerciseMode = exerciseMode, + vocabularyItem = item, + onMoreClick = { + @Suppress("HardCodedStringLiteral") + bottomSheetTerm = if (switchOrder) "first" else "second" + showBottomSheet = true + } + ) + } + } + + + !switchOrder + if(isFlipped || !exerciseMode) + DraggableActionPanel( + modifier = Modifier + .align(Alignment.CenterEnd) + .height((IntrinsicSize.Min)), + isEditing = isEditing, + onEditClick = { isEditing = true }, + onSaveClick = { handleSave() }, + onCancelClick = handleCancel, + + onStatisticsClick = onStatisticsClick, + onMoveToCategoryClick = onMoveToCategoryClick, + onMoveToStageClick = onMoveToStageClick, + onDeleteClick = onDeleteClick, + + showAnalyzeGrammarButton = item.features.isNullOrBlank(), + onAnalyzeGrammarClick = { + vocabularyViewModel.fetchAndApplyGrammaticalDetailsForList(listOf(item)) + }, + ) + } + + if (showGrammarDialogFor != null) { + val isFirst = showGrammarDialogFor == "first" + val featureToEdit = if (isFirst) editedFeatures.first else editedFeatures.second + val langForDialog = if (isFirst) languageFirst else languageSecond + + if (langForDialog != null) { + GrammarEditDialog( + feature = featureToEdit, + language = langForDialog, + onDismiss = { showGrammarDialogFor = null }, + onSave = { newFeature -> + editedFeatures = if (isFirst) { + editedFeatures.copy(first = newFeature) + } else { + editedFeatures.copy(second = newFeature) + } + showGrammarDialogFor = null + } + ) + } + } + + if (showBottomSheet) { + val sheetState = rememberModalBottomSheetState() + ModalBottomSheet( + onDismissRequest = { + showBottomSheet = false + bottomSheetTerm = null + }, + sheetState = sheetState + ) { + AdditionalContentBottomSheet( + vocabularyItemId = item.id, + term = bottomSheetTerm ?: "first", + navController = navController + ) + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +fun VocabularyCardPreview() { + val item = VocabularyItem( + id = 1, + wordFirst = "Hello", + wordSecond = "Hola", + languageFirstId = R.string.language_1, + languageSecondId = R.string.language_2 + ) + val navController = NavController(LocalContext.current) + VocabularyCard( + vocabularyItem = item, + navController = navController, + exerciseMode = false, + isFlipped = false, + switchOrder = false, + onStatisticsClick = {}, + onMoveToCategoryClick = {}, + onMoveToStageClick = {}, + onDeleteClick = {}, + userSpellingAnswer = null, + isUserSpellingCorrect = null + ) +} + +@Composable +private fun FrequencyPill(zipfFrequency: Float?) { + if (zipfFrequency == null) return + + val semanticColors = MaterialTheme.semanticColors + val (label, color) = when { + zipfFrequency <= 3f -> Pair(stringResource(R.string.text_rare), semanticColors.wrongContainer) + zipfFrequency <= 4f -> Pair(stringResource(R.string.text_infrequent), semanticColors.stageGradient2) + zipfFrequency <= 4.5f -> Pair(stringResource(R.string.text_uncommon), semanticColors.stageGradient3) + zipfFrequency <= 5.5f -> Pair(stringResource(R.string.text_common), semanticColors.stageGradient4) + zipfFrequency <= 6.5f -> Pair(stringResource(R.string.text_frequent), semanticColors.stageGradient5) + zipfFrequency <= 7.5f -> Pair(stringResource(R.string.text_very_frequent), semanticColors.stageGradient6) + else -> Pair(stringResource(R.string.text_very_frequent), semanticColors.successContainer) + } + + Surface( + shape = RoundedCornerShape(16.dp), + color = color.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 4.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) + } +} + +@Composable +private fun GrammarPill( + wordDetails: GrammaticalFeature?, + language: Language?, + isEditing: Boolean, + isRevealed: Boolean, + onEditGrammarClick: () -> Unit +) { + val configViewModel: LanguageConfigViewModel = viewModel() + val allConfigs by configViewModel.configs.collectAsState() + val languageConfig = language?.code?.let { allConfigs[it] } + val categoryConfig = wordDetails?.category?.let { languageConfig?.categories?.get(it) } + + val propertiesString = remember(wordDetails, categoryConfig) { + if (wordDetails == null) { + "" + } else { + formatGrammarDetails(wordDetails, categoryConfig) + } + } + + when { + isEditing && wordDetails == null -> { + // Show "add grammar details" pill with plus icon + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), + modifier = Modifier + .padding(horizontal = 4.dp) + .clickable { onEditGrammarClick() } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Icon( + imageVector = AppIcons.Add, + contentDescription = stringResource(R.string.text_add_grammar_details), + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringResource(R.string.text_add_grammar_details), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + isEditing && wordDetails != null -> { + // Show existing grammar details as clickable pill + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), + modifier = Modifier + .padding(horizontal = 4.dp) + .clickable { onEditGrammarClick() } + ) { + Text( + text = propertiesString, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) + } + } + wordDetails != null && isRevealed -> { + // Show grammar details as non-clickable pill + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f), + modifier = Modifier.padding(horizontal = 4.dp) + ) { + Text( + text = propertiesString, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp) + ) + } + } + } +} + +@Composable +private fun CardFace( + modifier: Modifier = Modifier, + isFront: Boolean, + isFirst: Boolean, + languageViewModel: LanguageViewModel, + isEditing: Boolean, + word: String, + onWordChange: (String) -> Unit, + language: Language?, + onLanguageIdChange: (Int?) -> Unit, + isRevealed: Boolean = true, + userSpellingAnswer: String?, + isUserSpellingCorrect: Boolean?, + correctWord: String, + wordDetails: GrammaticalFeature?, + onEditGrammarClick: () -> Unit, + isExerciseMode: Boolean = false, + vocabularyItem: VocabularyItem? = null, + onMoreClick: (() -> Unit)? = null, +) { + + + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + val activity = LocalContext.current.findActivity() + val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) + var playable by remember { mutableStateOf(false) } + + LaunchedEffect(language) { + if (language != null) { + playable = TextToSpeechHelper.isPlayable(context,language) + } + if (isEditing) { + if (isFirst) { + languageViewModel.setSelectedSourceLanguage(language?.nameResId) + } else { + languageViewModel.setSelectedTargetLanguage(language?.nameResId) + } + } + } + + val showSpellingCorrection = !isEditing && isRevealed && (userSpellingAnswer != null) + if(!isExerciseMode) { + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 0.dp) + ){ + val zipfFrequency = if(isFirst) vocabularyItem?.zipfFrequencyFirst else vocabularyItem?.zipfFrequencySecond + + FrequencyPill(zipfFrequency = zipfFrequency) + + GrammarPill( + wordDetails = wordDetails, + language = language, + isEditing = isEditing, + isRevealed = isRevealed, + onEditGrammarClick = onEditGrammarClick + ) + } + } + + Box(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 0.dp, vertical = 0.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (isEditing) { + SingleLanguageDropDown( + modifier = Modifier.weight(1f), + languageViewModel = languageViewModel, + selectedLanguage = language, + onLanguageSelected = { selected -> onLanguageIdChange(selected.nameResId) }, + ) + } else { + Text( + text = language?.name ?: stringResource(R.string.text_unknown_language), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + fontWeight = FontWeight.Bold, + ) + } + if (!isEditing && playable && isRevealed) { + IconButton(onClick = { + coroutineScope.launch { + settingsViewModel.speakingSpeed.value + val voice = settingsViewModel.getTtsVoiceForLanguage(language!!.code, language.region) + TextToSpeechHelper.speakOut(context, word, + language, voice) + } + }) { + Icon(AppIcons.TextToSpeech, stringResource(R.string.cd_text_to_speech)) + } + } + } + + Spacer(Modifier.height(2.dp)) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 4.dp) + ) { + Box( + modifier = Modifier + .then(if (!isRevealed) Modifier.blur(10.dp) else Modifier), + contentAlignment = Alignment.Center + ) { + if (showSpellingCorrection && !isFront && isExerciseMode) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val userAnswerAnnotatedString = buildAnnotatedString { + if (isUserSpellingCorrect == false) { + for (i in userSpellingAnswer.indices) { + val userChar = userSpellingAnswer.getOrNull(i) + val correctChar = correctWord.getOrNull(i) + if (userChar != null && correctChar != null && userChar.equals(correctChar, ignoreCase = true)) { + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onSurface)) { append(userChar) } + } else { + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.error)) { append(userChar.toString()) } + } + } + } else { + withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { append( + userSpellingAnswer + ) } + } + } + Text(text = userAnswerAnnotatedString, style = MaterialTheme.typography.headlineLarge, textAlign = TextAlign.Center) + if (isUserSpellingCorrect == false) { + Text(text = correctWord, style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center) + } + } + } + else if (isEditing) { + TextField( + value = word, + onValueChange = onWordChange, + textStyle = MaterialTheme.typography.headlineLarge.copy(textAlign = TextAlign.Center) + ) + } else { + val hasCompoundDelimiters = word.any { it == '-' || it == '–' || it == '—' || it == '‑' || it == '/' || it == '·' } + val isSingleToken = word.isNotBlank() && !word.any { it.isWhitespace() } + if (isSingleToken && !hasCompoundDelimiters) { + AutoResizeSingleLineText( + text = word, + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center + ) + } else { + val displayText = insertBreakOpportunities(word) + Text( + text = displayText, + style = MaterialTheme.typography.headlineLarge, + textAlign = TextAlign.Center + ) + } + } + } + + } + + + if (!isEditing) { + Box(modifier = Modifier + .weight(2f, fill = false) + .padding(0.dp)) { } + } + } + + // Info icon in bottom left + if (onMoreClick != null && !isEditing) { + IconButton( + onClick = onMoreClick, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + ) { + Icon( + imageVector = AppIcons.Info, + contentDescription = stringResource(R.string.more), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } +} + +@Preview +@Composable +private fun FrequencyPillPreview() { + FrequencyPill(zipfFrequency = 4.2f) +} + +@Suppress("HardCodedStringLiteral") +@Preview +@Composable +private fun CardFacePreview() { + val activity = LocalContext.current.findActivity() + val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) + val language = Language( + code = "en", + region = "US", + nameResId = R.string.language_1, + name = "English", + englishName = "English" + ) + val vocabularyItem = VocabularyItem( + id = 1, + wordFirst = "example", + wordSecond = "Beispiel", + languageFirstId = R.string.language_1, + languageSecondId = R.string.language_2, + zipfFrequencyFirst = 5.0f + ) + + CardFace( + isFront = true, + isFirst = true, + languageViewModel = languageViewModel, + isEditing = false, + word = "example", + onWordChange = {}, + language = language, + onLanguageIdChange = {}, + isRevealed = true, + userSpellingAnswer = null, + isUserSpellingCorrect = null, + correctWord = "example", + wordDetails = null, + onEditGrammarClick = {}, + isExerciseMode = false, + vocabularyItem = vocabularyItem + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/AllVocabularyWidget.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/AllVocabularyWidget.kt new file mode 100644 index 0000000..5242e5f --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/AllVocabularyWidget.kt @@ -0,0 +1,79 @@ +package eu.gaudian.translator.view.vocabulary.widgets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.SecondaryButton +import eu.gaudian.translator.viewmodel.VocabularyViewModel + +@Composable +fun AllVocabularyWidget( + vocabularyViewModel: VocabularyViewModel, + onOpenAllVocabulary: () -> Unit, + onStageClicked: (VocabularyStage) -> Unit +) { + val stageStats = vocabularyViewModel.stageStats.collectAsState().value + val totalItems = stageStats.sumOf { it.itemCount } + + + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + + } + + if (totalItems > 0) { + VocabularyStage.entries.forEach { stage -> + val stageCount = stageStats.find { it.stage == stage }?.itemCount ?: 0 + if (stageCount > 0) { + StageProgressBar( + stage = stage, + itemCount = stageCount, + totalItems = totalItems, + onClick = onStageClicked + ) + } + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp) + ) { + Text( + text = stringResource(R.string.text_no_vocabulary_available), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + SecondaryButton( + onClick = onOpenAllVocabulary, + text = stringResource(R.string.text_view_all), + icon = AppIcons.ArrowRight + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/CategoryWidget.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/CategoryWidget.kt new file mode 100644 index 0000000..1235b97 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/CategoryWidget.kt @@ -0,0 +1,364 @@ +package eu.gaudian.translator.view.vocabulary.widgets + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.TagCategory +import eu.gaudian.translator.model.VocabularyCategory +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.ui.theme.semanticColors +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.SecondaryButton +import eu.gaudian.translator.viewmodel.ProgressViewModel + +@Composable +fun CategoryProgressWidget( + onCategoryClicked: (VocabularyCategory?) -> Unit, + onViewAllClicked: () -> Unit +) { + val viewModel: ProgressViewModel = ProgressViewModel.getInstance(androidx.compose.ui.platform.LocalContext.current.applicationContext as android.app.Application) + val categoryProgressList by viewModel.categoryProgressList.collectAsState(initial = emptyList()) + val selectedCategories by viewModel.selectedCategories.collectAsState(initial = emptySet()) + + var expanded by remember { mutableStateOf(false) } + + Column( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + + ChartLegend() + + Row( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (selectedCategories.isEmpty()) { + // Show all categories if none are selected + categoryProgressList.forEach { data -> + CategoryProgressCircle( + category = data.vocabularyCategory.name, + totalItems = data.totalItems, + itemsCompleted = data.itemsCompleted, + itemsInStages = data.itemsInStages, + newItems = data.newItems, + onClick = { onCategoryClicked(data.vocabularyCategory) }, + showPercentage = true, + type = if(data.vocabularyCategory is TagCategory) CategoryCircleType.List else CategoryCircleType.Filter + ) + } + } else { + // Show only selected categories + selectedCategories.forEach { categoryId -> + val data = categoryProgressList.find { it.vocabularyCategory.id == categoryId } + if (data != null) { + CategoryProgressCircle( + category = data.vocabularyCategory.name, + totalItems = data.totalItems, + itemsCompleted = data.itemsCompleted, + itemsInStages = data.itemsInStages, + newItems = data.newItems, + onClick = { onCategoryClicked(data.vocabularyCategory) }, + showPercentage = true, + type = if(data.vocabularyCategory is TagCategory) CategoryCircleType.List else CategoryCircleType.Filter + ) + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + Box { + SecondaryButton( + onClick = { expanded = true }, + text = stringResource(R.string.label_select), + icon = AppIcons.FilterFilled + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + ) { + categoryProgressList.forEach { data -> + val isSelected = selectedCategories.contains(data.vocabularyCategory.id) + DropdownMenuItem( + text = { Text(text = data.vocabularyCategory.name) }, + onClick = { viewModel.toggleCategorySelection(data.vocabularyCategory.id) }, + leadingIcon = { + Checkbox( + checked = isSelected, + onCheckedChange = { viewModel.toggleCategorySelection(data.vocabularyCategory.id) } + ) + } + ) + } + } + } + + SecondaryButton( + onClick = onViewAllClicked, + text = stringResource(id = R.string.text_view_all), + icon = AppIcons.ArrowForward + ) + } + } +} + +enum class CategoryCircleType { Filter, List } + +@Composable +fun CategoryProgressCircle( + category: String? = null, + totalItems: Int, + itemsCompleted: Int, + itemsInStages: Int, + newItems: Int, + circleSize: Dp = 120.dp, + onClick: (() -> Unit)? = null, + showPercentage: Boolean = false, + type: CategoryCircleType? = null +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = if (onClick != null) { + Modifier + .clip(MaterialTheme.shapes.medium) + .clickable { onClick() } + .padding(8.dp) + } else { + Modifier + .clip(MaterialTheme.shapes.medium) + .padding(8.dp) + } + ) { + if (category != null) { + Text( + text = category, + style = MaterialTheme.typography.titleSmall + ) + } + + when { + totalItems == 0 -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(circleSize) + .background(MaterialTheme.colorScheme.surfaceContainerHighest, CircleShape).fillMaxWidth() + ) { + Text( + modifier = Modifier.align(Alignment.Center).padding(4.dp).fillMaxWidth(), + text = stringResource(R.string.text_no_items_available), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (type != null) { + CategoryTypeBadge(type) + } + } + } + itemsCompleted == totalItems -> { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(circleSize) + ) { + Icon( + imageVector = AppIcons.CheckCircle, + contentDescription = stringResource(R.string.text_all_items_completed), + tint = MaterialTheme.semanticColors.stageGradient6, + modifier = Modifier.size(circleSize * 0.6f) + ) + if (type != null) { + CategoryTypeBadge(type) + } + } + } + else -> { + val pCompleted = if (totalItems > 0) itemsCompleted.toFloat() / totalItems else 0f + val pInStages = if (totalItems > 0) itemsInStages.toFloat() / totalItems else 0f + val pNew = if (totalItems > 0) newItems.toFloat() / totalItems else 0f + + val animatedCompleted by animateFloatAsState(targetValue = pCompleted, animationSpec = tween(1000), label = "") + val animatedInStages by animateFloatAsState(targetValue = pInStages, animationSpec = tween(1000), label = "") + val animatedNew by animateFloatAsState(targetValue = pNew, animationSpec = tween(1000), label = "") + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(circleSize) + ) { + val strokeWidth = circleSize * 0.1f + // Background track + CircularProgressIndicator( + progress = { 1f }, + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surfaceVariant, + strokeWidth = strokeWidth, + ) + // New Items + CircularProgressIndicator( + progress = { animatedNew + animatedInStages + animatedCompleted }, + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.semanticColors.stageGradient1, + strokeWidth = strokeWidth, + strokeCap = StrokeCap.Round + ) + // In Stages + CircularProgressIndicator( + progress = { animatedInStages + animatedCompleted }, + modifier = Modifier.size(circleSize * 0.75f), + color = MaterialTheme.semanticColors.stageGradient3, + strokeWidth = strokeWidth, + strokeCap = StrokeCap.Round + ) + // Completed + CircularProgressIndicator( + progress = { animatedCompleted }, + modifier = Modifier.size(circleSize * 0.5f), + color = MaterialTheme.semanticColors.stageGradient6, + strokeWidth = strokeWidth, + strokeCap = StrokeCap.Round + ) + + if (showPercentage) { + Text( + text = "${(pCompleted * 100).toInt()}%", + style = MaterialTheme.typography.headlineSmall, + ) + } + if (type != null) { + CategoryTypeBadge(type) + } + } + } + } + // NOTE: You may need to add a plural string resource for "items" + Text( + text = stringResource(R.string.items, totalItems), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} +@Composable +private fun ChartLegend() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) + ) { + LegendItem(color = MaterialTheme.semanticColors.stageGradient6, label = stringResource(R.string.label_done)) + LegendItem(color = MaterialTheme.semanticColors.stageGradient3, label = stringResource(R.string.label_in_stages)) + LegendItem(color = MaterialTheme.semanticColors.stageGradient1, label = stringResource(R.string.label_new)) + } +} + +@Composable +private fun LegendItem(color: Color, label: String) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = Modifier + .size(10.dp) + .background(color, shape = CircleShape) + ) + Text(text = label, style = MaterialTheme.typography.labelMedium) + } +} + + + +@ThemePreviews +@Composable +private fun VocabularyStatsWidgetPreview() { + LevelWidget( + totalWords = 500, learnedWords = 150, + onNavigateToProgress = { } + ) +} + +@Suppress("HardCodedStringLiteral") +@ThemePreviews +@Composable +private fun CategoryProgressCirclePreview() { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CategoryProgressCircle(category = "Greetings", totalItems = 100, itemsCompleted = 20, itemsInStages = 30, newItems = 50, showPercentage = true) + CategoryProgressCircle(category = "Food", totalItems = 50, itemsCompleted = 50, itemsInStages = 0, newItems = 0) + CategoryProgressCircle(category = "Empty", totalItems = 0, itemsCompleted = 0, itemsInStages = 0, newItems = 0) + } +} + + +@Composable +private fun BoxScope.CategoryTypeBadge(type: CategoryCircleType) { + val icon = when (type) { + CategoryCircleType.Filter -> AppIcons.FilterCategory + CategoryCircleType.List -> AppIcons.FilterList + } + val contentDesc = when (type) { + CategoryCircleType.Filter -> stringResource(R.string.text_filter) + CategoryCircleType.List -> stringResource(R.string.text_list) + } + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .size(20.dp) + .background(MaterialTheme.colorScheme.surface, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = contentDesc, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(12.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/DueTodayWidget.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/DueTodayWidget.kt new file mode 100644 index 0000000..157da94 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/DueTodayWidget.kt @@ -0,0 +1,72 @@ +package eu.gaudian.translator.view.vocabulary.widgets + +import android.annotation.SuppressLint +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.viewmodel.VocabularyViewModel + +@SuppressLint("SuspiciousIndentation") +@Composable +fun DueTodayWidget(vocabularyViewModel: VocabularyViewModel, onStageClicked: (VocabularyStage) -> Unit) { + val dueTodayItems = vocabularyViewModel.dueTodayItems.collectAsState().value + val stageMapping = vocabularyViewModel.stageMapping.collectAsState().value + val dueTodayCount = dueTodayItems.size + val dueTodayByStage = dueTodayItems.groupingBy { stageMapping[it.id] ?: VocabularyStage.NEW }.eachCount() + + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + if (dueTodayCount > 0) { + VocabularyStage.entries.forEach { stage -> + val stageCount = dueTodayByStage[stage] ?: 0 + if (stageCount > 0) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onStageClicked(stage) } + .padding(vertical = 4.dp) + ) { + DetailedStageProgressBar( + stage = stage, + itemCount = stageCount, + totalItems = stageMapping.values.count { it == stage }, + onStageTapped = onStageClicked + ) + } + } + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp) + ) { + Text( + text = stringResource(R.string.text_no_vocabulary_due_today), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/LanguageLevelWidget.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/LanguageLevelWidget.kt new file mode 100644 index 0000000..70b3fcb --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/LanguageLevelWidget.kt @@ -0,0 +1,123 @@ +package eu.gaudian.translator.view.vocabulary.widgets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.LanguageLevels +import eu.gaudian.translator.model.MyAppLanguageLevel +import eu.gaudian.translator.view.composable.ComponentDefaults + + +/** + * A composable widget that displays a single language level's information. + * It shows the level's icon, name, word count, and description in a styled card. + * + * @param level The [MyAppLanguageLevel] object to display. + * @param modifier A [Modifier] for this composable. + */ +@Composable +fun LanguageLevelWidget(level: MyAppLanguageLevel, modifier: Modifier = Modifier) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = ComponentDefaults.DefaultElevation), + shape = MaterialTheme.shapes.medium + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Level Icon + Image( + painter = painterResource(id = level.iconResId), + contentDescription = stringResource(id = level.nameResId), + modifier = Modifier + .size(64.dp) + .padding(end = 16.dp) + ) + + // Level Details Column + Column(modifier = Modifier.weight(1f)) { + // Level Name + Text( + text = stringResource(id = level.nameResId), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Words Known + // Note: For proper localization, it's best to use Plurals string resources. + // For simplicity here, we are using a simple string. + Text( + text = stringResource(R.string.words_known, level.wordsKnown), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Description + Text( + text = stringResource(id = level.descriptionResId), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +/** + * A preview function to display the LanguageLevelWidget in Android Studio. + * This uses the "Newborn" tier as an example. + */ +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "Single Level Preview") +@Composable +fun LanguageLevelWidgetPreview() { + // This preview uses the Newborn object from your MyAppLanguageLevel sealed class. + // Ensure you have a drawable resource named 'ic_level_newborn' and the + // corresponding string resources for the preview to render correctly. + + LanguageLevelWidget(level = MyAppLanguageLevel.Newborn) + +} + +/** + * A preview function that displays all language levels in a scrollable list. + */ +@Suppress("HardCodedStringLiteral") +@Preview(showBackground = true, name = "All Levels List Preview", heightDp = 800) +@Composable +fun AllLanguageLevelsPreview() { + + LazyColumn { + items(LanguageLevels.all) { level -> + LanguageLevelWidget(level = level) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/LevelWidget.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/LevelWidget.kt new file mode 100644 index 0000000..8014e62 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/LevelWidget.kt @@ -0,0 +1,133 @@ +package eu.gaudian.translator.view.vocabulary.widgets + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.model.LanguageLevels +import eu.gaudian.translator.model.MyAppLanguageLevel + +/** + * Main widget that displays word statistics and the user's current language level. + */ +@Composable +fun LevelWidget( + totalWords: Int, + learnedWords: Int, + onNavigateToProgress: () -> Unit // Add this parameter +) { + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { onNavigateToProgress() }, // Make whole widget clickable + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Original row with word statistics + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + StatItem(value = totalWords.toString(), label = stringResource(R.string.label_total_words)) + StatItem(value = learnedWords.toString(), label = stringResource(R.string.label_learned)) + StatItem( + value = (totalWords - learnedWords).toString(), + label = stringResource(R.string.remaining) + ) + } + + // Determine the current language level based on the number of learned words. + val currentLevel = LanguageLevels.getLevelForWords(learnedWords) + + // Display the detailed language level widget below the stats. + LanguageLevelInfoWidget(level = currentLevel) + } +} + +/** + * A composable that displays a single statistic (e.g., total words). + */ +@Composable +fun StatItem(value: String, label: String) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = value, style = MaterialTheme.typography.headlineMedium) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * A widget (previously LanguageLevelWidget) that displays detailed information + * about a single language level in a styled card. + * + * @param level The [MyAppLanguageLevel] object to display. + * @param modifier A [Modifier] for this composable. + */ +@Composable +fun LanguageLevelInfoWidget(level: MyAppLanguageLevel, modifier: Modifier = Modifier) { + OutlinedCard( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Level Icon + Image( + painter = painterResource(id = level.iconResId), + contentDescription = stringResource(id = level.nameResId), + modifier = Modifier + .size(64.dp) + .padding(end = 16.dp) + ) + + // Level Details Column + Column(modifier = Modifier.weight(1f)) { + // Level Name + Text( + text = stringResource(id = level.nameResId), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + // Words Known + Text( + text = stringResource(R.string.words_known, level.wordsKnown), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = level.descriptionResId), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/ModernStartButton.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/ModernStartButton.kt new file mode 100644 index 0000000..2ca96db --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/ModernStartButton.kt @@ -0,0 +1,200 @@ +package eu.gaudian.translator.view.vocabulary.widgets + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.composable.AppIcons + +/** + * A modern, visually appealing set of start buttons for exercises. + * The public signature is identical to the original for drop-in replacement. + * + * @param onCustomClick Lambda for the primary custom exercise action. + * @param onDailyClick Lambda for daily exercises. It's called with `false` for a + * normal daily exercise and `true` for a daily spelling exercise. + */ +@Composable +fun ModernStartButtons( + onCustomClick: () -> Unit, + onDailyClick: (isSpelling: Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // A large, prominent "feature button" for the main call to action. + FeatureButton( + text = stringResource(R.string.text_custom_exercise), + icon = AppIcons.PlayCircleFilled, + onClick = onCustomClick, + modifier = Modifier.weight(1f) + ) + + // A column for the two secondary "daily" actions. + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SecondaryButton( + text = stringResource(R.string.text_daily_exercise), + icon = AppIcons.Today, + onClick = { onDailyClick(false) } + ) + + SecondaryButton( + text = stringResource(R.string.quick_word_pairs), + icon = AppIcons.SwapHoriz, + onClick = { onDailyClick(true) } + ) + } + } +} + +/** + * A visually rich feature button with a gradient background and a subtle + * press animation. Designed to be the primary call to action. + */ +@Composable +private fun FeatureButton( + text: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + @Suppress("HardCodedStringLiteral") val scale by animateFloatAsState(targetValue = if (isPressed) 0.95f else 1f, label = "label_scale" + ) + + Card( + modifier = modifier + .aspectRatio(1f) + .scale(scale) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick + ), + shape = MaterialTheme.shapes.extraLarge, + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.primary + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(16.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = text, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 14.sp + ), + color = MaterialTheme.colorScheme.onPrimary, + textAlign = TextAlign.Center + ) + } + } + } +} + +/** + * A clean and simple OutlinedButton for secondary actions, with an icon and text. + */ +@Composable +private fun SecondaryButton( + text: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + OutlinedButton( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + } +} + + +@ThemePreviews +@Composable +private fun ModernStartButtonsPreview() { + ModernStartButtons( + onCustomClick = {}, + onDailyClick = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/StageProgressBar.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/StageProgressBar.kt new file mode 100644 index 0000000..3d4d565 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/StageProgressBar.kt @@ -0,0 +1,173 @@ +package eu.gaudian.translator.view.vocabulary.widgets + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.ui.theme.semanticColors +import eu.gaudian.translator.view.composable.AppIcons + +@Composable +fun StageProgressBar(stage: VocabularyStage, itemCount: Int, totalItems: Int, onClick: (VocabularyStage) -> Unit) { + + val progress = if (totalItems > 0) itemCount.toFloat() / totalItems else 0f + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(durationMillis = 1000), + label = "progressAnimation", + ) + val context = LocalContext.current + + val color = when (stage) { + VocabularyStage.NEW -> MaterialTheme.semanticColors.stageGradient1 + VocabularyStage.STAGE_1 -> MaterialTheme.semanticColors.stageGradient2 + VocabularyStage.STAGE_2 -> MaterialTheme.semanticColors.stageGradient3 + VocabularyStage.STAGE_3 -> MaterialTheme.semanticColors.stageGradient4 + VocabularyStage.STAGE_4 -> MaterialTheme.semanticColors.stageGradient5 + VocabularyStage.STAGE_5 -> MaterialTheme.semanticColors.stageGradient6 + VocabularyStage.LEARNED -> MaterialTheme.semanticColors.stageGradient6 + } + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick(stage) }, + horizontalArrangement = Arrangement.SpaceBetween + + ) { + Text( + text = stage.toString(context), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "$itemCount", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape), + color = color, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + } +} + +@Preview +@Composable +fun StageProgressBarPreview() { + StageProgressBar( + stage = VocabularyStage.NEW, itemCount = 10, totalItems = 100, + onClick = { } + ) +} + + +@Composable +fun DetailedStageProgressBar( + stage: VocabularyStage, + itemCount: Int, + totalItems: Int, + modifier: Modifier = Modifier, + onStageTapped: (VocabularyStage) -> Unit +) { + val context = LocalContext.current + val progress = if (totalItems > 0) itemCount.toFloat() / totalItems else 0f + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(durationMillis = 1000), + label = "detailedProgressAnimation" + ) + + val stageColor = when (stage) { + VocabularyStage.NEW -> MaterialTheme.semanticColors.stageGradient1 + VocabularyStage.STAGE_1 -> MaterialTheme.semanticColors.stageGradient2 + VocabularyStage.STAGE_2 -> MaterialTheme.semanticColors.stageGradient3 + VocabularyStage.STAGE_3 -> MaterialTheme.semanticColors.stageGradient4 + VocabularyStage.STAGE_4 -> MaterialTheme.semanticColors.stageGradient5 + VocabularyStage.STAGE_5 -> MaterialTheme.semanticColors.stageGradient6 + VocabularyStage.LEARNED -> MaterialTheme.semanticColors.stageGradient6 + } + + val stageIcon = when (stage) { + VocabularyStage.NEW -> AppIcons.StageNew + VocabularyStage.STAGE_1 -> AppIcons.Stage1 + VocabularyStage.STAGE_2 -> AppIcons.Stage2 + VocabularyStage.STAGE_3 -> AppIcons.Stage3 + VocabularyStage.STAGE_4 -> AppIcons.Stage4 + VocabularyStage.STAGE_5 -> AppIcons.Stage5 + VocabularyStage.LEARNED -> AppIcons.StageLearned + } + + Column( + modifier = modifier + .clip(MaterialTheme.shapes.medium) + .clickable { onStageTapped(stage) } + .padding(vertical = 8.dp, horizontal = 4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = stageIcon, + contentDescription = stage.toString(context), + tint = stageColor + ) + Text( + text = stage.toString(context), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Text( + text = "$itemCount / $totalItems", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape), + color = stageColor, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/StatusWidget.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/StatusWidget.kt new file mode 100644 index 0000000..15c6ee7 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/StatusWidget.kt @@ -0,0 +1,145 @@ +package eu.gaudian.translator.view.vocabulary.widgets + +import android.app.Application +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.semanticColors +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.viewmodel.VocabularyViewModel +import okhttp3.internal.platform.PlatformRegistry.applicationContext + +@Composable +fun StatusWidget( + vocabularyViewModel: VocabularyViewModel = VocabularyViewModel.getInstance(applicationContext as Application), + onNavigateToNew: () -> Unit, + onNavigateToDuplicates: () -> Unit, + onNavigateToFaulty: () -> Unit, + onNavigateToNoGrammar: () -> Unit, + onNavigateToMissingLanguage: (Int) -> Unit +) { + val newItemsCount by vocabularyViewModel.newItemsCount.collectAsState() + val duplicateCount by vocabularyViewModel.duplicateItemsCount.collectAsState() + val faultyItemsCount by vocabularyViewModel.faultyItemsCount.collectAsState() + val itemsWithoutGrammarCount by vocabularyViewModel.itemsWithoutGrammarCount.collectAsState() + val missingLanguageInfo by vocabularyViewModel.missingLanguageInfo.collectAsState() + + val hasIssues = newItemsCount > 0 || duplicateCount > 0 || faultyItemsCount > 0 || itemsWithoutGrammarCount > 0 || missingLanguageInfo.isNotEmpty() + + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (hasIssues) { + if (newItemsCount > 0) { + StatusItem( + icon = AppIcons.Sort, + text = stringResource(R.string.status_widget_new_items), + count = newItemsCount, + onClick = onNavigateToNew, + color = MaterialTheme.colorScheme.primary + ) + } + if (duplicateCount > 0) { + StatusItem( + icon = AppIcons.Error, + text = stringResource(R.string.status_widget_duplicates), + count = duplicateCount, + onClick = onNavigateToDuplicates, + color = MaterialTheme.colorScheme.error + ) + } + if (faultyItemsCount > 0) { + StatusItem( + icon = AppIcons.Error, + text = stringResource(R.string.status_widget_faulty_items), + count = faultyItemsCount, + onClick = onNavigateToFaulty, + color = MaterialTheme.colorScheme.error + ) + } + missingLanguageInfo.forEach { (languageId, count) -> + StatusItem( + icon = AppIcons.Error, + text = stringResource(R.string.language_with_id_d_not_found, languageId), + count = count, + onClick = { onNavigateToMissingLanguage(languageId) }, + color = MaterialTheme.colorScheme.error + ) + } + if (itemsWithoutGrammarCount > 0) { + StatusItem( + icon = AppIcons.Error, + text = stringResource(R.string.items_without_grammar_infos), + count = itemsWithoutGrammarCount, + onClick = onNavigateToNoGrammar, + color = MaterialTheme.colorScheme.error + ) + } + } else { + StatusItem( + icon = AppIcons.CheckCircle, + text = stringResource(R.string.status_widget_all_good), + onClick = null, + color = MaterialTheme.semanticColors.success + ) + } + } +} + +@Composable +private fun StatusItem( + icon: ImageVector, + text: String, + onClick: (() -> Unit)?, + color: Color, + count: Int? = null +) { + var modifier = Modifier.fillMaxWidth() + if (onClick != null) { + modifier = modifier.clickable(onClick = onClick) + } + + Row( + modifier = modifier.padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color + ) + Text( + modifier = Modifier.weight(1f), + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + if (count != null) { + Text( + text = count.toString(), + style = MaterialTheme.typography.bodyLarge, + color = color, + fontWeight = FontWeight.Bold + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/StreakWidget.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/StreakWidget.kt new file mode 100644 index 0000000..0bb5720 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/StreakWidget.kt @@ -0,0 +1,259 @@ +package eu.gaudian.translator.view.vocabulary.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.composable.AppIcons +import eu.gaudian.translator.view.composable.SecondaryButton +import eu.gaudian.translator.viewmodel.DayStreak + +@Composable +fun StreakWidget( + streak: Int, + lastSevenDays: List, + dueTodayCount: Int, + wordsCompleted: Int, + onStatisticsClicked: () -> Unit +) { + Column( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp) + ) { + // Section 1: Top Stats (Streak & Due Today) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically + ) { + StatItem( + emoji = "🔥", + value = streak.toString(), + label = stringResource(R.string.text_day_streak), + iconColor = MaterialTheme.colorScheme.onSurface + ) + + StatItem( + icon = AppIcons.Vocabulary, + value = dueTodayCount.toString(), + label = stringResource(R.string.text_due_today), + iconColor = MaterialTheme.colorScheme.secondary + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Section 2: Weekly Progress + Text( + text = stringResource(R.string.text_last_7_days), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 12.dp, bottom = 12.dp), + ) + + WeeklyProgress(lastSevenDays = lastSevenDays) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.text_total_learned_words) + ": $wordsCompleted", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + SecondaryButton( + onClick = onStatisticsClicked, + icon = AppIcons.ArrowForward, + text = stringResource(R.string.text_more_stats) + ) + } + } +} + +@Composable +private fun StatItem( + icon: ImageVector? = null, + emoji: String? = null, + value: String, + label: String, + iconColor: Color, + gradientBrush: Brush? = null +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + when { + emoji != null -> { + Text( + text = emoji, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.size(28.dp) + ) + } + gradientBrush != null && icon != null -> { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier + .size(28.dp) + .drawWithCache { + onDrawWithContent { + // Draw the gradient first + drawRect( + brush = gradientBrush, + size = size + ) + // Then draw the icon shape using SrcIn to clip the gradient to the icon shape + drawContent() + } + }, + tint = Color.White // Use white to preserve the gradient colors + ) + } + icon != null -> { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(28.dp), + tint = iconColor + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +private fun WeeklyProgress(lastSevenDays: List) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + lastSevenDays.forEach { day -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val backgroundColor = if (day.targetMet) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + } + + val contentColor = if (day.targetMet) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } + + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + if (day.targetMet) { + Icon( + imageVector = AppIcons.Check, + contentDescription = stringResource(R.string.cd_target_met), + tint = contentColor, + modifier = Modifier.size(24.dp) + ) + } + } + + Text( + text = day.day, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +@Suppress("HardCodedStringLiteral") +@ThemePreviews +@Composable +private fun StreakWidgetPreview() { + val lastSevenDays = listOf( + DayStreak(day = "Mon", targetMet = true), + DayStreak(day = "Tue", targetMet = false), + DayStreak(day = "Wed", targetMet = true), + DayStreak(day = "Thu", targetMet = true), + DayStreak(day = "Fri", targetMet = false), + DayStreak(day = "Sat", targetMet = true), + DayStreak(day = "Sun", targetMet = true) + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + StreakWidget( + streak = 5, + lastSevenDays = lastSevenDays, + dueTodayCount = 10, + wordsCompleted = 120, + onStatisticsClicked = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/WeeklyActivityChartWidget.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/WeeklyActivityChartWidget.kt new file mode 100644 index 0000000..b908ba3 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/WeeklyActivityChartWidget.kt @@ -0,0 +1,214 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.vocabulary.widgets + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.ui.theme.semanticColors +import eu.gaudian.translator.viewmodel.WeeklyActivityStat +import kotlinx.coroutines.delay + +/** + * A widget that displays weekly activity statistics in a visually appealing bar chart. + * It's designed to be consistent with the app's modern, floating UI style. + * + * @param weeklyStats A list of [WeeklyActivityStat] for the last 7 days. + */ +@Composable +fun WeeklyActivityChartWidget( + weeklyStats: List +) { + val maxValue = remember(weeklyStats) { + (weeklyStats.flatMap { listOf(it.newlyAdded, it.completed, it.answeredRight) }.maxOrNull() ?: 0).let { + if (it < 10) 10 else ((it / 5) + 1) * 5 + } + } + + val hasNoData = remember(weeklyStats) { + weeklyStats.all { it.newlyAdded == 0 && it.completed == 0 && it.answeredRight == 0 } + } + + if (hasNoData) { + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.text_no_data_available), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + ChartLegend() + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(220.dp), + verticalAlignment = Alignment.Bottom + ) { + // Y-Axis Labels + Column( + modifier = Modifier + .fillMaxHeight() + .padding(end = 8.dp), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.End + ) { + Text(maxValue.toString(), style = MaterialTheme.typography.labelSmall) + Text((maxValue / 2).toString(), style = MaterialTheme.typography.labelSmall) + Text("0", style = MaterialTheme.typography.labelSmall) + } + + // Chart Bars + Row( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.Bottom + ) { + weeklyStats.forEach { stat -> + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Bottom + ) { + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .weight(1f) + .fillMaxWidth(0.8f) + ) { + Bar(value = stat.newlyAdded, maxValue = maxValue, color = MaterialTheme.semanticColors.stageGradient1) + Bar(value = stat.completed, maxValue = maxValue, color = MaterialTheme.semanticColors.stageGradient3) + Bar(value = stat.answeredRight, maxValue = maxValue, color = MaterialTheme.semanticColors.stageGradient5) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stat.day, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } + } +} + +@Composable +private fun RowScope.Bar(value: Int, maxValue: Int, color: Color) { + var startAnimation by remember { mutableStateOf(false) } + val barHeight by animateFloatAsState( + targetValue = if (startAnimation) value.toFloat() / maxValue.toFloat() else 0f, + animationSpec = tween(durationMillis = 1000), + label = "barHeightAnimation" + ) + + LaunchedEffect(Unit) { + delay(200) // Small delay to ensure the UI is ready before animating + @Suppress("AssignedValueIsNeverRead") + startAnimation = true + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight(barHeight) + .clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp)) + .background(color) + ) +} + +@Composable +private fun ChartLegend() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + LegendItem(color = MaterialTheme.semanticColors.stageGradient1, label = stringResource(R.string.label_added)) + LegendItem(color = MaterialTheme.semanticColors.stageGradient3, label = stringResource(R.string.label_completed)) + LegendItem(color = MaterialTheme.semanticColors.stageGradient5, label = stringResource(R.string.label_correct)) + } +} + +@Composable +private fun LegendItem(color: Color, label: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .background(color, shape = CircleShape) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = label, style = MaterialTheme.typography.labelMedium, fontSize = 12.sp) + } +} + +@ThemePreviews +@Composable +fun WeeklyActivityChartWidgetPreview() { + val sampleStats = listOf( + WeeklyActivityStat("Mon", 10, 5, 20), + WeeklyActivityStat("Tue", 12, 3, 15), + WeeklyActivityStat("Wed", 8, 8, 25), + WeeklyActivityStat("Thu", 15, 2, 18), + WeeklyActivityStat("Fri", 5, 10, 30), + WeeklyActivityStat("Sat", 7, 6, 22), + WeeklyActivityStat("Sun", 9, 4, 17) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + WeeklyActivityChartWidget(weeklyStats = sampleStats) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/WidgetButtonOutline.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/WidgetButtonOutline.kt new file mode 100644 index 0000000..daabd91 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/WidgetButtonOutline.kt @@ -0,0 +1,79 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.view.vocabulary.widgets + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.composable.AppIcons + +@Composable +fun WidgetButtonOutline( + onClick: () -> Unit, + modifier: Modifier = Modifier, + showIcon: Boolean = false, + content: @Composable RowScope.() -> Unit // RowScope is fine for this version +) { + Surface( + onClick = onClick, + modifier = modifier.fillMaxWidth(), // The clickable area is still full-width + shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), + color = Color.Transparent, + tonalElevation = 2.dp + ) { + // This Row now only takes up as much space as its children need. + // It is placed at the start of the Surface by default. + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start // Explicitly align to the start + ) { + // Your content is placed first + content() + + if (showIcon) { + // A small spacer for breathing room + Spacer(modifier = Modifier.width(8.dp)) + + // The Icon is placed immediately after the spacer + Icon( + AppIcons.ArrowForward, + contentDescription = "Arrow Icon", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +@ThemePreviews +@Composable +fun WidgetButtonOutlinePreview() { + WidgetButtonOutline(onClick = {}) { + Text("Button Text") + } +} + +@ThemePreviews +@Composable +fun WidgetButtonOutlinePreviewIcon() { + WidgetButtonOutline(onClick = {}, showIcon = true) { + Text("Button Text") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/ApiViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/ApiViewModel.kt new file mode 100644 index 0000000..cb86be9 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/ApiViewModel.kt @@ -0,0 +1,373 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.gaudian.translator.model.LanguageModel +import eu.gaudian.translator.model.communication.ApiManager +import eu.gaudian.translator.model.communication.ApiProvider +import eu.gaudian.translator.model.repository.ApiRepository +import eu.gaudian.translator.model.repository.SettingsRepository +import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +data class ProviderState( + val provider: ApiProvider, + val apiKey: String = "", + val hasKey: Boolean = false, + val validationMessage: String = "", + val isEditing: Boolean = false +) + +data class ApiKeyManagementState( + val providerStates: List = emptyList(), + val isAddingProvider: Boolean = false, + val providerKeyForAddingModel: String? = null, + val isAddingModel: Boolean = false, + val addModelError: String? = null, + val isScanningModels: Boolean = false, + val scannedModels: List = emptyList(), + val providerKeyForEditing: String? = null, + val modelIdForEditing: String? = null, + val showWipeAllConfirm: Boolean = false +) + +@HiltViewModel +class ApiViewModel @Inject constructor( + application: Application, + private val settingsRepository: SettingsRepository, + private val apiManager: ApiManager, + private val apiRepository: ApiRepository, +) : AndroidViewModel(application) { + + @Suppress("PrivatePropertyName") + private val TAG = "ApiViewModel" + private val _apiKeyManagementState = MutableStateFlow(ApiKeyManagementState()) + val apiKeyManagementState: StateFlow = _apiKeyManagementState.asStateFlow() + val interval:Long = 1000 + + // 1. Source of Truth: All providers from DB + val allProviders: StateFlow> = apiRepository.getProviders() + .onEach { Log.d(TAG, "allProviders updated: ${it.size} items") } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(interval), emptyList()) + + // 2. Derived: Only providers that are active (Valid Key or Custom) + val allValidProviders: StateFlow> = allProviders + .map { list -> list.filter { it.hasValidKey || it.isCustom } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(interval), emptyList()) + + // 3. Derived: All models + val allModels: StateFlow> = allProviders + .map { providers -> providers.flatMap { it.models } } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(interval), emptyList()) + + // 4. Derived: Valid Models + // Since we now manually remove models when keys are missing, we can trust allModels. + // However, keeping the filter is safer for edge cases. + val allValidModels: StateFlow> = allProviders + .map { providers -> + providers + .filter { it.hasValidKey || it.isCustom } + .flatMap { it.models } + } + .onEach { Log.d(TAG, "allValidModels updated: ${it.size} models available") } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(interval), emptyList()) + + val translationModel: StateFlow = apiRepository.getTranslationModel() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(interval), null) + + val exerciseModel: StateFlow = apiRepository.getExerciseModel() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(interval), null) + + val vocabularyModel: StateFlow = apiRepository.getVocabularyModel() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(interval), null) + + val dictionaryModel: StateFlow = apiRepository.getDictionaryModel() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(interval), null) + + init { + // Sync UI state + viewModelScope.launch { + apiRepository.getProviders() + .combine(settingsRepository.getAllApiKeys()) { providers, apiKeys -> + providers.map { provider -> + val apiKey = apiKeys[provider.key] ?: "" + val hasKey = apiKey.isNotBlank() + + val currentUiState = _apiKeyManagementState.value.providerStates + .find { it.provider.key == provider.key } + + ProviderState( + provider = provider, + apiKey = currentUiState?.apiKey.takeIf { !it.isNullOrBlank() && currentUiState?.isEditing == true } ?: apiKey, + hasKey = hasKey, + isEditing = currentUiState?.isEditing ?: false, + validationMessage = currentUiState?.validationMessage ?: "" + ) + } + } + .collect { providerStates -> + _apiKeyManagementState.update { it.copy(providerStates = providerStates) } + } + } + } + + /* --- Provider Management --- */ + + fun startAddingProvider() = _apiKeyManagementState.update { it.copy(isAddingProvider = true) } + + fun cancelAddProvider() = _apiKeyManagementState.update { it.copy(isAddingProvider = false) } + + fun addProvider(provider: ApiProvider) = viewModelScope.launch { + Log.i(TAG, "Adding custom provider: ${provider.displayName}") + // Logic to ensure unique keys + val existingKeys = allProviders.value.map { it.key }.toSet() + fun slugify(input: String): String { + val base = input.lowercase().trim() + .replace("[^a-z0-9]+".toRegex(), "-") + .trim('-') + return when { + base.isBlank() -> "custom" + base.length <= 40 -> base + else -> base.take(40).trim('-') + } + } + var desired = provider.key.trim() + desired = if (desired.isBlank()) slugify(provider.displayName) else slugify(desired) + var unique = desired + var counter = 1 + while (unique.isBlank() || existingKeys.contains(unique)) { + unique = if (desired.isBlank()) "custom-$counter" else "$desired-$counter" + counter++ + } + + val safeProvider = provider.copy(key = unique, isCustom = true) + apiRepository.addProvider(safeProvider) + cancelAddProvider() + } + + fun deleteProvider(providerKey: String) = viewModelScope.launch { + Log.i(TAG, "Deleting provider: $providerKey") + apiRepository.deleteProvider(providerKey) + settingsRepository.deleteApiKey(providerKey) + } + + fun updateApiKeyForProvider(providerKey: String, newKey: String) { + _apiKeyManagementState.update { currentState -> + val updatedStates = currentState.providerStates.map { + if (it.provider.key == providerKey) it.copy(apiKey = newKey, validationMessage = "") else it + } + currentState.copy(providerStates = updatedStates) + } + } + + fun deleteApiKeyForProvider(providerKey: String) = viewModelScope.launch { + Log.i(TAG, "Deleting API key for: $providerKey") + settingsRepository.deleteApiKey(providerKey) + + // MANUAL LOGIC: Key deleted -> Remove pre-configured models + apiRepository.removeDefaultModels(providerKey) + + // Refresh fallback selections + apiRepository.initialInit() + } + + fun saveApiKeyForProvider(providerKey: String) { + val providerState = _apiKeyManagementState.value.providerStates.find { it.provider.key == providerKey } ?: return + val keyToSave = providerState.apiKey.filter { !it.isWhitespace() } + val provider = providerState.provider + + viewModelScope.launch(Dispatchers.IO) { + val (isValid, message) = apiManager.validateApiKey(keyToSave, provider) + withContext(Dispatchers.Main) { + if (isValid) { + settingsRepository.saveApiKey(provider, keyToSave) + + // MANUAL LOGIC: Key activated -> Add pre-configured models + Log.i(TAG, "Key valid. Adding default models for $providerKey") + apiRepository.addDefaultModels(providerKey) + + toggleEditModeForProvider(providerKey, false) + apiRepository.initialInit() // Re-check fallbacks + } else { + val errorMessage = "Invalid key. $message" + _apiKeyManagementState.update { currentState -> + val updatedStates = currentState.providerStates.map { + if (it.provider.key == providerKey) it.copy(validationMessage = errorMessage) else it + } + currentState.copy(providerStates = updatedStates) + } + } + } + } + } + + fun toggleEditModeForProvider(providerKey: String, isEditing: Boolean? = null) { + _apiKeyManagementState.update { currentState -> + val updatedStates = currentState.providerStates.map { + if (it.provider.key == providerKey) { + it.copy(isEditing = isEditing ?: !it.isEditing, validationMessage = "") + } else { + it + } + } + currentState.copy(providerStates = updatedStates) + } + } + + /* --- Provider Editing (Name/Url) --- */ + + fun startEditingProvider(providerKey: String) = _apiKeyManagementState.update { + it.copy(providerKeyForEditing = providerKey, modelIdForEditing = null) + } + + fun applyEditProvider(updated: ApiProvider) = viewModelScope.launch { + apiRepository.updateProvider(updated) + cancelEditing() + } + + /* --- Model Management --- */ + + fun startAddingModelForProvider(providerKey: String) = _apiKeyManagementState.update { + it.copy(providerKeyForAddingModel = providerKey, scannedModels = emptyList(), addModelError = null) + } + + fun cancelAddModel() = _apiKeyManagementState.update { + it.copy(providerKeyForAddingModel = null, isAddingModel = false, addModelError = null, isScanningModels = false, scannedModels = emptyList()) + } + + fun startEditingModel(providerKey: String, modelId: String) = _apiKeyManagementState.update { + it.copy(providerKeyForEditing = providerKey, modelIdForEditing = modelId, addModelError = null) + } + + fun cancelEditing() = _apiKeyManagementState.update { + it.copy(providerKeyForEditing = null, modelIdForEditing = null) + } + + fun applyEditModel(providerKey: String, updatedModel: LanguageModel) = viewModelScope.launch { + val state = _apiKeyManagementState.value + val provider = state.providerStates.find { it.provider.key == providerKey }?.provider ?: return@launch + val originalId = state.modelIdForEditing ?: return@launch + + val duplicateExists = provider.models.any { it.modelId == updatedModel.modelId && it.modelId != originalId } + if (duplicateExists) { + _apiKeyManagementState.update { it.copy(addModelError = "A model with this ID already exists.") } + return@launch + } + + val newModels = provider.models.map { m -> + if (m.modelId == originalId && m.isCustom) updatedModel.copy(isCustom = true) else m + } + val updatedProvider = provider.copy(models = newModels) + apiRepository.updateProvider(updatedProvider) + cancelEditing() + } + + fun scanModelsForProvider(providerKey: String) = viewModelScope.launch { + val state = _apiKeyManagementState.value + val providerState = state.providerStates.find { it.provider.key == providerKey } ?: return@launch + + _apiKeyManagementState.update { it.copy(isScanningModels = true, addModelError = null, scannedModels = emptyList()) } + + val apiKey = settingsRepository.getAllApiKeys().first()[providerKey] ?: providerState.apiKey + val (models, error) = apiManager.fetchAvailableModels(apiKey, providerState.provider) + + _apiKeyManagementState.update { + it.copy( + isScanningModels = false, + addModelError = error, + scannedModels = models + ) + } + } + + fun addModelToProvider(providerKey: String, model: LanguageModel) = viewModelScope.launch { + _apiKeyManagementState.update { it.copy(isAddingModel = true, addModelError = null) } + val providerState = _apiKeyManagementState.value.providerStates.find { it.provider.key == providerKey } + + if (providerState == null) { + _apiKeyManagementState.update { it.copy(isAddingModel = false, addModelError = "Provider not found.") } + return@launch + } + + val apiKeyForValidation = settingsRepository.getAllApiKeys().first()[providerKey] + ?: providerState.apiKey.takeIf { it.isNotBlank() } + val base = providerState.provider.baseUrl.trim().lowercase() + val isLocalHost = (base.contains("localhost") || base.contains("127.0.0.1") || base.startsWith("10.")) + + var canAddDirectly = isLocalHost || apiKeyForValidation == null + var validationMessage: String? = null + + if (!canAddDirectly) { + val (isValid, message) = apiManager.validateModel(apiKeyForValidation!!, providerState.provider, model) + validationMessage = message + canAddDirectly = isValid + } + + if (canAddDirectly) { + val provider = providerState.provider + // Mark manually added models as custom + val filteredModels = provider.models.filterNot { it.modelId == model.modelId }.toMutableList() + filteredModels.add(model.copy(isCustom = true)) + + val updatedProvider = provider.copy(models = filteredModels) + apiRepository.updateProvider(updatedProvider) + cancelAddModel() + } else { + _apiKeyManagementState.update { + it.copy(isAddingModel = false, addModelError = "Model validation failed: ${validationMessage ?: "Unknown error"}") + } + } + } + + fun deleteModelFromProvider(providerKey: String, modelId: String) = viewModelScope.launch { + val provider = _apiKeyManagementState.value.providerStates.find { it.provider.key == providerKey }?.provider + if (provider != null) { + val updatedModels = provider.models.filterNot { it.modelId == modelId && it.isCustom } + if (updatedModels.size < provider.models.size) { + val updatedProvider = provider.copy(models = updatedModels) + apiRepository.updateProvider(updatedProvider) + } else { + Log.w(TAG, "Attempted to delete a non-custom model or model not found.") + } + } + } + fun showWipeAllConfirm() = _apiKeyManagementState.update { it.copy(showWipeAllConfirm = true) } + fun hideWipeAllConfirm() = _apiKeyManagementState.update { it.copy(showWipeAllConfirm = false) } + + fun confirmWipeAll() = viewModelScope.launch { + try { + apiRepository.wipeAll() + } finally { + _apiKeyManagementState.update { it.copy(showWipeAllConfirm = false) } + } + } + + fun checkProviderAvailability(baseUrl: String, callback: (Boolean, String) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val (ok, message) = apiManager.checkProviderAvailability(baseUrl) + withContext(Dispatchers.Main) { callback(ok, message) } + } + } + + fun setTranslationModel(model: LanguageModel?) = viewModelScope.launch { model?.let { apiRepository.setTranslationModel(it) } } + fun setExerciseModel(model: LanguageModel?) = viewModelScope.launch { model?.let { apiRepository.setExerciseModel(it) } } + fun setVocabularyModel(model: LanguageModel?) = viewModelScope.launch { model?.let { apiRepository.setVocabularyModel(it) } } + fun setDictionaryModel(model: LanguageModel?) = viewModelScope.launch { model?.let { apiRepository.setDictionaryModel(it) } } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/CategoryViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/CategoryViewModel.kt new file mode 100644 index 0000000..f1dc2c0 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/CategoryViewModel.kt @@ -0,0 +1,150 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import eu.gaudian.translator.model.VocabularyCategory +//import eu.gaudian.translator.model.VocabularyDictionary +import eu.gaudian.translator.model.repository.VocabularyRepository +import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * TODO: Convert CategoryViewModel to HiltViewModel + * + * This ViewModel needs to be converted to use Hilt for dependency injection. + * Currently it manually instantiates dependencies and uses a singleton pattern. + * + * Checklist: + * - [ ] Add @HiltViewModel annotation to the class + * - [ ] Change constructor to use @Inject and inject dependencies: + * - VocabularyRepository + * - [ ] Create/update RepositoryModule.kt to provide singleton instances of: + * - VocabularyRepository + * - [ ] Modify companion object getInstance() method to use Hilt's EntryPoint system + * - [ ] Create CategoryViewModelEntryPoint interface for accessing the singleton instance + * - [ ] Remove manual dependency instantiation from constructor and init block + * - [ ] Update all places where CategoryViewModel.getInstance() is called to ensure compatibility + * - [ ] Test that the ViewModel works correctly with dependency injection + */ +class CategoryViewModel(application: Application) : AndroidViewModel(application) { + + companion object { + @Volatile private var INSTANCE: CategoryViewModel? = null + fun getInstance(application: Application): CategoryViewModel = INSTANCE ?: synchronized(this) { + INSTANCE ?: CategoryViewModel(application).also { INSTANCE = it } + } + } + + private val repository: VocabularyRepository = VocabularyRepository.getInstance(application) + + val categories: StateFlow> = repository.getAllCategoriesFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + private val _showDeleteCategoryDialog = MutableStateFlow(false) + val showDeleteCategoryDialog: StateFlow = _showDeleteCategoryDialog.asStateFlow() + + private val _showDeleteItemsDialog = MutableStateFlow(false) + val showDeleteItemsDialog: StateFlow = _showDeleteItemsDialog.asStateFlow() + + private val _showEditCategoryDialog = MutableStateFlow(false) + val showEditCategoryDialog: StateFlow = _showEditCategoryDialog.asStateFlow() + + private val _navigateBack = MutableStateFlow(false) + + private val _updateUI = MutableStateFlow(false) + + var categoryToDelete: Int = 0 + var categoryVocabularyItemDelete: Int = 0 + var categoryToEdit: Int = 0 + + init { + viewModelScope.launch { + repository.initializeRepository() + } + } + + fun setNavigateBack(navigate: Boolean) { + _navigateBack.value = navigate + } + + fun setUpdateUI(update: Boolean) { + _updateUI.value = update + Log.d("CategoryViewModel", "Update UI: $update") + } + + fun getCategoryById(id: Int): Flow { + return categories.map { categoryList -> + categoryList.firstOrNull { it.id == id } + } + } + + fun createCategory(category: VocabularyCategory) { + viewModelScope.launch { + repository.saveCategory(category) + } + } + + fun updateCategory(category: VocabularyCategory) { + viewModelScope.launch { + repository.saveCategory(category) + setUpdateUI(true) + } + } + + fun deleteCategoryById(id: Int) { + viewModelScope.launch { + try { + repository.deleteCategoryById(id) + setNavigateBack(true) + } catch (_: Exception) { + + } + } + } + + suspend fun calculateNewDictionaries(): Set> { + val allPairs = getAllPossibleDictionariesInt() + val existingPairs = categories.value.mapNotNull { category -> + (category as? eu.gaudian.translator.model.VocabularyFilter)?.languagePairs + }.distinct() + return allPairs.filterNot { existingPairs.contains(it) }.toSet() + } + + suspend fun getAllPossibleDictionariesInt(): Set> { + val context = viewModelScope.coroutineContext + return withContext(context) { + repository.calcAvailableDictionaries() + } + } + + fun setShowDeleteCategoryDialog(show: Boolean, categoryId: Int) { + _showDeleteCategoryDialog.value = show + categoryToDelete = categoryId + } + + fun setShowDeleteItemsDialog(show: Boolean, categoryId: Int) { + Log.d("CategoryViewModel", "setShowDeleteItemsDialog: $show, categoryId: $categoryId") + _showDeleteItemsDialog.value = show + categoryVocabularyItemDelete = categoryId + } + + fun setShowEditCategoryDialog(show: Boolean, categoryId: Int) { + _showEditCategoryDialog.value = show + categoryToEdit = categoryId + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/CorrectionViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/CorrectionViewModel.kt new file mode 100644 index 0000000..b7c5a33 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/CorrectionViewModel.kt @@ -0,0 +1,153 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.viewmodel + +import android.app.Application +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.utils.CorrectionService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + + +/** + * TODO: Convert CorrectionViewModel to HiltViewModel + * + * This ViewModel needs to be converted to use Hilt for dependency injection. + * Currently it manually instantiates dependencies in the constructor. + * + * Checklist: + * - [ ] Add @HiltViewModel annotation to the class + * - [ ] Change constructor to use @Inject and inject dependencies: + * - CorrectionService + * - [ ] Create/update RepositoryModule.kt to provide singleton instances of: + * - CorrectionService + * - [ ] Remove manual dependency instantiation from constructor + * - [ ] Update all places where CorrectionViewModel() is instantiated to use Hilt injection + * - [ ] Test that the ViewModel works correctly with dependency injection + */ +class CorrectionViewModel(application: Application) : AndroidViewModel(application) { + + enum class Tone { NONE, FORMAL, CASUAL, COLLOQUIAL, POLITE, PROFESSIONAL, FRIENDLY, ACADEMIC, CREATIVE } + + val correctionService = CorrectionService(application.applicationContext) + + private val _textFieldValue = MutableStateFlow(TextFieldValue("")) + val textFieldValue = _textFieldValue.asStateFlow() + + private val _explanation = MutableStateFlow(null) + val explanation = _explanation.asStateFlow() + + private val _correctedText = MutableStateFlow(null) + val correctedText = _correctedText.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + private val _grammarOnly = MutableStateFlow(true) + val grammarOnly = _grammarOnly.asStateFlow() + + private val _tone = MutableStateFlow(Tone.NONE) + val tone = _tone.asStateFlow() + + fun setCorrectToneEnabled(enabled: Boolean) { + _grammarOnly.value = !enabled + if (!enabled) { + _tone.value = Tone.NONE + } + } + + fun setTone(newTone: Tone) { + _tone.value = newTone + if (newTone != Tone.NONE) _grammarOnly.value = false + } + + fun onInputTextChanged(newValue: TextFieldValue) { + _textFieldValue.value = newValue + if (_error.value != null) _error.value = null + } + + fun performCorrection(language: Language, color : Color) { + val originalText = _textFieldValue.value.text + if (originalText.isBlank()) return + + viewModelScope.launch { + _isLoading.value = true + _explanation.value = null + _error.value = null + + val grammarOnly = _grammarOnly.value + val tone = _tone.value.name.lowercase() + + correctionService.correctText(originalText, language, grammarOnly, if (_tone.value == Tone.NONE) null else tone) + .onSuccess { response -> + val annotatedString = createDiffAnnotatedString(originalText, response.correctedText, color) + _textFieldValue.value = TextFieldValue(annotatedString) + _explanation.value = response.explanation + _correctedText.value = response.correctedText + } + .onFailure { exception -> + // This block executes only if the result is a failure + _error.value = "Correction failed: ${exception.message ?: "Unknown error"}" + } + + // The loading state is updated once at the end, regardless of outcome + _isLoading.value = false + } + } + + // Clears only the text and related outputs, preserving user-selected options + fun clearText() { + _textFieldValue.value = TextFieldValue("") + _explanation.value = null + _correctedText.value = null + _error.value = null + // Do not touch _grammarOnly or _tone here + } + + private fun createDiffAnnotatedString(original: String, corrected: String, color : Color): AnnotatedString { + if (original.trim() == corrected.trim()) { + return buildAnnotatedString { + withStyle(style = SpanStyle(background = color, fontWeight = FontWeight.Bold)) { + append(corrected) + } + } + } else { + return buildAnnotatedString { + val originalWords = original.split(' ') + val correctedWordsSet = corrected.split(' ').toSet() + originalWords.forEachIndexed { index, word -> + if (word.isNotEmpty()) { + if (word in correctedWordsSet) { + append(word) + } else { + withStyle(style = SpanStyle(color = Color.Gray, textDecoration = TextDecoration.LineThrough)) { + append(word) + } + } + if (index < originalWords.lastIndex) { + append(" ") + } + } + } + append("\n\n") + withStyle(style = SpanStyle(background = color, fontWeight = FontWeight.Bold)) { + append(corrected) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/DictionaryViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/DictionaryViewModel.kt new file mode 100644 index 0000000..b57a6fe --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/DictionaryViewModel.kt @@ -0,0 +1,1016 @@ +@file:Suppress("HardCodedStringLiteral", "unused") + +package eu.gaudian.translator.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.DictionaryEntry +import eu.gaudian.translator.model.EntryPart +import eu.gaudian.translator.model.EtymologyData +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.communication.FileInfo +import eu.gaudian.translator.model.communication.RetrofitClient +import eu.gaudian.translator.model.grammar.DictionaryEntryData +import eu.gaudian.translator.model.repository.DictionaryFileRepository +import eu.gaudian.translator.model.repository.DictionaryJsonService +import eu.gaudian.translator.model.repository.DictionaryLookupRepository +import eu.gaudian.translator.model.repository.DictionaryRepository +import eu.gaudian.translator.model.repository.DictionaryWordEntry +import eu.gaudian.translator.model.repository.LanguageRepository +import eu.gaudian.translator.model.repository.SettingsRepository +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.StatusMessageService +import eu.gaudian.translator.utils.dictionary.DictionaryService +import eu.gaudian.translator.utils.dictionary.LocalAlternativeForm +import eu.gaudian.translator.utils.dictionary.LocalDictionaryAccess +import eu.gaudian.translator.utils.dictionary.LocalDictionaryWordInfo +import eu.gaudian.translator.utils.dictionary.LocalRelatedWordInfo +import eu.gaudian.translator.utils.dictionary.LocalTranslationInfo +import eu.gaudian.translator.utils.parseDefinitionsFromHtml +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import java.util.concurrent.atomic.AtomicInteger +import javax.inject.Inject + +@HiltViewModel +class DictionaryViewModel @Inject constructor( + application: Application, + private val dictionaryService: DictionaryService, + private val dictionaryRepository: DictionaryRepository, + private val languageRepository: LanguageRepository, + private val settingsRepository: SettingsRepository, + private val dictionaryFileRepository: DictionaryFileRepository, + private val dictionaryLookupRepository: DictionaryLookupRepository, + private val dictionaryJsonService: DictionaryJsonService, + private val statusMessageService: StatusMessageService +) : AndroidViewModel(application) { + + private val idCounter = AtomicInteger(0) + + private val _uiState = MutableStateFlow(DictionaryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Wrapper class for UI display of dictionary items with pre-calculated values + data class DictionaryUiItem( + val fileInfo: FileInfo, + val isDownloaded: Boolean, + val hasUpdate: Boolean, + val size: Long + ) + + // StateFlow exposing local dictionary entries for a given word/language + private val _localEntries = MutableStateFlow>(emptyList()) + val localEntries: StateFlow> = _localEntries.asStateFlow() + + // StateFlow exposing current entry data with language info + private val _currentEntryData = MutableStateFlow(null) + val currentEntryData: StateFlow = _currentEntryData.asStateFlow() + + // StateFlow exposing TTS availability for current entry + private val _isTtsAvailable = MutableStateFlow(false) + val isTtsAvailable: StateFlow = _isTtsAvailable.asStateFlow() + + // StateFlow exposing local dictionary availability for selected language + private val _isLocalDictAvailable = MutableStateFlow(false) + val isLocalDictAvailable: StateFlow = _isLocalDictAvailable.asStateFlow() + + // StateFlow for suggestions with debounce support + private val _suggestions = MutableStateFlow>(emptyList()) + val suggestions: StateFlow> = _suggestions.asStateFlow() + + private val _searchHistoryEntries = MutableStateFlow>(emptyList()) + val searchHistoryEntries = _searchHistoryEntries.asStateFlow() + + private val _currentEntryId = MutableStateFlow(null) + + private val _navigateToEntry = MutableStateFlow(null) + val navigateToEntry: StateFlow = _navigateToEntry.asStateFlow() + + private val _generatingEntryIds = MutableStateFlow>(emptySet()) + val generatingEntryIds: StateFlow> = _generatingEntryIds.asStateFlow() + + private val _breadcrumbs = MutableStateFlow>(emptyList()) + val breadcrumbs: StateFlow> = _breadcrumbs.asStateFlow() + + private var _pendingBreadcrumbReset = false + private var _isNavigatingFromDictionaryResult = false + + // Public StateFlow to expose navigation source + private val _isNavigatingFromDictionaryResultFlow = MutableStateFlow(false) + val isNavigatingFromDictionaryResultFlow: StateFlow = _isNavigatingFromDictionaryResultFlow.asStateFlow() + + // Developer-only: currently displayed entry when cycling through local dictionary + private val _developerCurrentEntry = MutableStateFlow(null) + val developerCurrentEntry: StateFlow = _developerCurrentEntry.asStateFlow() + + private val _wordOfTheDay = MutableStateFlow(null) + val wordOfTheDay: StateFlow = _wordOfTheDay.asStateFlow() + + + init { + loadSavedEntries() + viewModelScope.launch { + dictionaryRepository.loadDictionaryEntry().collect { entries -> _searchHistoryEntries.value = entries } + } + loadWordOfTheDay(forceRefresh = false) + } + + private suspend fun fetchWiktionaryDefinitions(word: String, language: Language): List? { + val normalized = word.trim().ifEmpty { return null } + val pageName = normalized.replace(' ', '_') + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Wikt] start fetch for word='$normalized' (page='$pageName')") + return try { + // First attempt with given page + var response = RetrofitClient.api.getPageHtml(page = pageName) + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Wikt] raw response: $response") + var parse = response.parse + if (parse == null) { + // Retry with capitalized first letter (common on Wiktionary) + val cap = pageName.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + if (cap != pageName) { + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Wikt] retrying with capitalized page='$cap'") + response = RetrofitClient.api.getPageHtml(page = cap) + parse = response.parse + } + } + if (parse == null) { + @Suppress("HardCodedStringLiteral") + Log.w("DictionaryViewModel", "[Wikt] response.parse is null for word='$normalized'") + return null + } + val htmlContent = parse.text?.html + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Wikt] parse.title='${parse.title}', html length='${htmlContent?.length ?: 0}'") + if (htmlContent != null) { + val defs = parseDefinitionsFromHtml(htmlContent, language.name) + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Wikt] parsed definitions count=${defs.size}") + defs + } else { + @Suppress("HardCodedStringLiteral") + Log.w("DictionaryViewModel", "[Wikt] htmlContent (parse.text[\"*\"]) is null — can't parse definitions") + null + } + } catch (e: Exception) { + @Suppress("HardCodedStringLiteral") + Log.e("DictionaryViewModel", "[Wikt] exception: ${e::class.java.simpleName}: ${e.message}") + null + } + } + + + fun refreshWordOfTheDay() { + loadWordOfTheDay(forceRefresh = true) + } + + private fun loadWordOfTheDay(forceRefresh: Boolean) { + viewModelScope.launch { + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "loadWordOfTheDay(forceRefresh=$forceRefresh)") + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + val selectedLanguage = languageRepository.loadSelectedDictionaryLanguage().first() + + if (selectedLanguage == null) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = getApplication().getString(R.string.text_please_select_a_dictionary_language_first) + ) + return@launch + } + + val wordOfTheDay = dictionaryService.getWordOfTheDay(selectedLanguage, forceRefresh)?.copy( + id = idCounter.incrementAndGet() + ) + + if (wordOfTheDay != null) { + val updatedEntries = _searchHistoryEntries.value.toMutableList() + updatedEntries.add(0, wordOfTheDay) + _searchHistoryEntries.value = updatedEntries + _wordOfTheDay.value = wordOfTheDay + + try { + dictionaryRepository.saveDictionaryEntry(wordOfTheDay) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + error = getApplication().getString( + R.string.text_error_saving_entry, + e.localizedMessage + ) + ) + } + } else { + _uiState.value = _uiState.value.copy(error = getApplication().getString( + R.string.text_could_not_fetch_a_new_word + )) + } + _uiState.value = _uiState.value.copy(isLoading = false) + } + } + + + private fun loadSavedEntries() { + viewModelScope.launch { + try { + val entries = dictionaryRepository.loadDictionaryEntry().first() + _searchHistoryEntries.value = entries + val maxId = entries.maxOfOrNull { it.id } ?: 0 + idCounter.set(maxId) + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "Loaded ${entries.size} saved entries; maxId=$maxId") + } catch (e: Exception) { + @Suppress("HardCodedStringLiteral") + Log.e("DictionaryViewModel", "Failed to load stored values: ${e.message}") + _uiState.value = _uiState.value.copy( + error = getApplication().getString( + R.string.text_error_loading_stored_values, + e.localizedMessage + ) + ) + } + } + } + + fun performSearch(query: String, language: Language, regenerate: Boolean = false, useDownloaded: Boolean = false, isDrillDown: Boolean = false) { + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "performSearch(query='$query', lang='${language.englishName}', regen=$regenerate, useDownloaded=$useDownloaded, isDrillDown=$isDrillDown)") + + if (!isDrillDown && !regenerate) { + _pendingBreadcrumbReset = true + } + + // 1. Check for existing entry in history + val existingEntry = _searchHistoryEntries.value.find { + it.word.equals(query, ignoreCase = true) && it.languageCode == language.nameResId + } + + // 2. Prepare the entry to navigate to + val entryToUse = if (existingEntry != null && !regenerate) { + // Use existing entry + existingEntry + } else { + // Create a new entry (or update existing if regenerating) + val id = if (regenerate && existingEntry != null) existingEntry.id else idCounter.incrementAndGet() + DictionaryEntry( + id = id, + word = query, + definition = emptyList(), // Start empty, will be populated by AI/Wiktionary + languageCode = language.nameResId, + languageName = language.name + ) + } + + // 3. Save/Update initial state in repository + viewModelScope.launch { + if (regenerate && existingEntry != null) { + // If regenerating, we might want to clear old definitions first? + // For now, we keep them until new ones arrive, or we could clear them. + // Let's keep the object as is for now, but we will trigger the loading state. + // Actually, if we want to show "Generating...", we should probably signal that. + // But we don't want to wipe the screen blank if we have old data. + // The UI will show a loading indicator overlay or similar. + } else if (existingEntry == null) { + dictionaryRepository.saveDictionaryEntry(entryToUse) + } + } + + // 4. Navigate immediately + _navigateToEntry.value = entryToUse + + // 5. If we are in "Offline Mode" (useDownloaded = true), we stop here. + // The ResultScreen will show local entries. + if (useDownloaded) { + return + } + + // 6. If Online, trigger AI/Wiktionary fetch in background + val entryId = entryToUse.id + _generatingEntryIds.value += entryId + + viewModelScope.launch { + try { + // A. Try Wiktionary first if enabled + val tryWiktionary = settingsRepository.tryWiktionaryFirst.flow.first() + if (tryWiktionary) { + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "Attempting Wiktionary for '$query'") + val wiktionaryDefs = withContext(Dispatchers.IO) { fetchWiktionaryDefinitions(query, language) } + + if (!wiktionaryDefs.isNullOrEmpty()) { + @Suppress("HardCodedStringLiteral") + val parts = listOf( + EntryPart( + title = "Wiktionary", + content = JsonArray(wiktionaryDefs.map { JsonPrimitive(it) }) + ) + ) + updateEntryDefinitions(entryId, parts) + _generatingEntryIds.value -= entryId + return@launch + } + } + + // B. Fallback to AI Service + dictionaryService.searchDefinition(query, language) + .onSuccess { entryFromServer -> + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "AI service returned entry for '$query'") + updateEntryDefinitions(entryId, entryFromServer.definition) + } + .onFailure { exception -> + @Suppress("HardCodedStringLiteral") + Log.e("DictionaryViewModel", "AI service failed for '$query': ${exception.message}") + // Optionally update entry with error part or just show toast + // For now, we just stop loading. + } + + } catch (e: Exception) { + Log.e("DictionaryViewModel", "Error during search: ${e.message}") + } finally { + _generatingEntryIds.value -= entryId + } + } + } + + private suspend fun updateEntryDefinitions(entryId: Int, newDefinitions: List) { + val currentList = _searchHistoryEntries.value + val entryIndex = currentList.indexOfFirst { it.id == entryId } + if (entryIndex != -1) { + val updatedEntry = currentList[entryIndex].copy(definition = newDefinitions) + dictionaryRepository.updateDictionaryEntry(updatedEntry) + } + } + + fun onNavigationDone() { + _navigateToEntry.value = null + } + + fun getDictionaryEntryById(id: Int): DictionaryEntry? { + // Use the already loaded searchHistory - don't call loadSavedEntries() here + // as it would create a race condition + return _searchHistoryEntries.value.find { it.id == id } + } + + + + fun clearCurrentEntryId() { + _currentEntryId.value = null + } + + /** + * Clears all breadcrumbs when leaving DictionaryResultScreen + */ + fun clearBreadcrumbs() { + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] CLEARING ALL BREADCRUMBS - user is leaving DictionaryResultScreen") + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] Current breadcrumbs before clearing: ${_breadcrumbs.value.map { it.word }}") + _breadcrumbs.value = emptyList() + _pendingBreadcrumbReset = false + _isNavigatingFromDictionaryResult = false + // Also reset the StateFlow for consistency + _isNavigatingFromDictionaryResultFlow.value = false + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] Breadcrumbs cleared successfully") + } + + /** + * Called when leaving DictionaryResultScreen. + * Only clears breadcrumbs if this is truly leaving (not internal navigation). + */ + fun onLeavingDictionaryResultScreen() { + // If we're not navigating internally, clear everything + if (!_isNavigatingFromDictionaryResult) { + clearBreadcrumbs() + } + // Reset the internal navigation flag + _isNavigatingFromDictionaryResult = false + } + + /** + * Sets the flag indicating navigation is coming from DictionaryResultScreen + */ + fun setNavigatingFromDictionaryResult(isFromDictionaryResult: Boolean) { + _isNavigatingFromDictionaryResult = isFromDictionaryResult + } + + /** + * Sets the flag to true when internal navigation happens within DictionaryResultScreen + */ + fun setInternalNavigation() { + _isNavigatingFromDictionaryResult = true + } + + /** + * Enhanced breadcrumb update logic that respects navigation source + */ + fun updateBreadcrumbs(entry: DictionaryEntry) { + val current = _breadcrumbs.value + + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] updateBreadcrumbsEnhanced called for entry: '${entry.word}' (id: ${entry.id})") + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] Current breadcrumbs: ${current.map { it.word }}") + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] Pending reset: $_pendingBreadcrumbReset, Is from DictionaryResult: $_isNavigatingFromDictionaryResult") + + // If pending reset is true, start fresh regardless of navigation source + if (_pendingBreadcrumbReset) { + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] ACTION: Resetting breadcrumbs due to pending reset flag") + _breadcrumbs.value = listOf(entry) + _pendingBreadcrumbReset = false + _isNavigatingFromDictionaryResult = true // Set flag for future internal navigations + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] New breadcrumbs: ${_breadcrumbs.value.map { it.word }}") + return + } + + // If we're not navigating from DictionaryResultScreen, clear breadcrumbs and start fresh + if (!_isNavigatingFromDictionaryResult) { + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] ACTION: Clearing breadcrumbs - external navigation detected") + _breadcrumbs.value = listOf(entry) + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] New breadcrumbs: ${_breadcrumbs.value.map { it.word }}") + // Don't set the flag to true here - let it be set when internal navigation happens + return + } + + // Normal breadcrumb logic: append or truncate + val index = current.indexOfFirst { it.id == entry.id } + if (index != -1) { + // Truncate after this entry + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] ACTION: Truncating breadcrumbs at index $index") + _breadcrumbs.value = current.take(index + 1) + } else { + // Append + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] ACTION: Appending entry to breadcrumbs") + _breadcrumbs.value = current + entry + } + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "[Breadcrumb] Final breadcrumbs: ${_breadcrumbs.value.map { it.word }}") + } + + data class DictionaryUiState( + val isLoading: Boolean = false, + val error: String? = null, + ) + + private val _etymologyData = MutableStateFlow(null) + val etymologyData: StateFlow = _etymologyData.asStateFlow() + + private val _manifest = MutableStateFlow(null) + val manifest: StateFlow = _manifest.asStateFlow() + + private val _downloadedDictionaries = MutableStateFlow>(emptyList()) + val downloadedDictionaries = _downloadedDictionaries.asStateFlow() + + private val _orphanedFiles = MutableStateFlow>(emptyList()) + val orphanedFiles = _orphanedFiles.asStateFlow() + + private val _downloadProgress = MutableStateFlow(null) + val downloadProgress = _downloadProgress.asStateFlow() + + private val _isDownloading = MutableStateFlow(false) + val isDownloading = _isDownloading.asStateFlow() + + + private val _errorMessage = MutableStateFlow(null) + val errorMessage = _errorMessage.asStateFlow() + + init { + viewModelScope.launch { + dictionaryFileRepository.manifest.collect { _manifest.value = it } + } + viewModelScope.launch { + dictionaryFileRepository.downloadedDictionaries.collect { _downloadedDictionaries.value = it } + } + viewModelScope.launch { + dictionaryFileRepository.orphanedFiles.collect { _orphanedFiles.value = it } + } + } + + fun fetchEtymology(query: String, language: Language) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + dictionaryService.getEtymology(query, language) + .onSuccess { + _etymologyData.value = it + _uiState.value = _uiState.value.copy(isLoading = false) + } + .onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = exception.message ?: getApplication().getString(R.string.text_failed_to_fetch_etymology) + ) + } + } + } + + /** + * Fetches the manifest from the server. + */ + fun fetchManifest() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + try { + dictionaryFileRepository.fetchManifest() + _uiState.value = _uiState.value.copy(isLoading = false) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(isLoading = false) + showErrorMessage( + getApplication().getString( + R.string.text_failed_to_fetch_manifest, + e.message + )) + } + } + } + + /** + * Downloads a dictionary file. + */ + fun downloadDictionary(fileInfo: FileInfo) { + viewModelScope.launch { + _isDownloading.value = true + try { + val success = dictionaryFileRepository.downloadDictionary(fileInfo) { progress -> + _downloadProgress.value = progress + } + if (success) { + _uiState.value = _uiState.value.copy(isLoading = false, error = null) + showSuccessToast(getApplication().getString(R.string.text_dictionary_downloaded_successfully)) + } else { + showErrorMessage( + getApplication().getString( + R.string.text_failed_to_download_dictionary, + fileInfo.name + )) + } + } catch (e: Exception) { + showErrorMessage( + getApplication().getString( + R.string.text_error_downloading_dictionary, + e.message + )) + } finally { + _downloadProgress.value = null + _isDownloading.value = false + } + } + } + + /** + * Deletes a downloaded dictionary. + */ + fun deleteDictionary(fileInfo: FileInfo) { + viewModelScope.launch { + try { + val success = dictionaryFileRepository.deleteDictionary(fileInfo) + if (success) { + showSuccessToast(getApplication().getString(R.string.text_dictionary_deleted_successfully)) + } else { + showErrorMessage( + getApplication().getString( + R.string.text_failed_to_delete_dictionary, + fileInfo.name + )) + } + } catch (e: Exception) { + showErrorMessage( + getApplication().getString( + R.string.text_error_deleting_dictionary, + e.message + )) + } + } + } + + /** + * Checks if a newer version is available. + */ + fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean { + return dictionaryFileRepository.isNewerVersionAvailable(fileInfo) + } + + /** + * Gets the local version of a dictionary. + */ + fun getLocalVersion(fileId: String): String { + return dictionaryFileRepository.getLocalVersion(fileId) + } + + /** + * Gets the size of a downloaded dictionary. + */ + fun getDictionarySize(fileInfo: FileInfo): Long { + return dictionaryFileRepository.getDictionarySize(fileInfo) + } + + /** + * Deletes all downloaded dictionaries. + */ + fun deleteAllDictionaries() { + viewModelScope.launch { + try { + val success = dictionaryFileRepository.deleteAllDictionaries() + if (success) { + showSuccessToast(getApplication().getString(R.string.text_all_dictionaries_deleted_successfully)) + } else { + showErrorMessage(getApplication().getString(R.string.text_failed_to_delete_some_dictionaries)) + } + } catch (e: Exception) { + showErrorMessage( + getApplication().getString( + R.string.text_error_deleting_dictionaries, + e.message + )) + } + } + } + + /** + * Deletes an orphaned file. + */ + fun deleteOrphanedFile(fileInfo: FileInfo) { + viewModelScope.launch { + try { + val success = dictionaryFileRepository.deleteOrphanedFile(fileInfo) + if (success) { + showSuccessToast(getApplication().getString(R.string.text_orphaned_file_deleted_successfully)) + } else { + showErrorMessage( + getApplication().getString( + R.string.text_failed_to_delete_orphaned_file, + fileInfo.name + )) + } + } catch (e: Exception) { + showErrorMessage( + getApplication().getString( + R.string.text_error_deleting_orphaned_file, + e.message + )) + } + } + } + + /** + * Searches for local dictionary entries for a word in a specific language. + */ + fun getLocalDictionaryEntries(word: String, langCode: String): List { + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "Fetching local dictionary entries for word '$word' in language '$langCode'") + val result = dictionaryLookupRepository.searchWord(word.trim(), langCode) + @Suppress("HardCodedStringLiteral") + Log.d("DictionaryViewModel", "Retrieved ${result.size} local dictionary entries") + Log.d("DictionaryViewModel", "Result: $result") + return result + } + + /** + * Convenience wrapper: parses the first local dictionary entry for a word into + * a display-agnostic [LocalDictionaryWordInfo]. Returns null if no entry exists + * or JSON cannot be parsed. + */ + fun getLocalDictionaryWordInfo(word: String, langCode: String): LocalDictionaryWordInfo? { + val entries = getLocalDictionaryEntries(word, langCode) + val first = entries.firstOrNull() ?: return null + return LocalDictionaryAccess.parseWordInfo(first) + } + + /** + * Retrieves all translations for a word in the given language from the local + * dictionary. Returns an empty list if no local entry is available. + */ + fun getLocalTranslations(word: String, langCode: String): List { + return getLocalDictionaryWordInfo(word, langCode)?.translations.orEmpty() + } + + /** + * Retrieves all related words (synonyms, hyponyms, etc.) across all + * relation types for a given word and language. + */ + fun getLocalRelatedWords(word: String, langCode: String): List { + return getLocalDictionaryWordInfo(word, langCode)?.relatedWords.orEmpty() + } + + /** + * Retrieves only synonyms for a word from the local dictionary. + */ + fun getLocalSynonyms(word: String, langCode: String): List { + return getLocalDictionaryWordInfo(word, langCode)?.synonyms.orEmpty() + } + + /** + * Retrieves only hyponyms for a word from the local dictionary. + */ + fun getLocalHyponyms(word: String, langCode: String): List { + return getLocalDictionaryWordInfo(word, langCode)?.hyponyms.orEmpty() + } + + /** + * Retrieves alternative forms (e.g. alternative spellings) for a word from + * the local dictionary. + */ + fun getLocalAlternativeForms(word: String, langCode: String): List { + return getLocalDictionaryWordInfo(word, langCode)?.alternativeForms.orEmpty() + } + + /** + * Retrieves word suggestions based on prefix for a specific language. + */ + fun getSuggestions(prefix: String, langCode: String, limit: Int): List { + return dictionaryLookupRepository.getSuggestions(prefix, langCode, limit) + } + + /** + * Checks if a dictionary is downloaded for the language. + */ + fun hasDictionaryForLanguage(langCode: String): Boolean { + Log.d("DictionaryViewModel", "Checking if dictionary exists for language: $langCode") + return dictionaryLookupRepository.hasDictionaryForLanguage(langCode) + } + + /** + * 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 { + Log.d("DictionaryViewModel", "Checking word availability: word='$word', lang='$langCode'") + val result = dictionaryLookupRepository.hasWordInDictionary(word, langCode) + Log.d("DictionaryViewModel", "Word availability result: word='$word', lang='$langCode', available=$result") + return result + } + + /** + * Developer-only helper: cycles through local dictionary entries for the given language + * and navigates to each one in sequence. Intended for testing dictionary rendering. + */ + @Suppress("HardCodedStringLiteral") + fun developerCycleLocalDictionaryEntries(language: Language, maxEntriesToDisplay: Int = 200000, delayMs: Long = 2L) { + viewModelScope.launch { + val langCode = language.code.lowercase() + Log.d("DictionaryViewModel", "[DEV] Starting cycle for local dictionary entries in '$langCode'") + + // Fetch a broad list of words from the dictionary (developer-focused; may be large) + val allWords: List = withContext(Dispatchers.IO) { + dictionaryLookupRepository.getAllWords(langCode, limit = maxEntriesToDisplay * 5) + } + + var shownCount = 0 + for (word in allWords) { + if (shownCount >= maxEntriesToDisplay) break + + // Look up local entries for the word + val localEntries: List = withContext(Dispatchers.IO) { + getLocalDictionaryEntries(word, langCode) + } + + if (localEntries.isNotEmpty()) { + val parts = localEntries.map { entry: DictionaryWordEntry -> + EntryPart( + title = getApplication().getString(R.string.label_definitions), + content = JsonPrimitive(entry.json) + ) + } + + val entry = DictionaryEntry( + id = idCounter.incrementAndGet(), + word = word, + definition = parts, + languageCode = language.nameResId, + languageName = language.name + ) + + // Directly expose the current entry for the developer UI to render + _developerCurrentEntry.value = entry + shownCount++ + delay(delayMs) + } + } + + Log.d("DictionaryViewModel", "[DEV] Finished cycling dictionary entries. Shown=$shownCount") + } + } + + /** + * Shows an error toast message. + */ + private fun showErrorMessage(message: String) { + _errorMessage.value = message + viewModelScope.launch { + StatusMessageService.trigger( + eu.gaudian.translator.utils.StatusAction.ShowMessage( + message, + MessageDisplayType.ERROR, + 5 + ) + ) + } + } + + /** + * Shows a success toast message. + */ + private fun showSuccessToast(message: String) { + viewModelScope.launch { + StatusMessageService.trigger( + eu.gaudian.translator.utils.StatusAction.ShowMessage( + message, + MessageDisplayType.SUCCESS, + 3 + ) + ) + } + } + + /** + * Gets structured dictionary data for a word entry using the new JSON service. + * This provides clean access to parsed JSON data without UI coupling. + */ + suspend fun getStructuredDictionaryData(entry: DictionaryWordEntry) = dictionaryJsonService.parseEntry(entry) + + /** + * Gets phonetics data for a word entry. + */ + suspend fun getPhoneticsData(entry: DictionaryWordEntry) = dictionaryJsonService.getPhonetics(entry) + + /** + * Gets translations data for a word entry. + */ + suspend fun getTranslationsData(entry: DictionaryWordEntry) = dictionaryJsonService.getTranslations(entry) + + /** + * Gets etymology data for a word entry. + */ + suspend fun getEtymologyData(entry: DictionaryWordEntry) = dictionaryJsonService.getEtymology(entry) + + /** + * Gets relations data (synonyms, hyponyms, etc.) for a word entry. + */ + suspend fun getRelationsData(entry: DictionaryWordEntry) = dictionaryJsonService.getAllRelatedWords(entry) + + /** + * Gets senses/definitions data for a word entry. + */ + suspend fun getSensesData(entry: DictionaryWordEntry) = dictionaryJsonService.getSenses(entry) + + /** + * Gets inflection data for a word entry. + */ + suspend fun getInflectionsData(entry: DictionaryWordEntry) = dictionaryJsonService.getInflections(entry) + + /** + * Clears the JSON service cache. Useful for testing. + */ + fun clearJsonServiceCache() = dictionaryJsonService.clearCache() + + // --- EntryData and State Management --- + + /** + * Data class representing entry data with language information. + * Used to pass entry and language info from ViewModel to UI. + */ + data class EntryData( + val entry: DictionaryEntry, + val language: Language? + ) + + /** + * Updates the current entry data with language info and TTS availability. + * Called from UI layer when entry changes. + */ + fun updateCurrentEntryData(entry: DictionaryEntry, language: Language?, isTtsAvailable: Boolean) { + _currentEntryData.value = EntryData(entry, language) + _isTtsAvailable.value = isTtsAvailable + } + + /** + * Loads local dictionary entries for a word and updates the StateFlow. + * Should be called when an entry is displayed to fetch local data. + */ + fun loadLocalEntriesForEntry(entry: DictionaryEntry, languageCode: String) { + viewModelScope.launch { + val entries = getLocalDictionaryEntries(entry.word, languageCode.lowercase()) + _localEntries.value = entries + } + } + + /** + * Clears the local entries StateFlow. + */ + fun clearLocalEntries() { + _localEntries.value = emptyList() + } + + /** + * Updates the local dictionary availability for the given language. + */ + fun updateLocalDictAvailability(langCode: String) { + Log.d("DictionaryViewModel", "Local dictionary availability updated: $langCode") + _isLocalDictAvailable.value = hasDictionaryForLanguage(langCode.lowercase()) + } + + /** + * Fetches suggestions with debounce support and updates the StateFlow. + */ + fun fetchSuggestions(query: String, langCode: String, limit: Int = 5, debounceMs: Long = 100) { + viewModelScope.launch { + delay(debounceMs) + if (query.length >= 3) { + _suggestions.value = getSuggestions(query, langCode.lowercase(), limit) + } else { + _suggestions.value = emptyList() + } + } + } + + /** + * Clears suggestions. + */ + fun clearSuggestions() { + _suggestions.value = emptyList() + } + + /** + * Creates a list of DictionaryUiItem for the manifest files with pre-calculated values. + */ + fun getDictionaryUiItems(files: List, downloadedDictionaries: List): List { + return files.map { fileInfo -> + val isDownloaded = downloadedDictionaries.any { it.id == fileInfo.id } + val hasUpdate = isDownloaded && isNewerVersionAvailable(fileInfo) + val size = if (isDownloaded) getDictionarySize(fileInfo) else fileInfo.assets.sumOf { it.sizeBytes } + DictionaryUiItem(fileInfo, isDownloaded, hasUpdate, size) + } + } + + // --- Structured Data State Management --- + + /** + * Internal cache for structured data results - uses mutableMapOf with explicit type + */ + private val structuredDataCache = mutableMapOf>() + + /** + * Gets or creates a StateFlow for structured dictionary data. + * This allows the UI to observe data changes for specific entries. + * Loading state is indicated by null value in the flow. + */ + fun getStructuredDictionaryDataState(entry: DictionaryWordEntry): StateFlow { + val key = entry.word + "_" + entry.langCode + val existing = structuredDataCache[key] + if (existing != null) return existing + + val newFlow: MutableStateFlow = MutableStateFlow(null) + structuredDataCache[key] = newFlow + + viewModelScope.launch { + val result = dictionaryJsonService.parseEntry(entry) + newFlow.value = result + } + + return newFlow + } + + /** + * Gets or creates a loading StateFlow for structured data. + * Returns true if data is still loading (null). + */ + fun getStructuredDictionaryDataLoading(entry: DictionaryWordEntry): StateFlow { + val key = entry.word + "_" + entry.langCode + // Create a derived flow that emits true when data is null + val dataFlow = getStructuredDictionaryDataState(entry) + val loadingFlow = MutableStateFlow(true) + viewModelScope.launch { + dataFlow.collect { data -> + loadingFlow.value = (data == null) + } + } + return loadingFlow + } + + /** + * Clears all caches for structured data. + */ + fun clearStructuredDataCache() { + structuredDataCache.clear() + } +} diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/ExerciseViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/ExerciseViewModel.kt new file mode 100644 index 0000000..832f2ca --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/ExerciseViewModel.kt @@ -0,0 +1,619 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.viewmodel + +import android.annotation.SuppressLint +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import eu.gaudian.translator.R +import eu.gaudian.translator.model.CategorizationQuestion +import eu.gaudian.translator.model.Exercise +import eu.gaudian.translator.model.FillInTheBlankQuestion +import eu.gaudian.translator.model.Language +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.SubtitleLine +import eu.gaudian.translator.model.TrueFalseQuestion +import eu.gaudian.translator.model.VocabularyTestQuestion +import eu.gaudian.translator.model.WordOrderQuestion +import eu.gaudian.translator.model.repository.ExerciseRepository +import eu.gaudian.translator.model.repository.LanguageRepository +import eu.gaudian.translator.model.repository.VocabularyRepository +import eu.gaudian.translator.utils.ExerciseService +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.TranslationService +import eu.gaudian.translator.utils.YouTubeApiService +import eu.gaudian.translator.utils.YouTubeExerciseService +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.reflect.KClass + +sealed class AnswerResult { + object UNCHECKED : AnswerResult() + object CORRECT : AnswerResult() + data class INCORRECT(val feedback: String) : AnswerResult() +} + +data class ExerciseSessionState( + val exercise: Exercise, + val questions: List, + val currentQuestionIndex: Int = 0, + val correctAnswers: Int = 0, + val wrongAnswers: Int = 0, + val selectedAnswer: Any? = null, + val answerResult: AnswerResult = AnswerResult.UNCHECKED +) { + val currentQuestion: Question get() = questions[currentQuestionIndex] + val isExerciseFinished: Boolean get() = currentQuestionIndex >= questions.size + + + fun isAnswerSelected(): Boolean { + return when (val question = currentQuestion) { + is FillInTheBlankQuestion, is ListeningComprehensionQuestion, is VocabularyTestQuestion -> (selectedAnswer as? String)?.isNotBlank() == true + is WordOrderQuestion -> (selectedAnswer as? List<*>)?.isNotEmpty() == true + is MatchingPairsQuestion -> ((selectedAnswer as? Map<*, *>)?.size ?: 0) == question.pairs.size + is CategorizationQuestion -> ((selectedAnswer as? Map<*, *>)?.size ?: 0) == question.items.size + else -> selectedAnswer != null + } + } +} + +sealed class AiGenerationState { + object Idle : AiGenerationState() + data class Generating(val statusMessage: String) : AiGenerationState() + data class Success(val generatedExerciseTitle: String) : AiGenerationState() + data class Error(val errorMessage: String) : AiGenerationState() +} + +sealed class YouTubeExerciseState { + object Idle : YouTubeExerciseState() + object Loading : YouTubeExerciseState() + data class Success( + val videoId: String, + val videoTitle: String, + val subtitles: List + ) : YouTubeExerciseState() + data class Error(val message: String) : YouTubeExerciseState() +} + +/** + * TODO: Convert ExerciseViewModel to HiltViewModel + * + * This ViewModel needs to be converted to use Hilt for dependency injection. + * Currently it manually instantiates dependencies and uses a singleton pattern. + * + * Checklist: + * - [ ] Add @HiltViewModel annotation to the class + * - [ ] Change constructor to use @Inject and inject dependencies: + * - ExerciseRepository + * - ExerciseService + * - VocabularyRepository + * - LanguageRepository + * - TranslationService + * - YouTubeApiService + * - YouTubeExerciseService + * - [ ] Create/update RepositoryModule.kt to provide singleton instances of: + * - ExerciseRepository + * - ExerciseService + * - VocabularyRepository + * - LanguageRepository + * - TranslationService + * - YouTubeApiService + * - YouTubeExerciseService + * - [ ] Modify companion object getInstance() method to use Hilt's EntryPoint system + * - [ ] Create ExerciseViewModelEntryPoint interface for accessing the singleton instance + * - [ ] Remove manual dependency instantiation from constructor and init block + * - [ ] Update all places where ExerciseViewModel.getInstance() is called to ensure compatibility + * - [ ] Test that the ViewModel works correctly with dependency injection + */ +class ExerciseViewModel(application: Application) : AndroidViewModel(application) { + data class YouTubeSessionParams(val url: String, val sourceLanguage: String?, val targetLanguage: String?) + companion object { + @SuppressLint("StaticFieldLeak") + @Volatile private var INSTANCE: ExerciseViewModel? = null + fun getInstance(application: Application): ExerciseViewModel = INSTANCE ?: synchronized(this) { + INSTANCE ?: ExerciseViewModel(application).also { INSTANCE = it } + } + } + + private fun normalizeText(input: String?): String { + if (input.isNullOrBlank()) return "" + @Suppress("HardCodedStringLiteral") val normalized = java.text.Normalizer.normalize(input, java.text.Normalizer.Form.NFD) + .replace("\\p{InCombiningDiacriticalMarks}+".toRegex(), "") + val noPunct = normalized.replace("\\p{Punct}".toRegex(), " ") + return noPunct.trim().replace("\\s+".toRegex(), " ").lowercase() + } + + private fun normalizeTokens(tokens: List): List = tokens.map { normalizeText(it) } + + private fun normalizeMap(map: Map): Map = map.mapNotNull { (k, v) -> + val nk = normalizeText(k?.toString()) + val nv = normalizeText(v?.toString()) + if (nk.isNotBlank() && nv.isNotBlank()) nk to nv else null + }.toMap() + + + @SuppressLint("StaticFieldLeak") + val context = application.applicationContext!! + val exerciseRepository = ExerciseRepository(application.applicationContext) + val exerciseService = ExerciseService(application.applicationContext) + val vocabularyRepository = VocabularyRepository.getInstance(application.applicationContext) + + // Translation helpers + private val languageRepository = LanguageRepository(application.applicationContext) + private val translationService = TranslationService(application.applicationContext) + private var currentSubtitleTranslationJob: Job? = null + + private val _exercises = MutableStateFlow>(emptyList()) + val exercises: StateFlow> = _exercises.asStateFlow() + + private val _exerciseSessionState = MutableStateFlow(null) + val exerciseSessionState: StateFlow = _exerciseSessionState.asStateFlow() + + private val _aiGenerationState = MutableStateFlow(AiGenerationState.Idle) + val aiGenerationState: StateFlow = _aiGenerationState.asStateFlow() + + private val _youTubeSessionParams = MutableStateFlow(null) + val youTubeSessionParams: StateFlow = _youTubeSessionParams.asStateFlow() + + private val _youTubeExerciseState = MutableStateFlow(YouTubeExerciseState.Idle) + val youTubeExerciseState: StateFlow = _youTubeExerciseState.asStateFlow() + + private var currentlyFetchingVideoId: String? = null + + + init { + loadExercisesFromRepository() + } + + private fun loadExercisesFromRepository() { + viewModelScope.launch { + exerciseRepository.getAllExercisesFlow().collect { exercisesList -> + _exercises.value = exercisesList + } + } + } + + fun generateExerciseWithAi( + category: String, + questionTypes: List>, + difficulty: String, + amount: Int, + sourceLanguage: String? = null, + targetLanguage: String? = null + ) { + Log.i("ExerciseViewModel", "generateExerciseWithAi: category=$category, types=${questionTypes.map { it.simpleName }}, difficulty=$difficulty, amount=$amount, source=$sourceLanguage, target=$targetLanguage") + viewModelScope.launch { + try { + _aiGenerationState.value = AiGenerationState.Generating(getApplication().applicationContext.getString(R.string.text_contacting_ai)) + + val result = exerciseService.generateExerciseWithQuestions( + category, + questionTypes, + difficulty, + amount, + sourceLanguage, + targetLanguage + ) { msg -> + _aiGenerationState.value = AiGenerationState.Generating(msg) + } + + if (result != null) { + val (exercise, questions, newVocab) = result + Log.i("ExerciseViewModel", "AI generated exercise '$exercise.title}' with ${questions.size} questions and ${newVocab.size} new vocab items") + + var savedVocabIds: List = emptyList() + if (newVocab.isNotEmpty()) { + vocabularyRepository.introduceVocabularyItems(newVocab) + val allVocab = vocabularyRepository.getAllVocabularyItems() + savedVocabIds = allVocab.filter { v -> newVocab.any { it.isDuplicate(v) } }.map { it.id } + } + + exerciseRepository.saveNewExerciseWithQuestions( + exercise.copy(associatedVocabularyIds = savedVocabIds), + questions + ) + Log.d("ExerciseViewModel", "Saved AI-generated exercise '${exercise.title}' with ${questions.size} questions; associatedVocabularyIds=${savedVocabIds.size}") + + _aiGenerationState.value = AiGenerationState.Success( + getApplication().applicationContext.getString( + R.string.exercise_2d_created, + exercise.title + )) + } else { + _aiGenerationState.value = AiGenerationState.Error(context.getString(R.string.text_ai_failed_to_create_the_exercise)) + } + + } catch (e: Exception) { + Log.e(context.getString(R.string.text_ai_generation_failed_with_an_exception), e) + _aiGenerationState.value = AiGenerationState.Error(e.message ?: context.getString(R.string.text_an_unknown_error_occurred)) + } finally { + delay(2500) + _aiGenerationState.value = AiGenerationState.Idle + } + } + } + + fun startAdHocExercise(exercise: Exercise, questions: List) { + _exerciseSessionState.value = ExerciseSessionState( + exercise = exercise, + questions = questions + ) + } + + fun startExercise(exercise: Exercise) { + viewModelScope.launch { + val allQuestions = exerciseRepository.getAllQuestionsFlow().first() + val questionsForExercise = allQuestions.filter { it.id in exercise.questions } + + if (questionsForExercise.size != exercise.questions.size) { + Log.w(context.getString(R.string.text_mismatch_between_question_ids_in_exercise_and_questions_found_in_repository)) + } + + _exerciseSessionState.value = ExerciseSessionState( + exercise = exercise, + questions = questionsForExercise + ) + } + } + + fun selectAnswer(answer: Any) { + _exerciseSessionState.update { it?.copy(selectedAnswer = answer) } + } + + fun checkAnswer() { + val currentState = _exerciseSessionState.value ?: return + if (!currentState.isAnswerSelected()) return + + var isCorrect: Boolean + var feedback = context.getString(R.string.text_that_s_not_quite_right) + + when (val question = currentState.currentQuestion) { + is TrueFalseQuestion -> { + isCorrect = question.correctAnswer == currentState.selectedAnswer as? Boolean + if (!isCorrect) { + question.explanation.ifBlank { context.getString(R.string.text_the_correct_answer_is_2d) + " " +(question.correctAnswer) } + .also { feedback = it } + } + } + is MultipleChoiceQuestion -> { + isCorrect = question.correctAnswerIndex == currentState.selectedAnswer as? Int + if (!isCorrect) feedback = context.getString(R.string.text_the_correct_answer_is_2d)+ question.options[question.correctAnswerIndex] + } + is FillInTheBlankQuestion -> { + val expected = normalizeText(question.correctAnswer) + val given = normalizeText(currentState.selectedAnswer as? String) + isCorrect = expected == given + if (!isCorrect) feedback = context.getString(R.string.text_the_correct_answer_is_2d) + " " + question.correctAnswer + } + is WordOrderQuestion -> { + val expected = normalizeTokens(question.correctOrder) + val given = normalizeTokens((currentState.selectedAnswer as? List<*>)?.map { it?.toString() ?: "" } ?: emptyList()) + isCorrect = expected == given + if (!isCorrect) feedback = context.getString( + R.string.text_the_correct_order_is_2d, + question.correctOrder.joinToString(" ") + ) + } + is MatchingPairsQuestion -> { + val selRaw = (currentState.selectedAnswer as? Map<*, *>) ?: emptyMap() + val sel = normalizeMap(selRaw) + val target = normalizeMap(question.pairs) + isCorrect = target == sel + if (!isCorrect) { + val correctCount = sel.count { (k, v) -> target[k] == v } + feedback = context.getString(R.string.text_check_your_matches) + " (" + correctCount + "/" + target.size + ")" + } + } + is ListeningComprehensionQuestion -> { + val expected = normalizeText(question.name) + val given = normalizeText(currentState.selectedAnswer as? String) + isCorrect = expected == given + if (!isCorrect) feedback = + context.getString(R.string.text_the_correct_sentence_was_2d, question.name) + } + is CategorizationQuestion -> { + val selRaw = (currentState.selectedAnswer as? Map<*, *>) ?: emptyMap() + val sel = normalizeMap(selRaw) + val target = normalizeMap(question.correctMapping) + isCorrect = target == sel + if (!isCorrect) { + val correctCount = sel.count { (item, cat) -> target[item] == cat } + feedback = context.getString(R.string.text_some_items_are_in_the_wrong_category) + " ($correctCount/" + question.items.size + ")" + } + } + is VocabularyTestQuestion -> { + val expected = normalizeText(question.correctAnswer) + val given = normalizeText(currentState.selectedAnswer as? String) + isCorrect = expected == given + if (!isCorrect) feedback = + context.getString(R.string.text_the_correct_translation_is_2d, question.correctAnswer) + } + } + + if (isCorrect) { + _exerciseSessionState.update { it?.copy(correctAnswers = it.correctAnswers + 1, answerResult = AnswerResult.CORRECT) } + } else { + _exerciseSessionState.update { it?.copy(wrongAnswers = it.wrongAnswers + 1, answerResult = AnswerResult.INCORRECT(feedback)) } + } + } + + fun nextQuestion() { + val currentState = _exerciseSessionState.value ?: return + val nextIndex = currentState.currentQuestionIndex + 1 + if (nextIndex >= currentState.questions.size) { + _exerciseSessionState.update { + it?.copy( + currentQuestionIndex = currentState.questions.size, + answerResult = AnswerResult.UNCHECKED + ) + } + } else { + _exerciseSessionState.update { + it?.copy( + currentQuestionIndex = nextIndex, + selectedAnswer = null, + answerResult = AnswerResult.UNCHECKED + ) + } + } + } + + fun closeExercise() { + _exerciseSessionState.value = null + } + + fun deleteExercise(exerciseId: String) { + viewModelScope.launch { + exerciseRepository.deleteExercise(exerciseId) + } + } + + fun startYouTubeExercise(url: String, sourceLanguage: Language, targetLanguage: Language) { + _youTubeSessionParams.value = YouTubeSessionParams(url = url, sourceLanguage = sourceLanguage.code, targetLanguage = targetLanguage.code) + _youTubeExerciseState.value = YouTubeExerciseState.Idle + } + + fun fetchSubtitlesForVideoId(videoId: String) { + // Guard against re-fetching if already in progress or already successfully loaded. + if (videoId == currentlyFetchingVideoId) { + Log.i("ExerciseViewModel", "Already fetching subtitles for $videoId. Ignoring request.") + return + } + val currentState = _youTubeExerciseState.value + if (currentState is YouTubeExerciseState.Success && currentState.videoId == videoId) { + Log.i("ExerciseViewModel", "Subtitles for $videoId are already loaded. Ignoring request.") + return + } + + val params = _youTubeSessionParams.value ?: run { + Log.e("ExerciseViewModel", "No YouTube session params while trying to fetch subtitles for $videoId") + _youTubeExerciseState.value = YouTubeExerciseState.Error("Session parameters not found. Please start the exercise again.") + return + } + val langCode = params.sourceLanguage ?: "en" // Default to English + + Log.i("ExerciseViewModel", "Fetching subtitles for videoId=$videoId (preferredLang=$langCode)") + + // Cancel any ongoing subtitle translation job when starting a new fetch + currentSubtitleTranslationJob?.cancel() + + viewModelScope.launch { + currentlyFetchingVideoId = videoId + _youTubeExerciseState.value = YouTubeExerciseState.Loading + Log.d("ExerciseViewModel", "State set to Loading for videoId=$videoId") + try { + val title = YouTubeApiService.getVideoTitle(videoId) + Log.i("ExerciseViewModel", "Fetched video title: '${title.take(100)}'") + + var available: List = emptyList() + var attempts = 0 + // Retry fetching transcripts as the page might still be loading JS. + while (attempts < 3 && available.isEmpty()) { + if (attempts > 0) { + Log.i("ExerciseViewModel", "Transcript list was empty. Retrying after delay... (Attempt ${attempts + 1}/3)") + delay(2500) + } + try { + available = YouTubeExerciseService.listTranscripts(videoId) + } catch (e: Exception) { + Log.w("ExerciseViewModel", "listTranscripts failed on attempt $attempts", e) + } + attempts++ + } + + Log.i("ExerciseViewModel", "Available transcript languages for $videoId: $available") + val chosenLang = when { + available.contains(langCode) -> langCode + available.contains("en") -> "en" + available.isNotEmpty() -> available.first() + else -> null + } + Log.d("ExerciseViewModel", "Chosen transcript language: $chosenLang (preferred was $langCode)") + + val finalLang = chosenLang ?: throw Exception("No transcripts available for this video.") + val subtitles = YouTubeExerciseService.getTranscript(videoId, finalLang) + Log.i("ExerciseViewModel", "Fetched ${subtitles.size} subtitle lines for $videoId in lang=${finalLang}") + + _youTubeExerciseState.value = YouTubeExerciseState.Success( + videoId = videoId, + videoTitle = title, + subtitles = subtitles + ) + Log.d("ExerciseViewModel", "YouTubeExerciseState set to Success for videoId=$videoId") + + // Kick off subtitle translations in background, prioritizing earliest lines first + startTranslatingSubtitles(videoId) + } catch (e: Exception) { + Log.e("ExerciseViewModel", "Failed to fetch YouTube data for $videoId", e) + _youTubeExerciseState.value = YouTubeExerciseState.Error(e.message ?: "An unknown error occurred.") + } finally { + // Clear the lock only if the fetch was for the current video. + if(currentlyFetchingVideoId == videoId) { + currentlyFetchingVideoId = null + } + Log.d("ExerciseViewModel", "Fetch complete for videoId=$videoId; fetch lock released") + } + } + } + + private fun startTranslatingSubtitles(videoId: String) { + val params = _youTubeSessionParams.value + val targetCode = params?.targetLanguage + if (targetCode.isNullOrBlank()) { + Log.w("ExerciseViewModel", "No target language set for YouTube exercise; skipping subtitle translation") + return + } + + currentSubtitleTranslationJob?.cancel() + currentSubtitleTranslationJob = viewModelScope.launch { + // Resolve target language by code + val allLangs = try { languageRepository.loadMasterLanguages().first() } catch (_: Exception) { emptyList() } + val targetLang = allLangs.firstOrNull { it.code.equals(targetCode, ignoreCase = true) } + if (targetLang == null) { + Log.w("ExerciseViewModel", "Could not resolve target Language for code=$targetCode; skipping subtitle translation") + return@launch + } + + val cache = mutableMapOf() + + fun updateLine(index: Int, translated: String) { + val state = _youTubeExerciseState.value + if (state is YouTubeExerciseState.Success && state.videoId == videoId) { + val updated = state.subtitles.toMutableList() + val old = updated.getOrNull(index) + if (old != null && old.translatedText != translated) { + updated[index] = old.copy(translatedText = translated) + _youTubeExerciseState.value = YouTubeExerciseState.Success( + videoId = state.videoId, + videoTitle = state.videoTitle, + subtitles = updated + ) + } + } + } + + val initialState = _youTubeExerciseState.value as? YouTubeExerciseState.Success ?: return@launch + val subtitles = initialState.subtitles + // Ensure order by start time (should already be) + val indices = subtitles.indices.sortedBy { subtitles[it].start } + + for (i in indices) { + val current = (_youTubeExerciseState.value as? YouTubeExerciseState.Success) ?: break + if (current.videoId != videoId) break // video changed + + val line = current.subtitles.getOrNull(i) ?: continue + if (line.text.isBlank() || line.translatedText != null) continue + + val cached = cache[line.text] + if (cached != null) { + updateLine(i, cached) + continue + } + + val res = translationService.simpleTranslateTo(line.text, targetLang) + if (res.isSuccess) { + val translated = res.getOrNull() ?: continue + cache[line.text] = translated + updateLine(i, translated) + } else { + Log.w("ExerciseViewModel", "Subtitle translation failed at index=$i: ${res.exceptionOrNull()?.message}") + } + // Tiny pause to avoid hammering the server + delay(50) + } + Log.i("ExerciseViewModel", "Subtitle translation finished for videoId=$videoId") + } + } + + fun clearYouTubeSubtitles() { + Log.d("ExerciseViewModel", "Clearing YouTube subtitles and resetting state to Idle") + currentlyFetchingVideoId = null + currentSubtitleTranslationJob?.cancel() + _youTubeExerciseState.value = YouTubeExerciseState.Idle + } + + /** + * Generates questions from YouTube video subtitles using AI and saves the exercise. + * This should be called from ViewModel scope to avoid coroutine cancellation issues. + */ + fun generateAndSaveYouTubeExercise( + subtitles: List, + videoTitle: String, + videoId: String, + sourceLanguage: String?, + targetLanguage: String?, + onComplete: (Exercise) -> Unit, + onError: (String) -> Unit + ) { + viewModelScope.launch { + try { + Log.i("ExerciseViewModel", "Generating AI questions for YouTube video: $videoTitle") + + val questions = exerciseService.generateQuestionsFromSubtitles( + subtitles = subtitles, + videoTitle = videoTitle, + sourceLanguage = sourceLanguage, + targetLanguage = targetLanguage + ) { progress -> + Log.d("ExerciseViewModel", "Question generation progress: $progress") + } + + if (questions.isEmpty()) { + onError("No questions could be generated from the video content") + return@launch + } + + Log.i("ExerciseViewModel", "Successfully generated ${questions.size} questions from YouTube video") + + // Create the exercise with YouTube URL for future reference + val youtubeUrl = _youTubeSessionParams.value?.url + val exercise = Exercise( + id = "youtube_${videoId}_${System.currentTimeMillis()}", + title = "YouTube: $videoTitle", + questions = questions.map { it.id }, + sourceLanguage = sourceLanguage, + targetLanguage = targetLanguage, + contextTitle = "YouTube Video: $videoTitle", + contextText = "Questions based on the YouTube video content.", + youtubeUrl = youtubeUrl + ) + + // Save the exercise and questions to repository + exerciseRepository.saveNewExerciseWithQuestions(exercise, questions) + + Log.i("ExerciseViewModel", "Saved YouTube exercise '${exercise.title}' with ${questions.size} questions") + + onComplete(exercise) + + } catch (e: Exception) { + Log.e("ExerciseViewModel", "Failed to generate and save YouTube exercise", e) + onError(e.message ?: "Unknown error occurred while generating questions") + } + } + } + + /** + * Navigates back to YouTube video from an exercise session. + * Restarts the YouTube exercise with the saved URL. + */ + fun restartYouTubeExercise(youtubeUrl: String, sourceLanguage: String?, targetLanguage: String?) { + // Update the YouTube session params to restart the exercise + _youTubeSessionParams.value = YouTubeSessionParams( + url = youtubeUrl, + sourceLanguage = sourceLanguage, + targetLanguage = targetLanguage + ) + // Reset the exercise state to allow restarting + _youTubeExerciseState.value = YouTubeExerciseState.Idle + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/LanguageConfigViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/LanguageConfigViewModel.kt new file mode 100644 index 0000000..82e0514 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/LanguageConfigViewModel.kt @@ -0,0 +1,108 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import eu.gaudian.translator.model.grammar.LanguageConfig +import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +/** + * TODO: Convert LanguageConfigViewModel to HiltViewModel + * + * This ViewModel needs to be converted to use Hilt for dependency injection. + * Currently it manually instantiates dependencies in the constructor. + * + * Checklist: + * - [ ] Add @HiltViewModel annotation to the class + * - [ ] Change constructor to use @Inject and inject dependencies: + * - Application (provided by Hilt) + * - [ ] Remove manual dependency instantiation from constructor + * - [ ] Update all places where LanguageConfigViewModel() is instantiated to use Hilt injection + * - [ ] Test that the ViewModel works correctly with dependency injection + */ +class LanguageConfigViewModel(application: Application) : AndroidViewModel(application) { + + private val _configs = MutableStateFlow>(emptyMap()) + val configs = _configs.asStateFlow() + + private val jsonParser = Json { ignoreUnknownKeys = true } + + init { + loadAllConfigs() + } + + val allWordClasses: StateFlow> = configs.map { configsMap -> + configsMap.values + .flatMap { it.categories.keys } + .distinct() + .sorted() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + /** + * Loads all language configuration files from the "language_configs" asset directory. + */ + private fun loadAllConfigs() { + viewModelScope.launch { + val context = getApplication().applicationContext + try { + val configFiles = context.assets.list("language_configs") ?: return@launch + val loadedConfigs = mutableMapOf() + + for (fileName in configFiles) { + if (fileName.endsWith(".json")) { + val jsonString = context.assets.open("language_configs/$fileName") + .bufferedReader() + .use { it.readText() } + + val config = jsonParser.decodeFromString(jsonString) + loadedConfigs[config.language_code] = config + } + } + _configs.value = loadedConfigs + } catch (e: Exception) { + // Log the error for debugging, but don't crash the app + Log.e("Failed to load language configs: ${e.message}") + e.printStackTrace() + } + } + } + + /** + * Retrieves the configuration for a specific language code. + * + * @param langCode The ISO language code (e.g., "de"). + * @return The LanguageConfig for the given code, or null if not found. + */ + fun getConfigForLanguage(langCode: String): LanguageConfig? { + Log.d("Fetching config for language: $langCode") + return _configs.value[langCode] + } + + /** + * Retrieves the set of articles for a specific language code. + * This method is robust and will not crash if the language or articles are not found. + * + * @param langCode The ISO language code (e.g., "de"). + * @return A Set of articles for the given language. Returns an empty set if + * the language code is not found or if the language has no articles defined. + */ + fun getArticlesForLanguage(langCode: String): Set { + return try { + val config = _configs.value[langCode] + config?.articles?.toSet() ?: emptySet() + } catch (e: Exception) { + Log.e("Error retrieving articles for '$langCode': ${e.message}") + emptySet() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/LanguageViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/LanguageViewModel.kt new file mode 100644 index 0000000..741af13 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/LanguageViewModel.kt @@ -0,0 +1,196 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.repository.LanguageListType +import eu.gaudian.translator.model.repository.LanguageRepository +import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject + +/** + * LanguageViewModel - Hilt-enabled ViewModel for language management + * + * This ViewModel uses Hilt for dependency injection and manages language-related state + * including selected languages, language lists, and language preferences. + */ +@HiltViewModel +class LanguageViewModel @Inject constructor( + application: Application +) : AndroidViewModel(application) { + + val languageRepository = LanguageRepository(application) + + private val languageSwitchMutex = Mutex() + + + // Enabled languages (visible across the app) + val allLanguages: StateFlow> = languageRepository.loadLanguages(LanguageListType.ALL) + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + // Master catalog (DEFAULT + CUSTOM), used by LanguageOptionsScreen to toggle visibility + val masterLanguages: StateFlow> = languageRepository.loadMasterLanguages() + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val favoriteLanguages: StateFlow> = languageRepository.loadLanguages(LanguageListType.FAVORITE) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val languageHistory: StateFlow> = languageRepository.loadLanguages(LanguageListType.HISTORY) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val selectedSourceLanguage: StateFlow = languageRepository.loadSelectedSourceLanguage() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val selectedTargetLanguage: StateFlow = languageRepository.loadSelectedTargetLanguage() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val selectedDictionaryLanguage: StateFlow = languageRepository.loadSelectedDictionaryLanguage() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val isAllSelected: StateFlow = kotlinx.coroutines.flow.combine(masterLanguages, allLanguages) { master, enabled -> + val masterIds = master.map { it.nameResId }.toSet() + val enabledIds = enabled.map { it.nameResId }.toSet() + masterIds.isNotEmpty() && enabledIds.containsAll(masterIds) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + + suspend fun getLanguageById(id: Int): Language { + + + val languages = allLanguages.first { it.isNotEmpty() } + + val language = languages.find { it.nameResId == id } + + if (language == null) { + Log.e("LanguageViewModel", "Language with ID $id not found, returning dummy language.") + return Language( + code = "error", + region = "error", + nameResId = 0, + name = "Error", + englishName = "Error", + isCustom = false, + isSelected = false + ) + } + + return language + } + + fun getLanguageByIdFlow(id: Int?): Flow { + return allLanguages.map { languages -> + languages.find { it.nameResId == id } + } + } + + fun switchLanguages() { + viewModelScope.launch { + languageSwitchMutex.withLock { + val source = selectedSourceLanguage.value + val target = selectedTargetLanguage.value + languageRepository.saveSelectedSourceLanguage(target) + languageRepository.saveSelectedTargetLanguage(source) + } + } + } + + fun setSelectedSourceLanguage(languageId: Int?) { + viewModelScope.launch { + if (languageId == 0 || languageId == null) { + languageRepository.saveSelectedSourceLanguage(null) + return@launch + } + val language = getLanguageById(languageId) + languageRepository.saveSelectedSourceLanguage(language) + } + } + + fun setSelectedTargetLanguage(languageId: Int?) { + viewModelScope.launch { + val language = if (languageId != null) getLanguageById(languageId) else null + languageRepository.saveSelectedTargetLanguage(language) + } + } + + fun setSelectedSourceLanguage(language: Language?) { + viewModelScope.launch { + languageRepository.saveSelectedSourceLanguage(language) + } + } + + fun setSelectedTargetLanguage(language: Language?) { + viewModelScope.launch { + languageRepository.saveSelectedTargetLanguage(language) + } + } + + fun setSelectedDictionaryLanguage(language: Language?) { + viewModelScope.launch { + languageRepository.saveSelectedDictionaryLanguage(language) + } + } + + fun updateFavoriteLanguages(languages: List) { + viewModelScope.launch { + languageRepository.saveLanguages(LanguageListType.FAVORITE, languages) + } + } + + fun updateLanguageHistory(languages: List) { + viewModelScope.launch { + languageRepository.saveLanguages(LanguageListType.HISTORY, languages) + } + } + + fun addCustomLanguage(language: Language) { + viewModelScope.launch { + languageRepository.addCustomLanguage(language) + // No need to re-observe. The 'allLanguages' and 'customLanguages' StateFlows will update automatically. + } + } + + fun removeCustomLanguage(languageToRemove: Language) { + viewModelScope.launch { + languageRepository.deleteCustomLanguage(languageToRemove) + // No need to re-observe. Flows will update automatically. + } + } + + fun selectAllLanguages(isSelected: Boolean) { + viewModelScope.launch { + val masterIds = masterLanguages.value.map { it.nameResId } + languageRepository.setEnabledLanguagesByIds(if (isSelected) masterIds else emptyList()) + } + } + + fun toggleLanguageSelection(languageToToggle: Language) { + viewModelScope.launch { + val enabledIds = allLanguages.value.map { it.nameResId }.toMutableSet() + if (enabledIds.contains(languageToToggle.nameResId)) { + enabledIds.remove(languageToToggle.nameResId) + } else { + enabledIds.add(languageToToggle.nameResId) + } + languageRepository.setEnabledLanguagesByIds(enabledIds.toList()) + } + } + + fun editLanguage(languageId: Int, newName: String, newCode: String, newRegion: String) { + viewModelScope.launch { + languageRepository.editCustomLanguage(languageId, newName, newCode, newRegion) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/ProgressViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/ProgressViewModel.kt new file mode 100644 index 0000000..383009b --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/ProgressViewModel.kt @@ -0,0 +1,317 @@ +package eu.gaudian.translator.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.application +import androidx.lifecycle.viewModelScope +import eu.gaudian.translator.model.VocabularyCategory +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.model.repository.SettingsRepository +import eu.gaudian.translator.model.repository.VocabularyRepository +import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.toLocalDateTime +import java.text.DateFormatSymbols +import java.util.Locale + + +data class CategoryProgress( + val vocabularyCategory: VocabularyCategory, + val totalItems: Int, + val newItems: Int, + val itemsInStages: Int, + val itemsCompleted: Int, +) + +data class DayStreak( + val day: String, + val targetMet: Boolean, +) + +data class WeeklyActivityStat( + val day: String, + val newlyAdded: Int, + val completed: Int, + val answeredRight: Int +) + +data class StageStats( + val stage: VocabularyStage, + val itemCount: Int +) + + +/** + * TODO: Convert ProgressViewModel to HiltViewModel + * + * This ViewModel needs to be converted to use Hilt for dependency injection. + * Currently it manually instantiates dependencies and uses a singleton pattern. + * + * Checklist: + * - [ ] Add @HiltViewModel annotation to the class + * - [ ] Change constructor to use @Inject and inject dependencies: + * - VocabularyRepository + * - SettingsRepository + * - [ ] Create/update RepositoryModule.kt to provide singleton instances of: + * - VocabularyRepository + * - SettingsRepository + * - [ ] Modify companion object getInstance() method to use Hilt's EntryPoint system + * - [ ] Create ProgressViewModelEntryPoint interface for accessing the singleton instance + * - [ ] Remove manual dependency instantiation from constructor and init block + * - [ ] Update all places where ProgressViewModel.getInstance() is called to ensure compatibility + * - [ ] Test that the ViewModel works correctly with dependency injection + */ +class ProgressViewModel(application: Application) : AndroidViewModel(application) { + companion object { + @Volatile private var INSTANCE: ProgressViewModel? = null + fun getInstance(application: Application): ProgressViewModel = INSTANCE ?: synchronized(this) { + INSTANCE ?: ProgressViewModel(application).also { INSTANCE = it } + } + } + + + val vocabularyRepository = VocabularyRepository.getInstance(application.applicationContext) + val settingsRepository = SettingsRepository(application.applicationContext) + + // Single-flight + coalescing scheduler for refresh() + private val refreshMutex = Mutex() + @Volatile private var isRefreshing = false + @Volatile private var pendingRefresh = false + private var refreshDebounceJob: Job? = null + private val refreshDebounceMs = 250L + + private val _categoryProgressList = MutableStateFlow>(emptyList()) + val categoryProgressList: StateFlow> = _categoryProgressList.asStateFlow() + + private val _lastSevenDays = MutableStateFlow>(emptyList()) + val lastSevenDays: StateFlow> = _lastSevenDays.asStateFlow() + + private val _streak = MutableStateFlow(0) + val streak: StateFlow = _streak.asStateFlow() + + private val _selectedCategories = MutableStateFlow>(emptySet()) + val selectedCategories: StateFlow> = _selectedCategories.asStateFlow() + + private val _dueTodayCount = MutableStateFlow(0) + val dueTodayCount: StateFlow = _dueTodayCount.asStateFlow() + + private val _totalWordsCompleted = MutableStateFlow(0) + val totalWordsCompleted: StateFlow = _totalWordsCompleted.asStateFlow() + + private val _totalWordsInProgress = MutableStateFlow(0) + val totalWordsInProgress: StateFlow = _totalWordsInProgress.asStateFlow() + + private val _weeklyActivityStats = MutableStateFlow>(emptyList()) + val weeklyActivityStats: StateFlow> = _weeklyActivityStats.asStateFlow() + + private val _dailyVocabularyStats = MutableStateFlow>(emptyMap()) + val dailyVocabularyStats: StateFlow> = _dailyVocabularyStats.asStateFlow() + + + init { + viewModelScope.launch { + // Delay the first refresh a bit to avoid colliding with repository init + delay(1500L) + tickerFlowInternal(5000L).collect { + // Coalesced refresh requests + requestRefresh() + } + } + viewModelScope.launch { + vocabularyRepository.getDueTodayItemsFlow().collect { + _dueTodayCount.value = it.size + } + } + } + + private fun tickerFlowInternal(period: Long) = flow { + while (true) { + emit(Unit) + delay(period) + } + } + + + suspend fun getLastSevenDays(): List { + val dailyCounts = vocabularyRepository.getCorrectCountsForLastDays(7) + + return dailyCounts.entries + .sortedByDescending { it.key } + .map { (date, _) -> + // Map ISO day (Mon=1...Sun=7) to Calendar day (Sun=1...Sat=7) for DateFormatSymbols + // kotlinx.datetime.DayOfWeek is an enum (Mon=0...Sun=6). + // Logic: ((ordinal + 1) % 7) + 1 -> Mon(0)=>2, Sun(6)=>1 + val calendarDay = ((date.dayOfWeek.ordinal + 1) % 7) + 1 + + // Get localized short name (e.g., "Mon", "Seg") from the device's default locale + val localizedDay = DateFormatSymbols.getInstance(Locale.getDefault()).shortWeekdays[calendarDay].uppercase() + + DayStreak(localizedDay, vocabularyRepository.isTargetMetForDate(date)) + } + } + + suspend fun calculateDailyStreak(): Int { + val dailyData = vocabularyRepository.getCorrectCountsForLastDays(365) + val today = kotlin.time.Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + var currentStreak = 0 + + for (i in 0..365) { + val dateToCheck = today.minus(i, DateTimeUnit.DAY) + if (dailyData.containsKey(dateToCheck) && vocabularyRepository.isTargetMetForDate(dateToCheck)) { + currentStreak++ + } else { + break + } + } + return currentStreak + } + + fun toggleCategorySelection(category: Int) { + val currentSelection = _selectedCategories.value.toMutableSet() + if (currentSelection.contains(category)) { + currentSelection.remove(category) + } else { + currentSelection.add(category) + } + _selectedCategories.value = currentSelection + saveSelectedCategories(currentSelection) + } + + private fun loadSelectedCategories() { + viewModelScope.launch { + val stringSet = settingsRepository.selectedCategories.flow.first() + _selectedCategories.value = stringSet.mapNotNull { it.toIntOrNull() }.toSet() + } + } + + private fun saveSelectedCategories(categories: Set) { + viewModelScope.launch { + settingsRepository.selectedCategories.set(categories.map { it.toString() }.toSet()) + } + } + + suspend fun getWeeklyActivityStats(): List { + val vocabularyRepository = VocabularyRepository.getInstance(application) + val today = kotlin.time.Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + + // 1. Create a list of the last 7 dates in chronological order. + val lastSevenDates = (0..6).map { i -> + today.minus(6 - i, DateTimeUnit.DAY) + } + + // 2. Map each date to its corresponding statistics. + return lastSevenDates.map { date -> + val newlyAdded = vocabularyRepository.getNewlyAddedCountForDate(date) + val completed = vocabularyRepository.getCompletedCountForDate(date) + val answeredRight = vocabularyRepository.getCorrectAnswerCountForDate(date) + + // Calculate localized day name + val calendarDay = ((date.dayOfWeek.ordinal + 1) % 7) + 1 + val localizedDay = DateFormatSymbols.getInstance(Locale.getDefault()).shortWeekdays[calendarDay].uppercase() + + WeeklyActivityStat( + // 3. Get the actual day name from the date and take the first 3 letters. + day = localizedDay, + newlyAdded = newlyAdded, + completed = completed, + answeredRight = answeredRight + ) + } + } + + suspend fun getDailyVocabularyStats(): Map { + val today = kotlin.time.Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + val startDate = today.minus(365, DateTimeUnit.DAY) // Get up to a year of data + + return vocabularyRepository.getDailyVocabularyStats(startDate, today) + } + + + //let's keep as public entry point + fun refresh() { + requestRefresh() + } + + private fun requestRefresh() { + pendingRefresh = true + refreshDebounceJob?.cancel() + refreshDebounceJob = viewModelScope.launch { + delay(refreshDebounceMs) + triggerRefreshIfNeeded() + } + } + + private fun triggerRefreshIfNeeded() { + if (isRefreshing) return + if (!pendingRefresh) return + pendingRefresh = false + isRefreshing = true + viewModelScope.launch { + try { + runRefreshInternal() + } finally { + isRefreshing = false + if (pendingRefresh) { + refreshDebounceJob?.cancel() + refreshDebounceJob = viewModelScope.launch { + delay(refreshDebounceMs) + triggerRefreshIfNeeded() + } + } + } + } + } + + private suspend fun runRefreshInternal() { + refreshMutex.lock() + try { + loadSelectedCategories() + try { + val progressDeferred = viewModelScope.async { vocabularyRepository.calculateCategoryProgress() } + val lastSevenDaysDeferred = viewModelScope.async { getLastSevenDays() } + val streakDeferred = viewModelScope.async { calculateDailyStreak() } + val weeklyStatsDeferred = viewModelScope.async { getWeeklyActivityStats() } + val dailyStatsDeferred = viewModelScope.async { getDailyVocabularyStats() } + + val progressList = progressDeferred.await() + val stageList = vocabularyRepository.calculateStageStatistics() + _categoryProgressList.value = progressList + _totalWordsCompleted.value = stageList.firstOrNull { it.stage == VocabularyStage.LEARNED }?.itemCount ?: 0 + + _totalWordsInProgress.value = stageList + .filter { it.stage != VocabularyStage.LEARNED && it.stage != VocabularyStage.NEW } + .sumOf { it.itemCount } + + if (_selectedCategories.value.isEmpty() && progressList.isNotEmpty()) { + val initialCategory = setOf(progressList.first().vocabularyCategory.id) + _selectedCategories.value = initialCategory + saveSelectedCategories(initialCategory) + } + + _lastSevenDays.value = lastSevenDaysDeferred.await() + _streak.value = streakDeferred.await() + _weeklyActivityStats.value = weeklyStatsDeferred.await() + _dailyVocabularyStats.value = dailyStatsDeferred.await() + } catch (e: Exception) { + @Suppress("HardCodedStringLiteral") + Log.e("ProgressViewModel", "Error during refresh: ${e.message}", e) + } + } finally { + refreshMutex.unlock() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/SettingsViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..d729e76 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/SettingsViewModel.kt @@ -0,0 +1,282 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.viewmodel + +import android.app.Application +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.application +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.gaudian.translator.R +import eu.gaudian.translator.model.WidgetType +import eu.gaudian.translator.model.repository.ApiLogRepository +import eu.gaudian.translator.model.repository.SettingsRepository +import eu.gaudian.translator.model.repository.dataStore +import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@HiltViewModel +class SettingsViewModel @Inject constructor( + application: Application, + private val settingsRepository: SettingsRepository, + private val apiLogRepository: ApiLogRepository +) : AndroidViewModel(application) { + companion object { + // Stable, non-localized keys for vocabulary stages + const val STAGE_NEW: String = "stage_new" + const val STAGE_1: String = "stage_1" + const val STAGE_2: String = "stage_2" + const val STAGE_3: String = "stage_3" + const val STAGE_4: String = "stage_4" + const val STAGE_5: String = "stage_5" + const val STAGE_LEARNED: String = "stage_learned" + } + + val apiLogs = apiLogRepository.getLogs() + fun clearApiLogs() = viewModelScope.launch { apiLogRepository.clear() } + + private object PrefKeys { + val WIDGET_ORDER = stringPreferencesKey("widget_order") + val COLLAPSED_WIDGET_IDS = stringSetPreferencesKey("collapsed_widget_ids") + val DASHBOARD_SCROLL_POSITION = stringPreferencesKey("dashboard_scroll_position") + val DASHBOARD_SCROLL_OFFSET = stringPreferencesKey("dashboard_scroll_offset") + } + + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() + init { + viewModelScope.launch { + //TODO do connection check + + _isInitialized.value = true + } + } + + val theme: StateFlow = settingsRepository.theme.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Default") + val darkModePreference: StateFlow = settingsRepository.darkModePreference.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "System") + val fontPreference: StateFlow = settingsRepository.fontPreference.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Default") + val introCompleted: StateFlow = settingsRepository.introCompleted.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + val showBottomNavLabels: StateFlow = settingsRepository.showBottomNavLabels.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + + + val isDeveloperModeEnabled: StateFlow = settingsRepository.developerMode.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val dictionarySwitches: StateFlow> = settingsRepository.dictionarySwitches.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + val customPrompt: StateFlow = settingsRepository.customPromptTranslation.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val customPromptVocabulary: StateFlow = settingsRepository.customPromptVocabulary.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val customPromptDictionary: StateFlow = settingsRepository.customPromptDictionary.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val customPromptExercise: StateFlow = settingsRepository.customPromptExercise.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") + val speakingSpeed: StateFlow = settingsRepository.speakingSpeed.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 100) + + val showHints: StateFlow = settingsRepository.showHints.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + fun setShowHints(enabled: Boolean) = viewModelScope.launch { settingsRepository.showHints.set(enabled) } + + val experimentalFeatures: StateFlow = settingsRepository.experimentalFeatures.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + val connectionConfigured: StateFlow = settingsRepository.connectionConfigured.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + val tryWiktionaryFirst: StateFlow = settingsRepository.tryWiktionaryFirst.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + // LibreTranslate toggle + val useLibreTranslate: StateFlow = settingsRepository.useLibreTranslate.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + fun setUseLibreTranslate(enabled: Boolean) = viewModelScope.launch { settingsRepository.useLibreTranslate.set(enabled) } + + fun setExperimentalFeatures(enabled: Boolean) = viewModelScope.launch { settingsRepository.experimentalFeatures.set(enabled) } + fun setTryWiktionaryFirst(enabled: Boolean) = viewModelScope.launch { settingsRepository.tryWiktionaryFirst.set(enabled) } + + val widgetOrder: StateFlow> = application.dataStore.data + .map { preferences -> + val allWidgets = WidgetType.DEFAULT_ORDER + val orderString = preferences[PrefKeys.WIDGET_ORDER] + + if (orderString.isNullOrEmpty()) { + allWidgets + } else { + val savedWidgets = orderString.split(",").mapNotNull { WidgetType.fromId(it) } + + + if (savedWidgets.size != allWidgets.size) { + saveWidgetOrder(allWidgets) + allWidgets + } else { + savedWidgets + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), WidgetType.DEFAULT_ORDER) + + + val collapsedWidgetIds: StateFlow> = application.dataStore.data + .map { preferences -> + preferences[PrefKeys.COLLAPSED_WIDGET_IDS] ?: emptySet() + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + val dailyGoal: StateFlow = settingsRepository.dailyGoal.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 10) + val criteriaCorrect: StateFlow = settingsRepository.criteriaCorrect.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 3) + val criteriaWrong: StateFlow = settingsRepository.criteriaWrong.flow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2) + val intervals: StateFlow> = combine( + settingsRepository.intervalNew.flow, + settingsRepository.intervalStage1.flow, + settingsRepository.intervalStage2.flow, + settingsRepository.intervalStage3.flow, + settingsRepository.intervalStage4.flow, + settingsRepository.intervalStage5.flow, + settingsRepository.intervalLearned.flow + ) { values -> + mapOf( + STAGE_NEW to values[0], + STAGE_1 to values[1], + STAGE_2 to values[2], + STAGE_3 to values[3], + STAGE_4 to values[4], + STAGE_5 to values[5], + STAGE_LEARNED to values[6] + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + fun setInterval(stage: String, days: Int) = viewModelScope.launch { + when (stage) { + STAGE_NEW -> settingsRepository.intervalNew.set(days) + STAGE_1 -> settingsRepository.intervalStage1.set(days) + STAGE_2 -> settingsRepository.intervalStage2.set(days) + STAGE_3 -> settingsRepository.intervalStage3.set(days) + STAGE_4 -> settingsRepository.intervalStage4.set(days) + STAGE_5 -> settingsRepository.intervalStage5.set(days) + STAGE_LEARNED -> settingsRepository.intervalLearned.set(days) + } + } + + /** Resets all vocabulary stage intervals to their default values. */ + fun resetIntervalsToDefaults() = viewModelScope.launch { + // Defaults as defined in SettingsRepository + settingsRepository.intervalNew.set(1) + settingsRepository.intervalStage1.set(3) + settingsRepository.intervalStage2.set(7) + settingsRepository.intervalStage3.set(14) + settingsRepository.intervalStage4.set(30) + settingsRepository.intervalStage5.set(60) + settingsRepository.intervalLearned.set(90) + } + + + fun setTheme(theme: String) = viewModelScope.launch { + settingsRepository.theme.set(theme) + } + fun setDarkModePreference(preference: String) = viewModelScope.launch { settingsRepository.darkModePreference.set(preference) } + fun setFontPreference(font: String) = viewModelScope.launch { settingsRepository.fontPreference.set(font) } + fun setShowBottomNavLabels(enabled: Boolean) = viewModelScope.launch { settingsRepository.showBottomNavLabels.set(enabled) } + + fun setDeveloperMode(isEnabled: Boolean) = viewModelScope.launch { settingsRepository.developerMode.set(isEnabled) } + fun setDailyGoal(goal: Int) = viewModelScope.launch { settingsRepository.dailyGoal.set(goal) } + fun setDictionarySwitch(switchKey: String, isChecked: Boolean) { + viewModelScope.launch { + val currentSwitches = settingsRepository.dictionarySwitches.flow.first().toMutableSet() + + // Normalize legacy values which stored localized labels instead of stable keys + // by removing the old label for this key (if present) before updating. + val resources = application.resources + val keys = resources.getStringArray(R.array.dictionary_content_keys) + val labels = resources.getStringArray(R.array.dictionary_content) + val index = keys.indexOf(switchKey) + if (index >= 0) { + val legacyLabel = labels[index] + currentSwitches.remove(legacyLabel) + } + + if (isChecked) { + currentSwitches.add(switchKey) + } else { + currentSwitches.remove(switchKey) + } + settingsRepository.dictionarySwitches.set(currentSwitches) + } + } + fun saveCustomPrompt(customPrompt: String) = viewModelScope.launch { settingsRepository.customPromptTranslation.set(customPrompt) } + fun saveCustomVocabularyPrompt(customPrompt: String) = viewModelScope.launch { settingsRepository.customPromptVocabulary.set(customPrompt) } + fun saveCustomPromptDictionary(customPrompt: String) = viewModelScope.launch { settingsRepository.customPromptDictionary.set(customPrompt) } + fun saveCustomExercisePrompt(customPrompt: String) = viewModelScope.launch { settingsRepository.customPromptExercise.set(customPrompt) } + fun saveSpeakingSpeed(speed: Int) = viewModelScope.launch { settingsRepository.speakingSpeed.set(speed) } + fun saveTtsVoiceForLanguage(code: String, region: String, voiceName: String?) = viewModelScope.launch { settingsRepository.setTtsVoiceForLanguage(code, region, voiceName) } + suspend fun getTtsVoiceForLanguage(code: String, region: String): String? = settingsRepository.getTtsVoiceForLanguage(code, region).first() + fun setCriteriaCorrect(value: Int) = viewModelScope.launch { settingsRepository.criteriaCorrect.set(value) } + fun setCriteriaWrong(value: Int) = viewModelScope.launch { settingsRepository.criteriaWrong.set(value) } + fun saveWidgetOrder(order: List) = viewModelScope.launch { + application.dataStore.edit { settings -> + settings[PrefKeys.WIDGET_ORDER] = order.joinToString(",") { it.id } + } + Log.d("SettingsViewModel", "Widget order saved successfully") + } + suspend fun setWidgetExpandedState(widgetId: String, isExpanded: Boolean) { + application.dataStore.edit { settings -> + val currentCollapsedIds = settings[PrefKeys.COLLAPSED_WIDGET_IDS]?.toMutableSet() ?: mutableSetOf() + if (isExpanded) { + currentCollapsedIds.remove(widgetId) + } else { + currentCollapsedIds.add(widgetId) + } + settings[PrefKeys.COLLAPSED_WIDGET_IDS] = currentCollapsedIds + } + } + + // Dashboard scroll state persistence + val dashboardScrollState: StateFlow> = application.dataStore.data + .map { preferences -> + val firstVisibleItemIndex = preferences[PrefKeys.DASHBOARD_SCROLL_POSITION]?.toIntOrNull() ?: 0 + val firstVisibleItemScrollOffset = preferences[PrefKeys.DASHBOARD_SCROLL_OFFSET]?.toIntOrNull() ?: 0 + Pair(firstVisibleItemIndex, firstVisibleItemScrollOffset) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Pair(0, 0)) + + fun saveDashboardScrollState(firstVisibleItemIndex: Int, firstVisibleItemScrollOffset: Int) = viewModelScope.launch { + application.dataStore.edit { settings -> + settings[PrefKeys.DASHBOARD_SCROLL_POSITION] = firstVisibleItemIndex.toString() + settings[PrefKeys.DASHBOARD_SCROLL_OFFSET] = firstVisibleItemScrollOffset.toString() + } + Log.d("SettingsViewModel", "Dashboard scroll state saved: index=$firstVisibleItemIndex, offset=$firstVisibleItemScrollOffset") + } + fun setIntroCompleted(completed: Boolean) = viewModelScope.launch { + settingsRepository.introCompleted.set(completed) + } + + /** + * Checks if the user has seen the "what's new" dialog for the current version + */ + suspend fun hasSeenCurrentVersion(currentVersion: String): Boolean { + return settingsRepository.hasSeenCurrentVersion(currentVersion) + } + + /** + * Marks the current version as seen by the user + */ + fun markVersionAsSeen(version: String) = viewModelScope.launch { + settingsRepository.markVersionAsSeen(version) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/StatusViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/StatusViewModel.kt new file mode 100644 index 0000000..a150e46 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/StatusViewModel.kt @@ -0,0 +1,301 @@ +@file:Suppress("HardCodedStringLiteral", "unused") + +package eu.gaudian.translator.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.StatusAction +import eu.gaudian.translator.utils.StatusMessageService +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.LinkedList +import java.util.Queue + +enum class MessageAction { + NAVIGATE_TO_API_KEYS +} + +sealed class StatusState { + object Hidden : StatusState() + object Loading : StatusState() + data class Message( + val id: Long, + val text: String, + val type: MessageDisplayType = MessageDisplayType.INFO, + val action: MessageAction? = null + ) : StatusState() +} + +// Priorities are now more distinct to ensure clear hierarchy. +enum class MessageDisplayType(val priority: Int) { + INFO(1), + INTERN(1), + LOADING(2), + SUCCESS(3), + ERROR(4), + ACTIONABLE_ERROR(5) +} + +/** + * TODO: Convert StatusViewModel to HiltViewModel + * + * This ViewModel needs to be converted to use Hilt for dependency injection. + * Currently it manually instantiates dependencies and uses a singleton pattern. + * + * Checklist: + * - [ ] Add @HiltViewModel annotation to the class + * - [ ] Change constructor to use @Inject and inject dependencies: + * - StatusMessageService + * - [ ] Create/update RepositoryModule.kt to provide singleton instances of: + * - StatusMessageService + * - [ ] Modify companion object getInstance() method to use Hilt's EntryPoint system + * - [ ] Create StatusViewModelEntryPoint interface for accessing the singleton instance + * - [ ] Remove manual dependency instantiation from constructor and init block + * - [ ] Update all places where StatusViewModel.getInstance() is called to ensure compatibility + * - [ ] Test that the ViewModel works correctly with dependency injection + */ +class StatusViewModel(application: Application) : AndroidViewModel(application) { + companion object { + @Volatile private var INSTANCE: StatusViewModel? = null + fun getInstance(application: Application): StatusViewModel = INSTANCE ?: synchronized(this) { + INSTANCE ?: StatusViewModel(application).also { INSTANCE = it } + } + } + + private val messageQueue: Queue> = LinkedList() + private var messageDisplayJob: Job? = null + private var activeLoadingJob: Job? = null + private var messageIdCounter = 0L + + private val _status = MutableStateFlow(StatusState.Hidden) + val status: StateFlow = _status.asStateFlow() + + init { + viewModelScope.launch { + StatusMessageService.actions.collect { action -> + handleAction(action) + } + } + } + + private fun handleAction(action: StatusAction) { + Log.d("StatusViewModel", "Received action: $action") + when (action) { + is StatusAction.ShowMessage -> showMessageInternal(action.text, action.type, action.timeoutInSeconds) + is StatusAction.ShowActionableMessage -> showPermanentActionableMessageInternal(action.text, action.type, action.action) + is StatusAction.ShowPermanentMessage -> showPermanentMessageInternal(action.text, action.type) + is StatusAction.PerformLoadingOperation -> performLoadingOperationInternal(action.block) + is StatusAction.CancelLoadingOperation -> cancelLoadingOperationInternal() + is StatusAction.CancelPermanentMessage -> cancelPermanentMessageInternal() + is StatusAction.HideMessageBar -> hideMessageBarInternal() + is StatusAction.CancelAllMessages -> cancelAllMessagesInternal() + } + } + + fun showApiKeyMissingMessage() = viewModelScope.launch { + StatusMessageService.trigger( + StatusAction.ShowActionableMessage( + text = "API Key is missing or invalid.", + type = MessageDisplayType.ACTIONABLE_ERROR, + action = MessageAction.NAVIGATE_TO_API_KEYS + ) + ) + } + + private fun showPermanentActionableMessageInternal(message: String, type: MessageDisplayType, action: MessageAction) { + cancelAllOperations() // Clear any other messages or loaders. + _status.value = StatusState.Message(messageIdCounter++, message, type, action) + } + + private fun showPermanentMessageInternal(message: String, type: MessageDisplayType) { + cancelAllOperations() + _status.value = StatusState.Message(messageIdCounter++, message, type, action = null) + } + + fun showPermanentMessage(message: String, type: MessageDisplayType) = viewModelScope.launch { + StatusMessageService.trigger(StatusAction.ShowPermanentMessage(message, type)) + } + + fun cancelPermanentMessage() = viewModelScope.launch { + StatusMessageService.trigger(StatusAction.CancelPermanentMessage) + } + + fun performLoadingOperation(block: suspend () -> Unit) = viewModelScope.launch { + StatusMessageService.trigger(StatusAction.PerformLoadingOperation(block)) + } + + fun cancelLoadingOperation() = viewModelScope.launch { + StatusMessageService.trigger(StatusAction.CancelLoadingOperation) + } + + fun showInfoMessage(message: String, timeoutInSeconds: Int = 3) = viewModelScope.launch { + StatusMessageService.trigger(StatusAction.ShowMessage(message, MessageDisplayType.INFO, timeoutInSeconds)) + } + + fun showLoadingMessage(message: String, timeoutInSeconds: Int = 0) = viewModelScope.launch { // Default timeout 0 for indefinite + StatusMessageService.trigger(StatusAction.ShowMessage(message, MessageDisplayType.LOADING, timeoutInSeconds)) + } + + fun showErrorMessage(message: String, timeoutInSeconds: Int = 5) = viewModelScope.launch { // Default timeout 5 for errors + StatusMessageService.trigger(StatusAction.ShowMessage(message, MessageDisplayType.ERROR, timeoutInSeconds)) + } + + fun showSuccessMessage(message: String, timeoutInSeconds: Int = 3) = viewModelScope.launch { + StatusMessageService.trigger(StatusAction.ShowMessage(message, MessageDisplayType.SUCCESS, timeoutInSeconds)) + } + + fun hideMessageBar() = viewModelScope.launch { + StatusMessageService.trigger(StatusAction.HideMessageBar) + } + + fun cancelAllMessages() = viewModelScope.launch { + StatusMessageService.trigger(StatusAction.CancelAllMessages) + } + + private fun cancelPermanentMessageInternal() { + if (_status.value is StatusState.Message) { + // This logic can be simplified or adjusted based on desired behavior for permanent messages + _status.value = StatusState.Hidden + processNextMessageInQueue() + } + } + + private fun performLoadingOperationInternal(block: suspend () -> Unit) { + cancelAllOperations() + _status.value = StatusState.Loading + Log.d("StatusViewModel", "Starting loading operation.") + activeLoadingJob = viewModelScope.launch { + try { + block() + Log.d("StatusViewModel", "Loading operation completed successfully.") + } catch (e: CancellationException) { + Log.i("StatusViewModel", "Loading operation was cancelled.") + } catch (e: Exception) { + Log.e("StatusViewModel", "Loading operation failed.", e) + showErrorMessage("Operation failed: ${e.localizedMessage ?: "Unknown error"}") + } finally { + if (activeLoadingJob == this.coroutineContext[Job]) { + if (_status.value == StatusState.Loading) { + _status.value = StatusState.Hidden + } + activeLoadingJob = null + processNextMessageInQueue() + } + } + } + } + + private fun cancelLoadingOperationInternal() { + activeLoadingJob?.cancel() + activeLoadingJob = null + if (_status.value == StatusState.Loading || (_status.value as? StatusState.Message)?.type == MessageDisplayType.LOADING) { + _status.value = StatusState.Hidden + processNextMessageInQueue() + } + } + + // --- REVISED LOGIC --- + private fun showMessageInternal(message: String, type: MessageDisplayType, timeoutInSeconds: Int) { + val currentState = _status.value + val currentPriority = (currentState as? StatusState.Message)?.type?.priority ?: -1 + + // If a high-priority message arrives, it interrupts any lower-priority one. + if (type.priority >= currentPriority) { + messageDisplayJob?.cancel() // Cancel the current display job + messageQueue.clear() // Clear pending lower-priority messages + messageQueue.offer(Pair(message, type)) + processNextMessageInQueue(timeoutInSeconds) + } else if (activeLoadingJob?.isActive != true) { + // Queue if lower priority and no full-screen loading is active + messageQueue.offer(Pair(message, type)) + if (currentState == StatusState.Hidden) { + processNextMessageInQueue(timeoutInSeconds) + } + } else { + Log.d("StatusViewModel", "Full-screen loading active or lower priority. Queuing message: $message") + messageQueue.offer(Pair(message, type)) + } + } + + private fun hideMessageBarInternal() { + messageDisplayJob?.cancel() + messageDisplayJob = null + if (_status.value is StatusState.Message) { + _status.value = StatusState.Hidden + } + if (activeLoadingJob?.isActive != true) { + processNextMessageInQueue() + } + } + + private fun cancelAllMessagesInternal() { + Log.d("StatusViewModel", "Cancelling all messages.") + messageQueue.clear() + messageDisplayJob?.cancel() + messageDisplayJob = null + // Do not cancel activeLoadingJob here unless that's the desired behavior. + // Assuming CancelAllMessages is for the message bar only. + if (_status.value is StatusState.Message) { + _status.value = StatusState.Hidden + } + } + + private fun cancelAllOperations() { + messageQueue.clear() + messageDisplayJob?.cancel() + messageDisplayJob = null + activeLoadingJob?.cancel() + activeLoadingJob = null + _status.value = StatusState.Hidden + } + + // --- REVISED LOGIC --- + private fun processNextMessageInQueue(timeoutInSeconds: Int = 3) { + if (activeLoadingJob?.isActive == true) { + Log.d("StatusViewModel", "Full-screen loading is active, not processing message queue now.") + return + } + if (messageDisplayJob?.isActive == true) { + // A message is already being displayed and its timeout is active. + return + } + + val messagePair = messageQueue.poll() + if (messagePair != null) { + val currentId = messageIdCounter++ + val (text, type) = messagePair + _status.value = StatusState.Message(currentId, text, type) + + val timeoutMillis = timeoutInSeconds * 1000L + + if (timeoutMillis > 0) { + messageDisplayJob = viewModelScope.launch { + delay(timeoutMillis) + // Ensure we are hiding the correct message before proceeding. + if ((_status.value as? StatusState.Message)?.id == currentId) { + _status.value = StatusState.Hidden + // After hiding, check if there's another message to show. + processNextMessageInQueue() + } + } + } + } else { + if (_status.value is StatusState.Message) { + _status.value = StatusState.Hidden + } + } + } + + override fun onCleared() { + super.onCleared() + Log.d("StatusViewModel", "onCleared called. Cancelling all operations.") + cancelAllOperations() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/TranslationViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/TranslationViewModel.kt new file mode 100644 index 0000000..1418593 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/TranslationViewModel.kt @@ -0,0 +1,344 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import eu.gaudian.translator.model.LanguageModel +import eu.gaudian.translator.model.TranslationHistoryItem +import eu.gaudian.translator.model.repository.ApiRepository +import eu.gaudian.translator.model.repository.DataStoreKeys +import eu.gaudian.translator.model.repository.LanguageRepository +import eu.gaudian.translator.model.repository.dataStore +import eu.gaudian.translator.model.repository.loadObjectList +import eu.gaudian.translator.model.repository.saveObjectList +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.TextToSpeechHelper +import eu.gaudian.translator.utils.TranslationService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * TODO: Convert TranslationViewModel to HiltViewModel + * + * This ViewModel needs to be converted to use Hilt for dependency injection. + * Currently it manually instantiates dependencies in the constructor. + * + * Checklist: + * - [ ] Add @HiltViewModel annotation to the class + * - [ ] Change constructor to use @Inject and inject dependencies: + * - TranslationService + * - ApiRepository + * - TextToSpeechHelper (if needed) + * - [ ] Create/update RepositoryModule.kt to provide singleton instances of: + * - TranslationService + * - ApiRepository + * - [ ] Modify companion object getInstance() method to use Hilt's EntryPoint system + * - [ ] Create TranslationViewModelEntryPoint interface for accessing the singleton instance + * - [ ] Remove manual dependency instantiation from constructor and init block + * - [ ] Update all places where TranslationViewModel.getInstance() is called to ensure compatibility + * - [ ] Test that the ViewModel works correctly with dependency injection + */ +class TranslationViewModel(application: Application) : AndroidViewModel(application) { + + // For back/forward navigation of history in the UI (like editors) + val languageRepository = LanguageRepository(application) + + private val _historyCursor = MutableStateFlow(-1) + + private val _canGoBack = MutableStateFlow(false) + val canGoBack: StateFlow get() = _canGoBack + + private val _canGoForward = MutableStateFlow(false) + val canGoForward: StateFlow get() = _canGoForward + + val historyCursor: StateFlow get() = _historyCursor + + private val translationService = TranslationService(application) + private val apiRepository = ApiRepository(application) + + private val _inputText = MutableStateFlow("") + val inputText: StateFlow get() = _inputText + + private val _translatedText = MutableStateFlow(null) + val translatedText: StateFlow get() = _translatedText + + private val _translatedVocabulary = MutableStateFlow(null) + val translatedVocabulary: StateFlow get() = _translatedVocabulary + + private val _translationHistory = MutableStateFlow>(mutableListOf()) + val translationHistory: StateFlow> get() = _translationHistory + + // Explanation support + private val _showExplanation = MutableStateFlow(false) + val showExplanation: StateFlow get() = _showExplanation + + private val _explanationText = MutableStateFlow(null) + val explanationText: StateFlow get() = _explanationText + + private val _selectedTranslationModel = MutableStateFlow(null) + val selectedTranslationModel: StateFlow get() = _selectedTranslationModel + + private val _isTranslating = MutableStateFlow(false) + val isTranslating: StateFlow get() = _isTranslating + + init { + viewModelScope.launch { + apiRepository.getTranslationModel().collect { model -> + _selectedTranslationModel.value = model + } + } + loadTranslationHistory() + } + + fun setInputText(text: String) { + _inputText.value = text + } + + fun clearInputAndOutputText() { + _inputText.value = "" + _translatedText.value = "" + _explanationText.value = null + + } + + fun setShowExplanation(show: Boolean) { + _showExplanation.value = show + if (!show) { + _explanationText.value = null + } else { + // Try to compute explanation if we already have a translation and input + val source = _inputText.value + val translated = _translatedText.value + if (source.isNotBlank() && !translated.isNullOrBlank()) { + viewModelScope.launch { + translationService.explainTranslation(source, translated) + .onSuccess { _explanationText.value = it } + .onFailure { Log.e("TranslationViewModel", "Explanation failed: ${it.message}") } + } + } + } + } + + fun toggleExplanation() { + setShowExplanation(!_showExplanation.value) + } + + fun translateSentence(sentence: String) { + val sentenceToTranslate = sentence.ifEmpty { _inputText.value } + if (sentenceToTranslate.isBlank()) { + return + } + + if (selectedTranslationModel.value == null) { + Log.e("TranslationViewModel", "Cannot translate because no model is selected.") + return + } + + + + viewModelScope.launch { + _isTranslating.value = true + _explanationText.value = null + + translationService.translateSentence(sentenceToTranslate) + .onSuccess { historyItem -> + _translatedText.value = historyItem.text + // We copy the item to ensure we capture the specific inputs used right now + addToTranslationHistory( + historyItem.copy( + sourceText = sentenceToTranslate, + sourceLanguageCode = languageRepository.loadSelectedSourceLanguage().first()?.nameResId, + targetLanguageCode = languageRepository.loadSelectedTargetLanguage().first()?.nameResId + ) + ) + Log.d("TranslationViewModel", "Translation successful: $historyItem") + + if (_showExplanation.value) { + translationService.explainTranslation(sentenceToTranslate, historyItem.text) + .onSuccess { explanation -> + _explanationText.value = explanation + } + .onFailure { exception -> + Log.e("TranslationViewModel", "Explanation failed: ${exception.message}") + } + } + } + .onFailure { exception -> + Log.e("TranslationViewModel", "Translation failed: ${exception.message}") + } + + _isTranslating.value = false + } + } + + fun translateVocabulary(word: String) { + viewModelScope.launch { + translationService.translateVocabulary(word) + .onSuccess { historyItem -> + _translatedVocabulary.value = historyItem.text + Log.d("TranslationViewModel", "Translation successful: $historyItem") + } + .onFailure { exception -> + Log.e("TranslationViewModel", "Translation failed: ${exception.message}") + } + } + } + + suspend fun getMultipleTranslations(sentence: String, contextPhrase: String? = null): Result> { + return translationService.getMultipleSynonyms(sentence, contextPhrase) + .also { result -> + result + .onSuccess { translations -> + Log.d("TranslationViewModel", "Multiple translations successful: $translations") + } + .onFailure { exception -> + Log.e("TranslationViewModel", "Multiple translations failed: ${exception.message}") + } + } + } + + fun applyAlternative(originalWord: String, chosenAlternative: String) { + val source = _inputText.value + val current = _translatedText.value + if (source.isBlank() || current.isNullOrBlank()) return + + viewModelScope.launch { + _isTranslating.value = true + translationService.rephraseWithAlternative(source, current, originalWord, chosenAlternative) + .onSuccess { updated -> + _translatedText.value = updated + if (_showExplanation.value) { + translationService.explainTranslation(source, updated) + .onSuccess { _explanationText.value = it } + .onFailure { Log.e("TranslationViewModel", "Explanation failed: ${it.message}") } + } + } + .onFailure { ex -> + Log.e("TranslationViewModel", "Rephrase with alternative failed: ${ex.message}") + } + _isTranslating.value = false + } + } + + private fun addToTranslationHistory(item: TranslationHistoryItem) { + viewModelScope.launch { + val currentHistory = _translationHistory.value + val newHistory = currentHistory.toMutableList() + val lang = languageRepository.loadSelectedTargetLanguage().first() + val playable = TextToSpeechHelper.isPlayable(getApplication(), lang) + + Log.d("TranslationViewModel", "Adding item to history: $item, item is playable is $playable") + + // Create a new item with updated timestamp and playable status + // Note: We do NOT pass translatedText here as it was removed from the data class + val newItem = item.copy( + sourceText = item.sourceText.ifEmpty { _inputText.value }, + playable = playable, + timestamp = System.currentTimeMillis() + ) + + newHistory.add(0, newItem) + _translationHistory.value = newHistory + _historyCursor.value = if (newHistory.isNotEmpty()) 0 else -1 + updateBackForwardState() + saveTranslationHistory() + } + } + + fun deleteTranslationHistoryItem(item: TranslationHistoryItem) { + viewModelScope.launch { + val currentHistory = _translationHistory.value + val newHistory = currentHistory.toMutableList() + // We compare IDs or references. Since data class equals works on fields, this should work. + // If you added an ID field, use that for safer removal. + if (newHistory.remove(item)) { + _translationHistory.value = newHistory + val size = newHistory.size + _historyCursor.value = if (size == 0) -1 else _historyCursor.value.coerceAtMost(size - 1) + updateBackForwardState() + saveTranslationHistory() + Log.d("TranslationViewModel", "Item deleted from history: $item") + } else { + Log.w("TranslationViewModel", "Item not found in history: $item") + } + } + } + + private fun saveTranslationHistory() { + viewModelScope.launch { + val history = _translationHistory.value + getApplication().dataStore.saveObjectList(DataStoreKeys.TRANSLATION_HISTORY_KEY, history) + } + } + + fun clearTranslationHistory() { + viewModelScope.launch { + _translationHistory.value = mutableListOf() + _historyCursor.value = -1 + _inputText.value = "" + _translatedText.value = "" + _explanationText.value = null + updateBackForwardState() + saveTranslationHistory() + } + } + + private fun loadTranslationHistory() { + viewModelScope.launch { + val history = getApplication().dataStore.loadObjectList(DataStoreKeys.TRANSLATION_HISTORY_KEY).first() + _translationHistory.value = history.toMutableList() + // Start with a fresh/empty screen; do not preload the last translation + _historyCursor.value = -1 + _explanationText.value = null + updateBackForwardState() + } + } + + private fun updateBackForwardState() { + val size = _translationHistory.value.size + val cursor = _historyCursor.value + // Allow going back from the "fresh" state (cursor == -1) to the most recent history item + _canGoBack.value = size > 0 && (cursor == -1 || (cursor < size - 1 && cursor >= 0)) + _canGoForward.value = size > 0 && cursor > 0 + } + + fun goBackInHistory() { + val size = _translationHistory.value.size + if (size == 0) return + val newCursor = (_historyCursor.value + 1).coerceAtMost(size - 1) + if (newCursor != _historyCursor.value) { + _historyCursor.value = newCursor + val item = _translationHistory.value[newCursor] + applyHistoryItem(item) + } + } + + fun goForwardInHistory() { + val size = _translationHistory.value.size + if (size == 0) return + val newCursor = (_historyCursor.value - 1).coerceAtLeast(0) + if (newCursor != _historyCursor.value) { + _historyCursor.value = newCursor + val item = _translationHistory.value[newCursor] + applyHistoryItem(item) + } + } + + fun applyHistoryItem(item: TranslationHistoryItem) { + // Find the index of this item to set the cursor correctly + val index = _translationHistory.value.indexOfFirst { it.id == item.id } + + _inputText.value = item.sourceText + _translatedText.value = item.text + _explanationText.value = null + + if (index >= 0) { + _historyCursor.value = index + } + updateBackForwardState() + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyExerciseViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyExerciseViewModel.kt new file mode 100644 index 0000000..96d21f4 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyExerciseViewModel.kt @@ -0,0 +1,489 @@ +package eu.gaudian.translator.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.model.repository.VocabularyRepository +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.StringHelper +import eu.gaudian.translator.view.vocabulary.VocabularyExerciseAction +import eu.gaudian.translator.view.vocabulary.VocabularyExerciseState +import eu.gaudian.translator.view.vocabulary.VocabularyExerciseType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import javax.inject.Inject +import kotlin.random.Random +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +enum class ScreenState { + START, + EXERCISE, + RESULT +} + +data class ExerciseConfig( + val shuffleCards: Boolean = false, + val shuffleLanguages: Boolean = false, + val trainingMode: Boolean = false, + val dueTodayOnly: Boolean = false, + val selectedExerciseTypes: Set = setOf(VocabularyExerciseType.GUESSING), + val exerciseItemCount: Int = 0, + val originalExerciseItems: List = emptyList(), + val originLanguageId: Int? = null, + val targetLanguageId: Int? = null +) + +data class ExerciseResults( + val finalScore: Int = 0, + val finalWrongAnswers: Int = 0 +) + +/** + * VocabularyExerciseViewModel - Hilt-enabled ViewModel for vocabulary exercises + * + * This ViewModel manages vocabulary exercise sessions including different exercise types, + * tracking progress, and managing exercise state across configuration changes. + */ +@HiltViewModel +class VocabularyExerciseViewModel @Inject constructor( + application: Application, +) : AndroidViewModel(application) { + + private val vocabularyRepository = VocabularyRepository.getInstance(application) + + + private val languageConfigViewModel = LanguageConfigViewModel(application) + private val languageViewModel = LanguageViewModel(application) + + private val _exerciseState = MutableStateFlow(null) + val exerciseState: StateFlow = _exerciseState.asStateFlow() + + // Added StateFlows for tracking correct and wrong answers + private val _correctAnswers = MutableStateFlow(0) + val correctAnswers: StateFlow = _correctAnswers.asStateFlow() + + private val _wrongAnswers = MutableStateFlow(0) + val wrongAnswers: StateFlow = _wrongAnswers.asStateFlow() + + // Track which items were answered wrong for retry functionality + private val _wrongItems = MutableStateFlow>(emptySet()) + + private var items: List = emptyList() + // Working list for this exercise session to allow safe in-place swaps without duplication + private var currentItems: MutableList = mutableListOf() + private var currentIndex = 0 + private var exerciseTypes: Set = setOf(VocabularyExerciseType.GUESSING) + private var shuffleLanguages = false + + private val _trainingMode = MutableStateFlow(false) + val trainingMode: StateFlow = _trainingMode.asStateFlow() + + // Screen state management to survive configuration changes + private val _screenState = MutableStateFlow(ScreenState.START) + val screenState: StateFlow = _screenState.asStateFlow() + + // Exercise configuration state + private val _exerciseConfig = MutableStateFlow(ExerciseConfig()) + + // Exercise results state + private val _exerciseResults = MutableStateFlow(ExerciseResults()) + + // State to persist total items and original items across configuration changes + private val _totalItems = MutableStateFlow(0) + val totalItems: StateFlow = _totalItems.asStateFlow() + + private val _originalItems = MutableStateFlow>(emptyList()) + val originalItems: StateFlow> = _originalItems.asStateFlow() + + fun startExercise( + items: List, + types: Set, + shuffleLanguages: Boolean + ) { + // Reset counters for the new exercise session + _correctAnswers.value = 0 + _wrongAnswers.value = 0 + _wrongItems.value = emptySet() + + this.items = items + this.currentItems = items.shuffled().toMutableList() // Shuffle working list at the start of the exercise + this.currentIndex = 0 + this.exerciseTypes = types.ifEmpty { setOf(VocabularyExerciseType.GUESSING) } + this.shuffleLanguages = shuffleLanguages + loadExercise() + } + + // Start exercise with only the wrong items from previous session + fun startRetryExercise( + originalItems: List, + types: Set, + shuffleLanguages: Boolean + ) { + // Capture wrong items before resetting counters + val wrongItemIds = _wrongItems.value + val retryItems = originalItems.filter { it.id in wrongItemIds } + + // Reset counters for the new exercise session + _correctAnswers.value = 0 + _wrongAnswers.value = 0 + _wrongItems.value = emptySet() + + this.items = retryItems + this.currentItems = retryItems.shuffled().toMutableList() + this.currentIndex = 0 + this.exerciseTypes = types.ifEmpty { setOf(VocabularyExerciseType.GUESSING) } + this.shuffleLanguages = shuffleLanguages + loadExercise() + } + + fun onAction(action: VocabularyExerciseAction) { + viewModelScope.launch { + when (action) { + is VocabularyExerciseAction.Reveal -> revealAnswer() + is VocabularyExerciseAction.Submit -> checkAnswer(action.answer) + is VocabularyExerciseAction.Next -> nextItem() + is VocabularyExerciseAction.UpdateWordJumble -> updateJumbledWord(action.assembledWord) + } + } + } + + private fun isSentenceItem(item: VocabularyItem): Boolean { + return StringHelper.isSentenceLoose(item.wordFirst) || StringHelper.isSentenceLoose(item.wordSecond) + } + + private fun loadExercise() { + if (currentIndex < currentItems.size) { + // Ensure item categories align with exercise type by attempting a swap instead of replacement + val randomType = exerciseTypes.random() + val config = _exerciseConfig.value + + // Try to swap based on desired categories + val currentIsSentence = isSentenceItem(currentItems[currentIndex]) + when (randomType) { + VocabularyExerciseType.WORD_JUMBLE, VocabularyExerciseType.SPELLING -> { + // Prefer non-sentence. If current is sentence, try to find a later non-sentence to swap with. + if (currentIsSentence) { + val swapIndex = (currentIndex + 1 until currentItems.size).firstOrNull { !isSentenceItem(currentItems[it]) } + if (swapIndex != null) { + val tmp = currentItems[currentIndex] + currentItems[currentIndex] = currentItems[swapIndex] + currentItems[swapIndex] = tmp + } + } + } + VocabularyExerciseType.GUESSING, VocabularyExerciseType.MULTIPLE_CHOICE -> { + // Prefer sentence. If current is non-sentence, try to find a later sentence to swap with. + if (!currentIsSentence) { + val swapIndex = (currentIndex + 1 until currentItems.size).firstOrNull { isSentenceItem(currentItems[it]) } + if (swapIndex != null) { + val tmp = currentItems[currentIndex] + currentItems[currentIndex] = currentItems[swapIndex] + currentItems[swapIndex] = tmp + } + } + } + } + + val itemToUse = currentItems[currentIndex] + + // Determine language switching based on selected origin and target languages + val isSwitched = when { + // If specific languages are selected, use them to determine switching + config.originLanguageId != null || config.targetLanguageId != null -> { + val originMatchesFirst = config.originLanguageId == null || config.originLanguageId == itemToUse.languageFirstId + val targetMatchesFirst = config.targetLanguageId == null || config.targetLanguageId == itemToUse.languageFirstId + val originMatchesSecond = config.originLanguageId == null || config.originLanguageId == itemToUse.languageSecondId + val targetMatchesSecond = config.targetLanguageId == null || config.targetLanguageId == itemToUse.languageSecondId + + val itemMatchesForward = originMatchesFirst && targetMatchesSecond + val itemMatchesReversed = originMatchesSecond && targetMatchesFirst + + // If the item doesn't match the selected language pair (even partially), don't use it + if (!(itemMatchesForward || itemMatchesReversed)) { + // Try to find a matching item + val matchingIndex = (currentIndex + 1 until currentItems.size).firstOrNull { index -> + val item = currentItems[index] // Get the VocabularyItem using the index + val itemOriginMatchesFirst = config.originLanguageId == null || config.originLanguageId == item.languageFirstId + val itemTargetMatchesFirst = config.targetLanguageId == null || config.targetLanguageId == item.languageFirstId + val itemOriginMatchesSecond = config.originLanguageId == null || config.originLanguageId == item.languageSecondId + val itemTargetMatchesSecond = config.targetLanguageId == null || config.targetLanguageId == item.languageSecondId + (itemOriginMatchesFirst && itemTargetMatchesSecond) || (itemOriginMatchesSecond && itemTargetMatchesFirst) + } + + if (matchingIndex != null) { + val tmp = currentItems[currentIndex] + currentItems[currentIndex] = currentItems[matchingIndex] + currentItems[matchingIndex] = tmp + + // Check if we need to switch the new item + val newItem = currentItems[currentIndex] + // Switch if origin is specified and it's the second language + config.originLanguageId != null && config.originLanguageId == newItem.languageSecondId + } else { + // No matching item found, fall back to random behavior + if (shuffleLanguages) Random.nextBoolean() else (config.originLanguageId != null && config.originLanguageId == itemToUse.languageSecondId) + } + } else { + // Item matches, determine if we need to switch + // Switch if origin is specified and it's not the first language, or if target is specified and it IS the first language + (config.originLanguageId != null && config.originLanguageId != itemToUse.languageFirstId) || + (config.originLanguageId == null && config.targetLanguageId != null && config.targetLanguageId == itemToUse.languageFirstId) + } + } + // Fallback to original random behavior + else -> if (shuffleLanguages) Random.nextBoolean() else false + } + + _exerciseState.value = when (randomType) { + VocabularyExerciseType.GUESSING -> VocabularyExerciseState.Guessing( + item = itemToUse, + isSwitched = isSwitched + ) + VocabularyExerciseType.SPELLING -> VocabularyExerciseState.Spelling( + item = itemToUse, + isSwitched = isSwitched + ) + VocabularyExerciseType.MULTIPLE_CHOICE -> { + val correctAnswer = if (isSwitched) itemToUse.wordFirst else itemToUse.wordSecond + val options = generateMultipleChoiceOptions(correctAnswer, isSwitched) + VocabularyExerciseState.MultipleChoice( + item = itemToUse, + isSwitched = isSwitched, + options = options + ) + } + VocabularyExerciseType.WORD_JUMBLE -> { + val wordToJumble = if (isSwitched) itemToUse.wordFirst else itemToUse.wordSecond + VocabularyExerciseState.WordJumble( + item = itemToUse, + isSwitched = isSwitched, + jumbledLetters = wordToJumble.toList().mapIndexed { index, char -> Pair(char, index) }.shuffled() + ) + } + } + @Suppress("HardCodedStringLiteral") + Log.d("ExerciseDebug", "Item: ${itemToUse.wordFirst} (${itemToUse.languageFirstId}) / ${itemToUse.wordSecond} (${itemToUse.languageSecondId}), Switched: $isSwitched") + @Suppress("HardCodedStringLiteral") + Log.d("ExerciseDebug", "Origin Lang: ${config.originLanguageId}, Target Lang: ${config.targetLanguageId}") + + _exerciseState.value = when (randomType) { + VocabularyExerciseType.GUESSING -> VocabularyExerciseState.Guessing( + item = itemToUse, + isSwitched = isSwitched + ) + VocabularyExerciseType.SPELLING -> VocabularyExerciseState.Spelling( + item = itemToUse, + isSwitched = isSwitched + ) + VocabularyExerciseType.MULTIPLE_CHOICE -> { + val correctAnswer = if (isSwitched) itemToUse.wordFirst else itemToUse.wordSecond + val options = generateMultipleChoiceOptions(correctAnswer, isSwitched) + VocabularyExerciseState.MultipleChoice( + item = itemToUse, + isSwitched = isSwitched, + options = options + ) + } + VocabularyExerciseType.WORD_JUMBLE -> { + val wordToJumble = if (isSwitched) itemToUse.wordFirst else itemToUse.wordSecond + VocabularyExerciseState.WordJumble( + item = itemToUse, + isSwitched = isSwitched, + jumbledLetters = wordToJumble.toList().mapIndexed { index, char -> Pair(char, index) }.shuffled() + ) + } + } + } else { + _exerciseState.value = null // End of exercise + } + } + + private fun generateMultipleChoiceOptions(correctAnswer: String, isSwitched: Boolean): List { + val options = mutableSetOf(correctAnswer) + val allWords = currentItems.map { if (isSwitched) it.wordFirst else it.wordSecond } + + val sameStartLetterWords = allWords.filter { it.firstOrNull() == correctAnswer.firstOrNull() && it != correctAnswer } + options.addAll(sameStartLetterWords.take(3)) + + if (options.size < 4) { + val sameLengthWords = allWords.filter { it.length == correctAnswer.length && it != correctAnswer && !options.contains(it) } + options.addAll(sameLengthWords.take(4 - options.size)) + } + + if (options.size < 4) { + val vowelCount = correctAnswer.count { "aeiouAEIOU".contains(it) } + val similarVowelWords = allWords.filter { it.count { v -> "aeiouAEIOU".contains(v) } == vowelCount && it != correctAnswer && !options.contains(it) } + options.addAll(similarVowelWords.take(4 - options.size)) + } + + if (options.size < 4) { + val distractors = allWords + .filter { it != correctAnswer && !options.contains(it) } + .shuffled() + .take(4 - options.size) + options.addAll(distractors) + } + + + return options.shuffled().take(4) + } + + private fun revealAnswer() { + _exerciseState.value = when (val state = _exerciseState.value) { + is VocabularyExerciseState.Guessing -> state.copy(isRevealed = true) + is VocabularyExerciseState.Spelling -> state.copy(isRevealed = true) + is VocabularyExerciseState.MultipleChoice -> state.copy(isRevealed = true) + is VocabularyExerciseState.WordJumble -> state.copy(isRevealed = true) + null -> null + } + } + + private fun checkAnswer(answer: Any) { + viewModelScope.launch { + val state = _exerciseState.value ?: return@launch + val correctAnswer = if (state.isSwitched) state.item.wordFirst else state.item.wordSecond + + // Check if the state is a Spelling type before proceeding with specific logic + val isCorrect = when (state) { + is VocabularyExerciseState.Spelling -> { + val userAnswer = (answer as String).trim() + val languageId = if (state.isSwitched) state.item.languageFirstId else state.item.languageSecondId + val language = languageViewModel.getLanguageById(languageId ?: 0) + + // Get articles for the language + val articles = languageConfigViewModel.getArticlesForLanguage(language.code) + + // Normalize and split possible answers using helper + val normalizedCorrectAnswer = StringHelper.normalizeAnswer(correctAnswer) + val possibleAnswers = StringHelper.possibleAnswersFromSlash(normalizedCorrectAnswer) + + val userNormalizedAnswer = StringHelper.normalizeAnswer(userAnswer) + + // Check if the user's answer matches any of the possible correct answers + val isDirectMatch = possibleAnswers.any { + it.equals(userNormalizedAnswer, ignoreCase = true) + } + + // Check if user answer with a removed article matches any possible correct answer + val isMatchWithArticleRemoved = if (articles.isNotEmpty()) { + val userWithoutArticle = StringHelper.removeLeadingArticlesOneOf(userNormalizedAnswer, articles) + possibleAnswers.any { it.equals(userWithoutArticle, ignoreCase = true) } + } else { + false + } + + //TODO val hasGuessedSynonym = vocabularyRepository.getSynonymsForItem(state.item.id) + + isDirectMatch || isMatchWithArticleRemoved + } + is VocabularyExerciseState.Guessing -> answer as Boolean + is VocabularyExerciseState.MultipleChoice -> (answer as String).equals(correctAnswer, ignoreCase = true) + is VocabularyExerciseState.WordJumble -> (answer as String).equals(correctAnswer, ignoreCase = true) + } + + if (isCorrect) { + _correctAnswers.value++ + } else { + _wrongAnswers.value++ + // Track the wrong item for retry functionality + _wrongItems.value += state.item.id + } + + updateVocabularyItemState(state.item.id, isCorrect) + + _exerciseState.value = when (state) { + is VocabularyExerciseState.Guessing -> state.copy(isCorrect = isCorrect, isRevealed = true) + is VocabularyExerciseState.Spelling -> state.copy(isCorrect = isCorrect, isRevealed = true, userAnswer = answer as String) + is VocabularyExerciseState.MultipleChoice -> state.copy(isCorrect = isCorrect, isRevealed = true, selectedAnswer = answer as String) + is VocabularyExerciseState.WordJumble -> state.copy(isCorrect = isCorrect, isRevealed = true) + } + } + } + + private fun updateJumbledWord(assembledWord: List>) { + _exerciseState.value = when (val state = _exerciseState.value) { + is VocabularyExerciseState.WordJumble -> state.copy(assembledWord = assembledWord) + else -> state + } + } + + private fun nextItem() { + currentIndex++ + loadExercise() + } + + fun onTrainingModeChanged(value: Boolean) { + _trainingMode.value = value + } + + fun startExerciseWithConfig( + items: List, + config: ExerciseConfig + ) { + _exerciseConfig.value = config + _totalItems.value = items.size + _originalItems.value = items + startExercise(items, config.selectedExerciseTypes, config.shuffleLanguages) + _screenState.value = ScreenState.EXERCISE + } + + fun finishExercise(score: Int, wrongAnswers: Int) { + _exerciseResults.value = ExerciseResults(score, wrongAnswers) + _screenState.value = ScreenState.RESULT + } + + fun resetExercise() { + _screenState.value = ScreenState.START + _exerciseConfig.value = ExerciseConfig() + _exerciseResults.value = ExerciseResults() + _correctAnswers.value = 0 + _wrongAnswers.value = 0 + _wrongItems.value = emptySet() + _exerciseState.value = null + _totalItems.value = 0 + _originalItems.value = emptyList() + } + + fun retryWrongAnswers(originalItems: List) { + val wrongItemIds = _wrongItems.value + val retryItems = originalItems.filter { it.id in wrongItemIds } + + if (retryItems.isNotEmpty()) { + _totalItems.value = retryItems.size + _originalItems.value = originalItems + val config = _exerciseConfig.value.copy( + exerciseItemCount = retryItems.size, + originalExerciseItems = originalItems + ) + _exerciseConfig.value = config + startRetryExercise(originalItems, config.selectedExerciseTypes, config.shuffleLanguages) + _screenState.value = ScreenState.EXERCISE + } + } + + @OptIn(ExperimentalTime::class) + private fun updateVocabularyItemState(vocabularyItemId: Int, isCorrect: Boolean) { + viewModelScope.launch { + val item = vocabularyRepository.getVocabularyItemById(vocabularyItemId) + item?.let { + if (!trainingMode.value) { + vocabularyRepository.updateFlashcardStage(it, isCorrect) + } + if (isCorrect) { + val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + val currentCount = vocabularyRepository.getDailyCorrectCount(today) + vocabularyRepository.updateDailyCorrectCount(today, currentCount + 1) + } + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyViewModel.kt new file mode 100644 index 0000000..c9ea59d --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyViewModel.kt @@ -0,0 +1,1327 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.viewmodel + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.net.Uri +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import eu.gaudian.translator.model.CardSet +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.VocabularyCategory +import eu.gaudian.translator.model.VocabularyGrammarDetails +import eu.gaudian.translator.model.VocabularyItem +import eu.gaudian.translator.model.VocabularyStage +import eu.gaudian.translator.model.jsonParser +import eu.gaudian.translator.model.repository.LanguageListType +import eu.gaudian.translator.model.repository.LanguageRepository +import eu.gaudian.translator.model.repository.VocabularyFileSaver +import eu.gaudian.translator.model.repository.VocabularyRepository +import eu.gaudian.translator.utils.Log +import eu.gaudian.translator.utils.StringHelper +import eu.gaudian.translator.utils.VocabularyService +import eu.gaudian.translator.utils.dictionary.DictionaryService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlin.system.measureTimeMillis +import kotlin.time.ExperimentalTime + +/** + * TODO: Convert VocabularyViewModel to HiltViewModel + * + * This ViewModel needs to be converted to use Hilt for dependency injection. + * Currently it manually instantiates dependencies in the constructor and companion object. + * + * Checklist: + * - [ ] Add @HiltViewModel annotation to the class + * - [ ] Change constructor to use @Inject and inject dependencies: + * - ApiManager + * - ApiRepository + * - SettingsRepository + * - ApiLogRepository + * - VocabularyRepository + * - LanguageRepository + * - VocabularyService + * - StatusViewModel (consider if this should be injected or accessed differently) + * - LanguageConfigViewModel (consider if this should be injected or accessed differently) + * - [ ] Create/update RepositoryModule.kt to provide singleton instances of: + * - VocabularyRepository + * - LanguageRepository + * - VocabularyService + * - [ ] Modify companion object getInstance() method to use Hilt's EntryPoint system + * - [ ] Create VocabularyViewModelEntryPoint interface for accessing the singleton instance + * - [ ] Remove manual dependency instantiation from constructor and init block + * - [ ] Update all places where VocabularyViewModel.getInstance() is called to ensure compatibility + * - [ ] Test that the ViewModel works correctly with dependency injection + */ +class VocabularyViewModel(application: Application) : AndroidViewModel(application) { + fun extractUniqueWords(text: String): List { + if (text.isBlank()) return emptyList() + val words = text.lowercase() + .replace("\n", " ") + .split(Regex("""[^\p{L}\p{Nd}']+""")) + .map { it.trim('"', '\'', '(', ')', '[', ']', '{', '}', '.', ',', '!', '?', ';', ':') } + .filter { it.isNotBlank() } + return words.distinct() + } + + fun generateVocabularyFromText(text: String) { + viewModelScope.launch { + try { + val src = languageRepository.loadSelectedSourceLanguage().first() + val tgt = languageRepository.loadSelectedTargetLanguage().first() + if (src == null || tgt == null) { + statusViewModel.showErrorMessage("Source and target languages must be selected.") + return@launch + } + val words = extractUniqueWords(text) + if (words.isEmpty()) { + statusViewModel.showErrorMessage("No words found in the provided text.") + return@launch + } + _isGenerating.value = true + statusViewModel.showLoadingMessage("Translating ${words.size} words
") + val result = vocabularyItemService.translateWordsBatch(words, src, tgt) + result.onSuccess { items -> + _generatedVocabularyItems.value = items + statusViewModel.cancelLoadingOperation() + }.onFailure { ex -> + statusViewModel.showErrorMessage("Failed to translate words: ${ex.message}") + } + } catch (e: Exception) { + statusViewModel.showErrorMessage("Unexpected error: ${e.message}") + } finally { + _isGenerating.value = false + } + } + } + companion object { + @Volatile private var INSTANCE: VocabularyViewModel? = null + fun getInstance(application: Application): VocabularyViewModel = INSTANCE ?: synchronized(this) { + INSTANCE ?: VocabularyViewModel(application).also { INSTANCE = it } + } + } + @Suppress("PrivatePropertyName") + private val TAG = "VocabularyViewModel" + + + val statusViewModel = StatusViewModel.getInstance(application) + val languageConfigViewModel = LanguageConfigViewModel(application) + val vocabularyRepository = VocabularyRepository.getInstance(application) + val languageRepository = LanguageRepository(application) + + private val vocabularyItemService = VocabularyService(application) + + + val vocabularyItems: StateFlow> = vocabularyRepository.getAllVocabularyItemsFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val categories: StateFlow> = vocabularyRepository.getAllCategoriesFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val dueTodayItems: StateFlow> = vocabularyRepository.getDueTodayItemsFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = emptyList() + ) + val languagesPresent: StateFlow> = vocabularyItems + .map { items -> + items.flatMap { listOf(it.languageFirstId, it.languageSecondId) }.toSet() + } + .distinctUntilChanged() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + + + + val stageStats: StateFlow> = vocabularyItems.map { + vocabularyRepository.calculateStageStatistics() + } + .flowOn(Dispatchers.Default) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + val stageMapping: StateFlow> = vocabularyRepository.loadStageMapping() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + val dictionaryService = DictionaryService(application) + + private val _cardSet = MutableStateFlow(null) + val cardSet: StateFlow = _cardSet.asStateFlow() + + private val fileSaver = VocabularyFileSaver(application, vocabularyRepository) + private val _saveStatus = MutableLiveData() + private lateinit var saveFileLauncher: ActivityResultLauncher + + private val _isGenerating = MutableStateFlow(false) + val isGenerating: StateFlow = _isGenerating.asStateFlow() + + private val _generationResult = MutableSharedFlow() + + + private val _generatedVocabularyItems = MutableStateFlow>(emptyList()) + val generatedVocabularyItems: StateFlow> = _generatedVocabularyItems.asStateFlow() + + private val _idFilter = MutableStateFlow?>(null) + + // Navigation state for VocabularyCardHost + private val _currentNavigationItems = MutableStateFlow>(emptyList()) + val currentNavigationItems: StateFlow> = _currentNavigationItems.asStateFlow() + + private val _currentNavigationPosition = MutableStateFlow(0) + val currentNavigationPosition: StateFlow = _currentNavigationPosition.asStateFlow() + + val newItemsCount: StateFlow = combine(vocabularyItems, stageMapping) { items, stageMap -> + if (stageMap.isEmpty() && items.isNotEmpty()) { + items.size + } else { + items.count { (stageMap[it.id] ?: VocabularyStage.NEW) == VocabularyStage.NEW } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + + /** + * A flow that contains a list of all vocabulary items that have at least one duplicate in the database. + * Duplicates are determined by comparing words and languages, ignoring order. This serves as the primary + * function to find all duplicates. + */ + val allDuplicateItems: StateFlow> = vocabularyItems + .map { items -> + // Group items by their content (words and languages, order-independent) + items.groupBy { + // The key for grouping is a pair of sets: one for words, one for language IDs. + Pair( + setOf(it.wordFirst.lowercase(), it.wordSecond.lowercase()), + setOf(it.languageFirstId, it.languageSecondId) + ) + } + .values // We only need the groups of items, not the keys + .filter { group -> group.size > 1 } // A group with more than one item signifies duplicates + .flatten() // Convert the list of duplicate groups into a a single list of all duplicate items + } + .flowOn(Dispatchers.Default) // Perform this potentially heavy computation on a background thread + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + /** + * A flow that provides the total count of duplicate vocabulary items. + * This is derived from the `allDuplicateItems` flow. + */ + val duplicateItemsCount: StateFlow = allDuplicateItems + .map { it.size } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = 0 + ) + + var categoryVocabularyItemDelete: Int = 0 + + init { + Log.d(TAG, "Initializing VocabularyViewModel") + + viewModelScope.launch { + val duration = measureTimeMillis { + vocabularyRepository.initializeRepository() + } + Log.d(TAG, "init: Repositories initialized in ${duration}ms") + + val stageMap = stageMapping.first() + val itemCount = vocabularyItems.first().size + Log.d(TAG, "DEBUG: After init - Items: $itemCount, Stage mappings: ${stageMap.size}") + } + } + + /** + * Returns a Flow that emits a single VocabularyItem, updating only when that specific item changes. + * This is more efficient than observing the entire list for changes to a single item. + * + * @param id The ID of the vocabulary item to observe. + * @return A Flow that emits the VocabularyItem with the given ID, or null if it doesn't exist. + */ + fun getVocabularyItemFlow(id: Int): Flow { + Log.d(TAG, "Setting up a flow for item ID $id") + return vocabularyItems + .map { items -> + items.find { it.id == id } + } + .distinctUntilChanged() + } + + fun clearCardSet() { + _cardSet.value = null + } + + fun filterByIds(ids: List) { + _idFilter.value = ids.toSet() + } + fun clearFilter() { + _idFilter.value = null + } + + + fun addVocabularyItemToCategory(vocabularyItems: List, categoryId: Int) { + Log.d(TAG, "Adding ${vocabularyItems.size} vocabulary items to category $categoryId") + viewModelScope.launch { + try { + val duration = measureTimeMillis { + vocabularyItems.forEach { item -> + vocabularyRepository.addVocabularyItemToList(item.id, categoryId) + } + } + Log.d(TAG, "Successfully added vocabulary items to category in ${duration}ms") + } catch (e: Exception) { + statusViewModel.showErrorMessage("Error in addVocabularyItemToCategory: ${e.message}") + Log.e(TAG, "Error in addVocabularyItemToCategory: ${e.message}") + } + } + } + fun addVocabularyItemToCategories(vocabularyItems: List, categoryIds: List) { + Log.d("Adding vocabulary item to categories: $categoryIds") + for (categoryId in categoryIds) { + addVocabularyItemToCategory(vocabularyItems, categoryId) + } + } + + fun removeVocabularyItemsFromCategory(items: List, categoryId: Int) { + Log.d(TAG, "Removing ${items.size} vocabulary items from category $categoryId") + if (items.isEmpty()) return + + viewModelScope.launch { + try { + val duration = measureTimeMillis { + items.forEach { item -> + vocabularyRepository.removeVocabularyItemFromList(item.id, categoryId) + } + } + Log.d(TAG, "Successfully removed vocabulary items from category in ${duration}ms") + } catch (e: Exception) { + statusViewModel.showErrorMessage("Error removing items from category: ${e.message}") + Log.e(TAG, "Error in removeVocabularyItemsFromCategory: ${e.message}") + } + } + } + + fun addVocabularyItemToStage(vocabularyItems: List, stage: VocabularyStage) { + Log.d(TAG, "Adding ${vocabularyItems.size} vocabulary items to stage $stage") + viewModelScope.launch { + try { + + vocabularyRepository.changeVocabularyItemsStage(vocabularyItems, stage) + } catch (e: Exception) { + statusViewModel.showErrorMessage("Error in addVocabularyItemToStage: ${e.message}") + Log.e(TAG, "Error in addVocabularyItemToStage: ${e.message}") + } + } + } + + fun addVocabularyItems(items: List, categoryIds: List = emptyList()) { + Log.d(TAG, "Adding ${items.size} new vocabulary items, categories: $categoryIds") + viewModelScope.launch { + try { + val duration = measureTimeMillis { + vocabularyRepository.introduceVocabularyItems(items, categoryIds) + } + Log.d( + TAG, + "Successfully added ${items.size} new vocabulary items in ${duration}ms" + ) + } catch (e: Exception) { + statusViewModel.showErrorMessage("Error adding items: ${e.message}") + Log.e(TAG, "Error adding items: ${e.message}") + } + } + } + + + + fun deleteVocabularyItemsById(list: List) { + Log.d(TAG, "Deleting vocabulary items with IDs: $list") + viewModelScope.launch { + try { + val duration = measureTimeMillis { + vocabularyRepository.deleteVocabularyItemsByIds(list) + } + Log.d(TAG, "Successfully deleted vocabulary items in ${duration}ms") + } catch (e: Exception) { + statusViewModel.showErrorMessage("Error deleting items: ${e.message}") + Log.e(TAG, "Error deleting item: ${e.message}") + } + } + } + + suspend fun editVocabularyItem(vocabularyItem: VocabularyItem) { + val itemWithNewFrequency = fetchAndUpdateZipfFrequency(vocabularyItem) + + Log.d(TAG, "Editing vocabulary item: ${vocabularyItem.id}") + try { + val duration = measureTimeMillis { + vocabularyRepository.editVocabularyItem(itemWithNewFrequency) + } + Log.d(TAG, "Successfully edited vocabulary item in ${duration}ms") + } catch (e: Exception) { + statusViewModel.showErrorMessage("Error in editVocabularyItem: ${e.message}") + Log.e(TAG, "Error in editVocabularyItem: ${e.message}") + } + } + + + suspend fun findDuplicates(items: List): List { + val allItems = vocabularyRepository.getAllVocabularyItems() + val results = items.map { item -> + val duplicateItem = allItems.find { existingItem -> item.isDuplicate(existingItem) } + duplicateItem != null + } + results.count { it } + return results + } + + /** + * Creates a flow to perform a quick check if a single vocabulary item has any duplicates. + * + * @param itemId The ID of the item to check. + * @return A Flow that emits `true` if a duplicate exists, `false` otherwise. + */ + fun isDuplicateFlow(itemId: Int): Flow = vocabularyItems.map { allItems -> + val currentItem = allItems.find { it.id == itemId } + if (currentItem != null) { + // Check if there is any *other* item that is a duplicate of the current one. + allItems.any { otherItem -> + otherItem.id != currentItem.id && currentItem.isDuplicate(otherItem) + } + } else { + false // Item not found, so it can't have a duplicate. + } + }.distinctUntilChanged() + + /** + * Finds all items in the repository that are duplicates of the given item, excluding the item itself. + */ + fun getDuplicatesOf(item: VocabularyItem): Flow> { + return vocabularyItems.map { allItems -> + allItems.filter { otherItem -> + item.id != otherItem.id && item.isDuplicate(otherItem) + } + } + } + + /** + * Handles the merging of two duplicate vocabulary items. + * Placeholder logic: Deletes the new item, effectively keeping the original. + * A full implementation would merge properties like stage, categories, and features. + */ + fun mergeDuplicateItems(newItem: VocabularyItem, existingItem: VocabularyItem) { + Log.d(TAG, "mergeDuplicateItems: Merging ${newItem.id} into ${existingItem.id}. (Placeholder logic)") + //TODO + // Placeholder: For now, this just deletes the new item. + // A full implementation would merge stages, categories, etc., and update the existing item based on the rules. + viewModelScope.launch { + statusViewModel.showSuccessMessage("Items merged!", 2) + deleteVocabularyItemsById(listOf(newItem.id)) + } + } + + + fun deleteData(type: DeleteType, id: Int? = null, item: VocabularyItem? = null, items: List? = null, categoryId: Int? = null) { + Log.d(TAG, "Deleting data of type $type") + viewModelScope.launch { + try { + val duration = measureTimeMillis { + when (type) { + DeleteType.VOCABULARY_ITEM_BY_ID -> id?.let { vocabularyRepository.deleteVocabularyItemById(it) } + DeleteType.VOCABULARY_ITEM -> item?.let { vocabularyRepository.deleteVocabularyItemById(it.id) } + DeleteType.VOCABULARY_ITEMS -> items?.map { it.id }?.let { vocabularyRepository.deleteVocabularyItemsByIds(it) } + DeleteType.REMOVE_FROM_CATEGORY -> { + if (id != null && categoryId != null) { + vocabularyRepository.removeVocabularyItemFromList(id, categoryId) + } + } + } + } + Log.d(TAG, "deleteData of type $type completed in ${duration}ms") + } catch (e: Exception) { + statusViewModel.showErrorMessage("Error in deleteData: ${e.message}") + Log.e(TAG, "Error in deleteData: ${e.message}") + } + } + } + + @Deprecated("use the new filterVocabularyItems") + fun filterVocabularyItems(languageFirst: Language?, languageSecond: Language?, query: String?, categoryId: Int?, stage: VocabularyStage?): Flow> { + Log.d(TAG, "[DEPRECATED] filterVocabularyItems called.") + val languages = listOfNotNull(languageFirst, languageSecond).ifEmpty { null }?.map { it.nameResId } + return filterVocabularyItems(languages = languages, query = query, categoryId = categoryId, stage = stage, sortOrder = SortOrder.NEWEST_FIRST) + } + + fun filterVocabularyItems( + languages: List?, + query: String?, + categoryId: Int?, // Accepts a single category ID + stage: VocabularyStage?, + wordClass: String? = null, + dueTodayOnly: Boolean = false, + sortOrder: SortOrder + ): Flow> { + Log.d(TAG, "[DEPRECATED] filterVocabularyItems called. with args: " + + "languages=$languages, query=$query, categoryId=$categoryId, stage=$stage, " + + "wordClass=$wordClass, dueTodayOnly=$dueTodayOnly, sortOrder=$sortOrder") + + return filterVocabularyItems( + languages = languages, + query = query, + categoryIds = categoryId?.let { listOf(it) }, + stage = stage, + wordClass = wordClass, + dueTodayOnly = dueTodayOnly, + sortOrder = sortOrder + ) + } + + + + + /** + * Filters vocabulary items based on multiple criteria, including a list of category IDs and a specific word class. + * + * @param languages A list of language resource IDs to filter by. + * @param query A search string to match against the words. + * @param categoryIds A list of category IDs to filter by. + * @param stage The vocabulary stage to filter by. + * @param wordClass The grammatical word class (e.g., "noun", "verb") to filter by. + * @param dueTodayOnly If true, only includes items due for review today. + * @param sortOrder The order to sort the final list. + * @return A Flow emitting the filtered and sorted list of VocabularyItems. + */ + fun filterVocabularyItems( + languages: List?, + query: String?, + categoryIds: List?, + stage: VocabularyStage?, + wordClass: String? = null, + dueTodayOnly: Boolean = false, + sortOrder: SortOrder + ): Flow> { + Log.d(TAG, "Setting up filterVocabularyItems Flow with categoryIds: $categoryIds, stage: $stage, wordClass: $wordClass, dueToday: $dueTodayOnly, sort: $sortOrder") + + val categoryItemsFlow: Flow?> = if (categoryIds.isNullOrEmpty() || categoryIds.contains(0)) { + Log.d(TAG, "Category filter: DISABLED (null/empty/contains 0)") + flowOf(null) + } else { + Log.d(TAG, "Category filter: ENABLED for categories $categoryIds") + vocabularyRepository.getCategoryMappingsFlow().map { mappings -> + mappings.filter { it.categoryId in categoryIds } + .map { it.vocabularyItemId } + .toSet() + } + } + + return combine( + vocabularyItems, + dueTodayItems, + _idFilter, + categoryItemsFlow, + if (stage == null) flowOf(null) else stageMapping.map { it } // Pass the full stage mapping instead of pre-filtering + ) { allItems, dueItems, idFilterSet, categoryItemsSet, stageMappingMap -> + + if (allItems.isEmpty()) { + Log.d(TAG, "filterVocabularyItems: Waiting for vocabulary items to load...") + return@combine emptyList() + } + + if (stage != null && stageMappingMap.isNullOrEmpty()) { + Log.d(TAG, "filterVocabularyItems: Items loaded (${allItems.size}) but stage mapping empty, triggering initialization...") + viewModelScope.launch { + vocabularyRepository.actualizeVocabularyStageMappings() + } + return@combine emptyList() + } + + val preFilteredItems = if (idFilterSet != null) { + allItems.filter { it.id in idFilterSet } + } else { + allItems + } + + Log.d(TAG, "--- Starting Filter ---") + Log.d(TAG, "Items to filter: ${preFilteredItems.size}") + Log.d(TAG, "Filter criteria: wordClass='$wordClass'") + + var filteredList: List + val duration = measureTimeMillis { + val dueItemsSet = if (dueTodayOnly) dueItems.map { it.id }.toSet() else emptySet() + val languageIdSet = languages?.takeIf { it.isNotEmpty() }?.toSet() + + filteredList = preFilteredItems.filter { item -> + val languageMatch = languageIdSet.isNullOrEmpty() || + item.languageFirstId in languageIdSet || item.languageSecondId in languageIdSet + val queryMatch = query.isNullOrEmpty() || + item.wordFirst.contains(query, ignoreCase = true) || item.wordSecond.contains(query, ignoreCase = true) + val categoryMatch = categoryItemsSet == null || item.id in categoryItemsSet + + val stageMatch = if (stage == null) { + true + } else if (stageMappingMap.isNullOrEmpty()) { + stage == VocabularyStage.NEW + } else { + (stageMappingMap[item.id] ?: VocabularyStage.NEW) == stage + } + + val dueTodayMatch = !dueTodayOnly || item.id in dueItemsSet + + // Expanded word class filtering with detailed logs + val wordClassMatch = if (wordClass.isNullOrBlank()) { + true // No filter applied, always true + } else { + if (item.features.isNullOrBlank()) { + false + } else { + try { + // Corrected class name to VocabularyFeatures + val features = jsonParser.decodeFromString(item.features) + val firstClass = features.first?.wordClass + val secondClass = features.second?.wordClass + + // Log the parsed values from the JSON + Log.d(TAG, "Item #${item.id} ('${item.wordFirst}') -> Parsed classes: first='${firstClass}', second='${secondClass}'") + + val isMatch = firstClass.equals(wordClass, ignoreCase = true) || + secondClass.equals(wordClass, ignoreCase = true) + + // Log the final comparison result for this item + Log.d(TAG, "Item #${item.id} ('${item.wordFirst}') -> Comparing with '$wordClass'. Match result: $isMatch") + isMatch + } catch (e: Exception) { + // Log any errors during JSON parsing, which is a common failure point + Log.e(TAG, "Item #${item.id} ('${item.wordFirst}') -> FAILED JSON PARSING: ${e.message}. JSON: ${item.features}") + false + } + } + } + + val finalMatch = languageMatch && queryMatch && categoryMatch && stageMatch && dueTodayMatch && wordClassMatch + + finalMatch + } + + filteredList = when (sortOrder) { + SortOrder.NEWEST_FIRST -> filteredList.sortedByDescending { it.id } + SortOrder.OLDEST_FIRST -> filteredList.sortedBy { it.id } + SortOrder.ALPHABETICAL -> filteredList.sortedBy { it.wordFirst } + SortOrder.LANGUAGE -> filteredList.sortedWith(compareBy({ it.languageFirstId }, { it.languageSecondId })) + } + } + Log.d(TAG, "--- Filter Complete ---") + Log.d(TAG, "Filtering & Sorting took ${duration}ms, result size: ${filteredList.size}") + filteredList + } + } + + suspend fun generateVocabularyItems(category: String, amount: Int) { + val selectedSourceLanguage = languageRepository.loadSelectedSourceLanguage().first() + val selectedTargetLanguage = languageRepository.loadSelectedTargetLanguage().first() + + if (selectedSourceLanguage == null || selectedTargetLanguage == null) { + _generationResult.emit("Source and target languages must be selected.") + return + } + + _isGenerating.value = true + Log.d(TAG, "Generating $amount vocabulary items for category '$category'") + try { + var result: Result> + val duration = measureTimeMillis { + result = vocabularyRepository.generateVocabularyItems(category, selectedSourceLanguage, selectedTargetLanguage, amount) + } + Log.d(TAG, "Vocabulary generation process took ${duration}ms") + result.onSuccess { + _generatedVocabularyItems.value = it + _generationResult.emit("Generation completed") + }.onFailure { exception -> + _generationResult.emit("Error generating vocabulary items: ${exception.message}") + } + } catch (e: Exception) { + _generationResult.emit("Error generating vocabulary items: ${e.message}") + } finally { + _isGenerating.value = false + } + } + + suspend fun getVocabularyItemById(id: Int): VocabularyItem? { + Log.d(TAG, "getVocabularyItemById: Fetching item $id") + var item: VocabularyItem? + val duration = measureTimeMillis { + item = vocabularyRepository.getVocabularyItemById(id) + } + Log.d(TAG, "getVocabularyItemById: Fetched item $id in ${duration}ms") + return item + } + + + fun prepareExercise( + categoryIdsAsJson: String?, + stageNamesAsJson: String?, + languageIdsAsJson: String?, + dailyOnly: Boolean = false + ) { + viewModelScope.launch { + val categoryList = categoryIdsAsJson?.takeIf { it.isNotBlank() } + ?.split(",") + ?.mapNotNull { it.toIntOrNull() } + ?.let { ids -> + ids.mapNotNull { id -> vocabularyRepository.getCategoryById(id) } + } ?: emptyList() + + val stageList = stageNamesAsJson?.takeIf { it.isNotBlank() }?.split(",") + ?.mapNotNull { try { VocabularyStage.valueOf(it) } catch (_: Exception) { null } } ?: emptyList() + + val languageList = languageIdsAsJson?.takeIf { it.isNotBlank() }?.split(",") + ?.mapNotNull { it.toIntOrNull() } + ?.let { ids -> + val allLangs = languageRepository.loadLanguages(LanguageListType.ALL).first() + allLangs.filter { it.nameResId in ids } + } ?: emptyList() + + loadCardSet(categoryList, stageList, languageList, dailyOnly) + } + } + + + fun loadCardSet( + categories: List? = null, + stages: List? = null, + languages: List? = null, + dailyOnly: Boolean = false + ) { + Log.d(TAG, "Loading card set with languages: $languages, categories: ${categories?.size}, stages: ${stages?.size}") + viewModelScope.launch { + statusViewModel.showLoadingMessage("Loading card set") + + try { + // Step 1: Wait for vocabulary items to be loaded + Log.d(TAG, "loadCardSet: Waiting for vocabulary items to load...") + val allItems = vocabularyItems.first { it.isNotEmpty() } + Log.d(TAG, "loadCardSet: Vocabulary items loaded (${allItems.size} items)") + + // Step 2: If we need stage filtering, ensure stage mappings are initialized + if (!stages.isNullOrEmpty()) { + Log.d(TAG, "loadCardSet: Stage filtering required, checking stage mappings...") + var stageMappingMap = stageMapping.first() + + // If stage mapping is empty, but we have items, initialize it + if (stageMappingMap.isEmpty() && allItems.isNotEmpty()) { + Log.d(TAG, "loadCardSet: Stage mapping empty, initializing...") + vocabularyRepository.actualizeVocabularyStageMappings() + + // Wait for stage mapping to be populated (with timeout) + var attempts = 0 + val maxAttempts = 10 + while (stageMappingMap.isEmpty() && attempts < maxAttempts) { + delay(100) // Wait 100ms between attempts + stageMappingMap = stageMapping.first() + attempts++ + Log.d(TAG, "loadCardSet: Waiting for stage mapping... attempt $attempts, size: ${stageMappingMap.size}") + } + + if (stageMappingMap.isEmpty()) { + Log.w(TAG, "loadCardSet: Stage mapping still empty after $maxAttempts attempts") + statusViewModel.showErrorMessage("Failed to initialize stage mappings") + _cardSet.value = null + return@launch + } else { + Log.d(TAG, "loadCardSet: Stage mapping ready with ${stageMappingMap.size} mappings") + } + } + } + + // Step 3: If we need category filtering, ensure category mappings are ready + if (!categories.isNullOrEmpty()) { + Log.d(TAG, "loadCardSet: Category filtering required, checking category mappings...") + val categoryMappings = vocabularyRepository.getCategoryMappings() + Log.d(TAG, "loadCardSet: Category mappings loaded (${categoryMappings.size} mappings)") + } + + // Step 4: Now perform the actual filtering + Log.d(TAG, "loadCardSet: All data ready, performing filtering...") + val filteredItems = filterVocabularyItems( + languages = languages?.map { it.nameResId }, + query = null, + categoryIds = categories?.map { it.id }, + stage = stages?.firstOrNull(), + sortOrder = SortOrder.NEWEST_FIRST, + dueTodayOnly = dailyOnly + ).first() + + Log.d(TAG, "loadCardSet: Filtering completed, found ${filteredItems.size} items") + + if (filteredItems.isNotEmpty()) { + val cardSetFromSource = CardSet( + languageFirst = languages?.getOrNull(0)?.nameResId, + languageSecond = languages?.getOrNull(1)?.nameResId, + cards = filteredItems + ) + _cardSet.value = cardSetFromSource + Log.d(TAG, "Successfully loaded card set with ${filteredItems.size} items") + } else { + _cardSet.value = null + Log.d(TAG, "No items found for the specified filters") + } + } catch (e: Exception) { + statusViewModel.showErrorMessage("Error loading card set: ${e.message}") + _cardSet.value = null + Log.e(TAG, "Error in loadCardSet: ${e.message}") + } + + statusViewModel.hideMessageBar() + if (_cardSet.value == null) { + statusViewModel.cancelAllMessages() + statusViewModel.showErrorMessage("No cards found for the specified filter", 3) + } + } + } + + fun deleteVocabularyItemsByCategory(categoryID: Int) { + Log.d(TAG, "Deleting vocabulary items by category ID $categoryID") + viewModelScope.launch { + try { + val duration = measureTimeMillis { + val itemsToDelete = vocabularyRepository.getVocabularyItemsByCategory(categoryID) + vocabularyRepository.deleteVocabularyItemsByIds(itemsToDelete.map { it.id }) + } + Log.d(TAG, "deleteVocabularyItemsByCategory for ID $categoryID took ${duration}ms") + } catch (e: Exception) { + statusViewModel.showErrorMessage("Error in deleteVocabularyItemsByCategory: ${e.message}") + } + } + } + + @OptIn(ExperimentalTime::class) + fun getVocabularyItemDetailsFlow(vocabularyItemId: Int): Flow = flow { + Log.d(TAG, "Getting details for item ID $vocabularyItemId") + try { + val duration = measureTimeMillis { + coroutineScope { + val stage = async { vocabularyRepository.getVocabularyItemStage(vocabularyItemId).first() } + val lastCorrect = async { vocabularyRepository.getLastCorrectAnswer(vocabularyItemId) } + val lastIncorrect = async { vocabularyRepository.getLastIncorrectAnswer(vocabularyItemId) } + val correctCount = async { vocabularyRepository.getCorrectAnswerCount(vocabularyItemId) } + val incorrectCount = async { vocabularyRepository.getIncorrectAnswerCount(vocabularyItemId) } + + emit( + VocabularyItemDetails( + stage = stage.await(), + lastCorrectAnswer = lastCorrect.await(), + lastIncorrectAnswer = lastIncorrect.await(), + correctAnswerCount = correctCount.await(), + incorrectAnswerCount = incorrectCount.await() + ) + ) + } + } + Log.d(TAG, "getVocabularyItemDetails for ID $vocabularyItemId took ${duration}ms") + } catch (e: Exception) { + Log.e(TAG, "Error in getVocabularyItemDetails: ${e.message}") + } + } + + fun initializeSaveFileLauncher(launcher: ActivityResultLauncher) { + saveFileLauncher = launcher + } + + fun saveRepositoryState() { + if (!::saveFileLauncher.isInitialized) { + statusViewModel.showErrorMessage("Save File Launcher not initialized.") + return + } + val intent = fileSaver.createSaveDocumentIntent(fileSaver.generateFilename()) + saveFileLauncher.launch(intent) + } + + fun saveCategory(categoryId: Int) { + if (!::saveFileLauncher.isInitialized) { + statusViewModel.showErrorMessage("Save File Launcher not initialized.") + return + } + val filename = fileSaver.generateFilenameForCategory(categoryId) + val intent = fileSaver.createSaveDocumentIntent(filename) + saveFileLauncher.launch(intent) + } + + fun handleSaveResult(result: ActivityResult, categoryId: Int? = null) { + Log.d(TAG, "Handling save result for categoryId: $categoryId") + if (result.resultCode == Activity.RESULT_OK) { + val uri: Uri? = result.data?.data + uri?.let { + viewModelScope.launch { + if (categoryId == null) { + val startSave = System.currentTimeMillis() + fileSaver.saveRepositoryToUri(it) + Log.d(TAG, "handleSaveResult: Saved repository in ${System.currentTimeMillis() - startSave}ms") + val filename = fileSaver.getFileNameFromUri(it) + _saveStatus.postValue("File saved to $filename") + } + if (categoryId != null) { + val startSave = System.currentTimeMillis() + fileSaver.saveCategoryToUri(it, categoryId) + Log.d(TAG, "handleSaveResult: Saved category in ${System.currentTimeMillis() - startSave}ms") + val filename = fileSaver.getFileNameFromUri(it) + _saveStatus.postValue("File saved to $filename") + } + } + } ?: run { + statusViewModel.showErrorMessage("Error: No URI received from file picker.") + Log.e(TAG, "handleSaveResult: No URI received") + } + } else { + statusViewModel.showErrorMessage("File save cancelled or failed.") + Log.d(TAG, "handleSaveResult: Save cancelled or failed") + } + } + + fun importVocabulary(jsonString: String) { + Log.d(TAG, "Attempting to import vocabulary from JSON string (length: ${jsonString.length})") + viewModelScope.launch { + try { + val duration = measureTimeMillis { + val vocabularyItems = Json.decodeFromString>(jsonString) + Log.d(TAG, "Successfully decoded ${vocabularyItems.size} items from JSON.") + vocabularyRepository.introduceVocabularyItems(vocabularyItems) + vocabularyRepository.cleanDuplicates() + } + statusViewModel.showSuccessMessage("Vocabulary items imported successfully.") + Log.d(TAG, "Vocabulary import process took ${duration}ms") + } catch (e: Exception) { + statusViewModel.showErrorMessage("Error importing vocabulary items: ${e.message}") + } + } + } + + fun wipeRepository() { + viewModelScope.launch { + try { + vocabularyRepository.wipeRepository() + statusViewModel.showErrorMessage("All repository data deleted.") + } catch (e: Exception) { + statusViewModel.showErrorMessage("Failed to wipe repository: $e.message}") + } + } + } + + suspend fun getSynonymsForItem( + vocabularyItemId: Int, + isForFirstWord: Boolean + ): List { + val vocabularyItem = getVocabularyItemById(vocabularyItemId) ?: return emptyList() + + val allLangs = languageRepository.loadLanguages(LanguageListType.ALL).first() + val langFirst = allLangs.find { it.nameResId == vocabularyItem.languageFirstId } + val langSecond = allLangs.find { it.nameResId == vocabularyItem.languageSecondId } + + if (langFirst == null || langSecond == null) { + Log.e(TAG, "Could not find languages for vocabulary item $vocabularyItemId") + return emptyList() + } + + val termLanguage = if (isForFirstWord) langFirst else langSecond + val translationLanguage = if (isForFirstWord) langSecond else langFirst + val term = if (isForFirstWord) vocabularyItem.wordFirst else vocabularyItem.wordSecond + val translation = if (isForFirstWord) vocabularyItem.wordSecond else vocabularyItem.wordFirst + + val dbSynonyms = withContext(Dispatchers.IO) { + try { + vocabularyRepository.getSynonymsForItem(vocabularyItemId) + } catch (e: Exception) { + Log.e(TAG, "Error fetching synonyms from database: ${e.message}") + emptyList() + } + } + val combinedItems = dbSynonyms.map { SynonymData(it, null) }.toMutableList() + + if (combinedItems.size < 4) { + vocabularyItemService.generateSynonyms( + term = term, + language = termLanguage, + amount = 4 - combinedItems.size, + translation = translation, + translationLanguage = translationLanguage + ).onSuccess { generatedSynonyms -> + val newItems = generatedSynonyms.map { synonym -> + val newItem = VocabularyItem( + id = 0, + languageFirstId = if (isForFirstWord) termLanguage.nameResId else translationLanguage.nameResId, + languageSecondId = if (isForFirstWord) translationLanguage.nameResId else termLanguage.nameResId, + wordFirst = if (isForFirstWord) synonym.word else translation, + wordSecond = if (isForFirstWord) translation else synonym.word + ) + SynonymData(item = newItem, proximity = synonym.proximity) + } + combinedItems.addAll(newItems) + }.onFailure { exception -> + Log.e(TAG, "AI synonym generation failed: ${exception.message}") + } + } + + return combinedItems + .filterNot { (item, _) -> + val currentTerm = if (item.languageFirstId == termLanguage.nameResId) item.wordFirst else item.wordSecond + currentTerm.equals(term, ignoreCase = true) + } + .distinctBy { (item, _) -> + if (item.languageFirstId == termLanguage.nameResId) item.wordFirst else item.wordSecond + } + } + + + suspend fun getAllLanguagesIdsPresent(): Set { + try { + return withContext(Dispatchers.IO) { + Log.d(TAG, "Fetching all present language IDs...") + val ids = vocabularyRepository.getAllLanguageIdsFromVocabulary() + Log.d(TAG, "Fetched ${ids.size} language IDs") + ids + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching all language IDs: ${e.message}") + return emptySet() + } + } + + suspend fun getExampleForItem(itemId: Int, isFirstWord: Boolean, languageFirst: Language?, languageSecond: Language?): Pair? { + Log.d(TAG, "Fetching example for item ID $itemId") + + val item = getVocabularyItemById(itemId) ?: return null + val word = if (isFirstWord) item.wordFirst else item.wordSecond + val wordTranslation = if (!isFirstWord) item.wordFirst else item.wordSecond + + if (languageFirst == null || languageSecond == null) return null + + return dictionaryService.getExampleSentence(word, wordTranslation, languageFirst, languageSecond).getOrNull() + } + + suspend fun fetchAndUpdateZipfFrequency(item: VocabularyItem): VocabularyItem { + Log.d(TAG, "Fetching zipf frequency for item ID ${item.id}") + var updatedItem = item + + try { + val languageFirst = languageRepository.getLanguageById(item.languageFirstId ?: 0) + if (languageFirst != null) { + Log.d(TAG, "Fetching zipf frequency for '${item.wordFirst}' in language '${languageFirst.name}'") + val frequencyResult = vocabularyItemService.fetchZipfFrequency(item.wordFirst, languageFirst.code) + frequencyResult.onSuccess { frequency -> + Log.d(TAG, "Fetched zipf frequency for '${item.wordFirst}': $frequency") + updatedItem = updatedItem.copy(zipfFrequencyFirst = frequency) + }.onFailure { exception -> + Log.e(TAG, "Failed to fetch zipf frequency for '${item.wordFirst}': ${exception.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching zipf frequency for first word: ${e.message}") + } + + try { + + val languageSecond = languageRepository.getLanguageById(item.languageSecondId ?: 0) + if (languageSecond != null) { + Log.d(TAG, "Fetching zipf frequency for '${item.wordSecond}' in language '${languageSecond.name}'") + val frequencyResult = vocabularyItemService.fetchZipfFrequency(item.wordSecond, languageSecond.code) + frequencyResult.onSuccess { frequency -> + Log.d(TAG, "Fetched zipf frequency for '${item.wordSecond}': $frequency") + updatedItem = updatedItem.copy(zipfFrequencySecond = frequency) + }.onFailure { exception -> + Log.e(TAG, "Failed to fetch zipf frequency for '${item.wordSecond}': ${exception.message}") + } + } + }catch (e: Exception) { + Log.e(TAG, "Error fetching zipf frequency for second word: ${e.message}") + } + + Log.d(TAG, "Updated item: $updatedItem") + return updatedItem + } + + + fun fetchAndApplyGrammaticalDetailsForList(items: List) { + if (items.isEmpty()) { + Log.d(TAG, "fetchAndApplyGrammaticalDetailsForList called with an empty list. Nothing to do.") + return + } + + viewModelScope.launch { + statusViewModel.showLoadingMessage("Fetching grammar for ${items.size} items...") + Log.d(TAG, "Attempting to fetch grammar details for a list of ${items.size} items.") + + try { + + // 1. Get all unique language IDs and fetch their objects in one go. + val languageIds = items.flatMap { listOfNotNull(it.languageFirstId, it.languageSecondId) }.toSet() + val languagesMap = languageRepository.getLanguagesByResourceIds(languageIds).associateBy { it.nameResId } + + // 2. Create a new list with articles removed from non-sentence items + val itemsWithoutArticles = items.map { item -> + if (StringHelper.isSentenceStrict(item.wordFirst) || StringHelper.isSentenceStrict(item.wordSecond)) { + item // Skip article removal for sentences + } else { + val articlesLangFirst = languagesMap[item.languageFirstId]?.code?.let { languageConfigViewModel.getArticlesForLanguage(it) } ?: emptySet() + val articlesLangSecond = languagesMap[item.languageSecondId]?.code?.let { languageConfigViewModel.getArticlesForLanguage(it) } ?: emptySet() + + val allArticles = (articlesLangFirst + articlesLangSecond) + + item.copy( + wordFirst = StringHelper.removeLeadingArticles(item.wordFirst, allArticles), + wordSecond = StringHelper.removeLeadingArticles(item.wordSecond, allArticles) + ) + } + } + + // 3. Call the new batch service method with the updated item list. + val result = vocabularyItemService.fetchAndApplyGrammaticalDetails(itemsWithoutArticles, languagesMap) + + // 4. Handle the result from the service. + result.onSuccess { updatedItems -> + Log.i(TAG, "Successfully fetched and mapped details for ${updatedItems.count { it.features != null }} items.") + + // 5. Persist the updated items back to the database. + vocabularyRepository.updateVocabularyItems(updatedItems) + statusViewModel.cancelLoadingOperation() + statusViewModel.showSuccessMessage("Grammar details updated!") + + }.onFailure { exception -> + Log.e(TAG, "Failed to fetch grammar details for list: ${exception.message}") + statusViewModel.showErrorMessage("Could not retrieve grammar details.") + } + } catch (e: Exception) { + Log.e(TAG, "An unexpected error occurred in fetchAndApplyGrammaticalDetailsForList: ${e.message}") + statusViewModel.showErrorMessage("An unexpected error occurred.") + } + } + } + + /** + * Removes leading articles from a VocabularyItem's words based on the provided article sets for each language. + * The item is then updated in the database. + * + * @param item The VocabularyItem to process. + * @param articlesLangFirst A set of articles for the first language. + * @param articlesLangSecond A set of articles for the second language. + */ + fun removeArticles(item: VocabularyItem, articlesLangFirst: Set, articlesLangSecond: Set) { + viewModelScope.launch { + try { + + // Don't remove articles if either word is a sentence + if (StringHelper.isSentenceStrict(item.wordFirst) || StringHelper.isSentenceStrict(item.wordSecond)) { + Log.d(TAG, "removeArticles: Skipping item ${item.id} as it contains a sentence.") + return@launch + } + + // Combine all articles and filter out any blank ones. + val allArticles = (articlesLangFirst + articlesLangSecond).filter { it.isNotBlank() } + if (allArticles.isEmpty()) { + Log.d(TAG, "removeArticles: No articles provided, skipping.") + return@launch + } + + + // Create a new item with the articles removed. + val updatedItem = item.copy( + wordFirst = StringHelper.removeLeadingArticles(item.wordFirst, allArticles), + wordSecond = StringHelper.removeLeadingArticles(item.wordSecond, allArticles) + ) + + // Only call the repository if a change was actually made. + if (updatedItem != item) { + Log.d(TAG, "removeArticles: Articles removed, updating item ${item.id}") + editVocabularyItem(updatedItem) + } else { + Log.d(TAG, "removeArticles: No articles found in item ${item.id}, no update needed.") + } + } catch (e: Exception) { + statusViewModel.showErrorMessage("Error removing articles: ${e.message}") + Log.e(TAG, "Error in removeArticles: ${e.message}") + } + } + } + private val validLanguageIds: StateFlow> = languageRepository.loadLanguages(LanguageListType.ALL) + .map { languages -> languages.map { it.nameResId }.toSet() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet()) + + val faultyItemsCount: StateFlow = combine(vocabularyItems, validLanguageIds) { items, validIds -> + // Return 0 if the list of valid IDs hasn't been loaded yet to prevent incorrect counts. + if (validIds.isEmpty()) return@combine 0 + + items.count { item -> + val lang1 = item.languageFirstId + val lang2 = item.languageSecondId + + item.wordFirst.isBlank() || + item.wordSecond.isBlank() || + lang1 == null || + lang2 == null || + lang1 == lang2 || + lang1 == 0 || + lang2 == 0 || + !validIds.contains(lang1) || + !validIds.contains(lang2) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = 0 + ) + + val itemsWithoutGrammarCount: StateFlow = vocabularyItems + .map { items -> + items.count { it.features.isNullOrEmpty() } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = 0 + ) + + val allItemsWithoutGrammar: StateFlow> = vocabularyItems + .map { items -> + items.filter { it.features.isNullOrEmpty() } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + + val allFaultyItems: StateFlow> = combine(vocabularyItems, validLanguageIds) { items, validIds -> + if (validIds.isEmpty()) emptyList() + else items.filter { item -> + val lang1 = item.languageFirstId + val lang2 = item.languageSecondId + item.wordFirst.isBlank() || item.wordSecond.isBlank() || lang1 == null || lang2 == null || + lang1 == lang2 || lang1 == 0 || lang2 == 0 || !validIds.contains(lang1) || + !validIds.contains(lang2) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val missingLanguageInfo: StateFlow> = combine(vocabularyItems, validLanguageIds) { items, validIds -> + if (validIds.isEmpty() || items.isEmpty()) { + emptyMap() + } else { + val presentIds = items.flatMap { listOfNotNull(it.languageFirstId, it.languageSecondId) }.toSet() + val missingIds = presentIds - validIds + + if (missingIds.isEmpty()) { + emptyMap() + } else { + missingIds.associateWith { missingId -> + items.count { it.languageFirstId == missingId || it.languageSecondId == missingId } + } + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + + fun getItemsForLanguage(languageId: Int): Flow> { + return vocabularyItems.map { items -> + items.filter { it.languageFirstId == languageId || it.languageSecondId == languageId } + } + } + + fun replaceLanguageId(oldId: Int, newId: Int) { + viewModelScope.launch { + Log.d(TAG, "Replacing language ID $oldId with $newId") + try { + val itemsToUpdate = vocabularyItems.value.filter { + it.languageFirstId == oldId || it.languageSecondId == oldId + }.map { item -> + val newFirstId = if (item.languageFirstId == oldId) newId else item.languageFirstId + val newSecondId = if (item.languageSecondId == oldId) newId else item.languageSecondId + item.copy(languageFirstId = newFirstId, languageSecondId = newSecondId) + } + + if (itemsToUpdate.isNotEmpty()) { + vocabularyRepository.updateVocabularyItems(itemsToUpdate) + statusViewModel.showSuccessMessage("Language ID updated for ${itemsToUpdate.size} items.") + Log.d(TAG, "Successfully updated ${itemsToUpdate.size} items.") + } else { + Log.d(TAG, "No items found with language ID $oldId to update.") + } + } catch (e: Exception) { + val errorMessage = "Error replacing language ID: ${e.message}" + statusViewModel.showErrorMessage(errorMessage) + Log.e(TAG, errorMessage) + } + } + } + + enum class DeleteType { VOCABULARY_ITEM_BY_ID, VOCABULARY_ITEM, VOCABULARY_ITEMS, REMOVE_FROM_CATEGORY } + enum class SortOrder { NEWEST_FIRST, OLDEST_FIRST, ALPHABETICAL, LANGUAGE } + + data class VocabularyItemDetails @OptIn(ExperimentalTime::class) constructor( + val stage: VocabularyStage, + val lastCorrectAnswer: kotlin.time.Instant?, + val lastIncorrectAnswer: kotlin.time.Instant?, + val correctAnswerCount: Int, + val incorrectAnswerCount: Int + ) + + data class SynonymData( + val item: VocabularyItem, + val proximity: Int? + ) + + // Navigation state management functions + fun setNavigationContext(items: List, currentItemId: Int) { + val currentIndex = items.indexOfFirst { it.id == currentItemId } + if (currentIndex != -1) { + _currentNavigationItems.value = items + _currentNavigationPosition.value = currentIndex + } + } + + fun navigateToNextItem(): Boolean { + val currentPos = _currentNavigationPosition.value + val items = _currentNavigationItems.value + if (currentPos < items.size - 1) { + _currentNavigationPosition.value = currentPos + 1 + return true + } + return false + } + + fun navigateToPreviousItem(): Boolean { + val currentPos = _currentNavigationPosition.value + if (currentPos > 0) { + _currentNavigationPosition.value = currentPos - 1 + return true + } + return false + } + +} diff --git a/app/src/main/res/drawable/ic_empty.png b/app/src/main/res/drawable/ic_empty.png new file mode 100644 index 0000000..32399d8 Binary files /dev/null and b/app/src/main/res/drawable/ic_empty.png differ diff --git a/app/src/main/res/drawable/ic_icon_construction.png b/app/src/main/res/drawable/ic_icon_construction.png new file mode 100644 index 0000000..eb612af Binary files /dev/null and b/app/src/main/res/drawable/ic_icon_construction.png differ diff --git a/app/src/main/res/drawable/ic_inro_practice.png b/app/src/main/res/drawable/ic_inro_practice.png new file mode 100644 index 0000000..e4d8d8e Binary files /dev/null and b/app/src/main/res/drawable/ic_inro_practice.png differ diff --git a/app/src/main/res/drawable/ic_intro_ai_agents.png b/app/src/main/res/drawable/ic_intro_ai_agents.png new file mode 100644 index 0000000..e6772fb Binary files /dev/null and b/app/src/main/res/drawable/ic_intro_ai_agents.png differ diff --git a/app/src/main/res/drawable/ic_intro_categories.png b/app/src/main/res/drawable/ic_intro_categories.png new file mode 100644 index 0000000..52d4379 Binary files /dev/null and b/app/src/main/res/drawable/ic_intro_categories.png differ diff --git a/app/src/main/res/drawable/ic_intro_flashcards.png b/app/src/main/res/drawable/ic_intro_flashcards.png new file mode 100644 index 0000000..b280fe4 Binary files /dev/null and b/app/src/main/res/drawable/ic_intro_flashcards.png differ diff --git a/app/src/main/res/drawable/ic_intro_help.png b/app/src/main/res/drawable/ic_intro_help.png new file mode 100644 index 0000000..db3ee70 Binary files /dev/null and b/app/src/main/res/drawable/ic_intro_help.png differ diff --git a/app/src/main/res/drawable/ic_intro_learning_journey.png b/app/src/main/res/drawable/ic_intro_learning_journey.png new file mode 100644 index 0000000..934c28f Binary files /dev/null and b/app/src/main/res/drawable/ic_intro_learning_journey.png differ diff --git a/app/src/main/res/drawable/ic_intro_lookup.png b/app/src/main/res/drawable/ic_intro_lookup.png new file mode 100644 index 0000000..6c9115d Binary files /dev/null and b/app/src/main/res/drawable/ic_intro_lookup.png differ diff --git a/app/src/main/res/drawable/ic_intro_robot.png b/app/src/main/res/drawable/ic_intro_robot.png new file mode 100644 index 0000000..e057d9d Binary files /dev/null and b/app/src/main/res/drawable/ic_intro_robot.png differ diff --git a/app/src/main/res/drawable/ic_intro_track_progress.png b/app/src/main/res/drawable/ic_intro_track_progress.png new file mode 100644 index 0000000..b6a212b Binary files /dev/null and b/app/src/main/res/drawable/ic_intro_track_progress.png differ diff --git a/app/src/main/res/drawable/ic_intro_welcome.png b/app/src/main/res/drawable/ic_intro_welcome.png new file mode 100644 index 0000000..39e47eb Binary files /dev/null and b/app/src/main/res/drawable/ic_intro_welcome.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ca3826a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_level_auctioneer.png b/app/src/main/res/drawable/ic_level_auctioneer.png new file mode 100644 index 0000000..693d2d4 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_auctioneer.png differ diff --git a/app/src/main/res/drawable/ic_level_avid_debater.png b/app/src/main/res/drawable/ic_level_avid_debater.png new file mode 100644 index 0000000..bcb840a Binary files /dev/null and b/app/src/main/res/drawable/ic_level_avid_debater.png differ diff --git a/app/src/main/res/drawable/ic_level_bee.png b/app/src/main/res/drawable/ic_level_bee.png new file mode 100644 index 0000000..aa15ebc Binary files /dev/null and b/app/src/main/res/drawable/ic_level_bee.png differ diff --git a/app/src/main/res/drawable/ic_level_bonobo.png b/app/src/main/res/drawable/ic_level_bonobo.png new file mode 100644 index 0000000..d6328c1 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_bonobo.png differ diff --git a/app/src/main/res/drawable/ic_level_bookworm.png b/app/src/main/res/drawable/ic_level_bookworm.png new file mode 100644 index 0000000..ce051e3 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_bookworm.png differ diff --git a/app/src/main/res/drawable/ic_level_chaser.png b/app/src/main/res/drawable/ic_level_chaser.png new file mode 100644 index 0000000..9ae457b Binary files /dev/null and b/app/src/main/res/drawable/ic_level_chaser.png differ diff --git a/app/src/main/res/drawable/ic_level_crow.png b/app/src/main/res/drawable/ic_level_crow.png new file mode 100644 index 0000000..fa1d977 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_crow.png differ diff --git a/app/src/main/res/drawable/ic_level_echo.png b/app/src/main/res/drawable/ic_level_echo.png new file mode 100644 index 0000000..8e5bd64 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_echo.png differ diff --git a/app/src/main/res/drawable/ic_level_elephant.png b/app/src/main/res/drawable/ic_level_elephant.png new file mode 100644 index 0000000..e201f3a Binary files /dev/null and b/app/src/main/res/drawable/ic_level_elephant.png differ diff --git a/app/src/main/res/drawable/ic_level_first_grader.png b/app/src/main/res/drawable/ic_level_first_grader.png new file mode 100644 index 0000000..e454831 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_first_grader.png differ diff --git a/app/src/main/res/drawable/ic_level_goldfish.png b/app/src/main/res/drawable/ic_level_goldfish.png new file mode 100644 index 0000000..e72b0f3 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_goldfish.png differ diff --git a/app/src/main/res/drawable/ic_level_gorilla.png b/app/src/main/res/drawable/ic_level_gorilla.png new file mode 100644 index 0000000..c0cc0e9 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_gorilla.png differ diff --git a/app/src/main/res/drawable/ic_level_high_school_grad.png b/app/src/main/res/drawable/ic_level_high_school_grad.png new file mode 100644 index 0000000..18e5181 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_high_school_grad.png differ diff --git a/app/src/main/res/drawable/ic_level_journalist.png b/app/src/main/res/drawable/ic_level_journalist.png new file mode 100644 index 0000000..0860c71 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_journalist.png differ diff --git a/app/src/main/res/drawable/ic_level_master_linguist.png b/app/src/main/res/drawable/ic_level_master_linguist.png new file mode 100644 index 0000000..da09bd9 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_master_linguist.png differ diff --git a/app/src/main/res/drawable/ic_level_middle_schooler.png b/app/src/main/res/drawable/ic_level_middle_schooler.png new file mode 100644 index 0000000..2828a35 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_middle_schooler.png differ diff --git a/app/src/main/res/drawable/ic_level_newborn.png b/app/src/main/res/drawable/ic_level_newborn.png new file mode 100644 index 0000000..fea098f Binary files /dev/null and b/app/src/main/res/drawable/ic_level_newborn.png differ diff --git a/app/src/main/res/drawable/ic_level_novelist.png b/app/src/main/res/drawable/ic_level_novelist.png new file mode 100644 index 0000000..f2b63da Binary files /dev/null and b/app/src/main/res/drawable/ic_level_novelist.png differ diff --git a/app/src/main/res/drawable/ic_level_oracle.png b/app/src/main/res/drawable/ic_level_oracle.png new file mode 100644 index 0000000..6bc930c Binary files /dev/null and b/app/src/main/res/drawable/ic_level_oracle.png differ diff --git a/app/src/main/res/drawable/ic_level_parrot.png b/app/src/main/res/drawable/ic_level_parrot.png new file mode 100644 index 0000000..5d75851 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_parrot.png differ diff --git a/app/src/main/res/drawable/ic_level_parrotlet.png b/app/src/main/res/drawable/ic_level_parrotlet.png new file mode 100644 index 0000000..7865648 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_parrotlet.png differ diff --git a/app/src/main/res/drawable/ic_level_pigeon.png b/app/src/main/res/drawable/ic_level_pigeon.png new file mode 100644 index 0000000..454eff9 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_pigeon.png differ diff --git a/app/src/main/res/drawable/ic_level_professor.png b/app/src/main/res/drawable/ic_level_professor.png new file mode 100644 index 0000000..30cf8ca Binary files /dev/null and b/app/src/main/res/drawable/ic_level_professor.png differ diff --git a/app/src/main/res/drawable/ic_level_puppy.png b/app/src/main/res/drawable/ic_level_puppy.png new file mode 100644 index 0000000..902dad1 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_puppy.png differ diff --git a/app/src/main/res/drawable/ic_level_rico.png b/app/src/main/res/drawable/ic_level_rico.png new file mode 100644 index 0000000..c308998 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_rico.png differ diff --git a/app/src/main/res/drawable/ic_level_sea_lion.png b/app/src/main/res/drawable/ic_level_sea_lion.png new file mode 100644 index 0000000..f168ab6 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_sea_lion.png differ diff --git a/app/src/main/res/drawable/ic_level_shakespeare.png b/app/src/main/res/drawable/ic_level_shakespeare.png new file mode 100644 index 0000000..85e2ab1 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_shakespeare.png differ diff --git a/app/src/main/res/drawable/ic_level_toddler.png b/app/src/main/res/drawable/ic_level_toddler.png new file mode 100644 index 0000000..7fd3840 Binary files /dev/null and b/app/src/main/res/drawable/ic_level_toddler.png differ diff --git a/app/src/main/res/drawable/ic_nothing_found.png b/app/src/main/res/drawable/ic_nothing_found.png new file mode 100644 index 0000000..4838503 Binary files /dev/null and b/app/src/main/res/drawable/ic_nothing_found.png differ diff --git a/app/src/main/res/drawable/no_connection.png b/app/src/main/res/drawable/no_connection.png new file mode 100644 index 0000000..76a03a6 Binary files /dev/null and b/app/src/main/res/drawable/no_connection.png differ diff --git a/app/src/main/res/font/lato_regular.ttf b/app/src/main/res/font/lato_regular.ttf new file mode 100644 index 0000000..bb2e887 Binary files /dev/null and b/app/src/main/res/font/lato_regular.ttf differ diff --git a/app/src/main/res/font/lora_regular.ttf b/app/src/main/res/font/lora_regular.ttf new file mode 100644 index 0000000..dc751db Binary files /dev/null and b/app/src/main/res/font/lora_regular.ttf differ diff --git a/app/src/main/res/font/merriweather_regular.ttf b/app/src/main/res/font/merriweather_regular.ttf new file mode 100644 index 0000000..8d41802 Binary files /dev/null and b/app/src/main/res/font/merriweather_regular.ttf differ diff --git a/app/src/main/res/font/opensans_regular.ttf b/app/src/main/res/font/opensans_regular.ttf new file mode 100644 index 0000000..705966c Binary files /dev/null and b/app/src/main/res/font/opensans_regular.ttf differ diff --git a/app/src/main/res/font/playfairdisplay_regular.ttf b/app/src/main/res/font/playfairdisplay_regular.ttf new file mode 100644 index 0000000..2cd12a3 Binary files /dev/null and b/app/src/main/res/font/playfairdisplay_regular.ttf differ diff --git a/app/src/main/res/font/roboto_regular.ttf b/app/src/main/res/font/roboto_regular.ttf new file mode 100644 index 0000000..7e3bb2f Binary files /dev/null and b/app/src/main/res/font/roboto_regular.ttf differ diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..c4a603d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..c4a603d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..2b7874f Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..b929284 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..6dcd94d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..c8ac93e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..309d395 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..86e8f1d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..7a74bfb Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ea6263c Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..ee9fa33 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..5e47b6b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..a4369ac Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..24848f5 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..46a6700 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..e85157c Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b948778 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-de-rDE/app_values.xml b/app/src/main/res/values-de-rDE/app_values.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/app/src/main/res/values-de-rDE/app_values.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values-de-rDE/arrays.xml b/app/src/main/res/values-de-rDE/arrays.xml new file mode 100644 index 0000000..c19f14f --- /dev/null +++ b/app/src/main/res/values-de-rDE/arrays.xml @@ -0,0 +1,39 @@ + + + Exercise Example Prompts + + + Version 0.3.0 \n• CSV-Import fÃŒr Vokabeln aktiviert\n• Option, fÃŒr einige unterstÃŒtzte Sprachen einen Übersetzungsserver statt KI-Modelle zu nutzen\n• UI-Fehlerbehebungen \n• Anzeige der WorthÀufigkeit \n• Leistungsoptimierungen \n• Verbesserte Übersetzungen (Deutsch und Portugiesisch) + Version 0.4.0 \n• Wörterbuch-Download hinzugefÃŒgt (Beta) \n• Verbesserungen der BenutzeroberflÀche \n• Fehlerbehebungen \n• Neugestaltete Vokabelkarte mit verbesserter UI \n• Mehr vorkonfigurierte Anbieter \n• Verbesserte Leistung + + + + + + word_class_gender + declension_noun + pronunciation + definition + origin + synonyms + antonyms + examples + flexion_verb + idioms + grammatical_features_prepositions + + + + Wortart und Genus (bei Nomen) + Deklination (bei Nomen) + Aussprache (IPA) und Worttrennung + Definition + Herkunft + Synonyme + Antonyme + Beispiele + Konjugation (bei Verben) + Redewendungen + Grammatikalische Merkmale (bei PrÀpositionen) + + diff --git a/app/src/main/res/values-de-rDE/hint_strings.xml b/app/src/main/res/values-de-rDE/hint_strings.xml new file mode 100644 index 0000000..bb5f83c --- /dev/null +++ b/app/src/main/res/values-de-rDE/hint_strings.xml @@ -0,0 +1,129 @@ + + + + Findest du dein Modell nicht? + Du kannst Modelle manuell hinzufÃŒgen. Gib die exakte Modell-ID aus der Dokumentation deines Anbieters und einen Anzeigenamen ein. Die App verwendet diese ID fÃŒr alle API-Aufrufe. + nano + mini + klein + mittel + groß / bezahlt + Vom Scan zur Auswahl – eine schnelle visuelle Anleitung + 1 + 2 + 3 + Starte den Scan + Tippe auf die Scan-SchaltflÀche, um verfÃŒgbare Modelle von deinem Anbieter abzurufen. + Filtern & auswÀhlen + Durchsuche die Liste. Bevorzuge Modelle, die fÃŒr Text/Chat markiert sind. Einige Anbieter kennzeichnen kostenlose/kostenpflichtige Modelle unterschiedlich. + Text/Chat + Validieren + Nutze „HinzufÃŒgen & Validieren“, um das Modell zu speichern und eine schnelle ÜberprÃŒfung bei deinem Anbieter durchzufÃŒhren. + HinzufÃŒgen & Validieren + Kontextbezogene Übersetzung + Erhalte Übersetzungen, die den Kontext deines GesprÀchs verstehen, fÃŒr genauere Ergebnisse. + Fortschrittsverfolgung fÃŒr Vokabeln + Fortschrittsverfolgung + Verfolge deinen Lernfortschritt mit detaillierten Statistiken und visuellen Indikatoren. + Lernstufen + Wörter durchlaufen beim Lernen verschiedene Stufen, mit zunehmenden Intervallen zwischen den Wiederholungen. + Wiederholungssystem + Das System der verteilten Wiederholung stellt sicher, dass du Wörter in optimalen Intervallen wiederholst, um sie langfristig zu behalten. + Anpassung + Passe Lernkriterien, tÀgliche Ziele und Wiederholungsintervalle an deinen Lernstil an. + Hilfe und Anleitungen + Hilfebereich + Alle Hinweise, die es in dieser App gibt, findest du auch hier. + Erste Schritte + Vokabelverwaltung + Erweiterte Funktionen + Eine „Liste“ ist eine einfache Kategorie, zu der du beliebige Vokabeln manuell hinzufÃŒgen kannst. Sie ist wie ein benutzerdefinierter Ordner fÃŒr deine Wörter. + Um alle Funktionen nutzen zu können, muss die App eine Verbindung zu einem Dienst fÃŒr große Sprachmodelle (LLM) herstellen. Dies geschieht ÃŒber einen API-Anbieter. + Du kannst deinen API-SchlÃŒssel zu einem vorkonfigurierten Anbieter (wie OpenAI oder Google) hinzufÃŒgen oder einen benutzerdefinierten Anbieter hinzufÃŒgen, um dich mit einem anderen Dienst, wie einem lokalen Modell, zu verbinden. Jeder Anbieter muss mit dem OpenAI-API-Standard kompatibel sein. + SchlÃŒssel-Statusanzeigen + Jede Anbieterkarte zeigt den Status deines API-SchlÃŒssels an: + Das bedeutet, dein SchlÃŒssel ist gespeichert und aktiv. + Das bedeutet, der API-SchlÃŒssel fehlt oder wurde gelöscht. + Fehlerbehebung + Wenn du Probleme hast, ÃŒberprÃŒfe bitte Folgendes: + • Stelle sicher, dass dein API-SchlÃŒssel gÃŒltig ist und die nötigen Berechtigungen hat.\n• ÜberprÃŒfe deine Netzwerkverbindung.\n• Sieh dir den Tab „Netzwerk-Logs“ fÃŒr detaillierte Fehlermeldungen an. + Du kannst zwei Arten von Kategorien erstellen, um deine Vokabeln zu organisieren: + Tag-Kategorie + Filter-Kategorie + Filter können Elemente abgleichen nach: keinem Sprachfilter, einer Liste von Sprachen oder einem Wörterbuchpaar. Du kannst optional auch nach Lernstufen filtern. Sprachliste und Wörterbuchpaar schließen sich gegenseitig aus. + Erstelle einen manuellen Tag, um Wörter deiner Wahl zu gruppieren. + Listenkategorie + FÃŒge manuell jedes gewÃŒnschte Wort zu dieser Kategorie hinzu. Sie ist perfekt, um eigene Lernlisten fÃŒr ein bestimmtes Thema oder Kapitel zu erstellen. + Apple + HinzufÃŒgen + Meine Obstliste + Filterkategorie + Diese Kategorie gruppiert Wörter automatisch basierend auf von dir festgelegten Regeln, wie ihrer Lernstufe oder Sprache. Das ist eine dynamische, automatische Art der Organisation. + Dog + Cat + „Stufe 1“-Filter + Schritt 1: Konfiguriere die KI + Schritt 2: WÀhle Inhalte aus + Nutze als NÀchstes die Schalter, um auszuwÀhlen, welche spezifischen Abschnitte (wie Synonyme, Antonyme usw.) bei einer Wörterbuchsuche enthalten sein sollen. + z.B. Synonyme + Beispiel-Schalter + Lass die KI Vokabeln fÃŒr dich finden. So nutzt du diese Funktion: + 1. Gib einen Suchbegriff ein + Was man im Zoo machen kann + 2. WÀhle deine Sprachen aus + WÀhle die Sprache, aus der du lernen möchtest (Quelle), und die Sprache, die du lernen möchtest (Ziel). + 3. WÀhle die Anzahl der Wörter + Verwende den Schieberegler, um auszuwÀhlen, wie viele Wörter du generieren möchtest (bis zu 25). + Nach dem Generieren kannst du die Wörter ÃŒberprÃŒfen, bevor du sie hinzufÃŒgst. + der Apfel + the apple + der Hund + the dog + ÜberprÃŒfe die generierten Vokabeln, bevor du sie zu deiner Sammlung hinzufÃŒgst. + Elemente auswÀhlen + Verwende die KontrollkÀstchen, um die Wörter auszuwÀhlen, die du behalten möchtest. Du kannst auch das KontrollkÀstchen oben verwenden, um alle Elemente auf einmal aus- oder abzuwÀhlen. + Duplikat + Umgang mit Duplikaten + Die App erkennt automatisch, ob ein Wort bereits in deinem Vokabular vorhanden ist. Diese Duplikate sind standardmÀßig nicht ausgewÀhlt, um Unordnung zu vermeiden. + Zu einer Liste hinzufÃŒgen (Optional) + Du kannst die ausgewÀhlten Wörter direkt zu einer deiner bestehenden Vokabellisten hinzufÃŒgen, indem du eine aus dem Dropdown-MenÃŒ unten auswÀhlst. + 1 Tag + 3 Tage + 1 Woche + 2 Wochen + 1 Monat + Das richtige KI-Modell finden + Wie der Scan funktioniert + Wenn du auf „Nach Modellen suchen“ tippst, fragt die App deinen ausgewÀhlten API-Anbieter nach einer Liste verfÃŒgbarer Modelle. Der Anbieter antwortet mit den Modellen, die fÃŒr dich sichtbar sind. + Die Ergebnisse hÀngen von deinem Konto, deiner Organisation und der Anbieterkonfiguration ab. + Einige Anbieter geben nur öffentliche Modelle zurÃŒck; private oder Unternehmensmodelle erfordern möglicherweise zusÀtzliche Berechtigungen. + Wenn du kÃŒrzlich Berechtigungen oder Kontingente geÀndert hast, versuche es nach einer kurzen Verzögerung erneut. + Warum einige Modelle möglicherweise nicht angezeigt werden + EingeschrÀnkt oder nicht fÃŒr dein Konto/deine Organisation zulÀssig + Nicht fÃŒr diese Aufgabe geeignet (z. B. nur Bild, nur Audio oder nur Embeddings) + Es werden nur textfÀhige Modelle mit TextvervollstÀndigung/Chat angezeigt + Die App konzentriert sich auf Modelle, die Text lesen und schreiben können. FÃŒr Übersetzung, Wörterbuch- und Vokabelgenerierung muss das Modell Text-Prompts unterstÃŒtzen und TextvervollstÀndigungen zurÃŒckgeben (Chat/Completions-API). + Die meisten Aufgaben funktionieren hervorragend mit schnellen, kleinen Modellen (z. B. nano/mini/klein). FÃŒr die Erstellung ganzer Übungen kann ein größeres oder kostenpflichtiges Modell erforderlich sein. + Tipps & Fehlerbehebung + ÜberprÃŒfe, ob dein API-SchlÃŒssel gÃŒltig ist und die Berechtigung hat, auf die gewÃŒnschten Modelle zuzugreifen. + Einige Anbieter erfordern die Auswahl einer Organisation/eines Projekts. Stelle sicher, dass dies korrekt konfiguriert ist. + Wenn ein Modell, von dem du weißt, dass es existiert, nicht angezeigt wird, versuche, es manuell ÃŒber die Modell-ID aus den Dokumenten des Anbieters einzugeben. + Suche nach Modellen, die als „Instruct“, „Chat“ oder „Text“ gekennzeichnet sind. Diese passen normalerweise am besten. + Wie die Übersetzung funktioniert + Alternative Übersetzungen + Tippe auf ein Wort in der Übersetzung, um alternative Bedeutungen zu sehen und die passendste auszuwÀhlen. + Eigene Übersetzungs-Prompts + Passe in den Einstellungen an, wie Übersetzungen mit KI-Prompts erstellt werden. WÀhle aus Beispiel-Prompts oder erstelle deine eigenen. + Mehrere Übersetzungsdienste + Wechsle zwischen KI-gestÃŒtzter Übersetzung oder einem Übersetzungsdienst fÃŒr verschiedene Optionen im Übersetzungsstil. + Übersetzungsverlauf + Greife auf deinen Übersetzungsverlauf zu, um frÃŒhere Übersetzungen wiederzuverwenden. + Text-zu-Sprache + Höre dir Übersetzungen mit Text-zu-Sprache-UnterstÃŒtzung an. Konfiguriere Stimmen fÃŒr verschiedene Sprachen in den Einstellungen. + Schnellaktionen + Kopiere Übersetzungen in die Zwischenablage, teile sie oder fÃŒge Wörter mit einem Tippen zu deinem Vokabular hinzu. + Auswahl des KI-Modells + WÀhle aus verschiedenen KI-Modellen fÃŒr ÜbersetzungsqualitÀt und -geschwindigkeit. + Zuerst wÀhlst du das beste KI-Modell fÃŒr deine BedÃŒrfnisse aus und schreibst optional eine benutzerdefinierte Aufforderung, um zu steuern, wie es Wörterbuchinhalte generiert. + + diff --git a/app/src/main/res/values-de-rDE/intro_strings.xml b/app/src/main/res/values-de-rDE/intro_strings.xml new file mode 100644 index 0000000..c10c3b7 --- /dev/null +++ b/app/src/main/res/values-de-rDE/intro_strings.xml @@ -0,0 +1,27 @@ + + + Intro ÃŒberspringen + Willkommen! + Dein persönlicher Begleiter, um neue Sprachen zu meistern! + Dein KI‑Sprachassistent + Nutze die Power eines KI‑Modells deiner Wahl, um Texte in jeder Sprache zu ÃŒbersetzen, zu definieren und zu verfeinern. Kompatibel mit den meisten Anbietern und sogar lokal gehosteten Sprachmodellen. + Leistungsstarkes Wörterbuch & Übersetzer + Schlage beliebige Wörter nach – mit Definitionen, Synonymen, BeispielsÀtzen und sogar der Herkunft ÃŒber die Etymologie‑Funktion. + Sofort‑Karteikarten, unbegrenzte Themen + Lass die KI Vokabel‑Karteikarten zu jedem erdenklichen Thema erzeugen! + Übung macht den Meister + Übe deinen Wortschatz mit abwechslungsreichen, interaktiven Aufgaben. Lernen war noch nie so spannend! + Deine Lernreise + Die App nutzt Spaced Repetition. Sie zeigt dir Vokabeln kurz bevor du sie zu vergessen drohst – so lernst du effizienter. + Organisiere deinen Wortschatz + Kategorien helfen dir, deinen Wortschatz sinnvoll zu gruppieren. Du kannst Filter oder Listen verwenden. + Verfolge deinen Fortschritt + Bleib motiviert mit tÀglichen Serien und detaillierten Wochenstatistiken. + Dies ist eine Beta‑Version + Da es sich um eine Beta handelt, funktionieren manche Funktionen eventuell noch nicht wie erwartet. Dein Feedback hilft mir sehr, die App zu verbessern. + Alles bereit! + Wenn du einen gÃŒltigen API‑SchlÃŒssel hast, verbinde dich mit deinem LLM und los geht’s! + Geschichte + Wissenschaft + Kochen + diff --git a/app/src/main/res/values-de-rDE/language_levels.xml b/app/src/main/res/values-de-rDE/language_levels.xml new file mode 100644 index 0000000..d356ffc --- /dev/null +++ b/app/src/main/res/values-de-rDE/language_levels.xml @@ -0,0 +1,85 @@ + + Neugeborenes + Du hast dein erstes Wort gesagt. Oder vielleicht war es nur ein RÃŒlpser. Wir zÀhlen es trotzdem. Die Reise von tausend Wörtern beginnt mit einem einzigen
 GerÀusch. + + Plapperndes Echo + Du beherrschst die Kunst, den letzten gehörten Laut zu wiederholen. Ist hier ein Echo? 
hier? + + GoldfischgedÀchtnis + Deine Aufmerksamkeitsspanne entspricht der eines Goldfisches. GlÃŒckwunsch? (Nur ein Scherz – die sind schlauer, als man denkt.) + + Schlaue Taube + Du könntest jetzt eine Taube in einer Debatte schlagen. Sie verstehen abstrakte Konzepte
 aber können immer noch nicht „Spatzenhirn“ buchstabieren. + + Koshik der Elefant + Du kennst jetzt so viele Wörter wie ein 6-Tonnen-Elefant. Beeindruckend, oder? Versuch nur nicht, durch Ohrenflattern zu kommunizieren. + + KlatschsÃŒchtige KrÀhe + Du kannst dir Gesichter merken und nachtragend sein. Im Grunde bist du bereit fÃŒr das Drama beim Klassentreffen. + + Honigbienen-Kartograf + Du dirigierst den Verkehr wie eine schwÀnzeltanzende Biene. „FÃŒr Nektar links am Eichenbaum abbiegen.“ GPS wer? + + Plaudernder Sperlingspapagei + Polly will einen Thesaurus! Du wiederholst Wörter wie ein kleiner Papagei. Niedlichkeits-Overload. + + Neugieriges Kleinkind + Du benennst Dinge zu 70 % falsch, aber mit 100 % Selbstvertrauen. „Wauwau!“ „Das ist ein Toaster.“ + + Rico der Hund + Du hast das „Border-Collie-Genie“-Level erreicht. Und jetzt hol das Wörterbuch! + + Auktionator in Ausbildung + Du sprichst schneller, als irgendjemand verstehen kann. Verkauft! An die Person, die gerade genickt hat. + + Alex der Papagei + Du krÀchzt nicht nur – du diskutierst ÃŒber Farben und Formen. Jemand sollte diesem Vogel einen Doktortitel geben. + + Pilita der Seelöwe + Du verstehst Symbole! NÀchstes Ziel: Einen Ball balancieren, wÀhrend du Shakespeare rezitierst. + + Kanzi der Bonobo + Du benutzt Lexigramme wie ein Primaten-Gelehrter. Zeit, deine Memoiren in Emojis zu schreiben. + + Koko der Gorilla + Du beherrschst 500 Wörter in GebÀrdensprache! Jetzt streite ÃŒber die Schlafenszeit wie ein SilberrÃŒcken-Kleinkind. + + Shakespearescher Beleidigungsgenerator + „Du bist eine ÃŒbelriechende, beulengehirnte KrebsblÃŒte! Du weißt nicht, was es bedeutet, aber es klingt beeindruckend.“ + + ErstklÀssler + Du kannst „Der Kater mit dem Hut“ lesen und passiv-aggressive Notizen fÃŒr den KÃŒhlschrank schreiben. + + Anwaltsadler im Praktikum + Du fÀngst an, „bis dato“ und „vorgenannt“ in alltÀglichen GesprÀchen zu verwenden. Deine Freunde machen sich Sorgen. + + Chaser der Superhund + Du kennst mehr Substantive als ein Kleinkind. Aus „Hol“ wurde gerade „Apportiere das orthografische Lexikon.“ + + BÃŒcherwurm + Du benutzt „ephemer“ korrekt. Deine Freunde nicken, wÀhrend sie heimlich googeln. + + MittelstufenschÃŒler + Fließend freigeschaltet: Sarkasmus, Augenrollen und „Mir ist langweilig“ in 5 Sprachen. + + Eifriger Debattierer + Du gewinnst Diskussionen, indem du deine Gegner in prÀziser Terminologie ertrÀnkst. Nervig? Nein. „Lexikalisch begabt.“ + + Abiturient + Du bestellst Kaffee wie ein Dichter und schreibst SMS wie ein Diplomat. + + Der Journalist + Du „vermeidest Verschleierung“ unironisch. Kollegen fÃŒrchten deinen Rotstift. + + Der Professor + Du benutzt beilÀufig „Antidisestablishmentarianismus“. Studenten weinen in deinen Vorlesungen. + + Der Romanautor + Deine SÀtze haben NebensÀtze, die ebenfalls Fußnoten benötigen. + + Meisterlinguist + WörterbÃŒcher zitieren dich. Duden.de ist deine Fanseite. + + Das Polyglotte Orakel + Du trÀumst in ausgestorbenen Dialekten. BÀume fragen dich um etymologischen Rat. + \ No newline at end of file diff --git a/app/src/main/res/values-de-rDE/languages.xml b/app/src/main/res/values-de-rDE/languages.xml new file mode 100644 index 0000000..1fd7ba6 --- /dev/null +++ b/app/src/main/res/values-de-rDE/languages.xml @@ -0,0 +1,110 @@ + + + + en,US,1 + zh,CN,2 + es,ES,3 + hi,IN,4 + ar,SA,5 + bn,BD,6 + pt,BR,7 + ru,RU,8 + pa,IN,9 + mr,IN,10 + te,IN,11 + tr,TR,12 + ko,KR,13 + fr,FR,14 + de,DE,15 + vi,VN,16 + ta,IN,17 + ur,PK,18 + id,ID,19 + it,IT,20 + ja,JP,21 + fa,IR,22 + bho,IN,23 + pl,PL,24 + ps,AF,25 + jv,ID,26 + mai,IN,27 + ml,IN,28 + su,ID,29 + ha,NG,30 + or,IN,31 + my,MM,32 + uk,UA,33 + yo,NG,34 + uz,UZ,35 + sd,PK,36 + am,ET,37 + ff,SN,38 + ro,RO,39 + ig,NG,40 + ceb,PH,41 + gu,IN,42 + kr,NG,43 + ms,MY,44 + kn,IN,45 + nl,NL,46 + hu,HU,47 + el,GR,48 + cz,CZ,49 + he,IL,50 + hr,HR,51 + + + Englisch + Mandarin + Spanisch + Hindi + Arabisch + Bengali + Portugiesisch + Russisch + Punjabi + Marathi + Telugu + TÃŒrkisch + Koreanisch + Französisch + Deutsch + Vietnamesisch + Tamil + Urdu + Indonesisch + Italienisch + Japanisch + Persisch + Bhojpuri + Polnisch + Paschtu + Javanisch + Maithili + Malayalam + Sundanesisch + Hausa + Odia + Burmesisch + Ukrainisch + Yoruba + Usbekisch + Sindhi + Amharisch + Fula + RumÀnisch + Igbo + Cebuano + Gujarati + Kanuri + Malaiisch + Kannada + NiederlÀndisch + Ungarisch + Griechisch + Tschechisch + HebrÀisch + Kroatisch + + + \ No newline at end of file diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml new file mode 100644 index 0000000..ae5de26 --- /dev/null +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -0,0 +1,877 @@ + + + + Speichern + Definitionen + Löschen + Start + Übersetzen + HinzufÃŒgen + Schließen + Abbrechen + BestÀtigen + AuswÀhlen + Fertig + EintrÀge löschen + Weiter + ZurÃŒck + Erfolg + Fehler + App-Logo + MenÃŒ umschalten + ZurÃŒck navigieren + Einklappen + Ausklappen + Text-zu-Sprache + Suche + Text löschen + Definition neu erstellen + Suche löschen + Übersetzungsverlauf + App beenden? + Neu laden + Einzeln + Streak + Alle Vokabeln + Kategoriefortschritt + Heute fÀllig + Erfolgsmeldung anzeigen + Kategorie hinzufÃŒgen + Einstellungen + Dashboard + Entwickleroptionen + Mehrere + Übersetzung + Auf Standard zurÃŒcksetzen + Excel wird nicht unterstÃŒtzt. Bitte CSV verwenden. + Fehler beim Parsen der Tabelle + Fehler beim Parsen der Tabelle: %1$s + Tabelle importieren (CSV) + Erste Spalte + Zweite Spalte + Spalte %1$d + Sprachen + Erste Sprache + Zweite Sprache + Erste Zeile ist eine Kopfzeile + Verwaiste Dateien + Vorschau (erste 5) fÃŒr Spalte 1: %1$s + Vorschau (erste 5) fÃŒr Spalte 2: %1$s + Zu importierende Zeilen: %1$d + Bitte zwei Spalten auswÀhlen. + Bitte zwei Sprachen auswÀhlen. + Keine Zeilen zum Importieren. Bitte Spalten und Kopfzeile prÃŒfen. + %1$d Vokabeln importiert. + Importieren + Vokabular mit KI erstellen + YouTube-Übung erstellen + YouTube-Link + Passe die Intervalle und Kriterien fÃŒr das Verschieben von Vokabeln an. Karten in niedrigeren Stufen werden öfter abgefragt. + Gib deinen benutzerdefinierten Prompt ein + Entwickelt von Jonas Gaudian + Besuche meine Webseite + Entwickler kontaktieren + Kontaktiere mich fÃŒr Fehlerberichte, Ideen, FunktionswÃŒnsche und mehr. + Neu + Stufe 1 + Stufe 2 + Stufe 3 + Stufe 4 + Stufe 5 + Gelernt + Heute fÀllig + Unbekannte Sprache + Übersetzungen zum HinzufÃŒgen auswÀhlen + Kartenreihenfolge mischen + Sprachen mischen + Trainingsmodus + Anzahl der Karten + Intervall-Einstellungen (in Tagen) + Gib ein Wort ein + Gib die Übersetzung ein + Nur heute fÀllige + Zum Neuanordnen ziehen + Widget einklappen + Widget ausklappen + Video nochmal schauen + Wöchentliche AktivitÀt + Laden
 + Laden anzeigen + Laden abbrechen + Dies ist eine Info-Nachricht. + Info-Nachricht anzeigen + Erfolg! + Hoppla! Etwas ist schiefgegangen. + Fehlermeldung anzeigen + Intro zurÃŒcksetzen + Versionsinformation nicht verfÃŒgbar. + Version: v%1$s-%2$s + Liste + Filter + Name der Kategorie + Keine WörterbÃŒcher verfÃŒgbar + Keine Wörterbuch-Sprachpaare gefunden. FÃŒge zuerst Vokabeln mit verschiedenen Sprachen hinzu. + VerfÃŒgbar zum Erstellen: + AusgewÀhlt + Eigene Sprache hinzufÃŒgen + Sprachcode + Z.B. de + Region + Z.B. AT + Name der Sprache + Z.B. Deutsch + Übersetzungen fehlgeschlagen: %1$s + Keine Vokabeln verfÃŒgbar. + API-SchlÃŒssel unter %1$s erhalten + SchlÃŒssel aktiv + Kein SchlÃŒssel + SchlÃŒssel optional + VerfÃŒgbare Modelle: + SchlÃŒssel Àndern + API-SchlÃŒssel eingeben + SchlÃŒssel speichern + Modell auswÀhlen + Beispiel-Prompts + Vorschau-Titel + Keine + Manuelle Vokabelliste + Filter: Alle EintrÀge + Stufe %1$s + Alle Sprachen + Alle Stufen + Mehr Optionen + Kategorie bearbeiten + Kategorie exportieren + Kategorie löschen + Kategorie auswÀhlen + %1$d Kategorien ausgewÀhlt + Kategorien auswÀhlen + In Stufen + Neu + Keine EintrÀge verfÃŒgbar + Alle EintrÀge abgeschlossen! + Text zur Korrektur eingeben + In die Zwischenablage kopiert + Korrigierten Text kopieren + Korrekt + ErklÀrung + Hier kannst du einen benutzerdefinierten Prompt fÃŒr das KI-Übersetzungsmodell festlegen, um den Übersetzungsstil anzupassen. + Vokabular-Prompt + Hier kannst du einen benutzerdefinierten Prompt festlegen, um zu definieren, wie neue Vokabeln erstellt werden. + WÀhle die Inhalte aus, die fÃŒr einen Wörterbucheintrag erstellt werden sollen. + + Benutzerdefinierter Wörterbuch-Prompt + Prompt speichern + Hell + Dunkel + System + Möchtest du diese Kategorie wirklich löschen? + Möchtest du diese %d Kategorien wirklich löschen? + Möchtest du wirklich alle EintrÀge in dieser Kategorie löschen? + Suchen
 + Wort des Tages + Suchverlauf + Korrektor + HinzugefÃŒgt + Abgeschlossen + Kontaktiere KI
 + Übung „%1$s“ erstellt! + KI-Generierung fehlgeschlagen (Ausnahme) + KI konnte die Übung nicht erstellen. + Ein unbekannter Fehler ist aufgetreten. + ID-Konflikt zwischen Fragen in Übung und Repository. + Das ist nicht ganz richtig. + Die richtige Antwort ist: + Die richtige Reihenfolge ist: %1$s + ÜberprÃŒfe deine Zuordnungen! + Der richtige Satz war: %1$s + Einige EintrÀge sind in der falschen Kategorie. + Die richtige Übersetzung ist: %1$s + Neue Vokabeln fÃŒr diese Übung + Übung starten + Download + Einfach + Mittel + Schwer + Übung mit KI erstellen + Kategorie / Prompt + z.B. UnregelmÀßige Verben + Fragetypen + Frage + Schwierigkeit: %1$s + Anzahl: %1$d Fragen + Erstellen + Lass die KI Vokabeln fÃŒr dich finden! + Suchbegriff + Tipp + Hinweis: Du kannst nach jedem Begriff suchen, z.B. „Was man im Zoo machen kann“ oder „unregelmÀßige Verben“! + Sprachen auswÀhlen + Anzahl auswÀhlen + Anzahl: %1$d + Text zum Übersetzen eingeben + EinfÃŒgen + Auto + %1$d Sprachen ausgewÀhlt + Aus Favoriten entfernen + Zu Favoriten hinzufÃŒgen + Favoriten + Verlauf + Automatische Erkennung auswÀhlen + Keine auswÀhlen + Sprachoptionen + Alle Sprachen auswÀhlen + Eigene Sprache löschen + Sprachen tauschen + Erscheinungsbild + Anzeigemodus + Farbpalette + Schriftart + %1$s ausgewÀhlt + Tage-Streak + Letzte 7 Tage + Insgesamt gelernte Wörter + Mehr Statistiken + Ziel erreicht + Heute keine Vokabeln fÀllig + Alle ansehen + Eigene Übung + TÀgliche Übung + Wörter gesamt + Gelernt + Übrig + + Beispiele + Vokabular-Einstellungen + Lernkriterien + Min. richtig zum Aufsteigen + Max. falsch zum Absteigen + TÀgliches Lernziel + Ziel: Richtige Antworten pro Tag + Sicherung & Wiederherstellung + Vokabeldaten exportieren + Vokabeldaten importieren + Gefahrenzone + Alle Daten löschen + Übungseinstellungen + Beschreibung der Übungseinstellungen + Eigener Übungs-Prompt + NÀchste + Los geht\'s + HTTP-Statuscodes + Vokabel-Repository + 200 OK + Die Anfrage war erfolgreich. + Der Server konnte die Anfrage nicht verstehen. + 400 Bad Request + Authentifizierung erforderlich und fehlgeschlagen oder nicht vorhanden. + 401 Unauthorized + 403 Forbidden + Der Server hat die Anfrage verstanden, verweigert aber die Autorisierung. + 404 Not Found + Die angeforderte Ressource wurde nicht gefunden. + 429 Too Many Requests + Zu viele Anfragen: Der Benutzer hat zu viele Anfragen in kurzer Zeit gesendet. + 500 Internal Server Error + Auf dem Server ist ein unerwarteter Fehler aufgetreten. + Tippe die Übersetzung + Karte umdrehen + NÀchste Karte + Falsch + Falsch + Richtig + %1$d Fragen + Lange starten + Übung löschen + Übung erstellen + Übung schließen + Richtige Antworten + Falsche Antworten + %1$d Wörter gekonnt + Deine Sprachreise + Aktuelles Level + NÀchstes: %1$s + %1$d Wörter + %1$d Wörter + Du hast das letzte Level gemeistert! + Erreicht + %1$d Wörter erforderlich + Text kopiert + Heute fÀllig: %1$s + Stufe auswÀhlen + Dieser Modus beeinflusst deinen Fortschritt nicht. + Trainingsmodus (keine Statistik) + Übung vorbereiten + Übung starten (%1$d) + Anzahl der Karten: %1$d / %2$d + Keine Karten fÃŒr die gewÀhlten Filter gefunden. + Übungstypen wÀhlen + Optionen + Karten mischen + Beenden + Kategorien + Sortieren + Nach Name sortieren + Nach Größe sortieren + Nach Fortschritt % sortieren + Nach neuen EintrÀgen sortieren + Nach „in Arbeit“ sortieren + Vokabel hinzugefÃŒgt + Übersetzungen finden + EintrÀge in Kategorie löschen + Übersetzer + Vokabular + Fortschritt + Eigener Prompt + Repository + Übungen + Übungs-Prompt + Allgemein + Über + Allgemeine Einstellungen + %1$d EintrÀge + Beispiel + Synonyme + Bearbeiten + Definition + Statistiken + In Kategorie verschieben + In Stufe verschieben + Details zum Eintrag + Möchtest du wirklich beenden? + Ja + Nein + Stufe: %1$s + Zuletzt richtig: %1$s + Zuletzt falsch: %1$s + Richtige Antworten: %1$d + Falsche Antworten: %1$d + Karte (%1$d/%2$d) + Eintrags-ID: %1$d + Statistiken werden geladen
 + nach %1$s + Übersetze von %1$s + Bilde das Wort hier + Richtige Antwort: %1$s + Übung beenden? + Möchtest du wirklich beenden? Dein Fortschritt in dieser Sitzung geht verloren. + Ergebnis + Übung abgeschlossen! + So hast du abgeschnitten: + Nochmal versuchen + Abschließen + Vokabel-AktivitÀt + Weniger + Mehr + Aktueller Streak + Abgeschlossene Wörter + In Arbeit + %1$d Tage + Fortschritt nach Kategorie + Filter anwenden + Nach Stufe filtern + Kategorie + Sprache + Alle löschen + Aus Kategorie entfernen + Mehr Aktionen + Alle auswÀhlen + Auswahl aufheben + Vokabular suchen
 + Keine Vokabeln gefunden. Vielleicht die Filter Àndern? + Kategorie: %1$s + Repository-Status importiert von %1$s + Gefundene EintrÀge + HinzufÃŒgen (%1$d) + EintrÀge zum HinzufÃŒgen auswÀhlen + Liste auswÀhlen (optional) + %1$d Stufen ausgewÀhlt + Kein Modell fÃŒr die Aufgabe ausgewÀhlt: %1$s + Neue Kategorie erstellen + Keine neuen Vokabeln zum Sortieren + Erstellen + Status + Neue EintrÀge + Duplikate + „Es gibt keine Probleme mit deinem Vokabular, alles gut!“ + Neue Vokabeln sortieren + Eigenen Anbieter hinzufÃŒgen + Anbieter löschen + Keine Modelle konfiguriert + Eigenes Modell hinzufÃŒgen + Modelle scannen + Scannen
 + Keine Modelle gefunden + Modell hinzufÃŒgen + Modell löschen + System-Theme + System-Standard-Schriftart + Grammatik analysieren + Kontextbezogene Hinweise anzeigen + Info-Buttons auf dem Bildschirm fÃŒr Hilfe anzeigen. + Verstanden! + Hinweis anzeigen + Experimentelle Funktionen + Aktiviere experimentelle Funktionen, die noch nicht fÃŒr die Produktion bereit sind. + Nur Duplikate + Aktiviert + Neueste zuerst + Älteste zuerst + Nach Sprache + Lernstufen + Rechtliche Hinweise + Fehlerhafte EintrÀge + Herkunft + Herkunft von „%1$s“ + Verwandte Wörter + Wort des Tages aktualisieren + Suche nach der Herkunft eines Wortes + Synonym existiert + Synonym hinzufÃŒgen + Übersetzung + Etimologia + Übung + Wörterbuch + Open-Source-Lizenzen + Lizenzen umschalten + EintrÀge ohne Grammatik + Grammatikdetails abrufen + Keine EintrÀge ohne Grammatik + Alle Grammatikinfos abrufen + Anzahl der abzurufenden EintrÀge auswÀhlen + Abrufen fÃŒr %d EintrÀge + Abruf starten + Nach Wortart filtern + Alle Typen + Filtern und Sortieren + Sprache mit ID %1$d nicht gefunden + EintrÀge ohne Grammatikinfos + Fehlende Sprach-ID auflösen: %1$d + %1$d EintrÀge mit dieser fehlenden Sprach-ID gefunden. + Betroffene EintrÀge ausblenden + Betroffene EintrÀge anzeigen + Lösung 1: EintrÀge löschen + Alle %1$d betroffenen Vokabeln endgÃŒltig löschen. + %1$d EintrÀge löschen + Lösung 2: ID ersetzen + Diesen EintrÀgen eine andere Sprache zuweisen. + Ersetzen durch %1$s + Neue Sprache erstellen + Lösung 3: Sprache erstellen + Einen neuen benutzerdefinierten Spracheintrag fÃŒr diese ID erstellen. + Neuen löschen + Beide behalten + EintrÀge zusammenfÃŒhren + ZusammenfÃŒhren + Nur neue EintrÀge + Nur fehlerhafte EintrÀge + Wort + Duplikat erkannt + Ein Àhnlicher Eintrag existiert bereits. Wie möchtest du fortfahren? + Neuer Eintrag + Bestehender Eintrag (ID: %1$d) + Einstellungen fÃŒr Übersetzungs-Prompt + Audio abspielen + Text kopieren + Text teilen + Zum Wörterbuch hinzufÃŒgen + Dies ist ein Beispiel-Ausgabetext. + Deine Antwort + Tippe auf die Wörter unten, um den Satz zu bilden + Anhören + Tippe, was du hörst + Ordne diese Elemente zu: + Übersetze Folgendes (%1$s): + Deine Übersetzung + Dies ist ein Hinweis. + Dies ist der Hauptinhalt. + Dies ist der Inhalt in der Karte. + PrimÀrer Button + PrimÀr mit Icon + SekundÀrer Button + SekundÀr mit Icon + SekundÀr invers + E-Mail-Adresse + %1$s: Franz jagt im komplett verwahrlosten Taxi quer durch Bayern. + + Beschreibung + Modell-ID (z.B. mistralai/mistral-nemo-instruct-2407) + Anzeigename + Webseiten-URL + Endpunkt (z.B. /v1/chat/completions/) + Basis-URL (z.B. \'http://192.168.0.99:1234/\') + Auswahlmodus schließen + %1$d ausgewÀhlt + Suchanfrage + Suche schließen + Verwandte Vokabeln generieren + Verwerfen + Merkmale fÃŒr \'%1$s\' bearbeiten + Keine Grammatikkonfiguration fÃŒr diese Sprache gefunden. + Wortart + Level + Schnelle Wortpaare + Stufenfilter + Sprachpaar + Sprachfilter + Logs + Löschen + Noch keine Logs verfÃŒgbar. + Anfrage-JSON + Antwort-JSON + Nicht verfÃŒgbar + Teilen + Log per E-Mail senden + Zeit + N.A. + Modell + Dauer + ms + Ausnahme + ZeitÃŒberschreitung + Parse-Fehler + Translator API Log %1$s + Id: %1$s + Zeitstempel: %1$s + Anbieter: %1$s + Endpunkt: %1$s %2$s + Modell: %1$s + Status: %1$s %2$s + Dauer: %1$s %2$s + Ausnahme: %1$s + ZeitÃŒberschreitung: Ja + Parse-Fehler: %1$s + Fehler: %1$s + --- Anfrage --- + --- Antwort --- + Nur Fehler anzeigen + Nur kostenlose Modelle anzeigen + Modelle suchen + Ein Modell auswÀhlen + Kontext + Anzeigen + Verbergen + Ton + Formell + Zwanglos + Umgangssprachlich + Höflich + Professionell + Freundlich + Akademisch + Kreativ + Text bearbeiten: %1$s + Kein Text empfangen! + Fehler: Kein Text zum Bearbeiten + Nicht mit zu bearbeitendem Text gestartet + Eine einfache Liste, um deine Vokabeln manuell zu sortieren + Verbinde dein KI-Modell + Stimme + Standard + Sprechgeschwindigkeit + Testen + Hinweis: %1$s + Alles löschen + Verbindung + Name auf Englisch + Artikel entfernen + Warnung + SchlÃŒssel hinzufÃŒgen + Vorheriger Monat + NÀchster Monat + Meldung "API-SchlÃŒssel fehlt" anzeigen + Hier sortierst du deine neuen Vokabeln. Du kannst Rechtschreibung und Übersetzungen korrigieren und EintrÀge Kategorien zuweisen. + Die App hilft dir auch, Duplikate zu erkennen oder Artikel fÃŒr sauberere Vokabellisten zu entfernen. + Duplikat + Wenn du fertig bist, entscheide, was mit dem Eintrag geschehen soll: + In die erste Stufe verschieben + Vokabeln sortieren + " (optional)" + VerfÃŒgbarkeit prÃŒfen + Keine gÃŒltige API-Konfiguration gefunden. Bitte konfiguriere zuerst einen API-Anbieter in den Einstellungen. + Tag-Kategorie + Hier kannst du die Anweisungen zum Erstellen neuer Vokabeln anpassen, z.B. ob Definitionen oder BeispielsÀtze enthalten sein sollen. + Zuerst Wiktionary versuchen + Versuche zuerst, das Wort auf Wiktionary zu finden, bevor eine KI-Antwort generiert wird. + Frage %1$d von %2$d + Wahr + Falsch + PrÃŒfen + Richtig! + Falsch! + Und viele mehr! 
 + Deine eigene KI + Mistral + OpenAI + Claude + Gemini + DeepSeek + OpenRouter + Brauchst du Hilfe? + Wenn du Hilfe brauchst, findest du Hinweise in allen Bereichen der App. + Navigationsleisten-Beschriftungen + Textbeschriftungen in der Hauptnavigationsleiste anzeigen. + So funktioniert\'s + Antworte richtig + Das Wort steigt eine Stufe auf und du siehst es nach einer lÀngeren Pause wieder. + Antworte falsch + Das Wort steigt eine Stufe ab. So konzentrierst du dich auf schwierige Vokabeln. + Anpassbar + Du kannst alle Intervalle und Regeln in den Einstellungen anpassen. + Wortpaar-Einstellungen + Anzahl der Fragen: %1$d + Fragen mischen + Trainingsmodus + Bilde die Paare + Wortpaar-Übung + Trainingsmodus ist aktiv: Antworten beeinflussen den Fortschritt nicht. + Übung starten + "Verwende diesen Bildschirm, um eine benutzerdefinierte Anweisung fÃŒr das KI-Übersetzungsmodell zu definieren. Du kannst den Ton, den Stil oder das Format der Übersetzung festlegen." + " Tage" + Vokabel hinzufÃŒgen + Vokabular mit KI erstellen + Keine Vokabeln gefunden. Jetzt hinzufÃŒgen? + Dadurch werden alle konfigurierten API-Anbieter, Modelle und gespeicherten API-SchlÃŒssel entfernt. Diese Aktion kann nicht rÃŒckgÀngig gemacht werden. + Alle Anbieter und Modelle löschen? + Seiten tauschen + Kein Fortschritt + Theme-Vorschau + Beispielwort + Übersetungs-Server verwenden + Wenn aktiviert, verwenden Übersetzungen einen Übersetzungsserver fÃŒr unterstÃŒtzte Sprachpaare. Nicht unterstÃŒtzte Paare greifen automatisch auf dein KI-Modell zurÃŒck. + Was ist neu + Änderungsprotokoll + Sehr selten + Selten + Ungewöhnlich + HÀufig + Sehr hÀufig + Äußerst hÀufig + KI-Modell + Übersetzungsserver + Text + Gib einen Text ein, um Wörter zu extrahieren und zu ÃŒbersetzen + Gib einen Text ein + Falsche wiederholen + Von vorne beginnen + Wörterbuch-Optionen + So funktioniert das Wörterbuch: + FÃŒge einen YouTube-Link ein oder öffne ihn, um hier die Untertitel zu sehen. + Fehler: %1$s + Falsche Antworten wiederholen + Keine + Keine Daten verfÃŒgbar + Flexionen + Weniger + Übersetzungen + Beispiele anzeigen + Silbentrennung + Bedeutungen + Substantiv + Zukunft + Korrigieren + Wie viele Wörter möchtest du jeden Tag richtig beantworten? + Beispiel-Tipp Scanne nach Modellen Tipp + Wie man sich mit einer KI verbindet + Wie man Vokabeln mit KI generiert + Wörterbuch-Manager + Größe: %1$d MB + Aktualisieren + Version: %1$s + Erste Sprache-ID: %1$d + Zweite Sprache-ID: %1$d + NÀchstes Element + Vorheriges Element + Alle WörterbÃŒcher erfolgreich gelöscht + VerfÃŒgbare WörterbÃŒcher + Konnte kein neues Wort abrufen. + Wörterbuch erfolgreich gelöscht + Wörterbuch erfolgreich heruntergeladen + Willst du die App minimieren? + Fehler beim Löschen der WörterbÃŒcher: %1$s + Fehler beim Löschen des Wörterbuchs: %1$s + Fehler beim Löschen der verwaisten Datei: %1$s + Fehler beim Herunterladen des Wörterbuchs: %1$s + Fehler beim Laden der gespeicherten Werte: %1$s + Fehler beim Speichern des Eintrags: %1$s + Wörterbuch konnte nicht gelöscht werden: %1$s + Fehler beim Löschen der verwaisten Datei: %1$s + Einige WörterbÃŒcher konnten nicht gelöscht werden + Fehler beim Herunterladen des Wörterbuchs: %1$s + Etymologie konnte nicht abgerufen werden + Fehler beim Abrufen des Manifests: %1$s + Video beenden und Übung starten + Verwaistes File erfolgreich gelöscht + WÀhle zuerst eine Wörterbuch-Sprache aus. + Diese Dateien existieren lokal, sind aber nicht im Server-Manifest enthalten. Sie könnten von Àlteren Versionen stammen. + Heruntergeladenes Wörterbuch verwenden + Unbekanntes Wörterbuch (%1$s) + Du kannst WörterbÃŒcher fÃŒr bestimmte Sprachen herunterladen, die du statt der KI-Erzeugung fÃŒr den Wörterbuchinhalt verwenden kannst. + Grammatikdetails hinzufÃŒgen + Vokabeln-Eintrag löschen? + Bist du sicher, dass du diesen Vokabeln-Eintrag löschen willst? + Ursprungssprache + Zielsprache + Sprachenrichtung + + Du kannst eine optionale Einstellung vornehmen, welche Sprache zuerst oder zweit kommen soll. + Vermutung + Rechtschreibung + Multiple Choice + Wortwirrwarr + Nur Karten anzeigen, die heute fÀllig sind. + Kartenmischung + Mische die Reihenfolge der Sprachen. Beeinflusst nicht deine Sprachrichtungs-Einstellungen. + Konjugation: %1$s + Einklappen + Ausklappen + Verb + Adjektiv + Adverb + Pronomen + PrÀposition + Konjunktion + Vergangenheit + Stimmung + Indikativ + Konjunktiv + Imperativ + Developer JSON + Zeige Wörterbucheintrag + Zeige Weniger + Zeige %1$d Mehr + Rohdaten + Fehler beim Generieren von Fragen: %1$s + Generiere Fragen aus dem Video
 + Checksummenfehlabgleich fÃŒr %1$s. Erwartet: %2$s, Erhalten: %3$s + Download ist fehlgeschlagen: HTTP %1$d %2$s + Fehler beim Abrufen des Manifests: %1$s + (Hilfsverb: %1$s) + Beispiele ausblenden + *erforderlich + Suche nach verfÃŒgbaren Modellen + Entdecke automatisch Modelle von %1$s + Wird gescannt
 + Suche nach Modellen + Modell manuell hinzufÃŒgen + Gib die Modelldetails selbst ein + Anzeigename + z.B. GPT-4, Claude-3 + Erforderlich: Gib einen menschenlesbaren Namen ein + Model ID * + z. B. gpt-4, claude-3-sonnet + Pflichtfeld: Gib den genauen Modellbezeichner ein + Dieser muss genau mit dem Namen des Anbieters ÃŒbereinstimmen + Beschreibung + z. B. Schnell und effizient fÃŒr einfache Aufgaben + Optional: Beschreibe, wofÃŒr dieses Modell gut ist + Anbieter + Aufgaben + AI-Konfiguration + Anbieter löschen + + AbkÃŒrzung + Adjektiv + Adjektiv-Substantiv-Kompositum + Adjektivische Phrase + Adnomen + Adverb + Adverbiale Phrase + Affix + Ambiposition + Artikel + Zeichen + Umfix + Umposition + Klassifikator + Nebensatz + Kombinierende Form + Komponente + Konjunktion + Kontraktion + Konverb + ZÀhleinheit + Determinativ + Gerundium + Harter Redirect + Infix + Interfix + Interjektion + Interjektion + Name/Eigenname + Substantiv + Numerale/Zahl + Onomatopöie + Onomatopöie + Partizip + Partikel + Phrase + Postposition + PrÀfix + PrÀposition + PrÀpositionale Phrase + PrÀverb + Pronomen + Sprichwort + Interpunktion + Quantor + Romanisierung + Wurzel + Weicher Redirect + Stamm + Suffix + Silbe + Symbol + Typographische Variante + Unbekannt + Verb + Alle WörterbÃŒcher löschen? + Damit löschst du alle heruntergeladenen WörterbÃŒcher von deinem Handy. + Diese Datei existiert lokal, ist aber nicht im Servermanifest oder hat fehlende Assets. Sie könnte aus einer Àlteren Version stammen oder ein fehlgeschlagener Download sein. + Unbekannt + Ausruf + Artikel + Geschlecht + mÀnnlich + weiblich + sÀchlich + Modell löschen + Bist du sicher, dass du das Modell "%1$s" aus %2$s löschen willst? Diese Aktion kann nicht rÃŒckgÀngig gemacht werden. + Aufgabenmodell-Zuweisungen + Konfiguriere, welches KI-Modell fÃŒr jeden Aufgabentyp verwendet werden soll + Benutzerdefiniert + %1$d Modelle + Modelle suchen
 + %1$d Modelle + Keine Modelle gefunden + HÀufig + Plural + Zeitform + Gegenwart + Bist du sicher, dass du den Anbieter "%1$s" löschen möchtest? Dadurch werden auch alle Modelle entfernt, die mit diesem Anbieter verbunden sind. Diese Aktion kann nicht rÃŒckgÀngig gemacht werden. + Alternativen + Hier wird die Übersetzung erscheinen + SchlÃŒssel Löschen + Bist du sicher, dass du den Key fÃŒr diesen Provider löschen willst? + Lösche alle Provider und Modelle + Wörterbuchinhalt + KI-Definition + Heruntergeladen + Zeig mehr Aktionen + Wiktionary + Lizenziert unter + Inhalt stammt von + * erforderlich + Noch keine Geschichte + Abspielen + Aussprache + Die Zwischenablage ist leer + EinfÃŒgen + Target Tone: + Nur Grammatik + Deklination + Variationen + Auto Cycle (Dev) + Neu generieren + Vorlesen + + diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..2ac09f0 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/values-pt-rBR/app_values.xml b/app/src/main/res/values-pt-rBR/app_values.xml new file mode 100644 index 0000000..55344e5 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/app_values.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/arrays.xml b/app/src/main/res/values-pt-rBR/arrays.xml new file mode 100644 index 0000000..cbac903 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/arrays.xml @@ -0,0 +1,40 @@ + + + Exercise Example Prompts + + + + word_class_gender + declension_noun + pronunciation + definition + origin + synonyms + antonyms + examples + flexion_verb + idioms + grammatical_features_prepositions + + + + Classe da palavra e gênero (para substantivos) + Declinação (para substantivos) + Pronúncia (IPA) e separação de sílabas + Definição + Origem + SinÃŽnimos + AntÃŽnimos + Exemplos + Flexão (para verbos) + Expressões idiomáticas + Recursos gramaticais (para preposições) + + + + Versão 0.3.0 \n• Habilitada a importação de vocabulário via CSV\n• Opção para usar um servidor de tradução em vez de modelos de IA para alguns idiomas suportados\n• Correções de bugs na interface \n• Exibição da frequência de palavras \n• Otimizações de desempenho \n• Traduções melhoradas (Alemão e Português) + Versão 0.4.0 \n• Adicionado download de dicionário (beta) \n• Melhorias na interface \n• Correções de bugs \n• Cartão de vocabulário redesenhado com interface melhorada \n• Mais provedores pré-configurados \n• Desempenho melhorado + + + + diff --git a/app/src/main/res/values-pt-rBR/hint_strings.xml b/app/src/main/res/values-pt-rBR/hint_strings.xml new file mode 100644 index 0000000..195a1c2 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/hint_strings.xml @@ -0,0 +1,129 @@ + + + + Não consegue encontrar o seu modelo? + Você pode adicionar modelos manualmente. Insira o ID do modelo exato da documentação do seu provedor e um nome de exibição amigável. O aplicativo usará o ID para todas as chamadas de API. + nano + mini + pequeno + médio + grande / pago + Da busca à seleção – um guia visual rápido + 1 + 2 + 3 + Inicie a busca + Toque no botão de busca para obter os modelos disponíveis do seu provedor. + Filtre e escolha + Navegue pela lista. Prefira modelos marcados para texto/chat. Alguns provedores rotulam modelos gratuitos/pagos de forma diferente. + Texto/Chat + Validar + Use \"Adicionar e Validar\" para salvar o modelo e realizar uma verificação rápida com o seu provedor. + Adicionar e Validar + Tradução Consciente do Contexto + Obtenha traduções que entendem o contexto da sua conversa para resultados mais precisos. + Acompanhamento de Progresso do Vocabulário + Acompanhamento de Progresso + Acompanhe seu progresso de aprendizado com estatísticas detalhadas e indicadores visuais. + Estágios de Aprendizagem + As palavras passam por estágios à medida que você aprende, com intervalos crescentes entre as revisões. + Sistema de Revisão + O sistema de repetição espaçada garante que você revise as palavras em intervalos ideais para retenção a longo prazo. + Personalização + Personalize critérios de aprendizado, metas diárias e intervalos de revisão para combinar com seu estilo de aprendizado. + Ajuda e Instruções + Central de Ajuda + Todas as dicas que estão neste aplicativo também podem ser encontradas aqui. + Começando + Gerenciamento de Vocabulário + Recursos Avançados + Uma \'Lista\' é uma categoria simples onde você pode adicionar manualmente qualquer item de vocabulário que desejar. É como uma pasta personalizada para suas palavras. + Para usar todos os recursos, o aplicativo precisa se conectar a um serviço de Modelo de Linguagem Grande (LLM). Isso é feito através de um Provedor de API. + Você pode adicionar sua Chave de API a um provedor pré-configurado (como OpenAI ou Google) ou adicionar um provedor personalizado para se conectar a um serviço diferente, como um modelo local. Qualquer provedor deve ser compatível com o padrão da API da OpenAI. + Indicadores de Status da Chave + Cada cartão de provedor mostra o status da sua chave de API: + Isso significa que sua chave está salva e ativa. + Isso significa que a chave de API está faltando ou foi removida. + Solução de Problemas + Se você está tendo problemas, verifique o seguinte: + • Verifique se sua chave de API é válida e tem permissões.\n• Verifique sua conexão de rede.\n• Veja a aba de Logs de Rede para mensagens de erro detalhadas. + Você pode criar dois tipos de categorias para organizar seu vocabulário: + Categoria de Tag + Categoria de Filtro + O filtro pode corresponder itens por: nenhum filtro de idioma, uma lista de idiomas ou um par de dicionário. Você também pode opcionalmente filtrar por estágios de estudo. A lista de idiomas e o par de dicionário são mutuamente exclusivos. + Crie uma tag manual para agrupar as palavras que você escolher. + Categoria de Lista + Adicione manualmente qualquer palavra que desejar a esta categoria. É perfeito para criar listas de estudo personalizadas para um tópico ou capítulo específico. + Apple + Adicionar + Minha Lista de Frutas + Categoria de Filtro + Esta categoria agrupa palavras automaticamente com base em regras que você define, como seu estágio de aprendizado ou idioma. É uma forma dinâmica e automática de organizar. + Dog + Cat + Filtro \"Estágio 1\" + Passo 1: Configure a IA + Passo 2: Selecione o Conteúdo + Em seguida, use os seletores para escolher quais seções específicas (como sinÃŽnimos, antÃŽnimos, etc.) devem ser incluídas em uma consulta de dicionário. + ex., SinÃŽnimos + Exemplo de Seletor + Deixe a IA encontrar vocabulário para você. Veja como usar este recurso: + 1. Insira um termo de busca + Coisas para fazer no zoológico + 2. Selecione seus idiomas + Escolha o idioma do qual você quer aprender (origem) e o idioma que você quer aprender (destino). + 3. Selecione a quantidade de palavras + Use o controle deslizante para escolher quantas palavras você deseja gerar (até 25). + Após gerar, você poderá revisar as palavras antes de adicioná-las. + der Apfel + the apple + der Hund + the dog + Revise o vocabulário gerado antes de adicioná-lo à sua coleção. + Selecionar Itens + Use as caixas de seleção para selecionar as palavras que você deseja manter. Você também pode usar a caixa de seleção no topo para selecionar ou desmarcar todos os itens de uma vez. + Duplicado + Tratamento de Duplicatas + O aplicativo detecta automaticamente se uma palavra já existe no seu vocabulário. Essas duplicatas são desmarcadas por padrão para evitar desordem. + Adicionar a uma Lista (Opcional) + Você pode adicionar diretamente as palavras selecionadas a uma de suas listas de vocabulário existentes, escolhendo uma no menu suspenso na parte inferior. + 1 Dia + 3 Dias + 1 Semana + 2 Semanas + 1 Mês + Encontrando o modelo de IA certo + Como a busca funciona + Quando você toca em \"Procurar Modelos\", o aplicativo solicita ao seu provedor de API selecionado uma lista de modelos disponíveis. O provedor responde com os modelos que estão visíveis para você. + Os resultados dependem da sua conta, organização e configuração do provedor. + Alguns provedores retornam apenas modelos públicos; modelos privados ou empresariais podem exigir permissões adicionais. + Se você alterou permissões ou cotas recentemente, tente novamente após um curto período. + Por que alguns modelos podem não aparecer + Restrito ou não permitido para sua conta/organização + Não adequado para esta tarefa (por exemplo, apenas imagem, apenas áudio ou apenas embeddings) + Apenas modelos capazes de processar texto com conclusão de texto/chat são mostrados + O aplicativo foca em modelos que podem ler e escrever texto. Para tradução, dicionário e geração de vocabulário, o modelo deve suportar prompts de texto e retornar conclusões de texto (API de chat/completions). + A maioria das tarefas funciona muito bem com modelos rápidos e pequenos (por exemplo, nano/mini/pequeno). Para gerar exercícios completos, um modelo maior ou pago pode ser necessário. + Dicas e Solução de Problemas + Verifique se sua chave de API é válida e tem permissão para acessar os modelos desejados. + Alguns provedores exigem que uma organização/projeto seja selecionado. Certifique-se de que esteja configurado corretamente. + Se um modelo que você sabe que existe não aparecer, tente digitá-lo manualmente usando o ID do modelo mostrado na documentação do provedor. + Procure por modelos marcados como \'Instruct\', \'Chat\' ou \'Text\'. Geralmente, esses são os mais adequados. + Como a tradução funciona + Traduções Alternativas + Toque em qualquer palavra na tradução para ver significados alternativos e escolher o que melhor se encaixa. + Prompts de Tradução Personalizados + Personalize como as traduções são geradas usando prompts de IA nas Configurações. Escolha entre prompts de exemplo ou crie os seus. + Múltiplos Serviços de Tradução + Alterne entre a tradução com IA ou um serviço de tradução para diferentes opções de estilos de tradução. + Histórico de Tradução + Acesse seu histórico de traduções para reutilizar traduções anteriores. + Texto para Fala + Ouça as traduções com suporte a texto para fala. Configure vozes para diferentes idiomas nas Configurações. + Ações Rápidas + Copie traduções para a área de transferência, compartilhe-as ou adicione palavras ao seu vocabulário com um toque. + Seleção de Modelo de IA + Escolha entre diferentes modelos de IA para qualidade e velocidade de tradução. + Primeiro, selecione o melhor modelo de IA para suas necessidades e opcionalmente escreva um prompt personalizado para guiar como ele gera conteúdo do dicionário. + + diff --git a/app/src/main/res/values-pt-rBR/intro_strings.xml b/app/src/main/res/values-pt-rBR/intro_strings.xml new file mode 100644 index 0000000..9a86644 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/intro_strings.xml @@ -0,0 +1,27 @@ + + + Pular introdução + Bem-vindo! + Seu companheiro para dominar novos idiomas! + Seu Assistente de Idiomas com IA + Use o poder de um modelo de IA à sua escolha para traduzir, definir e aperfeiçoar textos em qualquer idioma. Compatível com a maioria dos provedores e até com modelos locais. + Dicionário & Tradutor poderosos + Pesquise qualquer palavra com definições detalhadas, sinÃŽnimos, frases de exemplo e até explore a origem com o recurso de etimologia. + Flashcards instantâneos, temas ilimitados + Deixe a IA gerar flashcards de vocabulário sobre qualquer assunto que imaginar! + A prática leva à perfeição + Exercite seu novo vocabulário com atividades divertidas e desafiadoras. Aprender nunca foi tão interativo! + Sua jornada de aprendizagem + O app usa Repetição Espaçada: mostra vocabulário pouco antes de você esquecer, tornando o aprendizado mais eficiente. + Organize seu vocabulário + Categorias ajudam a agrupar seu vocabulário de forma poderosa. Você pode usar filtros ou listas. + Acompanhe seu progresso + Mantenha-se motivado com séries diárias e gráficos semanais detalhados. + Esta é uma versão Beta + Como é uma versão beta, alguns recursos podem não funcionar como esperado. Seu feedback é essencial para melhorar o app. + Tudo pronto! + Se você tem uma chave de API válida, conecte-se ao seu LLM e vamos lá! + História + Ciência + Culinária + diff --git a/app/src/main/res/values-pt-rBR/language_levels.xml b/app/src/main/res/values-pt-rBR/language_levels.xml new file mode 100644 index 0000000..f29f5e6 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/language_levels.xml @@ -0,0 +1,85 @@ + + Recém-nascido + Você disse sua primeira palavra. Ou talvez tenha sido apenas um arroto. Vamos contar. A jornada de mil milhas palavras com um único
 barulho. + + Eco Ecoante + Você dominou a arte de repetir o último som que ouviu. Tem um eco aqui? 
aqui? + + Memória de Peixinho Dourado + Você igualou a capacidade de atenção de um peixinho dourado. Parabéns? (Brincadeira, eles são mais espertos do que as pessoas pensam.) + + Pombo Esperto + Você agora poderia ganhar um debate de um pombo. Eles entendem conceitos abstratos
 mas ainda não conseguem soletrar \'cabeça de vento\'. + + Koshik, o Elefante + Você agora conhece tantas palavras quanto um elefante de 6 toneladas. Impressionante, né? Só não tente se comunicar abanando as orelhas. + + Corvo Fofoqueiro + Você consegue lembrar de rostos e guardar rancor. Você está basicamente pronto para o drama da reunião de ex-alunos do colégio. + + Abelha Cartógrafa + Você está direcionando o trânsito como uma abelha dançante. \'Vire à esquerda no carvalho para o néctar\'. GPS para quê? + + Periquito Falante + Loro quer um dicionário de sinÃŽnimos! Você está repetindo palavras como um periquito. Excesso de fofura. + + Criança Curiosa + Você nomeia as coisas erradas 70% das vezes, mas com 100% de confiança. \'Cachorrinho!\' \'
Isso é uma torradeira.\' + + Rico, o Cão + Você atingiu o nível \'gênio Border Collie\'. Agora vá buscar um dicionário! + + Leiloeiro em Treinamento + Você está falando mais rápido do que qualquer um pode entender. Vendido! Para a pessoa que acabou de acenar. + + Alex, o Papagaio + Você não está apenas grasnando, está discutindo cores e formas. Alguém dê um PhD para este pássaro. + + Pilita, a Leoa-Marinha + Você entende símbolos! Próximo passo: equilibrar uma bola enquanto recita Shakespeare. + + Kanzi, o Bonobo + Você está usando lexigramas como um primata erudito. Hora de escrever sua autobiografia em emoji. + + Koko, a Gorila + Você sinaliza 500 palavras! Agora discuta sobre a hora de dormir como uma criança gorila. + + Gerador de Insultos Shakespearianos + "Tu és um fedorento, um tumor cerebral de cancro! Você não sabe o que significa, mas soa impressionante." + + Aluno da Primeira Série + Você consegue ler \'O Gato de Chapéu\' e escrever bilhetes passivo-agressivos na geladeira. + + Estagiário Jurídico + Você começou a usar \'doravante\' e \'supracitado\' em conversas casuais. Seus amigos estão preocupados. + + Chaser, o Supercão + Você conhece mais substantivos do que uma criança pequena. \'Pegar\' acabou de se tornar \'Recuperar o léxico ortográfico\'. + + Rato de Biblioteca + Você usa \'efêmero\' corretamente. Seus amigos acenam enquanto pesquisam secretamente no Google. + + Estudante do Ensino Fundamental + Fluência desbloqueada: Sarcasmo, revirar de olhos e \'estou entediado\' em 5 idiomas. + + Debatedor Ávido + Você vence discussões afogando os oponentes em terminologia precisa. Irritante? Não. \'Lexicalmente talentoso\'. + + Formado no Ensino Médio + Você pede café como um poeta e manda mensagens como um diplomata. + + O Jornalista + Você \'evita a ofuscação\' sem ironia. Colegas temem sua caneta vermelha. + + O Professor + Você usa \'antidisestablishmentarianism\' casualmente. Os alunos choram em suas aulas. + + O Romancista + Suas frases têm orações subordinadas que também precisam de notas de rodapé. + + Mestre Linguista + Dicionários citam você. Thesaurus.com é sua página de fã. + + O Oráculo Poliglota + Você sonha em dialetos mortos. As árvores pedem conselhos de etimologia a você. + \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/languages.xml b/app/src/main/res/values-pt-rBR/languages.xml new file mode 100644 index 0000000..029c243 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/languages.xml @@ -0,0 +1,110 @@ + + + + en,US,1 + zh,CN,2 + es,ES,3 + hi,IN,4 + ar,SA,5 + bn,BD,6 + pt,BR,7 + ru,RU,8 + pa,IN,9 + mr,IN,10 + te,IN,11 + tr,TR,12 + ko,KR,13 + fr,FR,14 + de,DE,15 + vi,VN,16 + ta,IN,17 + ur,PK,18 + id,ID,19 + it,IT,20 + ja,JP,21 + fa,IR,22 + bho,IN,23 + pl,PL,24 + ps,AF,25 + jv,ID,26 + mai,IN,27 + ml,IN,28 + su,ID,29 + ha,NG,30 + or,IN,31 + my,MM,32 + uk,UA,33 + yo,NG,34 + uz,UZ,35 + sd,PK,36 + am,ET,37 + ff,SN,38 + ro,RO,39 + ig,NG,40 + ceb,PH,41 + gu,IN,42 + kr,NG,43 + ms,MY,44 + kn,IN,45 + nl,NL,46 + hu,HU,47 + el,GR,48 + cz,CZ,49 + he,IL,50 + hr,HR,51 + + + Inglês + Mandarim + Espanhol + Hindi + Árabe + Bengali + Português + Russo + Punjabi + Marata + Telugu + Turco + Coreano + Francês + Alemão + Vietnamita + Tâmil + Urdu + Indonésio + Italiano + Japonês + Persa + Boiapuri + Polonês + Pashto + Javanês + Maithili + Malaiala + Sundanês + Hauçá + Odia + Birmanês + Ucraniano + Iorubá + Uzbeque + Sindi + Amárico + Fula + Romeno + Igbo + Cebuano + Guzerate + Canúri + Malaio + Canarês + Holandês + Húngaro + Grego + Tcheco + Hebraico + Croata + + + \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..9037a39 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,876 @@ + + + + Salvar + Definições + Excluir + Iniciar + Traduzir + Adicionar + Fechar + Cancelar + Confirmar + Selecionar + Concluído + Excluir Itens + Continuar + Voltar + Sucesso + Erro + Logo do App + Alternar Menu + Navegar para trás + Recolher + Expandir + Texto para Fala + Pesquisar + Limpar texto + Gerar Definição Novamente + Limpar pesquisa + Histórico de Tradução + Fechar o aplicativo? + Recarregar + Único + Sequência + Todo o Vocabulário + Progresso da Categoria + Para Hoje + Mostrar Mensagem de Sucesso + Adicionar Categoria + Configurações + Painel + Opções do Desenvolvedor + Múltiplos + Configurações de Tradução + Restaurar Padrões + Excel não é suportado. Use CSV. + Erro ao analisar tabela + Erro ao analisar tabela: %1$s + Importar Tabela (CSV) + Primeira Coluna + Segunda Coluna + Coluna %1$d + Idiomas + Primeiro Idioma + Segundo Idioma + Primeira linha é cabeçalho + Arquivos órfãos + Prévia (5 primeiros) para coluna 1: %1$s + Prévia (5 primeiros) para coluna 2: %1$s + Linhas para importar: %1$d + Por favor, selecione duas colunas. + Por favor, selecione dois idiomas. + Nenhuma linha para importar. Verifique as colunas e o cabeçalho. + %1$d itens de vocabulário importados. + Importar + Gerar vocabulário com IA + Criar Exercício do YouTube + Link do YouTube + Personalize os intervalos e critérios para mover os cartões de vocabulário. Cartões em estágios iniciais são perguntados com mais frequência. + Insira seu prompt personalizado + Desenvolvido por Jonas Gaudian + Visite meu site + Contatar desenvolvedor + Contate-me para relatar bugs, dar ideias, solicitar recursos e mais. + Novo + Estágio 1 + Estágio 2 + Estágio 3 + Estágio 4 + Estágio 5 + Aprendido + Para Hoje + Idioma Desconhecido + Selecione Traduções para Adicionar + Embaralhar ordem dos cartões + Embaralhar Idiomas + Modo de Treino + Quantidade de cartões + Intervalos (em dias) + Insira uma palavra + Insira a tradução + Apenas para hoje + Arraste para Reordenar + Recolher Widget + Expandir Widget + Atividade Semanal + Carregando
 + Mostrar Carregamento + Cancelar Carregamento + Esta é uma mensagem informativa. + Mostrar Mensagem Informativa + Sucesso! + Oops! Algo deu errado. + Mostrar Mensagem de Erro + Resetar Introdução + Informação de versão não disponível. + Versão: v%1$s-%2$s + Lista + Filtro + Nome da Categoria + Nenhum par de idiomas de dicionário encontrado. Adicione vocabulário com idiomas diferentes primeiro. + Disponível para criar: + Selecionado + Adicionar Idioma Personalizado + Código do Idioma + Ex: pt + Região + Ex: BR + Nome do idioma + Ex: Português + Falha ao obter traduções: %1$s + Nenhum vocabulário disponível. + Obtenha a Chave de API em %1$s + Chave Ativa + Sem Chave + Chave Opcional + Modelos Disponíveis: + Alterar Chave + Inserir Chave de API + Salvar Chave + Selecionar Modelo + Prompts de Exemplo + Título de Prévia + Nenhum + Lista de vocabulário manual + Filtro: Todos os itens + Estágio %1$s + Todos os Idiomas + Todos os Estágios + Mais opções + Editar Categoria + Exportar Categoria + Excluir Categoria + Selecionar Categoria + %1$d categorias selecionadas + Selecionar Categorias + Em Estágios + Novo + Nenhum item disponível + Todos os itens concluídos! + Insira o texto para corrigir + Copiado para a área de transferência + Copiar texto corrigido + Correto + Explicação + Aqui você pode definir um prompt personalizado para o modelo de tradução de IA, ajustando o estilo da tradução. + Prompt de Vocabulário + Aqui você pode definir um prompt personalizado para definir como novos itens de vocabulário são gerados. + Selecione o conteúdo a ser gerado para uma entrada de dicionário. + + Prompt Personalizado do Dicionário + Salvar Prompt + Claro + Escuro + Sistema + Tem certeza de que deseja excluir esta categoria? + Tem certeza de que deseja excluir estas %d categorias? + Tem certeza de que deseja excluir todos os itens desta categoria? + Pesquisar
 + Palavra do Dia + Histórico de Pesquisa + Corretor + Adicionado + Concluído + Contatando IA
 + Exercício \'%1$s\' criado! + Geração por IA falhou (exceção) + A IA falhou ao criar o exercício. + Ocorreu um erro desconhecido. + Conflito de IDs entre perguntas no exercício e no repositório. + Não está bem certo. + A resposta correta é: + A ordem correta é: %1$s + Verifique suas combinações! + A frase correta era: %1$s + Alguns itens estão na categoria errada. + A tradução correta é: %1$s + Novo Vocabulário para este Exercício + Iniciar Exercício + Baixar + Fácil + Médio + Difícil + Gerar Exercício com IA + Categoria / Prompt + ex., Verbos Irregulares + Tipos de Pergunta + Pergunta + Dificuldade: %1$s + Quantidade: %1$d Perguntas + Gerar + Deixe a IA encontrar vocabulário para você! + Termo de Busca + Dica: Você pode buscar qualquer termo, ex. \"Coisas para fazer no zoológico\" ou \"verbos irregulares\"! + Selecionar Idiomas + Selecionar Quantidade + Quantidade: %1$d + Digite o texto para traduzir + Colar + Automático + %1$d Idiomas Selecionados + Remover dos favoritos + Adicionar aos favoritos + Favoritos + Histórico + Selecionar Reconhecimento Automático + Não selecionar nenhum + Opções de Idioma + Selecionar todos os idiomas + Excluir idioma personalizado + Trocar Idiomas + Aparência + Modo de Aparência + Paleta de Cores + Estilo da Fonte + %1$s selecionado + Sequência de Dias + Últimos 7 dias + Total de Palavras Aprendidas + Mais Estatísticas + Meta Atingida + Nenhum Vocabulário para Hoje + Ver Todos + Exercício Personalizado + Exercício Diário + Total de Palavras + Aprendidas + Restantes + + Exemplos + Configurações de Vocabulário + Critérios de Aprendizagem + Mín. de Acertos para Avançar + Máx. de Erros para Regredir + Meta de Aprendizagem Diária + Meta de Respostas Corretas por Dia + Backup e Restauração + Exportar Dados do Vocabulário + Importar Dados do Vocabulário + Área de Risco + Apagar todos os dados + Configurações de Exercício + Descrição das Configurações de Exercício + Prompt de Exercício Personalizado + Próximo + Começar + Códigos de Status HTTP + Repositório de Vocabulário + 200 OK + A solicitação foi bem-sucedida. + O servidor não conseguiu entender a solicitação. + 400 Bad Request + Autenticação é necessária e falhou ou não foi fornecida. + 401 Unauthorized + 403 Forbidden + O servidor entendeu a solicitação, mas se recusa a autorizá-la. + 404 Not Found + O recurso solicitado não foi encontrado. + 429 Too Many Requests + Muitas solicitações: O usuário enviou muitas solicitações num determinado período. + 500 Internal Server Error + Uma condição inesperada foi encontrada no servidor. + Digite a tradução + Virar Cartão + Próximo Cartão + Incorreto + Errado + Certo + %1$d perguntas + Iniciar Longo + Excluir Exercício + Criar Exercício + Fechar exercício + Respostas corretas + Respostas erradas + %1$d Palavras Sabidas + Sua Jornada de Idiomas + Nível Atual + Próximo: %1$s + %1$d palavras + %1$d palavras + Você dominou o nível final! + Alcançado + %1$d palavras necessárias + Texto Copiado + Para Hoje: %1$s + Selecionar Estágio + Este modo não afetará o seu progresso nos estágios. + Modo de Treino (sem estatísticas) + Preparar Exercício + Iniciar Exercício (%1$d) + Número de Cartões: %1$d / %2$d + Nenhum cartão encontrado para os filtros selecionados. + Escolher Tipos de Exercício + Opções + Embaralhar Cartões + Sair + Categorias + Ordenar + Ordenar por Nome + Ordenar por Tamanho + Ordenar por % de Conclusão + Ordenar por Itens Novos + Ordenar por Em Progresso + Vocabulário Adicionado + Encontrar Traduções + Excluir Itens na Categoria + Tradutor + Vocabulário + Progresso + Prompt Personalizado + Repositório + Exercícios + Prompt de Exercício + Geral + Sobre + Configurações Gerais + %1$d itens + Exemplo + SinÃŽnimos + Editar + Definição + Estatísticas + Mover para Categoria + Mover para Estágio + Detalhes do Item + Tem certeza de que quer sair? + Sim + Não + Estágio: %1$s + Último acerto: %1$s + Último erro: %1$s + Respostas corretas: %1$d + Respostas incorretas: %1$d + Cartão (%1$d/%2$d) + ID do Item: %1$d + Carregando estatísticas
 + para %1$s + Traduzir de %1$s + Monte a palavra aqui + Resposta correta: %1$s + Sair do Exercício? + Tem certeza de que quer sair? O seu progresso nesta sessão será perdido. + Resultado + Exercício Concluído! + Veja como você se saiu: + Tentar Novamente + Finalizar + Atividade de Vocabulário + Menos + Mais + Sequência Atual + Palavras Concluídas + Em Progresso + %1$d dias + Progresso por Categoria + Aplicar Filtros + Filtrar por Estágio + Categoria + Idioma + Limpar Tudo + Remover da Categoria + Mais ações + Selecionar Tudo + Desmarcar Tudo + Pesquisar vocabulário
 + Nenhum item de vocabulário encontrado. Que tal tentar mudar os filtros? + Categoria: %1$s + Estado do repositório importado de %1$s + Itens Encontrados + Adicionar (%1$d) + Selecione os itens para adicionar + Selecionar Lista (opcional) + %1$d estágios selecionados + Nenhum modelo selecionado para a tarefa: %1$s + Criar Nova Categoria + Nenhum Vocabulário Novo para Organizar + Criar + Status + Novos Itens + Duplicados + "Não há problemas com o seu vocabulário, tudo certo!" + Organizar Novo Vocabulário + Adicionar Provedor Personalizado + Excluir Provedor + Nenhum Modelo Configurado + Adicionar Modelo Personalizado + Procurar modelos + Procurando
 + Nenhum modelo encontrado + Adicionar Modelo + Excluir Modelo + Tema do Sistema + Fonte Padrão do Sistema + Analisar Gramática + Mostrar Dicas Contextuais + Exibir botões de informação na tela para ajuda. + Entendi! + Mostrar Dica + Recursos Experimentais + Ative recursos experimentais que ainda não estão prontos para produção. + Apenas Duplicados + Ativado + Mais Recentes Primeiro + Mais Antigos Primeiro + Por Idioma + Estágios de Aprendizagem + Informações Legais + Itens com Falha + Origem + Origem de \"%1$s\" + Atualizar Palavra do Dia + Buscar a origem de uma palavra + SinÃŽnimo já existe + Adicionar sinÃŽnimo + Tradução + Exercício + Dicionário + Licenças de Código Aberto + Alternar Licenças + Itens Sem Gramática + Buscando Detalhes da Gramática + Nenhum Item sem Gramática + Buscar Todas as Infos de Gramática + Selecione a Quantidade de Itens para Buscar + Buscando para %d Itens + Iniciar Busca + Filtrar por Tipo de Palavra + Todos os Tipos + Filtrar e Ordenar + Idioma com id %1$d não encontrado + Itens sem infos de gramática + Resolver ID de Idioma Ausente: %1$d + Encontrados %1$d itens usando este ID de idioma ausente. + Ocultar Itens Afetados + Mostrar Itens Afetados + Solução 1: Excluir Itens + Excluir permanentemente todos os %1$d itens de vocabulário afetados. + Excluir %1$d Itens + Solução 2: Substituir ID + Atribuir um idioma diferente a estes itens. + Substituir por %1$s + Criar Novo Idioma + Solução 3: Criar Idioma + Criar uma nova entrada de idioma personalizada para este ID. + Excluir Novo + Manter Ambos + Mesclar Itens + Mesclar + Apenas itens novos + Apenas itens com falha + Palavra + Duplicado Detectado + Um item semelhante já existe. Como você gostaria de proceder? + Novo Item + Item Existente (ID: %1$d) + Configurações de Prompt de Tradução + Tocar áudio + Copiar texto + Compartilhar texto + Adicionar ao dicionário + Este é um texto de saída de exemplo. + Sua resposta + Toque nas palavras abaixo para formar a frase + Ouvir + Digite o que você ouve + Associe estes itens: + Traduza o seguinte (%1$s): + Sua tradução + Esta é uma dica. + Este é o conteúdo principal. + Este é o conteúdo dentro do cartão. + Botão Primário + Primário com Ícone + Botão Secundário + Secundário com Ícone + Secundário Inverso + Endereço de E-mail + %1$s: A rápida raposa marrom pula sobre o cão preguiçoso. + + Descrição + ID do Modelo (ex: mistralai/mistral-nemo-instruct-2407) + Nome de Exibição + URL do Site + Endpoint (ex: /v1/chat/completions/) + URL Base (ex: \'http://192.168.0.99:1234/\') + Fechar modo de seleção + %1$d Selecionado(s) + Termo de pesquisa + Fechar pesquisa + Gerar itens de vocabulário relacionados + Dispensar + Editar Recursos para \'%1$s\' + Nenhuma configuração de gramática encontrada para este idioma. + Tipo de Palavra + Níveis + Pares de palavras rápidos + Filtro de Estágio + Par de Idiomas + Filtro de Idioma + Logs + Limpar + Nenhum log disponível ainda. + JSON da Requisição + JSON da Resposta + Não disponível + Compartilhar + Enviar Log por E-mail + Hora + N/D + Modelo + Duração + ms + Exceção + Timeout + Erro de Análise + Log da API do Tradutor %1$s + Id: %1$s + Timestamp: %1$s + Provedor: %1$s + Endpoint: %1$s %2$s + Modelo: %1$s + Status: %1$s %2$s + Duração: %1$s %2$s + Exceção: %1$s + Timeout: Sim + Erro de Análise: %1$s + Erro: %1$s + --- Requisição --- + --- Resposta --- + Mostrar Apenas Erros + Mostrar Apenas Modelos Gratuitos + Buscar Modelos + Selecione um Modelo + Contexto + Mostrar + Ocultar + Tom + Formal + Casual + Coloquial + Polido + Profissional + Amigável + Acadêmico + Criativo + Editando Texto: %1$s + Nenhum texto recebido! + Erro: Nenhum texto para editar + Não iniciado com texto para editar + Uma lista simples para organizar o seu vocabulário manualmente + Conectando seu Modelo de IA + Voz + Padrão + Velocidade da Fala + Testar + Dica: %1$s + Excluir tudo + Conexão + Nome em Inglês + Remover artigos + Aviso + Adicionar Chave + Mês Anterior + Próximo Mês + Mostrar Mensagem de Chave de API Ausente + Nesta tela, você organiza o seu novo vocabulário. Pode corrigir a ortografia e as traduções, e atribuir itens a categorias. + O app também ajuda a detetar duplicados ou remover artigos para listas de vocabulário mais limpas. + Duplicado + Quando terminar, decida o que fazer com o item: + Mover para o Primeiro Estágio + Organização de Vocabulário + " (opcional)" + Verificar disponibilidade + Nenhuma configuração de API válida foi encontrada. Antes de usar o app, configure pelo menos um provedor de API. + Categoria de Tag + Esta tela permite personalizar as instruções para gerar novas entradas de vocabulário, controlando quais informações incluir. + Tentar Wikcionário Primeiro + Tente primeiro encontrar a palavra no Wikcionário antes de gerar uma resposta de IA + Pergunta %1$d de %2$d + Verdadeiro + Falso + Verificar + Correto! + Incorreto! + E muito mais! 
 + Sua Própria IA + Mistral + OpenAI + Claude + Gemini + DeepSeek + OpenRouter + Precisa de Ajuda? + Se precisar de ajuda, você pode encontrar dicas em todas as seções do aplicativo. + Rótulos da Barra de Navegação + Mostrar rótulos de texto na barra de navegação principal. + Como Funciona + Responda Corretamente + A palavra avança para o próximo estágio, e você a verá novamente após um intervalo maior. + Responda Incorretamente + A palavra volta um estágio. Isso ajuda você a focar no vocabulário que acha difícil. + Personalizável + Você pode personalizar todos os intervalos e regras nas configurações. + Configurações de Pares de Palavras + Quantidade de perguntas: %1$d + Embaralhar perguntas + Modo de treino + Combine os pares + Exercício de Pares de Palavras + Modo de treino ativado: respostas não afetarão o progresso. + Iniciar Exercício + "Use esta tela para definir uma instrução personalizada para o modelo de tradução de IA. Você pode especificar o tom, estilo ou formato da tradução." + " dias" + Adicionar Vocabulário + Criar Vocabulário com IA + Nenhum item de vocabulário encontrado. Adicionar agora? + Isso removerá todos os provedores de API, modelos e chaves de API configurados. Esta ação não pode ser desfeita. + Excluir todos os provedores e modelos? + Trocar lados + Sem progresso + Prévia do Tema + Palavra de Exemplo + Usar servidor de Tradução + Quando ativado, as traduções usarão um servidor de tradução para pares de idiomas suportados. Pares não suportados usarão automaticamente o seu modelo de IA. + O que há de novo + Registro de Alterações + Muito Raro + Raro + Incomum + Comum + Frequente + Muito Frequente + Modelo de IA + Servidor de Tradução + Texto + Insira um texto para extrair palavras e traduzir + Insira um texto + Repetir Erradas + Começar de Novo + Opções do Dicionário + É assim que o dicionário funciona: + Cole ou abra um link do YouTube para ver as legendas aqui. + Erro: %1$s + Repetir Respostas Erradas + Nenhuma + Flexões + Mais + Traduções + Mostrar exemplos + Hifenização + Significados + Substantivo + Corrigir + Quantas palavras você quer acertar por dia? + Dica de exemplo: Procure por modelos + Como se conectar a uma IA + Como gerar vocabulário com IA + Gerenciador de dicionários + Tamanho: %1$d MB + Atualizar + Versão: %1$s + Primeiro ID de Idioma: %1$d + Segundo ID de Idioma: %1$d + Próximo item + Item anterior + Todos os dicionários foram excluídos com sucesso + Dicionários disponíveis + Não foi possível buscar uma nova palavra. + Dicionário excluído com sucesso + Dicionário baixado com sucesso + Quer minimizar o app? + Erro ao excluir dicionários: %1$s + Erro ao excluir dicionário: %1$s + Erro ao deletar arquivo órfão: %1$s + Erro ao baixar dicionário: %1$s + Erro ao carregar valores armazenados: %1$s + Erro ao salvar entrada: %1$s + Falha ao deletar dicionário: %1$s + Falhou ao excluir arquivo órfão: %1$s + Falhou ao excluir alguns dicionários + Falhou ao baixar dicionário: %1$s + Falhou ao buscar etimologia + Falhou ao buscar manifesto: %1$s + Terminar Vídeo e Começar Exercício + Dica + Sem Dados Disponíveis + Nenhum dicionário disponível + Arquivo órfão deletado com sucesso + Apagar todos os dicionários? + Isso vai apagar todos os dicionários baixados do seu celular. + Esse arquivo existe localmente, mas não está no manifesto do servidor ou falta assets. Pode ser de uma versão antiga ou de um download falhado. + Desconhecido + Dicionário desconhecido (%1$s) + Você pode baixar dicionários de certos idiomas que podem ser usados em vez da geração por IA para o conteúdo do dicionário. + Adicionar detalhes gramaticais + Excluir Item do Vocabulário? + Tem certeza de que deseja excluir este item do vocabulário? + Idioma de Origem + Língua Alvo + Direção da Língua + + Você pode definir uma preferência opcional de qual língua vem primeiro ou segundo. + Chutando + Ortografia + Múltipla Escolha + Embaralhar Palavras + Apenas perguntar cartas que estão a vencer hoje. + Embaralhar Ordem das Cartas + Embaralhar qual idioma vem primeiro. Não afeta as preferências de direção do idioma. + Conjugação: %1$s + Recolher + Expandir + Verbo + Adjetivo + Advérbio + Pronome + Preposição + Conjunção + Interjeição + Artigo + Gênero + Masculino + Feminino + Neutro + Comum + Plural + Tempo + Presente + Passado + Futuro + Humor + Indicativo + Subjuntivo + Imperativo + Palavras relacionadas + JSON do desenvolvedor + Mostrar entrada no dicionário + Mostrar menos + Mostrar mais %1$d + Dados Brutos + Erro ao gerar perguntas: %1$s + Gerando perguntas do vídeo
 + Verificação de integridade falhou para %1$s. Esperado: %2$s, Recebido: %3$s + Falha no download: HTTP %1$d %2$s + Falha ao buscar manifesto: %1$s + (Auxiliar: %1$s) + Esconder exemplos + *obrigatório + Procurar por modelos disponíveis + Descobrir modelos automaticamente do %1$s + Procurando
 + Procurar por modelos + Adicionar modelo manualmente + Digite os detalhes do modelo você mesmo + Nome para exibir + ex.: GPT-4, Claude-3 + Obrigatório: Digite um nome legível por humanos + ID do Modelo * + ex.: gpt-4, claude-3-sonnet + Obrigatório: Digite o identificador exato do modelo + Isso deve combinar exatamente com o nome do modelo do provedor + Descrição + ex.: Rápido e eficiente para tarefas simples + Opcional: Descreva o que esse modelo é bom + Fornecedores + Tarefas + Configuração de IA + Excluir Fornecedor + Abreviação + Adjetivo + Composto Adjetivo-Substantivo + Locução Adjetiva + Adnominal + Advérbio + Locução Adverbial + Afixo + Ambiposição + Artigo + Caractere + Circunfixo + Circunposição + Classificador + Oração + Forma Combinante + Componente + Conjunção + Contração + Convérbio + Contador + Determinante + Gerúndio + Redirecionamento Rígido + Infixo + Interfixo + Interjeição + Interjeição + Nome/Substantivo Próprio + Substantivo + Numeral/Número + Onomatopeia + Onomatopeia + Particípio + Partícula + Locução/Frase + Posposição + Prefixo + Preposição + Locução Prepositiva + Prevérbio + Pronome + Provérbio + Pontuação + Quantificador + Romanização + Radical + Redirecionamento Suave + Tema + Sufixo + Sílaba + Símbolo + Variante Tipográfica + Desconhecido + Verbo + Por favor, selecione um idioma do dicionário primeiro. + Esses arquivos existem localmente, mas não estão no manifesto do servidor. Eles podem ser de versões antigas. + Usar dicionário baixado + Assistir ao Vídeo Novamente + Tem certeza que deseja excluir o provedor "%1$s"? Isso também removerá todos os modelos associados a este provedor. Esta ação não pode ser desfeita. + Deletar Modelo + Tem certeza que deseja deletar o modelo "%1$s" de %2$s? Essa ação não pode ser desfeita. + Atribuições do Modelo de Tarefa + Configurar qual modelo de IA usar para cada tipo de tarefa + Personalizado + %1$d modelos + Procurar modelos
 + %1$d modelos + Nenhum modelo encontrado + Origem da palavra + Alternativas + A tradução vai aparecer aqui + Apagar Chave + Tem certeza que quer apagar a Chave desse Fornecedor? + Deletar todos os provedores e modelos + Conteúdo do Dicionário + Definição de IA + Baixado + Mais ações + Wikcionário + Licenciado sob + Conteúdo obtido de + * obrigatório + Nada por aqui ainda + Tocar + Pronúncia + Área de transferência está vazia + Colar + Tom alvo: + Apenas gramática + Declinação + Variações + Auto Ciclo (Dev) + Regenerar + Ler em Voz Alta + + diff --git a/app/src/main/res/values/app_values.xml b/app/src/main/res/values/app_values.xml new file mode 100644 index 0000000..97e3f84 --- /dev/null +++ b/app/src/main/res/values/app_values.xml @@ -0,0 +1,8 @@ + + + + https://www.gaudian.eu + Polly + https://www.gaudian.eu/datenschutzerklarung-language-tool/ + + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml new file mode 100644 index 0000000..51e6531 --- /dev/null +++ b/app/src/main/res/values/arrays.xml @@ -0,0 +1,52 @@ + + + Exercise Example Prompts + + + + + word_class_gender + declension_noun + pronunciation + definition + origin + synonyms + antonyms + examples + flexion_verb + idioms + grammatical_features_prepositions + + + + Word class and gender (for nouns) + Declination (for nouns) + Pronunciation (IPA) and word separation + Definition + Origin + Synonyms + Antonyms + Examples + Flexion (for verbs) + Idioms + Grammatical features (for prepositions) + + + + Translate everything without adding anything else. + Use the second-person pronoun (informal) instead of formal "usted/vos/vous". + Make it very formal. + Make it informal. + + + + Version 0.3.0 \n• Enabled CSV Import for Vocabulary\n• Option to use a translation server for translations instead of AI models for some supported langugaes\n• UI bug fixes \n• Show word frequency \n• Performance optimizations \n• Improved translations (German and Portuguese) + Version 0.4.0 \n• Added dictionary download (beta) \n• UI enhancements \n• Bugfixes \n• Re-designed vocabulary card with improved UI \n• More pre-configured providers \n• Improved performance + + + + + + diff --git a/app/src/main/res/values/hint_strings.xml b/app/src/main/res/values/hint_strings.xml new file mode 100644 index 0000000..37bfc2c --- /dev/null +++ b/app/src/main/res/values/hint_strings.xml @@ -0,0 +1,153 @@ + + + + Can’t find your model? + You can add models manually. Enter the exact Model ID from your provider’s documentation and a friendly Display Name. The app will use the ID for all API calls. + + nano + mini + small + medium + large / paid + + From scan to selection – quick visual guide + 1 + 2 + 3 + Start the scan + Tap the scan button to fetch available models from your provider. + Filter & choose + Browse the list. Prefer models marked for text/chat. Some providers label free/paid models differently. + Text/Chat + Validate + Use Add & Validate to save the model and perform a quick check with your provider. + Add & Validate + + + + + Context-Aware Translation + Get translations that understand the context of your conversation for more accurate results. + + + Vocabulary Progress Tracking + Progress Tracking + Track your learning progress with detailed statistics and visual indicators. + Learning Stages + Words move through stages as you learn, with increasing intervals between reviews. + Review System + The spaced repetition system ensures you review words at optimal intervals for long-term retention. + Customization + Customize learning criteria, daily goals, and review intervals to match your learning style. + + + + + + Help and Instructions + Help Center + All hints that are in this app can be found here as well. + + + Getting Started + Vocabulary Management + Advanced Features + + A \'List\' is a simple category where you can manually add any vocabulary item you want. It\'s like a custom folder for your words. + To use all features, the app needs to connect to a Large Language Model (LLM) service. This is done through an API Provider. + You can add your API Key to a pre-configured provider (like OpenAI or Google) or add a custom provider to connect to a different service, like a local model. Any provider has to be compatible with the OpenAI API standard. + Key Status Indicators + Each provider card shows the status of your API key: + This means your key is saved and active. + This means the API key is missing or has been cleared. + Troubleshooting + If you\'re having issues, please check the following: + • Ensure your API key is valid and has permissions.\n• Check your network connection.\n• View the Network Logs tab for detailed error messages. + You can create two types of categories to organize your vocabulary: + Tag Category + Filter Category + Filter can match items by: no language filter, a list of languages, or a dictionary pair. You can also optionally filter by study stages. Language list and dictionary pair are mutually exclusive. + Create a manual tag to group words you choose. + List Category + Manually add any word you want to this category. It\'s perfect for creating custom study lists for a specific topic or chapter. + Apple + Add + My Fruit List + Filter Category + This category automatically groups words based on rules you set, like their learning stage or language. It\'s a dynamic, hands-free way to organize. + Dog + Cat + "Stage 1" Filter + Step 1: Configure the AI + First, select the best AI model for your needs and optionally write a custom prompt to guide how it generates dictionary content. + Step 2: Select Content + Next, use the toggles to choose which specific sections (like synonyms, antonyms, etc.) should be included in a dictionary lookup. + e.g., Synonyms + Example Toggle + Let AI find vocabulary for you. Here\s how to use this feature: + 1. Enter a search term + Things to do at the zoo + 2. Select your languages + Choose the language you want to learn from (source) and the language you want to learn (target). + 3. Select the amount of words + Use the slider to choose how many words you want to generate (up to 25). + After generating, you will be able to review the words before adding them. + der Apfel + the apple + der Hund + the dog + Review the generated vocabulary before adding it to your collection. + Select Items + Use the checkboxes to select the words you want to keep. You can also use the checkbox at the top to select or deselect all items at once. + Duplicate + Duplicate Handling + The app automatically detects if a word already exists in your vocabulary. These duplicates are unselected by default to avoid clutter. + Add to a List (Optional) + You can directly add the selected words to one of your existing vocabulary lists by choosing one from the dropdown menu at the bottom. + 1 Day + 3 Days + 1 Week + 2 Weeks + 1 Month + + Finding the right AI model + How Scan works + When you tap Scan for Models, the app asks your selected API provider for a list of available models. The provider responds with the models that are visible to you. + Results depend on your account, organization, and provider configuration. + Some providers only return public models; private or enterprise models may require additional permissions. + If you recently changed permissions or quotas, try again after a short delay. + + Why some models may not appear + Restricted or not allowed for your account/organization + Not suitable for this task (e.g., image-only, audio-only, or embeddings-only) + Only text-capable models with text completion/chat are shown + The app focuses on models that can read and write text. For translation, dictionary and vocabulary generation, the model must support text prompts and return text completions (chat/completions API). + Most tasks work great with fast, small models (e.g., nano/mini/small). For generating full exercises, a larger or paid model may be required. + + Tips & Troubleshooting + Verify that your API key is valid and has permission to access the desired models. + Some providers require an organization/project to be selected. Make sure it’s correctly configured. + If a model you know exists doesn’t show up, try typing it manually using the Model ID shown in the provider’s docs. + Look for models tagged as ‘Instruct’, ‘Chat’, or ‘Text’. Those are typically the best fit. + + + How translation works + Alternative Translations + Tap any word in the translation to see alternative meanings and choose the best fit. + Custom Translation Prompts + Customize how translations are generated using AI prompts in Settings. Choose from example prompts or create your own. + Multiple Translation Services + Switch between AI-powered translation or a translation service for different translation styles options. + Translation History + Access your translation history to reuse previous translations. + Text-to-Speech + Listen to translations with text-to-speech support. Configure voices for different languages in Settings. + Quick Actions + Copy translations to clipboard, share them, or add words to your vocabulary with one tap. + AI Model Selection + Choose from different AI models for translation quality and speed. + + + + + diff --git a/app/src/main/res/values/intro_strings.xml b/app/src/main/res/values/intro_strings.xml new file mode 100644 index 0000000..98f4ace --- /dev/null +++ b/app/src/main/res/values/intro_strings.xml @@ -0,0 +1,37 @@ + + + + Skip intro + Welcome! + Your personal companion for mastering new languages! + Your AI Language Assistant + Use the power of an AI model of your choice to translate, define, and perfect text in any language. Compatible with most AI Providers and even your locally hosted Language Model. + Powerful Dictionary & Translator + Look up any word for detailed definitions, synonyms, example sentences, and even explore its origin with the etymology feature. + Instant Flashcards, Limitless Topics + Let the AI generate vocabulary flashcards on any subject you can imagine! + Practice Makes Perfect + Engage with your new vocabulary through a variety of fun and challenging exercises. Learning has never been so interactive! + Your Learning Journey + The app uses a smart system called Spaced Repetition. It shows you vocabulary just before you\'re likely to forget it, making your learning more efficient. + Organize Your Vocabulary + Categories help you group your vocabulary in powerful ways. You can set filters or lists. + Track Your Progress + Stay motivated with daily streaks and detailed weekly activity charts. + This is a Beta Build + As this is a beta version, some features may not work as expected. Your feedback is invaluable in helping me improve the app. + You\'re All Set! + If you have a valid API key, just connect to your LLM and let\'s go!. + + + + + History + Science + Cooking + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/language_levels.xml b/app/src/main/res/values/language_levels.xml new file mode 100644 index 0000000..e442846 --- /dev/null +++ b/app/src/main/res/values/language_levels.xml @@ -0,0 +1,113 @@ + + + Newborn + You\'ve said your first word. Or maybe it was just a burp. We\'ll count it. The journey of a thousand words begins with a single
 noise. + + + Echoing Echo + You\'ve mastered the art of repeating the last sound you heard. Is there an echo in here? 
in here? + + + Goldfish Memory + You\'ve matched a goldfish\'s attention span. Congrats? (Just kidding—they\'re smarter than people think.) + + + Clever Pigeon + You could now out-debate a pigeon. They understand abstract concepts
 but still can\'t spell \'birdbrain.\' + + + Koshik the Elephant + You now know as many words as a 6-ton elephant. Impressive, right? Just don\'t try to communicate by flapping your ears. + + + Gossip-loving Crow + You can remember faces and hold a grudge. You\'re basically ready for high school reunion drama. + + + Honeybee Cartographer + You\'re directing traffic like a waggle-dancing bee. \'Turn left at the oak tree for nectar.\' GPS who? + + + Chatty Parrotlet + Polly wants a thesaurus! You\'re repeating words like a tiny parrot. Cuteness overload. + + + Curious Toddler + You name things wrong 70\% of the time but with 100\% confidence. \'Doggie!\' \'
That\'s a toaster.\' + + + Rico the Dog + You\'ve hit \'Border Collie genius\' level. Now go fetch a dictionary! + + + Auctioneer-in-Training + You\'re speaking faster than anyone can understand. Sold! To the person who just nodded. + + + Alex the Parrot + You\'re not just squawking—you\'re discussing colors and shapes. Someone get this bird a PhD. + + + Pilita the Sea Lion + You understand symbols! Next: balancing a ball while reciting Shakespeare. + + + Kanzi the Bonobo + You\'re using lexigrams like a primate scholar. Time to write your memoir in emoji. + + + Koko the Gorilla + You sign 500 words! Now argue about bedtime like a silverback toddler. + + + Shakespearean Insult Generator + "Thou art a foul-smelling, boil-brained canker-blossom! You don't know what it means, but it sounds impressive." + + + First Grader + You can read \'Cat in the Hat\' and write passive-aggressive fridge notes. + + + Legal Eagle Intern + You\'ve started using \'heretofore\' and \'aforementioned\' in casual conversation. Your friends are concerned. + + + Chaser the Superdog + You know more nouns than a toddler. \'Fetch\' just became \'Retrieve the orthographic lexicon.\' + + + Bookworm + You use \'ephemeral\' correctly. Your friends nod while secretly Googling. + + + Middle Schooler + Fluency unlocked: Sarcasm, eye-rolls, and \'I\'m bored\' in 5 languages. + + + Avid Debater + You win arguments by drowning opponents in precise terminology. Annoying? No. \'Lexically gifted.\' + + + High School Grad + You order coffee like a poet and text like a diplomat. + + + The Journalist + You \'eschew obfuscation\' unironically. Colleagues fear your red pen. + + + The Professor + You casually use \'antidisestablishmentarianism\'. Students weep in your lectures. + + + The Novelist + Your sentences have subordinate clauses that also need footnotes. + + + Master Linguist + Dictionaries cite you. Thesaurus.com is your fan page. + + + The Polyglot Oracle + You dream in dead dialects. Trees ask you for etymology advice. + diff --git a/app/src/main/res/values/languages.xml b/app/src/main/res/values/languages.xml new file mode 100644 index 0000000..0ddca8c --- /dev/null +++ b/app/src/main/res/values/languages.xml @@ -0,0 +1,110 @@ + + + + en,US,1 + zh,CN,2 + es,ES,3 + hi,IN,4 + ar,SA,5 + bn,BD,6 + pt,BR,7 + ru,RU,8 + pa,IN,9 + mr,IN,10 + te,IN,11 + tr,TR,12 + ko,KR,13 + fr,FR,14 + de,DE,15 + vi,VN,16 + ta,IN,17 + ur,PK,18 + id,ID,19 + it,IT,20 + ja,JP,21 + fa,IR,22 + bho,IN,23 + pl,PL,24 + ps,AF,25 + jv,ID,26 + mai,IN,27 + ml,IN,28 + su,ID,29 + ha,NG,30 + or,IN,31 + my,MM,32 + uk,UA,33 + yo,NG,34 + uz,UZ,35 + sd,PK,36 + am,ET,37 + ff,SN,38 + ro,RO,39 + ig,NG,40 + ceb,PH,41 + gu,IN,42 + kr,NG,43 + ms,MY,44 + kn,IN,45 + nl,NL,46 + hu,HU,47 + el,GR,48 + cz,CZ,49 + he,IL,50 + hr,HR,51 + + + English + Mandarin + Spanish + Hindi + Arabic + Bengali + Portuguese + Russian + Punjabi + Marathi + Telugu + Turkish + Korean + French + German + Vietnamese + Tamil + Urdu + Indonesian + Italian + Japanese + Persian + Bhojpuri + Polish + Pashto + Javanese + Maithili + Malayalam + Sundanese + Hausa + Odia + Burmese + Ukrainian + Yoruba + Uzbek + Sindhi + Amharic + Fula + Romanian + Igbo + Cebuano + Gujarati + Kanuri + Malay + Kannada + Dutch + Hungarian + Greek + Czech + Hebrew + Croatian + + + \ No newline at end of file diff --git a/app/src/main/res/values/native_languages.xml b/app/src/main/res/values/native_languages.xml new file mode 100644 index 0000000..88c1b36 --- /dev/null +++ b/app/src/main/res/values/native_languages.xml @@ -0,0 +1,55 @@ + + + + English + äž­æ–‡ + Español + à€¹à€¿à€šà¥à€Šà¥€ + العرؚية + àŠ¬àŠŸàŠ‚àŠ²àŠŸ + Português + РусскОй + àšªà©°àšœàšŸàš¬à©€ + à€®à€°à€Ÿà€ à¥€ + ఀెలుగు + TÃŒrkçe + 한국얎 + Français + Deutsch + Tiếng Việt + ஀மிஎ் + اردو + Bahasa Indonesia + Italiano + 日本語 + فارسی + à€­à¥‹à€œà€ªà¥à€°à¥€ + Polski + ٟښتو + Basa Jawa + à€®à¥ˆà€¥à€¿à€²à¥€ + àŽ®àŽ²àŽ¯àŽŸàŽ³àŽ‚ + Basa Sunda + Hausa + ଓଡଌିଆ + မဌန်မာ + УкраїМська + Yorùbá + OÊ»zbekcha + سنڌي + አማርኛ + Fulfulde + Română + Igbo + Cebuano + ગુજરટ઀ી + Kanuri + Bahasa Melayu + ಕಚ್ಚಡ + Nederlands + Magyar + ΕλληΜικά + ČeÅ¡tina + עבךית + Hrvatski + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6c6ac51 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,1052 @@ + + + Achieved + App Logo + Back + Clear Search + Clear Text + Collapse + Error + Expand + Navigate back + Paste + Re-generate Definition + Search + Start Exercise + Success + Switch Languages + Tag Category + Target Met + Text to Speech + Toggle Menu + Translation History + + Choose Exercise Types + + Clear All + + Close exercise + Close selection mode + + Colloquial + + Connecting Your AI Model + + Contact me for bug reports, ideas, feature requests, and more. + Contact developer + + Context + + Copied Text + + Copy text + + Correct answer: %1$s + Correct answers + Correct answers: %1$d + Tone + + Create + Create a new custom language entry for this ID. + Create New Category + Create New Language + + Creative + + Current Level + Current Streak + + Custom Exercise Prompt + + %1$d Selected + %1$s: The quick brown fox jumps over the lazy dog. + + Daily Learning Goal + + Danger Zone + + %1$d days + + Default + + Definition + + This will delete all downloaded dictionaries from your phone. + Delete all dictionaries? + Delete %1$d Items + Delete Exercise + Delete Items in Category + Delete Model + Delete New + Delete Provider + + Description + + Deselect All + + Dismiss + + Display info Buttons on the screen for help. + Display Name + + Due Today: %1$s + + Duplicate Detected + + Duplicates Only + + Duration + + Edit + Edit Features for \'%1$s\' + + Editing Text: %1$s + + Email Address + Email Log + + Enabled + + Endpoint (e.g., /v1/chat/completions/) + + No rows to import. Please check the selected columns and header row. + Error: No text to edit + Error parsing table + Error parsing table: %1$s + Please select two languages. + Please select two columns. + + Example + + Examples + + Exception + + Exercise \'%1$s\' created! + Exercise Complete! + Exercise Settings + Exercise Settings Description + + Existing Item (ID: %1$d) + + Experimental Features + Enable experimental features that are not yet ready for production. + + Export Vocabulary Data + + Failed to fetch manifest: %1$s + + Faulty items only + + Fetch All Grammar Infos + + Fetching for %d Items + Fetching Grammar Details + + Filter and Sort + Filter by Stage + Filter by Word Type + + Find Translations + + Finish + + Flip Card + + Formal + + Found %1$d items using this missing language ID. + Found Items + + Friendly + + Future + + General Settings + + Generate related vocabulary items + + Get Started + + Got it! + + Here\'s how you did: + + Hide Affected Items + Hide + + Hint: %1$s + Answer Correctly + Answer Incorrectly + Customizable + This is how the dictionary works: + Example Hint Scan for Models Hint + How It Works + How to connect to an AI + How to generate Vocabulary with AI + The word moves to the next stage, and you\'ll see it again after a longer break. + The word moves back another stage. This helps you focus on vocabulary you find difficult. + This screen lets you customize the instructions for generating new vocabulary entries. You can control what information is included, like definitions, example sentences, or phonetic transcriptions. + "Use this screen to define a custom instruction for the AI translation model. You can specify the tone, style, or format of the translation. " + You can costumize all intervals and rules in the settings. + + Imperative + + Import Vocabulary Data + + Incorrect + Incorrect answers: %1$d + + Indicative + + %1$d vocabulary items imported. + + If you need help, you can find hints in all sections of the app. + Need Help? + + Item Details + Item ID: %1$d + + %1$d items + Items without grammar infos + + Keep Both + + About + Academic + Correct + Add + Add (%1$d) + Add Category + Add Custom Model + Add Custom Provider + Add Key + Add Model + Add Model Manually + Add synonym + Add to dictionary + + Add Vocabulary + Added + Adjective + Adverb + AI Configuration + AI Model + + All Stages + All Types + All Vocabulary + Alternatives + %1$d models + Analyze Grammar + Appearance + Apply Filters + Article + Backup and Restore + By Language + Cancel + Card (%1$d/%2$d) + Casual + Categories + Category + Category: %1$s + Clear + Close + Close search + Collapse + Column %1$d + Common + Completed + Confirm + Conjugation: %1$s + Conjunction + Continue + Correct + Create Exercise + Create Vocabulary with AI + Custom + Definitions + Delete + Delete all + Delete Items + Delete Key + Delete Model + Delete Provider + Description + Developer JSON + Dictionary + Dictionary Content + Dictionary Manager + Dictionary Options + AI Definition + Downloaded + Display Name + Done + Download + Easy + Enter a text + Etymology + Exercise + Exercises + Expand + Feminine + First Column + First Language + Gender + General + " (Auxiliary: %1$s)" + Hyphenation + Inflections + Meanings + Guessing + Hard + First Row is a Header + Hide examples + Import + Import Table (CSV) + In Stages + Interjection + Auto + Language Direction\n + None + Languages + Learned + Learning Criteria + Logs + Masculine + Medium + Model ID * + More + Move to First Stage + Multiple Choice + Neuter + New + Noun + Origin Language + Orphaned Files + Plural + Preposition + Preview (first 5) for first column: %1$s + Preview (first 5) for second column: %1$s + Pronoun + Providers + Quit app? + Quit Exercise? + Raw Data: + Related Words + Reload + Remove Articles + Save + Scan for Models + Scanning
 + Search models
 + Second Column + Second Language + Select + Select Stage + Show %1$d More + Show dictionary entry + Show examples + Show Less + Show more actions + Size: %1$d MB + Spelling + *required + Start + Start Exercise + Start Exercise (%1$d) + Statistics + Status + System + Target Language + Task Model Assignments + Tasks + Tense + Total Words + Training Mode + Translate + Translate from %1$s + Translation + Translation Settings + Translations + Unknown + Unknown Dictionary (%1$s) + Update + Verb + Version: %1$s + Vocabulary + Vocabulary Activity + Warning + Wiktionary + Word + Word Jumble + Wrong + Your translation + + %1$d models + + Language + Language Filter + Language Pair + Language with id %1$d not found + + First Language ID: %1$d + + Second Language ID: %1$d + + Last correct: %1$s + Last incorrect: %1$s + + Learning Stages + + Legal Information + + Less + + Levels + + "Licensed under" + + Listen + + Translator API Log %1$s + No logs available yet. + Duration: %1$s %2$s + Endpoint: %1$s %2$s + Error: %1$s + Exception: %1$s + Id: %1$s + Model: %1$s + Parse Error: %1$s + Provider: %1$s + Status: %1$s %2$s + Timeout: Yes + Timestamp: %1$s + Request JSON + --- Request --- + Response JSON + --- Response --- + Time + + Max Wrong to Demote + + Create YouTube Exercise + Generate vocabulary with AI + + Merge + Merge Items + + Min. Correct to Advance + + Model + Model ID (e.g. mistralai/mistral-nemo-instruct-2407) + + Mood + + More + More actions + + Move to Category + Move to Stage + + ms + + N/A + + Name in English + + New Item + New items only + + Newest First + + Next + Next: %1$s + Next Card + Next item + Next Month + + No + No cards found for the selected filters. + No grammar configuration found for this language. + No Items without Grammar + No model selected for the task: %1$s + No Models Configured + No models found + No New Vocabulary to Sort + No text received! + No vocabulary items found. Perhaps try changing the filters? + + Not available + Not launched with text to edit + + Number of Cards: %1$d / %2$d + + Oldest First + + Only Show Errors + + Open Source Licenses + + Options + + Origin + Origin of \"%1$s\" + + Parse Error + + Past + + Permanently delete all %1$d affected vocabulary items. + + Play audio + + Polite + + + Abbreviation + Adjective + Adjective-Noun Compound + Adjectival Phrase + Adnominal + Adverb + Adverbial Phrase + Affix + Ambiposition + Article + Character + Circumfix + Circumposition + Classifier + Clause + Combining Form + Component + Conjunction + Contraction + Converb + Counter + Determiner + Gerund + Hard Redirect + Infix + Interfix + Interjection + Interjection + Name/Proper Noun + Noun + Numeral/Number + Onomatopoeia + Onomatopoeia + Participle + Particle + Phrase + Postposition + Prefix + Preposition + Prepositional Phrase + Preverb + Pronoun + Proverb + Punctuation + Quantifier + Romanization + Root + Soft Redirect + Stem + Suffix + Syllable + Symbol + Typographic Variant + Unknown + Verb + + Prepare Exercise + + Present + + Previous item + Previous Month + + Primary Button + Primary With Icon + + Professional + + Progress by Category + + %1$d questions + + Quick word pairs + + Quit + + Refresh Word of the Day + + Remaining + + Remove from Category + + Replace with %1$s + + Repository state imported from %1$s + + Reset to Defaults + + Resolve Missing Language ID: %1$d + + Result + + Right + + Scan models + + Scanning
 + + Search for a word\'s origin + Search Models + Search query + Search vocabulary
 + + Secondary Button + Secondary Inverse + Secondary With Icon + + Select a Model + Select All + Select items to add + Select List (optional) + Select Amount of Items to Fetch + + Translator + Connection + Exercise Prompt + Progress + Custom Prompt + Repository + Voice + + Share + Share text + + Show Affected Items + Show API Key Missing Message + Show + Show Contextual Hints + Show Free Models Only + Show Hint + + Shuffle Cards + + Solution 1: Delete Items + Solution 2: Replace ID + Solution 3: Create Language + + Sort + Sort by Completion % + Sort by In Progress + Sort by Name + Sort by New Items + Sort by Size + Sort New Vocabulary + + Duplicate + When you\'re done, decide what to do with the item: + The app also helps you detect duplicates or remove articles for cleaner vocabulary lists. + On this screen, you sort your new vocabulary. You can correct spelling and translations, and assign items to categories. + Vocabulary Sorting + + Speaking Speed + + Stage 1 + Stage 2 + Stage 3 + Stage 4 + Stage 5 + Stage: %1$s + Stage Filter + Learned + New + + %1$d stages selected + + Start Fetching + Start Long + + Statistics are loading
 + + \"There are no issues with your vocabulary, all good!\" + Duplicates + Faulty Items + New Items + + Subjunctive + + Synonym exists + + Synonyms + + System Default Font + System Theme + + Tap the words below to form the sentence + + Target Correct Answers Per Day + + Test + + Training mode + + 200 OK + %1$d categories selected + %1$d Languages Selected + %1$s selected + 400 Bad Request + 401 Unauthorized + 403 Forbidden + 404 Not Found + 429 Too Many Requests + 500 Internal Server Error + A similar item already exists. How would you like to proceed? + A simple list to manually sort your vocabulary + Add Custom Language + Add grammar details + Add to favorites + AI failed to create the exercise. + AI generation failed with an exception + + All dictionaries deleted successfully + All items completed! + All Languages + Amount: %1$d + Amount: %1$d Questions + Amount of cards + Amount of questions: %1$d + An unexpected condition was encountered on the server. + An unknown error occurred. + And many more! 
 + Appearance Mode + Are you sure you want to delete this vocabulary item? + Are you sure you want to delete all items in this category? + Are you sure you want to delete these %d categories? + Are you sure you want to delete this category? + Are you sure you want to quit? + Are you sure you want to quit? Your progress in this session will be lost. + Assemble the word here + Assign a different language to these items. + Assign these items: + Authentication is required and has failed or has not yet been provided. + Automatically discover models from %1$s + Available Dictionaries + Available Models: + Available to create: + Base URL (e.g., \'http://192.168.0.99:1234/\') + Cancel Loading + Category Name + Category / Prompt + Change Key + Check + Check availability + Check your matches! + Checksum mismatch for %1$s. Expected: %2$s, Got: %3$s + Claude + Collapse Widget + Color Palette + Common + Configure which AI model to use for each task type + Contacting AI
 + "Content sourced from " + Copied to clipboard + Copy corrected text + Correct! + Could not fetch a new word. + Custom Dictionary Prompt + Custom Exercise + Customize the intervals and criteria for moving vocabulary cards between stages. Cards in lower stages should be asked more often than those in higher stages. + Daily Exercise + How many words do you want to answer correctly each day? + Dark + Day Streak + " days" + DeepSeek + Delete all providers and models + Delete all providers and models? + Delete Category + Delete custom language + Delete Vocabulary Item? + Developed by Jonas Gaudian\n + Are you sure you want to delete the Key for this Provider? + Are you sure you want to delete the model \"%1$s\" from %2$s? This action cannot be undone. + Are you sure you want to delete the provider \"%1$s\"? This will also remove all models associated with this provider. This action cannot be undone. + Dictionary deleted successfully + Dictionary downloaded successfully + You can download dictionaries for certain languages which can be used insteaf of AI generation for dictionary content. + Difficulty: %1$s + Do you want to minimize the app? + Download failed: HTTP %1$d %2$s + Drag to Reorder + "Due Today" + Due Today Only + Only ask cards that are due today. + E.g. en + E.g. English + e.g., Fast and efficient for simple tasks + e.g., GPT-4, Claude-3 + e.g., gpt-4, claude-3-sonnet + e.g., Irregular Verbs + E.g. GB + Edit Category + Enter a text to extract words and translate + Enter API Key + Enter model details yourself + Enter text to correct + Enter text to translate + Enter your custom prompt + Error: %1$s + Error deleting dictionaries: %1$s + Error deleting dictionary: %1$s + Error deleting orphaned file: %1$s + Error downloading dictionary: %1$s + Error generating questions: %1$s + Error loading stored values: %1$s + Error saving entry: %1$s + Example Prompts + Excel is not supported. Use CSV instead. + Expand Widget + Explanation + Export Category + Failed to delete dictionary: %1$s + Failed to delete orphaned file: %1$s + Failed to delete some dictionaries + Failed to download dictionary: %1$s + Failed to fetch etymology + Failed to fetch manifest: %1$s + Failed to get translations: %1$s + False + Favorites + Filter + Filter: All items + Finish Video and Start Exercise + Font Style + Frequent + Gemini + Generate + Generate Exercise with AI + Generating questions from video
 + Get API Key at %1$s + Here you can set a custom prompt for the AI translation model. This allows you to fine-tune the translation style. + Here you can set a custom prompt for the AI vocabulary model. This allows you to define how new vocabulary entries are generated. + Hint + Hint: You can search for any term, e.g. \"Things to do at the zoo\" or \"irregular verbs\"! + In Progress + Incorrect! + Rare + Interval Settings (in days) + Key Active + Key Optional + Enter a word\n + Language Code + You can set an optional preference which language should come first or second. + Language Options + Last 7 Days + Let AI find vocabulary for you! + Light + List + Loading
 + Manual vocabulary list + Match the pairs + Mismatch between question IDs in exercise and questions found in repository. + Mistral + More options + More Stats + Name of the language + Navigation Bar Labels + New Vocabulary for this Exercise + No Data Available + No dictionaries available + No dictionary language pairs found. Add vocabulary items with different languages first. + No items available + No Key + No models found + No progress + No valid API configuration could be found in the settings. Before using this app, you have to configure at least one API provider. + No vocabulary available. + No Vocabulary Due Today + None + OpenAI + OpenRouter + " (optional)" + Optional: Describe what this model is good for + Orphaned file deleted successfully + This file exists locally but is not in the server manifest or missing assets. It may be from an older version or a failed download. + Paste or open a YouTube link to see its subtitles here. + Please select a dictionary language first. + Question + Question %1$d of %2$d + Question Types + Very Rare + Recent History + Region + Remove from favorites + Repeat Wrong + Repeat Wrong Guesses + Required: Enter a human-readable name + Required: Enter the exact model identifier + Reset Intro + Rows to import: %1$d + Sample Word + Save Key + Save Prompt + Scan for Available Models + Search
 + Search History + Search Term + Select all languages + Select Amount + Select Auto Recognition + Select Categories + Select Category + Select Languages + Select Model + Select None + Select the content to be generated for a dictionary entry. + Select Translations to Add + Selected + Version information not available. + Oops! Something went wrong. + This is an info message. + Show Error Message + Show Info Message + Show Loading + Show text labels on the main navigation bar. + Shuffle card order + Shuffle Card Order + Shuffle Languages + Shuffle what language comes first. Does not affect language direction preferences. + Shuffle questions + Some items are in the wrong category. + Stage %1$s + Start Over + Success! + Swap sides + Text + That\'s not quite right. + The correct answer is: + The correct order is: %1$s + The correct sentence was: %1$s + The correct translation is: %1$s + Theme Preview + These files exist locally but are not in the server manifest. They may be from older versions. + This must match the provider\'s model name exactly + This will remove all configured API providers, models, and stored API keys. This action cannot be undone. + Too Many Requests: The user has sent too many requests in a given amount of time. + Total Learned Words + Training Mode + Training mode is enabled: answers won’t affect progress. + Enter translation + Translation will appear here + True + Try first finding the word on Wiktionary before generating AI response + Try Wiktionary First + Uncommon + Unknown Language + Use downloaded dictionary + Version: v%1$s-%2$s + Very Frequent + View All + Visit my website + No Vocabulary Items could be found. Add now? + Vocabulary Prompt + Watch Video Again + Weekly Activity + Word of the Day + Word Pair Exercise + Word Pair Settings + Your Own AI + YouTube Link + + The request was successful. + The requested resource could not be found. + The server could not understand the request. + The server understood the request, but is refusing to authorize it. + + This is a hint. + This is a sample output text. + This is the content inside the card. + This is the main content. + This mode will not affect your progress in stages. + + Timeout + + Corrector + Dashboard + Developer Options + HTTP Status Codes + Items Without Grammar + Multiple + Settings + Show Success Message + Single + Preview Title + Category Progress + Due Today + Streak + + to %1$s + + Toggle Licenses + Use Translate server + When enabled, translations will use a translation server for supported language pairs. Unsupported pairs will automatically fall back to your AI model. + + Translate the following (%1$s): + + Translation Prompt Settings + Translation Server + + Try Again + + I am a voice trapped in a Computer. + + Type the translation + Type what you hear + + Vocabulary Added + Vocabulary Repository + Vocabulary Settings + + Website URL + + Changelog + What\'s New + + Wipe Repository (delete all data) + + Word Type + + %1$d words + %1$d words + Words Completed + %1$d Words Known + %1$d words required + + Wrong answers + + Yes + + You\'ve mastered the final level! + + Your Answer + Your Language Journey + * required + No history yet + Play + Pronunc iation + Clipboard is empty + Paste + Target Tone: + Grammar only + Declension + Variations + Auto Cycle (Dev) + Regenerate + Read Aloud + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..dbac7a8 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + + #1C1B1F + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..fa0f996 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..9ee9997 --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..f1da30f --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,17 @@ + + + + + + + 23.88.48.47 + 10.0.0.0/8 + 172.16.0.0/12 + 192.168.0.0/16 + + + + diff --git a/app/src/test/java/eu/gaudian/translator/HiltTestApplication.kt b/app/src/test/java/eu/gaudian/translator/HiltTestApplication.kt new file mode 100644 index 0000000..c5f91c4 --- /dev/null +++ b/app/src/test/java/eu/gaudian/translator/HiltTestApplication.kt @@ -0,0 +1,5 @@ +package eu.gaudian.translator + +import android.app.Application + +class HiltTestApplication : Application() diff --git a/app/src/test/java/eu/gaudian/translator/model/grammar/AdjectiveVariationsParserTest.kt b/app/src/test/java/eu/gaudian/translator/model/grammar/AdjectiveVariationsParserTest.kt new file mode 100644 index 0000000..4ed4c95 --- /dev/null +++ b/app/src/test/java/eu/gaudian/translator/model/grammar/AdjectiveVariationsParserTest.kt @@ -0,0 +1,253 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.grammar + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for AdjectiveVariationsParser. + * + * These tests ensure that the parsing logic works correctly for different + * languages and form combinations. + */ +class AdjectiveVariationsParserTest { + + @Test + fun `isAdjectiveWithVariations returns true for French adjective with POS`() { + val forms = listOf( + FormData("beau", listOf("masculine", "singular"), emptyList()), + FormData("belle", listOf("feminine", "singular"), emptyList()) + ) + + val result = AdjectiveVariationsParser.isAdjectiveWithVariations( + langCode = "fr", + pos = "adjective", + forms = forms + ) + + assertTrue("Should detect French adjective with POS", result) + } + + @Test + fun `isAdjectiveWithVariations returns true for French adjective without POS but with gender tags`() { + val forms = listOf( + FormData("beau", listOf("masculine", "singular"), emptyList()), + FormData("belle", listOf("feminine", "singular"), emptyList()) + ) + + val result = AdjectiveVariationsParser.isAdjectiveWithVariations( + langCode = "fr", + pos = null, + forms = forms + ) + + assertTrue("Should detect French adjective by gender/number tags", result) + } + + @Test + fun `isAdjectiveWithVariations returns false for unsupported language`() { + val forms = listOf( + FormData("beautiful", listOf("masculine", "singular"), emptyList()) + ) + + val result = AdjectiveVariationsParser.isAdjectiveWithVariations( + langCode = "en", + pos = "adjective", + forms = forms + ) + + assertFalse("Should not detect adjective for unsupported language", result) + } + + @Test + fun `isAdjectiveWithVariations returns false for empty forms`() { + val result = AdjectiveVariationsParser.isAdjectiveWithVariations( + langCode = "fr", + pos = "adjective", + forms = emptyList() + ) + + assertFalse("Should not detect adjective with no forms", result) + } + + @Test + fun `isAdjectiveWithVariations returns false for forms without gender tags`() { + val forms = listOf( + FormData("beautiful", listOf("positive"), emptyList()) + ) + + val result = AdjectiveVariationsParser.isAdjectiveWithVariations( + langCode = "fr", + pos = null, + forms = forms + ) + + assertFalse("Should not detect adjective without gender/number tags", result) + } + + @Test + fun `parseVariations handles complete French adjective`() { + val forms = listOf( + FormData("beaux", listOf("plural", "masculine"), listOf("bo")), + FormData("belle", listOf("singular", "feminine"), listOf("bɛl")), + FormData("belles", listOf("plural", "feminine"), listOf("bɛl")) + ) + + val result = AdjectiveVariationsParser.parseVariations(forms, "beau") + + assertEquals("Should have 4 variations", 4, result.variations.size) + assertTrue("Should have complete data", result.hasCompleteData) + + // Check masculine singular (should use lemma) + val mascSingular = result.variations.find { + it.gender == "masculine" && it.number == "singular" + } + assertNotNull("Should have masculine singular", mascSingular) + assertEquals("beau", mascSingular!!.form) + + // Check masculine plural + val mascPlural = result.variations.find { + it.gender == "masculine" && it.number == "plural" + } + assertNotNull("Should have masculine plural", mascPlural) + assertEquals("beaux", mascPlural!!.form) + + // Check feminine singular + val femSingular = result.variations.find { + it.gender == "feminine" && it.number == "singular" + } + assertNotNull("Should have feminine singular", femSingular) + assertEquals("belle", femSingular!!.form) + assertEquals(listOf("bɛl"), femSingular.ipas) + + // Check feminine plural + val femPlural = result.variations.find { + it.gender == "feminine" && it.number == "plural" + } + assertNotNull("Should have feminine plural", femPlural) + assertEquals("belles", femPlural!!.form) + } + + @Test + fun `parseVariations handles incomplete data`() { + val forms = listOf( + FormData("belle", listOf("singular", "feminine"), emptyList()), + FormData("belles", listOf("plural", "feminine"), emptyList()) + ) + + val result = AdjectiveVariationsParser.parseVariations(forms, "beau") + + assertEquals("Should have 3 variations (2 from forms + 1 from lemma)", 3, result.variations.size) + assertFalse("Should not have complete data", result.hasCompleteData) + + // Should have masculine singular from lemma + val mascSingular = result.variations.find { + it.gender == "masculine" && it.number == "singular" + } + assertNotNull("Should have masculine singular from lemma", mascSingular) + assertEquals("beau", mascSingular!!.form) + } + + @Test + fun `parseVariations handles no lemma fallback`() { + val forms = listOf( + FormData("belle", listOf("singular", "feminine"), emptyList()), + FormData("belles", listOf("plural", "feminine"), emptyList()) + ) + + val result = AdjectiveVariationsParser.parseVariations(forms, null) + + assertEquals("Should have 2 variations", 2, result.variations.size) + assertFalse("Should not have complete data", result.hasCompleteData) + + // Should not have masculine singular without lemma + val mascSingular = result.variations.find { + it.gender == "masculine" && it.number == "singular" + } + assertNull("Should not have masculine singular without lemma", mascSingular) + } + + @Test + fun `getLanguageDisplayLabels returns French labels`() { + val labels = AdjectiveVariationsParser.getLanguageDisplayLabels("fr") + + assertEquals("Masculin", labels["masculine"]) + assertEquals("Féminin", labels["feminine"]) + assertEquals("Singulier", labels["singular"]) + assertEquals("Pluriel", labels["plural"]) + } + + @Test + fun `getLanguageDisplayLabels returns Portuguese labels`() { + val labels = AdjectiveVariationsParser.getLanguageDisplayLabels("pt") + + assertEquals("Masculino", labels["masculine"]) + assertEquals("Feminino", labels["feminine"]) + assertEquals("Singular", labels["singular"]) + assertEquals("Plural", labels["plural"]) + } + + @Test + fun `getLanguageDisplayLabels returns German labels`() { + val labels = AdjectiveVariationsParser.getLanguageDisplayLabels("de") + + assertEquals("Maskulin", labels["masculine"]) + assertEquals("Feminin", labels["feminine"]) + assertEquals("Neutrum", labels["neuter"]) + assertEquals("Singular", labels["singular"]) + assertEquals("Plural", labels["plural"]) + } + + @Test + fun `getLanguageDisplayLabels returns English labels for unsupported language`() { + val labels = AdjectiveVariationsParser.getLanguageDisplayLabels("es") + + assertEquals("Masculine", labels["masculine"]) + assertEquals("Feminine", labels["feminine"]) + assertEquals("Neuter", labels["neuter"]) + assertEquals("Singular", labels["singular"]) + assertEquals("Plural", labels["plural"]) + } + + @Test + fun `parseVariations handles German adjective with neuter`() { + val forms = listOf( + FormData("gut", listOf("masculine", "singular"), emptyList()), + FormData("gute", listOf("feminine", "singular"), emptyList()), + FormData("gut", listOf("neuter", "singular"), emptyList()), + FormData("gute", listOf("plural"), emptyList()) + ) + + val result = AdjectiveVariationsParser.parseVariations(forms, "gut") + + assertEquals("Should have 4 variations", 4, result.variations.size) + + // Check neuter singular + val neuterSingular = result.variations.find { + it.gender == "neuter" && it.number == "singular" + } + assertNotNull("Should have neuter singular", neuterSingular) + assertEquals("gut", neuterSingular!!.form) + } + + @Test + fun `parseVariations handles case insensitive tags`() { + val forms = listOf( + FormData("Belle", listOf("SINGULAR", "FEMININE"), emptyList()) + ) + + val result = AdjectiveVariationsParser.parseVariations(forms, "beau") + + assertEquals("Should have 1 variation", 1, result.variations.size) + + val variation = result.variations.first() + assertEquals("feminine", variation.gender) + assertEquals("singular", variation.number) + assertEquals("Belle", variation.form) + } +} diff --git a/app/src/test/java/eu/gaudian/translator/model/grammar/DictionaryJsonParserTest.kt b/app/src/test/java/eu/gaudian/translator/model/grammar/DictionaryJsonParserTest.kt new file mode 100644 index 0000000..48a7974 --- /dev/null +++ b/app/src/test/java/eu/gaudian/translator/model/grammar/DictionaryJsonParserTest.kt @@ -0,0 +1,512 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.grammar + +import org.junit.Test +import org.junit.Assert.* +import org.junit.Before + +/** + * Unit tests for DictionaryJsonParser. + * + * These tests verify that the JSON parser correctly extracts and structures + * dictionary data from various JSON formats, ensuring robust handling of + * different dictionary data structures. + */ +class DictionaryJsonParserTest { + + private lateinit var parser: DictionaryJsonParser + + @Before + fun setUp() { + parser = DictionaryJsonParser + } + + @Test + fun `parseJson with valid complete dictionary entry returns structured data`() { + // Given + val json = """ + { + "translations": [ + { + "lang_code": "en", + "word": "house", + "sense": "building" + }, + { + "language_code": "fr", + "word": "maison", + "sense": "dwelling" + } + ], + "relations": { + "synonyms": [ + {"word": "home", "sense_index": "1"}, + {"word": "residence"} + ], + "hyponyms": [ + {"word": "cottage"} + ] + }, + "phonetics": { + "ipa": ["[haʊs]", "[haʊz]"] + }, + "hyphenation": ["house"], + "etymology": { + "texts": ["From Old English hÅ«s.", "Related to German Haus."] + }, + "senses": [ + { + "gloss": "A building for human habitation.", + "topics": ["architecture", "building"], + "examples": ["They live in a small house."] + } + ], + "grammatical_properties": { + "other_tags": ["noun", "countable"] + }, + "pronunciation": [ + { + "ipa": "/haʊs/", + "rhymes": "mouse" + } + ], + "audio_files": [ + { + "url": "https://example.com/house.mp3", + "description": "Pronunciation of house" + } + ], + "inflections": [ + { + "form": "houses", + "grammatical_features": ["plural"] + } + ] + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + + // Test translations + assertEquals(2, result?.translations?.size) + val enTranslation = result?.translations?.find { it.languageCode == "en" } + assertEquals("house", enTranslation?.word) + assertEquals("building", enTranslation?.sense) + + val frTranslation = result?.translations?.find { it.languageCode == "fr" } + assertEquals("maison", frTranslation?.word) + assertEquals("dwelling", frTranslation?.sense) + + // Test relations + assertEquals(2, result?.relations?.size) + val synonyms = result?.synonyms + assertEquals(2, synonyms?.size) + assertTrue(synonyms?.any { it.word == "home" } == true) + assertTrue(synonyms?.any { it.word == "residence" } == true) + + val hyponyms = result?.hyponyms + assertEquals(1, hyponyms?.size) + assertEquals("cottage", hyponyms?.first()?.word) + + // Test phonetics + assertEquals(2, result?.phonetics?.size) + assertTrue(result?.phonetics?.contains("haʊs") == true) + assertTrue(result?.phonetics?.contains("haʊz") == true) + + // Test hyphenation + assertEquals(1, result?.hyphenation?.size) + assertEquals("house", result?.hyphenation?.first()) + + // Test etymology + assertEquals(2, result?.etymology?.texts?.size) + assertTrue(result?.etymology?.texts?.contains("From Old English hÅ«s.") == true) + + // Test senses + assertEquals(1, result?.senses?.size) + val sense = result?.senses?.first() + assertEquals("A building for human habitation.", sense?.gloss) + assertTrue(sense?.topics?.contains("architecture") == true) + assertTrue(sense?.examples?.contains("They live in a small house.") == true) + + // Test grammatical properties + assertNotNull(result?.grammaticalProperties) + assertTrue(result?.grammaticalProperties?.otherTags?.contains("noun") == true) + + // Test pronunciation + assertEquals(1, result?.pronunciation?.size) + val pronunciation = result?.pronunciation?.first() + assertEquals("/haʊs/", pronunciation?.ipa) + assertEquals("mouse", pronunciation?.rhymes) + + // Test audio files + assertEquals(1, result?.audioFiles?.size) + val audio = result?.audioFiles?.first() + assertEquals("https://example.com/house.mp3", audio?.url) + assertEquals("Pronunciation of house", audio?.description) + + // Test inflections + assertEquals(1, result?.inflections?.size) + val inflection = result?.inflections?.first() + assertEquals("houses", inflection?.form) + assertTrue(inflection?.grammaticalFeatures?.contains("plural") == true) + } + + @Test + fun `parseJson with minimal dictionary entry returns data with empty collections`() { + // Given + val json = """{}""" + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(0, result?.translations?.size) + assertEquals(0, result?.relations?.size) + assertEquals(0, result?.phonetics?.size) + assertEquals(0, result?.hyphenation?.size) + assertEquals(0, result?.etymology?.texts?.size) + assertEquals(0, result?.senses?.size) + assertNull(result?.grammaticalProperties) + assertEquals(0, result?.pronunciation?.size) + assertEquals(0, result?.audioFiles?.size) + assertEquals(0, result?.inflections?.size) + } + + @Test + fun `parseJson with etymology as single text returns correct structure`() { + // Given + val json = """ + { + "etymology": { + "text": "Single etymology text." + } + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(1, result?.etymology?.texts?.size) + assertEquals("Single etymology text.", result?.etymology?.texts?.first()) + } + + @Test + fun `parseJson with etymology as array returns correct structure`() { + // Given + val json = """ + { + "etymology": [ + "First etymology text.", + "Second etymology text." + ] + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(2, result?.etymology?.texts?.size) + assertTrue(result?.etymology?.texts?.contains("First etymology text.") == true) + assertTrue(result?.etymology?.texts?.contains("Second etymology text.") == true) + } + + @Test + fun `parseJson with invalid JSON returns null`() { + // Given + val invalidJson = """{ invalid json }""" + + // When + val result = parser.parseJson(invalidJson) + + // Then + assertNull(result) + } + + @Test + fun `parseJson with translations using language_code field works correctly`() { + // Given + val json = """ + { + "translations": [ + { + "language_code": "de", + "word": "Haus" + } + ] + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(1, result?.translations?.size) + val translation = result?.translations?.first() + assertEquals("de", translation?.languageCode) + assertEquals("Haus", translation?.word) + } + + @Test + fun `parseJson with translations missing required fields filters them out`() { + // Given + val json = """ + { + "translations": [ + { + "lang_code": "en", + "word": "house" + }, + { + "lang_code": "fr" + // Missing word field + }, + { + "word": "casa" + // Missing lang_code field + } + ] + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(1, result?.translations?.size) + assertEquals("en", result?.translations?.first()?.languageCode) + assertEquals("house", result?.translations?.first()?.word) + } + + @Test + fun `parseJson with relations missing word field filters them out`() { + // Given + val json = """ + { + "relations": { + "synonyms": [ + {"word": "home"}, + {"sense_index": "1"} + // Missing word field + ] + } + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(1, result?.synonyms?.size) + assertEquals("home", result?.synonyms?.first()?.word) + } + + @Test + fun `parseJson handles phonetics with brackets correctly`() { + // Given + val json = """ + { + "phonetics": { + "ipa": ["[haʊs]", "[haʊz]"] + } + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(2, result?.phonetics?.size) + assertTrue(result?.phonetics?.contains("haʊs") == true) // Brackets should be removed + assertTrue(result?.phonetics?.contains("haʊz") == true) + } + + @Test + fun `parseJson with empty arrays returns empty collections`() { + // Given + val json = """ + { + "translations": [], + "relations": {}, + "phonetics": {"ipa": []}, + "hyphenation": [], + "etymology": {"texts": []}, + "senses": [], + "pronunciation": [], + "audio_files": [], + "inflections": [] + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(0, result?.translations?.size) + assertEquals(0, result?.relations?.size) + assertEquals(0, result?.phonetics?.size) + assertEquals(0, result?.hyphenation?.size) + assertEquals(0, result?.etymology?.texts?.size) + assertEquals(0, result?.senses?.size) + assertEquals(0, result?.pronunciation?.size) + assertEquals(0, result?.audioFiles?.size) + assertEquals(0, result?.inflections?.size) + } + + @Test + fun `parseJson with senses missing gloss filters them out`() { + // Given + val json = """ + { + "senses": [ + { + "gloss": "Valid definition", + "topics": ["topic1"], + "examples": ["example1"] + }, + { + "topics": ["topic2"] + // Missing gloss field + } + ] + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(1, result?.senses?.size) + assertEquals("Valid definition", result?.senses?.first()?.gloss) + } + + @Test + fun `parseJson with pronunciation missing ipa filters them out`() { + // Given + val json = """ + { + "pronunciation": [ + { + "ipa": "/haʊs/", + "rhymes": "mouse" + }, + { + "rhymes": "house" + // Missing ipa field + } + ] + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(1, result?.pronunciation?.size) + assertEquals("/haʊs/", result?.pronunciation?.first()?.ipa) + } + + @Test + fun `parseJson with audio files missing url filters them out`() { + // Given + val json = """ + { + "audio_files": [ + { + "url": "https://example.com/audio.mp3", + "description": "Audio description" + }, + { + "description": "Missing URL" + // Missing url field + } + ] + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(1, result?.audioFiles?.size) + assertEquals("https://example.com/audio.mp3", result?.audioFiles?.first()?.url) + } + + @Test + fun `parseJson with inflections missing form filters them out`() { + // Given + val json = """ + { + "inflections": [ + { + "form": "houses", + "grammatical_features": ["plural"] + }, + { + "grammatical_features": ["singular"] + // Missing form field + } + ] + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + assertEquals(1, result?.inflections?.size) + assertEquals("houses", result?.inflections?.first()?.form) + } + + @Test + fun `DictionaryEntryData convenience properties work correctly`() { + // Given + val json = """ + { + "relations": { + "synonyms": [{"word": "home"}, {"word": "residence"}], + "hyponyms": [{"word": "cottage"}], + "hypernyms": [{"word": "building"}] + } + } + """.trimIndent() + + // When + val result = parser.parseJson(json) + + // Then + assertNotNull(result) + + // Test synonyms convenience property + assertEquals(2, result?.synonyms?.size) + assertTrue(result?.synonyms?.all { it.relationType == "synonyms" } == true) + + // Test hyponyms convenience property + assertEquals(1, result?.hyponyms?.size) + assertTrue(result?.hyponyms?.all { it.relationType == "hyponyms" } == true) + + // Test allRelatedWords convenience property + assertEquals(3, result?.allRelatedWords?.size) + assertTrue(result?.allRelatedWords?.any { it.word == "home" } == true) + assertTrue(result?.allRelatedWords?.any { it.word == "cottage" } == true) + assertTrue(result?.allRelatedWords?.any { it.word == "building" } == true) + } +} diff --git a/app/src/test/java/eu/gaudian/translator/model/grammar/LocalDictionaryMorphologyMapperTest.kt b/app/src/test/java/eu/gaudian/translator/model/grammar/LocalDictionaryMorphologyMapperTest.kt new file mode 100644 index 0000000..a8e5685 --- /dev/null +++ b/app/src/test/java/eu/gaudian/translator/model/grammar/LocalDictionaryMorphologyMapperTest.kt @@ -0,0 +1,110 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.model.grammar + +import eu.gaudian.translator.model.grammar.LocalDictionaryMorphologyMapper.parseMorphology +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Test + +class LocalDictionaryMorphologyMapperTest { + + @Test + fun testGermanNounDeclensionParsing() { + // Test JSON from the issue description + val json = """ + { + "senses": [ + { + "glosses": ["Stille, Einsamkeit im Wald"], + "raw_tags": ["als religiöses und literarisches Motiv"], + "examples": [ + "„Der schöne Kranz gefiel wohl Allen,", + "„Zum gottgefÀlligen BÌßerleben zogen sich Eremiten, Klausner und Inklusen in die Waldeinsamkeit zurÃŒck und lebten dort in Höhlen oder HÃŒtten, vom Volk mit schaudernder Ehrfurcht bestaunt.“" + ], + "sense_index": "1" + } + ], + "phonetics": { + "ipa": ["[ˈvaltˌʔaɪ̯nzaːmkaɪ̯t]"] + }, + "hyphenation": ["Wald", "ein", "sam", "keit"], + "forms": [ + { + "form": "Waldeseinsamkeit", + "tags": ["variant"] + }, + { + "form": "die Waldeinsamkeit", + "tags": ["nominative", "singular"] + }, + { + "form": "die Waldeinsamkeiten", + "tags": ["nominative", "plural"] + }, + { + "form": "der Waldeinsamkeit", + "tags": ["genitive", "singular"] + }, + { + "form": "der Waldeinsamkeiten", + "tags": ["genitive", "plural"] + }, + { + "form": "der Waldeinsamkeit", + "tags": ["dative", "singular"] + }, + { + "form": "den Waldeinsamkeiten", + "tags": ["dative", "plural"] + }, + { + "form": "die Waldeinsamkeit", + "tags": ["accusative", "singular"] + }, + { + "form": "die Waldeinsamkeiten", + "tags": ["accusative", "plural"] + } + ], + "grammatical_features": { + "tags": ["feminine"], + "gender": "feminine" + } + } + """.trimIndent() + + val data = Json.decodeFromString(json) + val result = parseMorphology( + langCode = "de", + pos = "noun", + lemma = "Waldeinsamkeit", + data = data, + languageConfig = null + ) + + // Verify that we get a Noun morphology with declension + assert(result is WordMorphology.Noun) { "Expected Noun morphology, got ${result?.javaClass?.simpleName}" } + + val noun = result as WordMorphology.Noun + val declension = noun.declension + + // Verify we have all four cases + assertEquals(listOf("nominative", "genitive", "dative", "accusative"), declension.cases) + assertEquals(listOf("singular", "plural"), declension.numbers) + + // Verify we have forms for all case/number combinations + assertEquals(8, declension.forms.size) // 4 cases × 2 numbers = 8 forms + + // Verify specific forms + assertEquals("die Waldeinsamkeit", declension.forms["nominative" to "singular"]) + assertEquals("die Waldeinsamkeiten", declension.forms["nominative" to "plural"]) + assertEquals("der Waldeinsamkeit", declension.forms["genitive" to "singular"]) + assertEquals("der Waldeinsamkeiten", declension.forms["genitive" to "plural"]) + assertEquals("der Waldeinsamkeit", declension.forms["dative" to "singular"]) + assertEquals("den Waldeinsamkeiten", declension.forms["dative" to "plural"]) + assertEquals("die Waldeinsamkeit", declension.forms["accusative" to "singular"]) + assertEquals("die Waldeinsamkeiten", declension.forms["accusative" to "plural"]) + } +} \ No newline at end of file diff --git a/app/src/test/java/eu/gaudian/translator/utils/ApiArchitectureTest.kt b/app/src/test/java/eu/gaudian/translator/utils/ApiArchitectureTest.kt new file mode 100644 index 0000000..e3d6400 --- /dev/null +++ b/app/src/test/java/eu/gaudian/translator/utils/ApiArchitectureTest.kt @@ -0,0 +1,511 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import android.content.Context +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.communication.ApiManager +import eu.gaudian.translator.model.communication.ModelType +import eu.gaudian.translator.utils.dictionary.DictionaryService +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class JsonHelperTest { + + private lateinit var jsonHelper: JsonHelper + + @Before + fun setup() { + jsonHelper = JsonHelper() + } + + @Test + fun `parseJson should successfully parse valid JSON`() = runTest { + // Given + val validJson = """{"word": "hello", "parts": [{"title": "Definition", "content": "A greeting"}]}""" + + // When & Then + // This test would need proper serializer setup + // For now, just test the validation + assertTrue(jsonHelper.validateRequiredFields(validJson, listOf("word", "parts"))) + } + + @Test + fun `validateRequiredFields should return false for missing fields`() { + // Given + val incompleteJson = """{"word": "hello"}""" + val requiredFields = listOf("word", "parts") + + // When + val result = jsonHelper.validateRequiredFields(incompleteJson, requiredFields) + + // Then + assertFalse(result) + } + + @Test + fun `validateRequiredFields should return true for complete fields`() { + // Given + val completeJson = """{"word": "hello", "parts": []}""" + val requiredFields = listOf("word", "parts") + + // When + val result = jsonHelper.validateRequiredFields(completeJson, requiredFields) + + // Then + assertTrue(result) + } + + @Test + fun `extractField should return correct value`() { + // Given + val json = """{"word": "hello", "parts": []}""" + + // When + val result = jsonHelper.extractField(json, "word") + + // Then + assertEquals("hello", result) + } + + @Test + fun `extractField should return null for missing field`() { + // Given + val json = """{"word": "hello"}""" + + // When + val result = jsonHelper.extractField(json, "missing") + + // Then + assertEquals(null, result) + } +} + +class ApiRequestHandlerTest { + + private lateinit var apiManager: ApiManager + private lateinit var context: Context + private lateinit var apiRequestHandler: ApiRequestHandler + + @Before + fun setup() { + apiManager = mockk(relaxed = true) + context = mockk(relaxed = true) + apiRequestHandler = ApiRequestHandler(apiManager, context) + } + + @Test + fun `executeRequest with template should handle successful response`() = runTest { + // Given + val template = DictionaryDefinitionRequest( + word = "test", + language = "English", + requestedParts = "Definition" + ) + + // Mock the API manager response + every { + apiManager.getCompletion( + prompt = any(), + callback = any(), + modelType = ModelType.DICTIONARY + ) + } answers { + val callback = thirdArg<(String?) -> Unit>() + callback("""{"word": "test", "parts": [{"title": "Definition", "content": "A test"}]}""") + } + + // When + val result = apiRequestHandler.executeRequest(template) + + // Then + assertTrue(result.isSuccess) + result.getOrNull()?.let { response -> + assertEquals("test", response.word) + assertEquals(1, response.parts.size) + } + } + + @Test + fun `executeRequest with template should handle API failure`() = runTest { + // Given + val template = DictionaryDefinitionRequest( + word = "test", + language = "English", + requestedParts = "Definition" + ) + + every { + apiManager.getCompletion( + prompt = any(), + callback = any(), + modelType = ModelType.DICTIONARY + ) + } answers { + val callback = secondArg<(String) -> Unit>() + callback("API Error") + } + + // When + val result = apiRequestHandler.executeRequest(template) + + // Then + assertTrue(result.isFailure) + } +} + +class ApiRequestTemplatesTest { + + @Test + fun `DictionaryDefinitionRequest should build correct prompt`() { + // Given + val template = DictionaryDefinitionRequest( + word = "hello", + language = "English", + requestedParts = "Definition, Origin" + ) + + // When + val prompt = template.buildPrompt() + + // Then + assertTrue(prompt.contains("hello")) + assertTrue(prompt.contains("English")) + assertTrue(prompt.contains("Definition, Origin")) + assertTrue(prompt.contains("JSON object")) + } + + @Test + fun `VocabularyTranslationRequest should build correct prompt`() { + // Given + val words = listOf("hello", "world") + val template = VocabularyTranslationRequest( + words = words, + languageFirst = "English", + languageSecond = "Spanish" + ) + + // When + val prompt = template.buildPrompt() + + // Then + assertTrue(prompt.contains("English")) + assertTrue(prompt.contains("Spanish")) + assertTrue(prompt.contains("hello")) + assertTrue(prompt.contains("world")) + assertTrue(prompt.contains("flashcards")) + } + + @Test + fun `TextCorrectionRequest should build correct prompt`() { + // Given + val template = TextCorrectionRequest( + textToCorrect = "Helo world", + language = "English", + grammarOnly = true, + tone = null + ) + + // When + val prompt = template.buildPrompt() + + // Then + assertTrue(prompt.contains("Helo world")) + assertTrue(prompt.contains("English")) + assertTrue(prompt.contains("grammar, spelling, and punctuation")) + assertTrue(prompt.contains("correctedText")) + assertTrue(prompt.contains("explanation")) + } + + @Test + fun `SynonymGenerationRequest should build correct prompt`() { + // Given + val template = SynonymGenerationRequest( + amount = 5, + language = "English", + term = "happy", + translation = "feliz", + translationLanguage = "Spanish", + languageCode = "en" + ) + + // When + val prompt = template.buildPrompt() + + // Then + assertTrue(prompt.contains("happy")) + assertTrue(prompt.contains("feliz")) + assertTrue(prompt.contains("English")) + assertTrue(prompt.contains("Spanish")) + assertTrue(prompt.contains("synonyms")) + assertTrue(prompt.contains("proximity")) + } +} + +class DictionaryServiceTest { + + private lateinit var context: Context + private lateinit var dictionaryService: DictionaryService + + @Before + fun setup() { + context = mockk(relaxed = true) + dictionaryService = DictionaryService(context) + } + + @Test + fun `searchDefinition should handle successful response`() = runTest { + // This test would require mocking the ApiRequestHandler + // For now, just verify the method exists and basic structure + val language = Language( + name = "English", + nameResId = 1, + code = "en", + englishName = "English", + region = "" + ) + + // When & Then + // This would need proper mocking setup + assertNotNull(language) + assertEquals("English", language.name) + } + + @Test + fun `getExampleSentence should handle successful response`() = runTest { + val languageFirst = Language( + name = "English", + nameResId = 1, + code = "en", + englishName = "English", + region = "" + ) + + val languageSecond = Language( + name = "Spanish", + nameResId = 2, + code = "es", + englishName = "Spanish", + region = "" + ) + + // When & Then + assertNotNull(languageFirst) + assertNotNull(languageSecond) + } +} + +class VocabularyServiceTest { + + private lateinit var context: Context + private lateinit var vocabularyService: VocabularyService + + @Before + fun setup() { + context = mockk(relaxed = true) + vocabularyService = VocabularyService(context) + } + + @Test + fun `translateWordsBatch should handle empty list`() = runTest { + // Given + val emptyWords = emptyList() + val languageFirst = Language( + name = "English", + nameResId = 1, + code = "en", + englishName = "English", + region = "" + ) + + val languageSecond = Language( + name = "Spanish", + nameResId = 2, + code = "es", + englishName = "Spanish", + region = "" + ) + + // When + val result = vocabularyService.translateWordsBatch(emptyWords, languageFirst, languageSecond) + + // Then + assertTrue(result.isSuccess) + assertEquals(0, result.getOrNull()?.size) + } + + @Test + fun `translateWordsBatch should filter blank words`() = runTest { + // Given + val wordsWithBlanks = listOf("hello", "", "world", " ") + + // When & Then + // This would need proper mocking setup for actual API calls + assertEquals(2, wordsWithBlanks.filter { it.isNotBlank() }.size) + } + + @Test + fun `generateSynonyms should use correct parameters`() = runTest { + // Given + val language = Language( + name = "English", + nameResId = 1, + code = "en", + englishName = "English", + region = "" + ) + + val translationLanguage = Language( + name = "Spanish", + nameResId = 2, + code = "es", + englishName = "Spanish", + region = "" + ) + + // When & Then + // This would need proper mocking setup for actual API calls + assertNotNull(language) + assertNotNull(translationLanguage) + assertEquals("English", language.englishName) + assertEquals("Spanish", translationLanguage.englishName) + } +} + +class TranslationServiceTest { + + private lateinit var context: Context + private lateinit var translationService: TranslationService + + @Before + fun setup() { + context = mockk(relaxed = true) + translationService = TranslationService(context) + } + + @Test + fun `simpleTranslateTo should handle basic translation`() = runTest { + // Given + val targetLanguage = Language( + name = "Spanish", + nameResId = 1, + code = "es", + englishName = "Spanish", + region = "" + ) + + // When & Then + // This would need proper mocking setup for actual API calls + assertNotNull(targetLanguage) + assertEquals("Spanish", targetLanguage.name) + } + + @Test + fun `translateSentence should handle sentence translation`() = runTest { + // Given + val sentence = "Hello world" + + // When & Then + // This would need proper mocking setup for actual API calls + assertNotNull(sentence) + assertEquals("Hello world", sentence) + } +} + +class CorrectionServiceTest { + + private lateinit var context: Context + private lateinit var correctionService: CorrectionService + + @Before + fun setup() { + context = mockk(relaxed = true) + correctionService = CorrectionService(context) + } + + @Test + fun `correctText should handle basic correction`() = runTest { + // Given + val language = Language( + name = "English", + nameResId = 1, + code = "en", + englishName = "English", + region = "" + ) + + // When & Then + // This would need proper mocking setup for actual API calls + assertNotNull(language) + assertEquals("English", language.name) + } + + @Test + fun `correctText should handle grammar only mode`() = runTest { + // Given + val textToCorrect = "Helo world" + val language = Language( + name = "English", + nameResId = 1, + code = "en", + englishName = "English", + region = "" + ) + + // When & Then + // This would need proper mocking setup for actual API calls + assertNotNull(textToCorrect) + assertNotNull(language) + assertEquals("Helo world", textToCorrect) + } +} + +// Integration test example +class ApiArchitectureIntegrationTest { + + @Test + fun `end to end dictionary lookup should work`() = runTest { + // This would be an integration test that tests the full flow + // from service -> template -> API handler -> JSON parsing + + // Given + val mockContext = mockk(relaxed = true) + val mockApiManager = mockk(relaxed = true) + + // Setup mock API response + every { + mockApiManager.getCompletion( + prompt = any(), + callback = any(), + modelType = ModelType.DICTIONARY + ) + } answers { + val callback = thirdArg<(String?) -> Unit>() + callback("""{"word": "test", "parts": [{"title": "Definition", "content": "A test word"}]}""") + } + + // When + val apiHandler = ApiRequestHandler(mockApiManager, mockContext) + val template = DictionaryDefinitionRequest( + word = "test", + language = "English", + requestedParts = "Definition" + ) + val result = apiHandler.executeRequest(template) + + // Then + assertTrue(result.isSuccess) + result.getOrNull()?.let { response -> + assertEquals("test", response.word) + assertEquals(1, response.parts.size) + assertEquals("Definition", response.parts[0].title) + } + } +} diff --git a/app/src/test/java/eu/gaudian/translator/utils/BaseTest.kt b/app/src/test/java/eu/gaudian/translator/utils/BaseTest.kt new file mode 100644 index 0000000..c59dfc2 --- /dev/null +++ b/app/src/test/java/eu/gaudian/translator/utils/BaseTest.kt @@ -0,0 +1,36 @@ +package eu.gaudian.translator.utils + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * Test rule for setting up coroutine testing environment + */ +class CoroutineTestRule @OptIn(ExperimentalCoroutinesApi::class) constructor( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + + lateinit var testScope: TestScope + private set + + override fun starting(description: Description) { + super.starting(description) + testScope = TestScope(testDispatcher) + } +} + +/** + * Base test class for API architecture tests + */ +abstract class BaseApiTest { + + @get:org.junit.Rule + val coroutineRule = CoroutineTestRule() + + protected val testDispatcher = coroutineRule.testDispatcher + protected val testScope = coroutineRule.testScope +} diff --git a/app/src/test/java/eu/gaudian/translator/utils/JsonHelperTest.kt b/app/src/test/java/eu/gaudian/translator/utils/JsonHelperTest.kt new file mode 100644 index 0000000..37bedee --- /dev/null +++ b/app/src/test/java/eu/gaudian/translator/utils/JsonHelperTest.kt @@ -0,0 +1,323 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertTrue +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.junit.Before +import org.junit.Test + +class JsonHelperComprehensiveTest { + + private lateinit var jsonHelper: JsonHelper + private val jsonParser = Json { ignoreUnknownKeys = true; isLenient = true } + + @Before + fun setup() { + jsonHelper = JsonHelper() + } + + @Test + fun `parseJson should handle valid DictionaryApiResponse`() { + // Given + val validJson = """{"word": "hello", "parts": [{"title": "Definition", "content": "A greeting"}]}""" + + // When + val result = jsonHelper.validateRequiredFields(validJson, listOf("word", "parts")) + + // Then + assertTrue(result) + } + + @Test + fun `parseJson should handle valid VocabularyApiResponse`() { + // Given + val validJson = """{"flashcards": [{"front": {"language": "English", "word": "hello"}, "back": {"language": "Spanish", "word": "hola"}}]}""" + + // When + val result = jsonHelper.validateRequiredFields(validJson, listOf("flashcards")) + + // Then + assertTrue(result) + } + + @Test + fun `parseJson should handle valid TranslationApiResponse`() { + // Given + val validJson = """{"translatedText": "hola"}""" + + // When + val result = jsonHelper.validateRequiredFields(validJson, listOf("translatedText")) + + // Then + assertTrue(result) + } + + @Test + fun `parseJson should handle valid CorrectionResponse`() { + // Given + val validJson = """{"correctedText": "Hello world", "explanation": "Capitalized first letter"}""" + + // When + val result = jsonHelper.validateRequiredFields(validJson, listOf("correctedText", "explanation")) + + // Then + assertTrue(result) + } + + @Test + fun `parseJson should handle valid EtymologyApiResponse`() { + // Given + val validJson = """{"word": "hello", "timeline": [{"year": "1890", "language": "Old English", "description": "From greeting"}], "relatedWords": []}""" + + // When + val result = jsonHelper.validateRequiredFields(validJson, listOf("word", "timeline", "relatedWords")) + + // Then + assertTrue(result) + } + + @Test + fun `parseJson should handle valid SynonymApiResponse`() { + // Given + val validJson = """{"synonyms": [{"word": "hi", "proximity": 95}, {"word": "greetings", "proximity": 85}]}""" + + // When + val result = jsonHelper.validateRequiredFields(validJson, listOf("synonyms")) + + // Then + assertTrue(result) + } + + @Test + fun `validateRequiredFields should return false for empty JSON`() { + // Given + val emptyJson = """{}""" + val requiredFields = listOf("word") + + // When + val result = jsonHelper.validateRequiredFields(emptyJson, requiredFields) + + // Then + assertFalse(result) + } + + @Test + fun `validateRequiredFields should return false for malformed JSON`() { + // Given + val malformedJson = """{"word": "hello", "parts": [""" + val requiredFields = listOf("word", "parts") + + // When + val result = jsonHelper.validateRequiredFields(malformedJson, requiredFields) + + // Then + assertFalse(result) + } + + @Test + fun `validateRequiredFields should handle nested objects`() { + // Given + val nestedJson = """{"flashcards": [{"front": {"language": "English", "word": "hello"}]}""" + val requiredFields = listOf("flashcards") + + // When + val result = jsonHelper.validateRequiredFields(nestedJson, requiredFields) + + // Then + assertTrue(result) + } + + @Test + fun `extractField should extract simple field`() { + // Given + val json = """{"word": "hello", "parts": []}""" + + // When + val result = jsonHelper.extractField(json, "word") + + // Then + assertEquals("hello", result) + } + + @Test + fun `extractField should extract nested field`() { + // Given + val json = """{"flashcards": [{"front": {"language": "English", "word": "hello"}}]}""" + + // When + val result = jsonHelper.extractField(json, "flashcards") + + // Then + assertNotNull(result) + assertTrue(result!!.contains("English")) + assertTrue(result.contains("hello")) + } + + @Test + fun `extractField should return null for non-existent field`() { + // Given + val json = """{"word": "hello"}""" + + // When + val result = jsonHelper.extractField(json, "nonexistent") + + // Then + assertEquals(null, result) + } + + @Test + fun `extractField should handle array fields`() { + // Given + val json = """{"synonyms": [{"word": "hi", "proximity": 95}]}""" + + // When + val result = jsonHelper.extractField(json, "synonyms") + + // Then + assertNotNull(result) + assertTrue(result!!.contains("hi")) + assertTrue(result.contains("95")) + } + + @Test + fun `formatForDisplay should format simple JSON`() { + // Given + val json = """{"word": "hello", "parts": []}""" + + // When + val result = jsonHelper.formatForDisplay(json) + + // Then + assertTrue(result.contains("{\n")) + assertTrue(result.contains("}\n")) + assertTrue(result.contains(",\n")) + } + + @Test + fun `formatForDisplay should handle malformed JSON gracefully`() { + // Given + val malformedJson = """{"word": "hello", "parts": [""" + + // When + val result = jsonHelper.formatForDisplay(malformedJson) + + // Then + assertEquals(malformedJson, result) // Should return original if formatting fails + } + + @Test + fun `cleanAndValidateJson should handle markdown wrapped JSON`() { + // Given + val markdownJson = """ + ```json + {"word": "hello", "parts": []} + ``` + """.trim() + + // When + val result = jsonHelper.validateRequiredFields(markdownJson, listOf("word", "parts")) + + // Then + assertTrue(result) + } + + @Test + fun `cleanAndValidateJson should handle JSON with comments`() { + // Given + val jsonWithComments = """ + { + "word": "hello", // This is the word + "parts": [] /* This is the parts array */ + } + """.trim() + + // When + val result = jsonHelper.validateRequiredFields(jsonWithComments, listOf("word", "parts")) + + // Then + assertTrue(result) + } + + @Test + fun `cleanAndValidateJson should handle JSON with trailing commas`() { + // Given + val jsonWithTrailingComma = """ + { + "word": "hello", + "parts": [], + } + """.trim() + + // When + val result = jsonHelper.validateRequiredFields(jsonWithTrailingComma, listOf("word", "parts")) + + // Then + assertTrue(result) + } + + // Test data classes + @Serializable + data class TestDictionaryResponse( + val word: String, + val parts: List + ) + + @Serializable + data class TestEntryPart( + val title: String, + val content: String + ) + + @Serializable + data class TestVocabularyResponse( + val flashcards: List + ) + + @Serializable + data class TestFlashcard( + val front: TestCardSide, + val back: TestCardSide + ) + + @Serializable + data class TestCardSide( + val language: String, + val word: String + ) + + @Test + fun `real parsing test with DictionaryApiResponse`() { + // Given + val json = """{"word": "hello", "parts": [{"title": "Definition", "content": "A greeting"}]}""" + + // When + val result = jsonParser.decodeFromString(TestDictionaryResponse.serializer(), json) + + // Then + assertEquals("hello", result.word) + assertEquals(1, result.parts.size) + assertEquals("Definition", result.parts[0].title) + assertEquals("A greeting", result.parts[0].content) + } + + @Test + fun `real parsing test with VocabularyApiResponse`() { + // Given + val json = """{"flashcards": [{"front": {"language": "English", "word": "hello"}, "back": {"language": "Spanish", "word": "hola"}}]}""" + + // When + val result = jsonParser.decodeFromString(TestVocabularyResponse.serializer(), json) + + // Then + assertEquals(1, result.flashcards.size) + assertEquals("English", result.flashcards[0].front.language) + assertEquals("hello", result.flashcards[0].front.word) + assertEquals("Spanish", result.flashcards[0].back.language) + assertEquals("hola", result.flashcards[0].back.word) + } +} diff --git a/app/src/test/java/eu/gaudian/translator/utils/ServiceFixesTest.kt b/app/src/test/java/eu/gaudian/translator/utils/ServiceFixesTest.kt new file mode 100644 index 0000000..adf4679 --- /dev/null +++ b/app/src/test/java/eu/gaudian/translator/utils/ServiceFixesTest.kt @@ -0,0 +1,203 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.utils + +import android.content.Context +import eu.gaudian.translator.model.Language +import eu.gaudian.translator.model.VocabularyItem +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Before +import org.junit.Test + +class ServiceFixesTest { + + private lateinit var context: Context + private lateinit var vocabularyService: VocabularyService + private lateinit var exerciseService: ExerciseService + + @Before + fun setup() { + context = mockk(relaxed = true) + vocabularyService = VocabularyService(context) + exerciseService = ExerciseService(context) + } + + @Test + fun `VocabularyService generateSynonyms should have correct return type`() = runTest { + // Given + val language = Language( + name = "English", + nameResId = 1, + code = "en", + englishName = "English", + region = "" + ) + + val translationLanguage = Language( + name = "Spanish", + nameResId = 2, + code = "es", + englishName = "Spanish", + region = "" + ) + + // When & Then + // This test verifies the method signature is correct + // The actual API call would need mocking for full testing + assertNotNull(language) + assertNotNull(translationLanguage) + assertEquals("English", language.englishName) + assertEquals("Spanish", translationLanguage.englishName) + } + + @Test + fun `ExerciseService should use JsonHelper instead of VocabularyParser`() = runTest { + // Given + val exerciseTitle = "Test Exercise" + + // When & Then + // This test verifies the ExerciseService can be instantiated without errors + assertNotNull(exerciseService) + assertNotNull(exerciseTitle) + assertEquals("Test Exercise", exerciseTitle) + } + + @Test + fun `parseVocabularyFromJson should handle simple JSON`() { + // Given + val jsonResponse = """{"hello": "hola", "world": "mundo"}""" + + // When + val result = parseVocabularyFromJson(jsonResponse) + + // Then + assertEquals(2, result.size) + assertEquals("hello", result[0].wordFirst) + assertEquals("hola", result[0].wordSecond) + assertEquals("world", result[1].wordFirst) + assertEquals("mundo", result[1].wordSecond) + } + + @Test + fun `parseVocabularyFromJson should handle empty JSON`() { + // Given + val jsonResponse = """{}""" + + // When + val result = parseVocabularyFromJson(jsonResponse) + + // Then + assertEquals(0, result.size) + } + + @Test + fun `parseVocabularyFromJson should handle malformed JSON`() { + // Given + val malformedJson = """{"hello": "hola", "world": """ + + // When + val result = parseVocabularyFromJson(malformedJson) + + // Then + assertEquals(0, result.size) + } + + /** + * Helper method to test the private parseVocabularyFromJson method + */ + private fun parseVocabularyFromJson(jsonResponse: String): List { + return try { + // Clean and validate the JSON first + val jsonHelper = JsonHelper() + val cleanedJson = jsonHelper.cleanAndValidateJson(jsonResponse) + + // Parse the JSON object for vocabulary items + val jsonObject = kotlinx.serialization.json.Json.parseToJsonElement(cleanedJson).jsonObject + + val vocabularyItems = mutableListOf() + var id = 1 + + for ((wordFirst, wordSecondElement) in jsonObject) { + val wordSecond = wordSecondElement.jsonPrimitive.content.trim() + val vocabularyItem = VocabularyItem( + id = id++, + languageFirstId = -1, // Will be set by caller + languageSecondId = -1, // Will be set by caller + wordFirst = wordFirst.trim(), + wordSecond = wordSecond + ) + vocabularyItems.add(vocabularyItem) + } + + vocabularyItems + } catch (e: Exception) { + emptyList() + } + } + + @Test + fun `VocabularyService translateWordsBatch should handle empty list`() = runTest { + // Given + val emptyWords = emptyList() + val languageFirst = Language( + name = "English", + nameResId = 1, + code = "en", + englishName = "English", + region = "" + ) + + val languageSecond = Language( + name = "Spanish", + nameResId = 2, + code = "es", + englishName = "Spanish", + region = "" + ) + + // When + val result = vocabularyService.translateWordsBatch(emptyWords, languageFirst, languageSecond) + + // Then + assertTrue(result.isSuccess) + assertEquals(0, result.getOrNull()?.size) + } + + @Test + fun `VocabularyService generateVocabularyItems should handle basic parameters`() = runTest { + // Given + val category = "Basic" + val languageFirst = Language( + name = "English", + nameResId = 1, + code = "en", + englishName = "English", + region = "" + ) + + val languageSecond = Language( + name = "Spanish", + nameResId = 2, + code = "es", + englishName = "Spanish", + region = "" + ) + val amount = 5 + + // When & Then + // This test verifies the method exists and accepts parameters correctly + assertNotNull(category) + assertNotNull(languageFirst) + assertNotNull(languageSecond) + assertEquals("Basic", category) + assertEquals("English", languageFirst.name) + assertEquals("Spanish", languageSecond.name) + assertEquals(5, amount) + } +} diff --git a/app/src/test/java/eu/gaudian/translator/viewmodel/SettingsViewModelTest.kt b/app/src/test/java/eu/gaudian/translator/viewmodel/SettingsViewModelTest.kt new file mode 100644 index 0000000..c68e92a --- /dev/null +++ b/app/src/test/java/eu/gaudian/translator/viewmodel/SettingsViewModelTest.kt @@ -0,0 +1,89 @@ +@file:Suppress("HardCodedStringLiteral") + +package eu.gaudian.translator.viewmodel + +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SettingsViewModelTest { + + @Test + fun `SettingsViewModel class exists and has expected structure`() { + // Given - Get the ViewModel class + val viewModelClass = SettingsViewModel::class.java + + // Then - Class should exist + assertNotNull(viewModelClass) + + // Should be a ViewModel (extends AndroidViewModel) + assertTrue("Should extend AndroidViewModel", + androidx.lifecycle.AndroidViewModel::class.java.isAssignableFrom(viewModelClass)) + } + + @Test + fun `SettingsViewModel companion object has getInstance method`() { + // Given - Get the companion object + val companionObject = SettingsViewModel.Companion::class.java + + // Then - Should have getInstance method + assertTrue("Companion should have getInstance method", + companionObject.methods.any { it.name == "getInstance" }) + } + + @Test + fun `SettingsViewModel has expected constructor parameters`() { + // Given - Get the ViewModel class + val viewModelClass = SettingsViewModel::class.java + + // Then - Should have a constructor that takes Application and the four dependencies + val constructors = viewModelClass.constructors + assertTrue("Should have at least one constructor", constructors.isNotEmpty()) + + // Check if any constructor has the expected parameter types + val hasExpectedConstructor = constructors.any { constructor -> + val parameterTypes = constructor.parameterTypes + parameterTypes.size >= 5 && // Application + 4 dependencies + parameterTypes.any { it.simpleName == "Application" } + } + assertTrue("Should have constructor with Application and dependencies", hasExpectedConstructor) + } + + @Test + fun `SettingsViewModel declares expected properties`() { + // Given - Get the ViewModel class + val viewModelClass = SettingsViewModel::class.java + + // Then - Should declare all the expected properties + val declaredFields = viewModelClass.declaredFields.map { it.name } + + // Check for some key properties + assertTrue("Should have theme property", + declaredFields.any { it.contains("theme") } || + viewModelClass.methods.any { it.name == "getTheme" }) + + assertTrue("Should have apiKeyManagementState property", + declaredFields.any { it.contains("apiKeyManagementState") } || + viewModelClass.methods.any { it.name == "getApiKeyManagementState" }) + + assertTrue("Should have apiLogs property", + declaredFields.any { it.contains("apiLogs") } || + viewModelClass.methods.any { it.name == "getApiLogs" }) + } + + @Test + fun `SettingsViewModel has expected public methods`() { + // Given - Get the ViewModel class + val viewModelClass = SettingsViewModel::class.java + + // Then - Should have all the expected public methods + val publicMethods = viewModelClass.methods.map { it.name } + + // Check for some key methods + assertTrue("Should have setTheme method", publicMethods.contains("setTheme")) + assertTrue("Should have setDarkModePreference method", publicMethods.contains("setDarkModePreference")) + assertTrue("Should have saveCustomPrompt method", publicMethods.contains("saveCustomPrompt")) + assertTrue("Should have clearApiLogs method", publicMethods.contains("clearApiLogs")) + assertTrue("Should have saveWidgetOrder method", publicMethods.contains("saveWidgetOrder")) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8665dd8 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,9 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + id("androidx.navigation.safeargs.kotlin") version "2.9.7" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" + id("com.google.devtools.ksp") version "2.3.4" apply false +} \ No newline at end of file diff --git a/docs/API_REQUEST_ARCHITECTURE.md b/docs/API_REQUEST_ARCHITECTURE.md new file mode 100644 index 0000000..bd5ca9c --- /dev/null +++ b/docs/API_REQUEST_ARCHITECTURE.md @@ -0,0 +1,368 @@ +# API Request Architecture Documentation + +## Overview + +This document explains how to create and handle JSON-based API requests in the Translator application. The architecture has been refactored to use a unified, type-safe template system that ensures consistency and maintainability. + +## Architecture Components + +### 1. JsonHelper +**Location**: `app/src/main/java/eu/gaudian/translator/utils/JsonHelper.kt` + +The JsonHelper provides unified JSON parsing, validation, and error handling for all API responses. + +**Key Methods**: +```kotlin +// Parse JSON with comprehensive error handling +fun parseJson(json: String, serializer: KSerializer, serviceName: String): Result + +// Validate required fields exist in JSON +fun validateRequiredFields(json: String, requiredFields: List): Boolean + +// Extract specific field value +fun extractField(json: String, fieldName: String): String? + +// Clean and validate JSON string +fun cleanAndValidateJson(json: String): String +``` + +### 2. ApiRequestHandler +**Location**: `app/src/main/java/eu/gaudian/translator/utils/ApiRequestHandler.kt` + +The single entry point for all API calls. This is the ONLY component that should call `ApiManager.getCompletion()`. + +**Key Methods**: +```kotlin +// Preferred method - use templates +suspend fun executeRequest(template: ApiRequestTemplate): Result + +// Legacy method - deprecated +suspend fun executeRequest(prompt: String, serializer: KSerializer, modelType: ModelType): Result + +// Text-only requests +suspend fun executeTextRequest(prompt: String, modelType: ModelType): Result +``` + +### 3. ApiRequestTemplates +**Location**: `app/src/main/java/eu/gaudian/translator/utils/ApiRequestTemplates.kt` + +Type-safe request templates that define the structure and validation for different API operations. + +## Creating New JSON API Requests + +### Step 1: Define Your Response Data Class + +Create a serializable data class for your API response: + +```kotlin +@kotlinx.serialization.Serializable +data class MyApiResponse( + val requiredField: String, + val optionalField: Int? = null, + val items: List = emptyList() +) + +@kotlinx.serialization.Serializable +data class MyItem( + val id: String, + val name: String, + val value: Double +) +``` + +### Step 2: Create a Request Template + +Extend `BaseApiRequestTemplate` for type-safe requests: + +```kotlin +class MyApiRequest( + private val parameter1: String, + private val parameter2: Int, + private val language: String +) : BaseApiRequestTemplate() { + + override val responseSerializer = MyApiResponse.serializer() + override val modelType = ModelType.TRANSLATION // Choose appropriate type + override val serviceName = "MyService" + override val requiredFields = listOf("requiredField") // Fields that must exist + + init { + promptBuilder.basePrompt = "Perform my API operation with $parameter1 and $parameter2" + addDetail("Language: $language") + addDetail("Parameter 2 value: $parameter2") + withJsonResponse("a JSON object with 'requiredField' (string), 'optionalField' (integer), and 'items' array") + } +} +``` + +### Step 3: Use the Template in Your Service + +```kotlin +class MyService(context: Context) { + private val apiRequestHandler = ApiRequestHandler(ApiManager(context), context) + + suspend fun performMyOperation(param1: String, param2: Int, language: String): Result { + return try { + Log.i("MyService", "Performing operation with $param1, $param2 in $language") + + val template = MyApiRequest( + parameter1 = param1, + parameter2 = param2, + language = language + ) + + val result = apiRequestHandler.executeRequest(template) + + result.map { response -> + Log.i("MyService", "Successfully completed operation") + response + }.onFailure { exception -> + Log.e("MyService", "Failed to perform operation", exception) + } + } catch (e: Exception) { + Log.e("MyService", "Unexpected error in performMyOperation", e) + Result.failure(e) + } + } +} +``` + +## Available Model Types + +Choose the appropriate `ModelType` for your request: + +- `ModelType.TRANSLATION` - For translation-related requests +- `ModelType.DICTIONARY` - For dictionary and vocabulary requests +- `ModelType.VOCABULARY` - For vocabulary generation and analysis +- `ModelType.EXERCISE` - For exercise generation +- `ModelType.CORRECTION` - For text correction + +## JSON Response Structure Guidelines + +### 1. Always Define Required Fields +```kotlin +override val requiredFields = listOf("id", "name", "data") +``` + +### 2. Use Clear Field Names +```kotlin +// Good +data class Response(val userId: String, val userName: String, val isActive: Boolean) + +// Avoid +data class Response(val uid: String, val nm: String, val active: Boolean) +``` + +### 3. Include Type Information in Prompts +```kotlin +withJsonResponse("a JSON object with 'userId' (string), 'userName' (string), 'isActive' (boolean), and 'createdAt' (timestamp)") +``` + +## Error Handling Best Practices + +### 1. Always Wrap in Try-Catch +```kotlin +suspend fun myMethod(): Result = withContext(Dispatchers.IO) { + try { + // Your logic here + result.map { response -> + // Success handling + response + }.onFailure { exception -> + // Failure logging + Log.e("MyService", "Operation failed", exception) + } + } catch (e: Exception) { + Log.e("MyService", "Unexpected error", e) + Result.failure(e) + } +} +``` + +### 2. Log at Appropriate Levels +```kotlin +Log.i("ServiceName", "Starting operation with parameters: $param1, $param2") // Info level +Log.d("ServiceName", "Generated prompt: $prompt") // Debug level +Log.e("ServiceName", "Operation failed", exception) // Error level +``` + +### 3. Validate Responses +```kotlin +// The template automatically validates required fields +// But you can add custom validation if needed +result.map { response -> + if (response.items.isEmpty()) { + Log.w("MyService", "API returned empty items array") + } + response +} +``` + +## Testing Your API Requests + +### 1. Unit Test Your Template +```kotlin +@Test +fun `MyApiRequest should build correct prompt`() { + val template = MyApiRequest("test", 42, "English") + val prompt = template.buildPrompt() + + assertTrue(prompt.contains("test")) + assertTrue(prompt.contains("42")) + assertTrue(prompt.contains("English")) + assertTrue(prompt.contains("JSON object")) +} +``` + +### 2. Test JSON Parsing +```kotlin +@Test +fun `JsonHelper should parse valid response`() { + val json = """{"requiredField": "value", "optionalField": 123}""" + val result = jsonHelper.validateRequiredFields(json, listOf("requiredField")) + + assertTrue(result) +} +``` + +### 3. Integration Test +```kotlin +@Test +fun `end to end API request should work`() = runTest { + // Mock the API manager + every { + apiManager.getCompletion( + prompt = any(), + callback = any(), + modelType = ModelType.TRANSLATION + ) + } answers { + val callback = thirdArg<(String?) -> Unit>() + callback("""{"requiredField": "test", "optionalField": 123}""") + } + + val template = MyApiRequest("test", 42, "English") + val result = apiRequestHandler.executeRequest(template) + + assertTrue(result.isSuccess) + assertEquals("test", result.getOrNull()?.requiredField) +} +``` + +## Migration from Legacy Code + +### Before (Legacy): +```kotlin +val prompt = PromptBuilder("Do something") + .addDetail("Parameter: $param") + .withJsonResponse("JSON format") + .build() + +apiRequestHandler.executeRequest(prompt, MyResponse.serializer(), ModelType.TRANSLATION) +``` + +### After (New Architecture): +```kotlin +val template = MyApiRequest(param) +apiRequestHandler.executeRequest(template) +``` + +## Common Patterns + +### 1. Language-Specific Requests +```kotlin +class LanguageSpecificRequest( + private val text: String, + private val sourceLanguage: String, + private val targetLanguage: String +) : BaseApiRequestTemplate() { + + override val serviceName = "TranslationService" + + init { + promptBuilder.basePrompt = "Translate from $sourceLanguage to $targetLanguage: '$text'" + withJsonResponse("a JSON object with 'translatedText' containing the translation") + } +} +``` + +### 2. Batch Processing Requests +```kotlin +class BatchProcessingRequest( + private val items: List, + private val operation: String +) : BaseApiRequestTemplate() { + + override val serviceName = "BatchService" + + init { + val itemsJson = Json.encodeToString(items) + promptBuilder.basePrompt = "Perform $operation on these items: $itemsJson" + withJsonResponse("a JSON object with 'results' array containing processed items") + } +} +``` + +### 3. Configuration-Based Requests +```kotlin +class ConfigurableRequest( + private val baseText: String, + private val customInstructions: String, + private val outputFormat: String +) : BaseApiRequestTemplate() { + + override val serviceName = "ConfigurableService" + + init { + promptBuilder.basePrompt = baseText + addDetail(customInstructions) + withJsonResponse(outputFormat) + } +} +``` + +## Troubleshooting + +### Common Issues and Solutions + +1. **"Unresolved reference" errors** + - Make sure you're calling the correct method (private methods stay within their class) + - Check imports are correct + +2. **JSON parsing failures** + - Use `jsonHelper.validateRequiredFields()` to check structure first + - Check that your response data class matches the actual JSON structure + - Look at logs for detailed parsing error messages + +3. **Template not working** + - Verify all required fields are listed in `requiredFields` + - Check that the prompt clearly explains the expected JSON format + - Ensure the serializer matches your data class + +4. **API request timeouts** + - Check network connectivity + - Verify the API manager is properly initialized + - Look at API logs for detailed error information + +## Best Practices Summary + +1. **Always use templates** for new API requests +2. **Define required fields** to catch malformed responses early +3. **Log comprehensively** at appropriate levels +4. **Handle errors gracefully** with proper Result types +5. **Write tests** for both templates and parsing logic +6. **Keep prompts clear** and specific about JSON structure +7. **Use descriptive service names** for better logging +8. **Validate inputs** before making API requests + +## Future Considerations + +When extending the architecture: + +1. **Reuse existing templates** when possible +2. **Follow naming conventions** consistently +3. **Document custom logic** in comments +4. **Add comprehensive tests** for new functionality +5. **Consider backward compatibility** when changing existing APIs + +This architecture is designed to be easily extensible while maintaining type safety and consistency across all API operations. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..4540b75 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,33 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..13f0835 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,108 @@ +[versions] +agp = "9.0.0" +annotation = "1.9.1" +converterGson = "3.0.0" +core = "13.0.0" +coreSplashscreen = "1.2.0" +coreTesting = "2.2.0" +datastorePreferences = "1.2.0" +foundation = "1.10.2" +hiltAndroidTesting = "2.59.1" +jsoup = "1.22.1" +kotlin = "2.3.10" +coreKtx = "1.17.0" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +kotlinStdlib = "2.3.10" +kotlinxCoroutinesCore = "1.10.2" +kotlinxCoroutinesAndroid = "1.10.2" +kotlinxCoroutinesTest = "1.10.2" +kotlinxDatetime = "0.7.1" +kotlinxSerializationJson = "1.10.0" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.12.3" +composeBom = "2026.01.01" +loggingInterceptor = "5.3.2" +materialIconsExtended = "1.7.8" +mockitoCore = "5.21.0" +mockitoKotlin = "6.2.3" +navigationCompose = "2.9.7" +pagingRuntimeKtx = "3.4.0" +reorderable = "0.9.6" +retrofit = "3.0.0" +material = "1.13.0" +material3 = "1.4.0" +runner = "1.7.0" +timber = "5.0.1" +navigationTesting = "2.9.7" +foundationLayout = "1.10.2" +room = "2.8.4" +coreKtxVersion = "1.7.0" +truth = "1.4.5" +zstdJni = "1.5.7-7" + + +[libraries] +androidx-animation = { group = "androidx.compose.animation", name = "animation" } +androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } +androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" } +androidx-paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "pagingRuntimeKtx" } +androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } +converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } +core = { module = "com.pierfrancescosoffritti.androidyoutubeplayer:core", version.ref = "core" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hiltAndroidTesting" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlinStdlib" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } +reorderable = { module = "org.burnoutcrew.composereorderable:reorderable", version.ref = "reorderable" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +truth = { module = "com.google.truth:truth", version.ref = "truth" } +ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-animation-core = { group = "androidx.compose.animation", name = "animation-core" } +androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "navigationTesting" } +androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "coreKtxVersion" } +zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstdJni" } +hilt-android = { module = "com.google.dagger:hilt-android", version = "2.59.1" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version = "2.59.1" } +hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.3.0" } +mockk = { module = "io.mockk:mockk", version = "1.14.9" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +hilt-android = { id = "com.google.dagger.hilt.android", version = "2.59.1" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9fc0020 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Mar 02 11:54:12 CET 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..35312db --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,28 @@ +@file:Suppress("HardCodedStringLiteral") + +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + @Suppress("UnstableApiUsage") + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Translator" +include(":app") + \ No newline at end of file