migrate to gitea
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -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
|
||||
3
.idea/.gitignore
generated
vendored
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
6
.idea/AndroidProjectSystem.xml
generated
Normal file
6
.idea/AndroidProjectSystem.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
35
.idea/androidTestResultsUserPreferences.xml
generated
Normal file
35
.idea/androidTestResultsUserPreferences.xml
generated
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidTestResultsUserPreferences">
|
||||
<option name="androidTestResultsTableState">
|
||||
<map>
|
||||
<entry key="-1219046563">
|
||||
<value>
|
||||
<AndroidTestResultsTableState>
|
||||
<option name="preferredColumnWidths">
|
||||
<map>
|
||||
<entry key="Duration" value="90" />
|
||||
<entry key="Tests" value="360" />
|
||||
<entry key="samsung SM-S911B" value="120" />
|
||||
</map>
|
||||
</option>
|
||||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="-114785490">
|
||||
<value>
|
||||
<AndroidTestResultsTableState>
|
||||
<option name="preferredColumnWidths">
|
||||
<map>
|
||||
<entry key="Duration" value="90" />
|
||||
<entry key="Tests" value="360" />
|
||||
<entry key="samsung SM-S911B" value="120" />
|
||||
</map>
|
||||
</option>
|
||||
</AndroidTestResultsTableState>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
41
.idea/appInsightsSettings.xml
generated
Normal file
41
.idea/appInsightsSettings.xml
generated
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AppInsightsSettings">
|
||||
<option name="selectedTabId" value="Android Vitals" />
|
||||
<option name="tabSettings">
|
||||
<map>
|
||||
<entry key="Android Vitals">
|
||||
<value>
|
||||
<InsightsFilterSettings>
|
||||
<option name="failureTypes">
|
||||
<list>
|
||||
<option value="ANR" />
|
||||
</list>
|
||||
</option>
|
||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||
<option name="timeIntervalDays" value="SEVEN_DAYS" />
|
||||
<option name="visibilityType" value="ALL" />
|
||||
</InsightsFilterSettings>
|
||||
</value>
|
||||
</entry>
|
||||
<entry key="Firebase Crashlytics">
|
||||
<value>
|
||||
<InsightsFilterSettings>
|
||||
<option name="connection">
|
||||
<ConnectionSetting>
|
||||
<option name="appId" value="PLACEHOLDER" />
|
||||
<option name="mobileSdkAppId" value="" />
|
||||
<option name="projectId" value="" />
|
||||
<option name="projectNumber" value="" />
|
||||
</ConnectionSetting>
|
||||
</option>
|
||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||
<option name="visibilityType" value="ALL" />
|
||||
</InsightsFilterSettings>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/compiler.xml
generated
Normal file
6
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
24
.idea/deploymentTargetSelector.xml
generated
Normal file
24
.idea/deploymentTargetSelector.xml
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-02-06T10:01:23.649270100Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\jonas\.android\avd\Medium_Phone_28.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="ApiRequestIntegrationTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="ExampleInstrumentedTest">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
13
.idea/deviceManager.xml
generated
Normal file
13
.idea/deviceManager.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
20
.idea/gradle.xml
generated
Normal file
20
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
93
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
93
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,93 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="AndroidLintMinSdkTooLow" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="AndroidLintPluralsCandidate" enabled="false" level="WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="AndroidLintRegistered" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="HardCodedStringLiteral" enabled="true" level="WARNING" enabled_by_default="true" editorAttributes="INFO_ATTRIBUTES">
|
||||
<option name="ignoreForAssertStatements" value="true" />
|
||||
<option name="ignoreForExceptionConstructors" value="true" />
|
||||
<option name="ignoreForSpecifiedExceptionConstructors" value="" />
|
||||
<option name="ignoreForJUnitAsserts" value="true" />
|
||||
<option name="ignoreForClassReferences" value="true" />
|
||||
<option name="ignoreForPropertyKeyReferences" value="true" />
|
||||
<option name="ignoreForNonAlpha" value="true" />
|
||||
<option name="ignoreAssignedToConstants" value="false" />
|
||||
<option name="ignoreToString" value="false" />
|
||||
<option name="nonNlsCommentPattern" value="NON-NLS" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||
<option name="processCode" value="true" />
|
||||
<option name="processLiterals" value="true" />
|
||||
<option name="processComments" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="unused" enabled="false" level="WARNING" enabled_by_default="false" checkParameterExcludingHierarchy="false">
|
||||
<option name="LOCAL_VARIABLE" value="true" />
|
||||
<option name="FIELD" value="true" />
|
||||
<option name="METHOD" value="true" />
|
||||
<option name="CLASS" value="true" />
|
||||
<option name="PARAMETER" value="true" />
|
||||
<option name="REPORT_PARAMETER_FOR_PUBLIC_METHODS" value="true" />
|
||||
<option name="ADD_MAINS_TO_ENTRIES" value="true" />
|
||||
<option name="ADD_APPLET_TO_ENTRIES" value="true" />
|
||||
<option name="ADD_SERVLET_TO_ENTRIES" value="true" />
|
||||
<option name="ADD_NONJAVA_TO_ENTRIES" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
6
.idea/kotlinc.xml
generated
Normal file
6
.idea/kotlinc.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="2.0.0" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/markdown.xml
generated
Normal file
8
.idea/markdown.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MarkdownSettings">
|
||||
<option name="previewPanelProviderInfo">
|
||||
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
10
.idea/migrations.xml
generated
Normal file
10
.idea/migrations.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
16
.idea/misc.xml
generated
Normal file
16
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
<component name="VisualizationToolProject">
|
||||
<option name="state">
|
||||
<ProjectState>
|
||||
<option name="scale" value="0.24072438162544169" />
|
||||
</ProjectState>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
17
.idea/runConfigurations.xml
generated
Normal file
17
.idea/runConfigurations.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/valkyrie_settings.xml
generated
Normal file
7
.idea/valkyrie_settings.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Valkyrie.Settings">
|
||||
<option name="mode" value="Simple" />
|
||||
<option name="packageName" value="io.github.composegears.valkyrie" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
19
README.md
Normal file
19
README.md
Normal file
@@ -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 |
|
||||
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
177
app/build.gradle.kts
Normal file
177
app/build.gradle.kts
Normal file
@@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().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")
|
||||
|
||||
}
|
||||
21
app/proguard-rules.pro
vendored
Normal file
21
app/proguard-rules.pro
vendored
Normal file
@@ -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
|
||||
@@ -0,0 +1,177 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import eu.gaudian.translator.model.communication.ApiManager
|
||||
import eu.gaudian.translator.utils.ApiRequestHandler
|
||||
import eu.gaudian.translator.utils.DictionaryDefinitionRequest
|
||||
import eu.gaudian.translator.utils.TextCorrectionRequest
|
||||
import eu.gaudian.translator.utils.TextTranslationRequest
|
||||
import eu.gaudian.translator.utils.VocabularyGenerationRequest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ApiRequestIntegrationTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var apiRequestHandler: ApiRequestHandler
|
||||
private lateinit var apiManager: ApiManager
|
||||
|
||||
// TAG for filtering in Logcat
|
||||
@Suppress("PrivatePropertyName")
|
||||
private val TAG = "ApiTest"
|
||||
|
||||
@get:Rule
|
||||
val watcher = object : TestWatcher() {
|
||||
override fun starting(description: Description) {
|
||||
Log.i(TAG, "🟢 STARTING TEST: ${description.methodName}")
|
||||
}
|
||||
|
||||
override fun finished(description: Description) {
|
||||
Log.i(TAG, "🏁 FINISHED TEST: ${description.methodName}")
|
||||
}
|
||||
|
||||
override fun failed(e: Throwable?, description: Description) {
|
||||
Log.e(TAG, "❌ FAILED TEST: ${description.methodName}", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
try {
|
||||
Log.d(TAG, "SETUP: Initializing Context...")
|
||||
// Use targetContext to access the app's resources/files
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
Log.d(TAG, "SETUP: Injecting Test Config into SharedPreferences...")
|
||||
// Ensure we use the exact preference file name used by your SettingsRepository
|
||||
// Default is usually "package_name_preferences"
|
||||
val prefsName = "${context.packageName}_preferences"
|
||||
val prefs = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
|
||||
|
||||
prefs.edit().apply {
|
||||
putString("api_key", TestConfig.API_KEY)
|
||||
// If your ApiManager uses a specific settings key for provider, set it here:
|
||||
putString("selected_ai_provider", TestConfig.PROVIDER_NAME)
|
||||
putString("custom_server_url", TestConfig.BASE_URL)
|
||||
commit()
|
||||
}
|
||||
Log.d(TAG, "SETUP: Config injected. Key present: ${prefs.contains("api_key")}")
|
||||
|
||||
Log.d(TAG, "SETUP: Initializing ApiManager...")
|
||||
apiManager = ApiManager(context)
|
||||
|
||||
Log.d(TAG, "SETUP: Initializing ApiRequestHandler...")
|
||||
apiRequestHandler = ApiRequestHandler(apiManager, context)
|
||||
|
||||
Log.d(TAG, "SETUP: Complete.")
|
||||
|
||||
} catch (e: Exception) {
|
||||
// THIS is what you are missing in the current output
|
||||
Log.e(TAG, "🔥 CRITICAL SETUP FAILURE: ${e.message}", e)
|
||||
throw e // Re-throw to fail the test, but now it's logged
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDictionaryDefinitionRequest() = runBlocking {
|
||||
Log.d(TAG, "Testing Dictionary Definition...")
|
||||
val template = DictionaryDefinitionRequest(
|
||||
word = "Serendipity",
|
||||
language = "English",
|
||||
requestedParts = "Definition"
|
||||
)
|
||||
|
||||
val result = apiRequestHandler.executeRequest(template)
|
||||
|
||||
handleResult(result) { data ->
|
||||
assertNotNull("Word should not be null", data.word)
|
||||
assertTrue("Parts should not be empty", data.parts.isNotEmpty())
|
||||
Log.i(TAG, "✅ Dictionary Success: Defined '${data.word}'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTextTranslationRequest() = runBlocking {
|
||||
Log.d(TAG, "Testing Text Translation...")
|
||||
val template = TextTranslationRequest(
|
||||
text = "Hello, world!",
|
||||
sourceLanguage = "English",
|
||||
targetLanguage = "German"
|
||||
)
|
||||
|
||||
val result = apiRequestHandler.executeRequest(template)
|
||||
|
||||
handleResult(result) { data ->
|
||||
assertNotNull("Translation should not be null", data.translatedText)
|
||||
assertTrue("Translation should not be empty", data.translatedText.isNotBlank())
|
||||
Log.i(TAG, "✅ Translation Success: '${data.translatedText}'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCorrectionRequest() = runBlocking {
|
||||
Log.d(TAG, "Testing Text Correction...")
|
||||
val template = TextCorrectionRequest(
|
||||
textToCorrect = "I has went home.",
|
||||
language = "English",
|
||||
grammarOnly = true,
|
||||
tone = null
|
||||
)
|
||||
|
||||
val result = apiRequestHandler.executeRequest(template)
|
||||
|
||||
handleResult(result) { data ->
|
||||
assertNotEquals("Corrected text should be different", "I has went home.", data.correctedText)
|
||||
Log.i(TAG, "✅ Correction Success: -> '${data.correctedText}'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVocabularyGenerationRequest() = runBlocking {
|
||||
Log.d(TAG, "Testing Vocab Generation...")
|
||||
val template = VocabularyGenerationRequest(
|
||||
category = "Technology",
|
||||
languageFirst = "English",
|
||||
languageSecond = "Spanish",
|
||||
amount = 2
|
||||
)
|
||||
|
||||
val result = apiRequestHandler.executeRequest(template)
|
||||
|
||||
handleResult(result) { data ->
|
||||
assertEquals("Should receive exactly 2 cards", 2, data.flashcards.size)
|
||||
Log.i(TAG, "✅ Vocab Gen Success: Got ${data.flashcards.size} cards")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to log results consistently and fail with clear messages
|
||||
*/
|
||||
private fun <T> handleResult(result: Result<T>, assertions: (T) -> Unit) {
|
||||
if (result.isFailure) {
|
||||
val error = result.exceptionOrNull()
|
||||
Log.e(TAG, "❌ API REQUEST FAILED", error)
|
||||
fail("API Request failed with exception: ${error?.message}")
|
||||
} else {
|
||||
val data = result.getOrNull()!!
|
||||
Log.d(TAG, "Received Data: $data")
|
||||
assertions(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
17
app/src/androidTest/java/eu/gaudian/translator/TestConfig.kt
Normal file
17
app/src/androidTest/java/eu/gaudian/translator/TestConfig.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator
|
||||
|
||||
object TestConfig {
|
||||
// REPLACE with your actual API Key for the test
|
||||
const val API_KEY = "YOUR_REAL_API_KEY_HERE"
|
||||
|
||||
// Set to true if you want to see full log output in Logcat
|
||||
const val ENABLE_LOGGING = true
|
||||
|
||||
// Optional: If your ApiManager requires a specific provider (e.g., "Mistral", "OpenAI")
|
||||
const val PROVIDER_NAME = "Mistral"
|
||||
|
||||
// Optional: If you need to override the Base URL
|
||||
const val BASE_URL = "https://api.mistral.ai/"
|
||||
}
|
||||
49
app/src/main/AndroidManifest.xml
Normal file
49
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
|
||||
<application
|
||||
android:name=".MyApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
tools:targetApi="31"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Translator"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity android:name=".view.MainActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.App.Starting"
|
||||
tools:ignore="LockedOrientationActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".CorrectActivity"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
159
app/src/main/assets/language_configs/de.json
Normal file
159
app/src/main/assets/language_configs/de.json
Normal file
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"language_code": "de",
|
||||
"articles": ["der", "die", "das"],
|
||||
"categories": {
|
||||
"noun": {
|
||||
"display_key": "category_noun",
|
||||
"formatter": "({gender})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "gender",
|
||||
"display_key": "prop_gender",
|
||||
"type": "enum",
|
||||
"options": ["masculine", "feminine", "neuter", "plural"]
|
||||
},
|
||||
{
|
||||
"key": "plural",
|
||||
"display_key": "prop_plural",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "genitive_singular",
|
||||
"display_key": "prop_genitive_singular",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"gender": {
|
||||
"masculine": "der",
|
||||
"feminine": "die, singular",
|
||||
"neuter": "das",
|
||||
"plural": "die, plural"
|
||||
}
|
||||
},
|
||||
"declension_display": {
|
||||
"cases_order": ["nominative", "genitive", "dative", "accusative"],
|
||||
"case_labels": {
|
||||
"nominative": "Nom.",
|
||||
"genitive": "Gen.",
|
||||
"dative": "Dat.",
|
||||
"accusative": "Akk."
|
||||
},
|
||||
"numbers_order": ["singular", "plural"],
|
||||
"number_labels": {
|
||||
"singular": "Sing.",
|
||||
"plural": "Plur."
|
||||
}
|
||||
}
|
||||
},
|
||||
"verb": {
|
||||
"display_key": "category_verb",
|
||||
"formatter": "({verb_type})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "auxiliary_verb",
|
||||
"display_key": "prop_auxiliary_verb",
|
||||
"type": "enum",
|
||||
"options": ["haben", "sein"]
|
||||
},
|
||||
{
|
||||
"key": "participle_past",
|
||||
"display_key": "prop_participle_past",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "verb_type",
|
||||
"display_key": "prop_verb_type",
|
||||
"type": "enum",
|
||||
"options": ["strong", "weak", "mixed"]
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"verb_type": {
|
||||
"strong": "stark",
|
||||
"weak": "schwach",
|
||||
"mixed": "gemischt"
|
||||
}
|
||||
},
|
||||
"conjugation_display": {
|
||||
"pronouns": ["ich", "du", "er/sie/es", "wir", "ihr", "sie"],
|
||||
"tense_labels": {
|
||||
"present": "Präsens",
|
||||
"past": "Präteritum",
|
||||
"subjunctive_i": "Konjunktiv I",
|
||||
"subjunctive_ii": "Konjunktiv II"
|
||||
}
|
||||
}
|
||||
},
|
||||
"adjective": {
|
||||
"display_key": "category_adjective",
|
||||
"formatter": "({comparative}, {superlative})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "comparative",
|
||||
"display_key": "prop_comparative",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "superlative",
|
||||
"display_key": "prop_superlative",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"preposition": {
|
||||
"display_key": "category_preposition",
|
||||
"formatter": "(+{governs_case})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "governs_case",
|
||||
"display_key": "prop_governs_case",
|
||||
"type": "enum",
|
||||
"options": ["accusative", "dative", "genitive", "dative_or_accusative"]
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"governs_case": {
|
||||
"accusative": "Akk",
|
||||
"dative": "Dat",
|
||||
"genitive": "Gen",
|
||||
"dative_or_accusative": "Akk/Dat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"article": {
|
||||
"display_key": "category_article",
|
||||
"formatter": "({article_type})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "article_type",
|
||||
"display_key": "prop_article_type",
|
||||
"type": "enum",
|
||||
"options": ["definite", "indefinite"]
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"article_type": {
|
||||
"definite": "best.",
|
||||
"indefinite": "unbest."
|
||||
}
|
||||
}
|
||||
},
|
||||
"adverb": {
|
||||
"display_key": "category_adverb",
|
||||
"fields": []
|
||||
},
|
||||
"pronoun": {
|
||||
"display_key": "category_pronoun",
|
||||
"fields": []
|
||||
},
|
||||
"conjunction": {
|
||||
"display_key": "category_conjunction",
|
||||
"fields": []
|
||||
},
|
||||
"sentence": {
|
||||
"display_key": "category_sentence",
|
||||
"fields": []
|
||||
}
|
||||
}
|
||||
}
|
||||
73
app/src/main/assets/language_configs/en.json
Normal file
73
app/src/main/assets/language_configs/en.json
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"language_code": "en",
|
||||
"articles": ["the"],
|
||||
"categories": {
|
||||
"noun": {
|
||||
"display_key": "category_noun",
|
||||
"formatter": "(pl: {plural})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "plural",
|
||||
"display_key": "prop_plural",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"verb": {
|
||||
"display_key": "category_verb",
|
||||
"formatter": "({past_tense}, {past_participle})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "past_tense",
|
||||
"display_key": "prop_past_tense",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "past_participle",
|
||||
"display_key": "prop_past_participle",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"adjective": {
|
||||
"display_key": "category_adjective",
|
||||
"formatter": "({comparative}, {superlative})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "comparative",
|
||||
"display_key": "prop_comparative",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "superlative",
|
||||
"display_key": "prop_superlative",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"article": {
|
||||
"display_key": "category_article",
|
||||
"fields": []
|
||||
},
|
||||
"preposition": {
|
||||
"display_key": "category_preposition",
|
||||
"fields": []
|
||||
},
|
||||
"adverb": {
|
||||
"display_key": "category_adverb",
|
||||
"fields": []
|
||||
},
|
||||
"pronoun": {
|
||||
"display_key": "category_pronoun",
|
||||
"fields": []
|
||||
},
|
||||
"conjunction": {
|
||||
"display_key": "category_conjunction",
|
||||
"fields": []
|
||||
},
|
||||
"sentence": {
|
||||
"display_key": "category_sentence",
|
||||
"fields": []
|
||||
}
|
||||
}
|
||||
}
|
||||
76
app/src/main/assets/language_configs/es.json
Normal file
76
app/src/main/assets/language_configs/es.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"language_code": "es",
|
||||
"articles": ["el", "la", "los", "las"],
|
||||
"categories": {
|
||||
"noun": {
|
||||
"display_key": "category_noun",
|
||||
"formatter": "({gender})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "gender",
|
||||
"display_key": "prop_gender",
|
||||
"type": "enum",
|
||||
"options": ["masculine", "feminine"]
|
||||
},
|
||||
{
|
||||
"key": "plural",
|
||||
"display_key": "prop_plural",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"gender": {
|
||||
"masculine": "el",
|
||||
"feminine": "la"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verb": {
|
||||
"display_key": "category_verb",
|
||||
"formatter": "(-{conjugation_group})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "conjugation_group",
|
||||
"display_key": "prop_conjugation_group",
|
||||
"type": "enum",
|
||||
"options": ["ar", "er", "ir"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"adjective": {
|
||||
"display_key": "category_adjective",
|
||||
"formatter": "(f: {feminine_form})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "feminine_form",
|
||||
"display_key": "prop_feminine_form",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"article": {
|
||||
"display_key": "category_article",
|
||||
"fields": []
|
||||
},
|
||||
"preposition": {
|
||||
"display_key": "category_preposition",
|
||||
"fields": []
|
||||
},
|
||||
"adverb": {
|
||||
"display_key": "category_adverb",
|
||||
"fields": []
|
||||
},
|
||||
"pronoun": {
|
||||
"display_key": "category_pronoun",
|
||||
"fields": []
|
||||
},
|
||||
"conjunction": {
|
||||
"display_key": "category_conjunction",
|
||||
"fields": []
|
||||
},
|
||||
"sentence": {
|
||||
"display_key": "category_sentence",
|
||||
"fields": []
|
||||
}
|
||||
}
|
||||
}
|
||||
130
app/src/main/assets/language_configs/fr.json
Normal file
130
app/src/main/assets/language_configs/fr.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"language_code": "fr",
|
||||
"articles": ["le", "la", "les"],
|
||||
"categories": {
|
||||
"noun": {
|
||||
"display_key": "category_noun",
|
||||
"formatter": "({gender})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "gender",
|
||||
"display_key": "prop_gender",
|
||||
"type": "enum",
|
||||
"options": ["masculine", "feminine"]
|
||||
},
|
||||
{
|
||||
"key": "plural",
|
||||
"display_key": "prop_plural",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "starts_with_vowel_h",
|
||||
"display_key": "prop_starts_with_vowel_h",
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"gender": {
|
||||
"masculine": "m.",
|
||||
"feminine": "f."
|
||||
}
|
||||
}
|
||||
},
|
||||
"verb": {
|
||||
"display_key": "category_verb",
|
||||
"formatter": "(reg. -{verb_group})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "auxiliary_verb",
|
||||
"display_key": "prop_auxiliary_verb",
|
||||
"type": "enum",
|
||||
"options": ["avoir", "être"]
|
||||
},
|
||||
{
|
||||
"key": "participle_past",
|
||||
"display_key": "prop_participle_past",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "verb_group",
|
||||
"display_key": "prop_verb_group",
|
||||
"type": "enum",
|
||||
"options": ["1st_group (er)", "2nd_group (ir)", "3rd_group (re)"]
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"verb_group": {
|
||||
"1st_group (er)": "er",
|
||||
"2nd_group (ir)": "ir",
|
||||
"3rd_group (re)": "re"
|
||||
}
|
||||
}
|
||||
},
|
||||
"adjective": {
|
||||
"display_key": "category_adjective",
|
||||
"formatter": "(f: {feminine_form})",
|
||||
"declension_display": {
|
||||
"cases_order": ["masculine", "feminine"],
|
||||
"numbers_order": ["singular", "plural"]
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"key": "feminine_form",
|
||||
"display_key": "prop_feminine_form",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "position",
|
||||
"display_key": "prop_position",
|
||||
"type": "enum",
|
||||
"options": ["before_noun", "after_noun"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"preposition": {
|
||||
"display_key": "category_preposition",
|
||||
"fields": [
|
||||
{
|
||||
"key": "contractions",
|
||||
"display_key": "prop_contractions",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"article": {
|
||||
"display_key": "category_article",
|
||||
"formatter": "({article_type})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "article_type",
|
||||
"display_key": "prop_article_type",
|
||||
"type": "enum",
|
||||
"options": ["definite", "indefinite", "partitive"]
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"article_type": {
|
||||
"definite": "déf.",
|
||||
"indefinite": "indéf.",
|
||||
"partitive": "part."
|
||||
}
|
||||
}
|
||||
},
|
||||
"adverb": {
|
||||
"display_key": "category_adverb",
|
||||
"fields": []
|
||||
},
|
||||
"pronoun": {
|
||||
"display_key": "category_pronoun",
|
||||
"fields": []
|
||||
},
|
||||
"conjunction": {
|
||||
"display_key": "category_conjunction",
|
||||
"fields": []
|
||||
},
|
||||
"sentence": {
|
||||
"display_key": "category_sentence",
|
||||
"fields": []
|
||||
}
|
||||
}
|
||||
}
|
||||
111
app/src/main/assets/language_configs/hr.json
Normal file
111
app/src/main/assets/language_configs/hr.json
Normal file
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"language_code": "hr",
|
||||
"articles": [],
|
||||
"categories": {
|
||||
"noun": {
|
||||
"display_key": "category_noun",
|
||||
"formatter": "({gender})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "gender",
|
||||
"display_key": "prop_gender",
|
||||
"type": "enum",
|
||||
"options": ["masculine", "feminine", "neuter"]
|
||||
},
|
||||
{
|
||||
"key": "plural",
|
||||
"display_key": "prop_plural",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "genitive_singular",
|
||||
"display_key": "prop_genitive_singular",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"gender": {
|
||||
"masculine": "m.",
|
||||
"feminine": "f.",
|
||||
"neuter": "n."
|
||||
}
|
||||
}
|
||||
},
|
||||
"verb": {
|
||||
"display_key": "category_verb",
|
||||
"formatter": "({aspect})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "aspect",
|
||||
"display_key": "prop_aspect",
|
||||
"type": "enum",
|
||||
"options": ["perfective", "imperfective"]
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"aspect": {
|
||||
"perfective": "svršeni",
|
||||
"imperfective": "nesvršeni"
|
||||
}
|
||||
}
|
||||
},
|
||||
"adjective": {
|
||||
"display_key": "category_adjective",
|
||||
"formatter": "({comparative}, {superlative})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "comparative",
|
||||
"display_key": "prop_comparative",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "superlative",
|
||||
"display_key": "prop_superlative",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"preposition": {
|
||||
"display_key": "category_preposition",
|
||||
"formatter": "(+{governs_case})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "governs_case",
|
||||
"display_key": "prop_governs_case",
|
||||
"type": "enum",
|
||||
"options": ["genitive", "dative", "accusative", "locative", "instrumental", "dative_locative_instrumental"]
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"governs_case": {
|
||||
"genitive": "Gen",
|
||||
"dative": "Dat",
|
||||
"accusative": "Akk",
|
||||
"locative": "Lok",
|
||||
"instrumental": "Inst",
|
||||
"dative_locative_instrumental": "Dat/Lok/Inst"
|
||||
}
|
||||
}
|
||||
},
|
||||
"article": {
|
||||
"display_key": "category_article",
|
||||
"fields": []
|
||||
},
|
||||
"adverb": {
|
||||
"display_key": "category_adverb",
|
||||
"fields": []
|
||||
},
|
||||
"pronoun": {
|
||||
"display_key": "category_pronoun",
|
||||
"fields": []
|
||||
},
|
||||
"conjunction": {
|
||||
"display_key": "category_conjunction",
|
||||
"fields": []
|
||||
},
|
||||
"sentence": {
|
||||
"display_key": "category_sentence",
|
||||
"fields": []
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/src/main/assets/language_configs/it.json
Normal file
69
app/src/main/assets/language_configs/it.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"language_code": "it",
|
||||
"articles": ["il", "lo", "la", "i", "gli", "le"],
|
||||
"categories": {
|
||||
"noun": {
|
||||
"display_key": "category_noun",
|
||||
"formatter": "({gender})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "gender",
|
||||
"display_key": "prop_gender",
|
||||
"type": "enum",
|
||||
"options": ["masculine", "feminine"]
|
||||
},
|
||||
{
|
||||
"key": "plural",
|
||||
"display_key": "prop_plural",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"gender": {
|
||||
"masculine": "il/lo",
|
||||
"feminine": "la"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verb": {
|
||||
"display_key": "category_verb",
|
||||
"formatter": "(-{conjugation_group})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "conjugation_group",
|
||||
"display_key": "prop_conjugation_group",
|
||||
"type": "enum",
|
||||
"options": ["are", "ere", "ire"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"adjective": {
|
||||
"display_key": "category_adjective",
|
||||
"fields": []
|
||||
},
|
||||
"article": {
|
||||
"display_key": "category_article",
|
||||
"fields": []
|
||||
},
|
||||
"preposition": {
|
||||
"display_key": "category_preposition",
|
||||
"fields": []
|
||||
},
|
||||
"adverb": {
|
||||
"display_key": "category_adverb",
|
||||
"fields": []
|
||||
},
|
||||
"pronoun": {
|
||||
"display_key": "category_pronoun",
|
||||
"fields": []
|
||||
},
|
||||
"conjunction": {
|
||||
"display_key": "category_conjunction",
|
||||
"fields": []
|
||||
},
|
||||
"sentence": {
|
||||
"display_key": "category_sentence",
|
||||
"fields": []
|
||||
}
|
||||
}
|
||||
}
|
||||
69
app/src/main/assets/language_configs/nl.json
Normal file
69
app/src/main/assets/language_configs/nl.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"language_code": "nl",
|
||||
"articles": ["de", "het", "een"],
|
||||
"categories": {
|
||||
"noun": {
|
||||
"display_key": "category_noun",
|
||||
"formatter": "({gender})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "gender",
|
||||
"display_key": "prop_gender",
|
||||
"type": "enum",
|
||||
"options": ["masculine_feminine", "neuter"]
|
||||
},
|
||||
{
|
||||
"key": "plural",
|
||||
"display_key": "prop_plural",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"gender": {
|
||||
"masculine_feminine": "de",
|
||||
"neuter": "het"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verb": {
|
||||
"display_key": "category_verb",
|
||||
"formatter": "({verb_type})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "verb_type",
|
||||
"display_key": "prop_verb_type",
|
||||
"type": "enum",
|
||||
"options": ["strong", "weak"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"adjective": {
|
||||
"display_key": "category_adjective",
|
||||
"fields": []
|
||||
},
|
||||
"article": {
|
||||
"display_key": "category_article",
|
||||
"fields": []
|
||||
},
|
||||
"preposition": {
|
||||
"display_key": "category_preposition",
|
||||
"fields": []
|
||||
},
|
||||
"adverb": {
|
||||
"display_key": "category_adverb",
|
||||
"fields": []
|
||||
},
|
||||
"pronoun": {
|
||||
"display_key": "category_pronoun",
|
||||
"fields": []
|
||||
},
|
||||
"conjunction": {
|
||||
"display_key": "category_conjunction",
|
||||
"fields": []
|
||||
},
|
||||
"sentence": {
|
||||
"display_key": "category_sentence",
|
||||
"fields": []
|
||||
}
|
||||
}
|
||||
}
|
||||
98
app/src/main/assets/language_configs/pt.json
Normal file
98
app/src/main/assets/language_configs/pt.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"language_code": "pt",
|
||||
"articles": ["o", "a", "os", "as"],
|
||||
"categories": {
|
||||
"noun": {
|
||||
"display_key": "category_noun",
|
||||
"formatter": "({gender})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "gender",
|
||||
"display_key": "prop_gender",
|
||||
"type": "enum",
|
||||
"options": ["masculine", "feminine", "plural masculine", "plural feminine"]
|
||||
},
|
||||
{
|
||||
"key": "plural",
|
||||
"display_key": "prop_plural",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"gender": {
|
||||
"masculine": "o",
|
||||
"feminine": "a",
|
||||
"plural masculine": "os",
|
||||
"plural feminine": "as"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verb": {
|
||||
"display_key": "category_verb",
|
||||
"formatter": "(-{conjugation_group})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "conjugation_group",
|
||||
"display_key": "prop_conjugation_group",
|
||||
"type": "enum",
|
||||
"options": ["ar", "er", "ir", "irregular"]
|
||||
}
|
||||
],
|
||||
"mappings": {
|
||||
"conjugation_group": {
|
||||
"ar": "ar",
|
||||
"er": "er",
|
||||
"ir": "ir",
|
||||
"irregular": "irregular"
|
||||
}
|
||||
}
|
||||
},
|
||||
"adjective": {
|
||||
"display_key": "category_adjective",
|
||||
"formatter": "(f: {feminine_form})",
|
||||
"declension_display": {
|
||||
"cases_order": ["masculine", "feminine"],
|
||||
"numbers_order": ["singular", "plural"]
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"key": "feminine_form",
|
||||
"display_key": "prop_feminine_form",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"article": {
|
||||
"display_key": "category_article",
|
||||
"formatter": "({article_type})",
|
||||
"fields": [
|
||||
{
|
||||
"key": "article_type",
|
||||
"display_key": "prop_article_type",
|
||||
"type": "enum",
|
||||
"options": ["definite", "indefinite"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"preposition": {
|
||||
"display_key": "category_preposition",
|
||||
"fields": []
|
||||
},
|
||||
"adverb": {
|
||||
"display_key": "category_adverb",
|
||||
"fields": []
|
||||
},
|
||||
"pronoun": {
|
||||
"display_key": "category_pronoun",
|
||||
"fields": []
|
||||
},
|
||||
"conjunction": {
|
||||
"display_key": "category_conjunction",
|
||||
"fields": []
|
||||
},
|
||||
"sentence": {
|
||||
"display_key": "category_sentence",
|
||||
"fields": []
|
||||
}
|
||||
}
|
||||
}
|
||||
249
app/src/main/assets/providers_config.json
Normal file
249
app/src/main/assets/providers_config.json
Normal file
@@ -0,0 +1,249 @@
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"key": "together",
|
||||
"displayName": "Together AI",
|
||||
"baseUrl": "https://api.together.xyz/v1/",
|
||||
"endpoint": "chat/completions",
|
||||
"websiteUrl": "https://www.together.ai/",
|
||||
"isCustom": false,
|
||||
"models": [
|
||||
{
|
||||
"modelId": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"displayName": "Llama 3.3 70B Turbo",
|
||||
"provider": "together",
|
||||
"description": "Fast, accurate, and cost-effective open-source model."
|
||||
},
|
||||
{
|
||||
"modelId": "meta-llama/Llama-4-Maverick-17B-Instruct",
|
||||
"displayName": "Llama 4 Maverick 17B",
|
||||
"provider": "together",
|
||||
"description": "Next-gen efficient architecture; outperforms older 70B models."
|
||||
},
|
||||
{
|
||||
"modelId": "deepseek-ai/DeepSeek-V3",
|
||||
"displayName": "DeepSeek V3",
|
||||
"provider": "together",
|
||||
"description": "Top-tier open-source model specializing in code and logic."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "mistral",
|
||||
"displayName": "Mistral AI",
|
||||
"baseUrl": "https://api.mistral.ai/",
|
||||
"endpoint": "v1/chat/completions",
|
||||
"websiteUrl": "https://mistral.ai",
|
||||
"isCustom": false,
|
||||
"models": [
|
||||
{
|
||||
"modelId": "ministral-8b-latest",
|
||||
"displayName": "Ministral 8B",
|
||||
"provider": "mistral",
|
||||
"description": "Extremely efficient edge model for low-latency tasks."
|
||||
},
|
||||
{
|
||||
"modelId": "mistral-large-latest",
|
||||
"displayName": "Mistral Large 3",
|
||||
"provider": "mistral",
|
||||
"description": "Flagship model with top-tier reasoning and multilingual capabilities."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "openai",
|
||||
"displayName": "OpenAI",
|
||||
"baseUrl": "https://api.openai.com/",
|
||||
"endpoint": "v1/chat/completions",
|
||||
"websiteUrl": "https://platform.openai.com/",
|
||||
"isCustom": false,
|
||||
"models": [
|
||||
{
|
||||
"modelId": "gpt-5.1-instant",
|
||||
"displayName": "GPT-5.1 Instant",
|
||||
"provider": "openai",
|
||||
"description": "The standard high-speed efficiency model replacing older 'Nano' tiers."
|
||||
},
|
||||
{
|
||||
"modelId": "gpt-5-nano",
|
||||
"displayName": "GPT-5 Nano",
|
||||
"provider": "openai",
|
||||
"description": "Fast and cheap model sufficient for most tasks."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "anthropic",
|
||||
"displayName": "Anthropic",
|
||||
"baseUrl": "https://api.anthropic.com/",
|
||||
"endpoint": "v1/messages",
|
||||
"websiteUrl": "https://www.anthropic.com/",
|
||||
"isCustom": false,
|
||||
"models": [
|
||||
{
|
||||
"modelId": "claude-sonnet-5-20260203",
|
||||
"displayName": "Claude Sonnet 5",
|
||||
"provider": "anthropic",
|
||||
"description": "Latest stable workhorse (Feb 2026), balancing speed and top-tier reasoning."
|
||||
},
|
||||
{
|
||||
"modelId": "claude-4.5-haiku",
|
||||
"displayName": "Claude 4.5 Haiku",
|
||||
"provider": "anthropic",
|
||||
"description": "Fastest Claude model for pure speed and simple tasks."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "deepseek",
|
||||
"displayName": "DeepSeek",
|
||||
"baseUrl": "https://api.deepseek.com/",
|
||||
"endpoint": "chat/completions",
|
||||
"websiteUrl": "https://www.deepseek.com/",
|
||||
"isCustom": false,
|
||||
"models": [
|
||||
{
|
||||
"modelId": "deepseek-reasoner",
|
||||
"displayName": "DeepSeek R1",
|
||||
"provider": "deepseek",
|
||||
"description": "Reasoning-focused model (Chain of Thought) for complex math/code."
|
||||
},
|
||||
{
|
||||
"modelId": "deepseek-chat",
|
||||
"displayName": "DeepSeek V3",
|
||||
"provider": "deepseek",
|
||||
"description": "General purpose chat model, specialized in code and reasoning."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "gemini",
|
||||
"displayName": "Google Gemini",
|
||||
"baseUrl": "https://generativelanguage.googleapis.com/",
|
||||
"endpoint": "v1beta/models/gemini-3-flash-preview:generateContent",
|
||||
"websiteUrl": "https://ai.google/",
|
||||
"isCustom": false,
|
||||
"models": [
|
||||
{
|
||||
"modelId": "gemini-3-flash-preview",
|
||||
"displayName": "Gemini 3 Flash",
|
||||
"provider": "gemini",
|
||||
"description": "Current default: Massive context, grounded, and extremely fast."
|
||||
},
|
||||
{
|
||||
"modelId": "gemini-3-pro-preview",
|
||||
"displayName": "Gemini 3 Pro",
|
||||
"provider": "gemini",
|
||||
"description": "Top-tier reasoning model for complex agentic workflows."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "openrouter",
|
||||
"displayName": "OpenRouter",
|
||||
"baseUrl": "https://openrouter.ai/api/",
|
||||
"endpoint": "v1/chat/completions",
|
||||
"websiteUrl": "https://openrouter.ai",
|
||||
"isCustom": false,
|
||||
"models": []
|
||||
},
|
||||
{
|
||||
"key": "groq",
|
||||
"displayName": "Groq",
|
||||
"baseUrl": "https://api.groq.com/openai/",
|
||||
"endpoint": "v1/chat/completions",
|
||||
"websiteUrl": "https://groq.com/",
|
||||
"isCustom": false,
|
||||
"models": [
|
||||
{
|
||||
"modelId": "llama-4-scout-17b",
|
||||
"displayName": "Llama 4 Scout",
|
||||
"provider": "groq",
|
||||
"description": "Powerful Llama 4 model running at extreme speed."
|
||||
},
|
||||
{
|
||||
"modelId": "llama-3.3-70b-versatile",
|
||||
"displayName": "Llama 3.3 70B",
|
||||
"provider": "groq",
|
||||
"description": "Previous gen flagship, highly reliable and fast on Groq chips."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "xai",
|
||||
"displayName": "xAI Grok",
|
||||
"baseUrl": "https://api.x.ai/",
|
||||
"endpoint": "v1/chat/completions",
|
||||
"websiteUrl": "https://x.ai",
|
||||
"isCustom": false,
|
||||
"models": [
|
||||
{
|
||||
"modelId": "grok-4-1-fast-reasoning",
|
||||
"displayName": "Grok 4.1 Fast",
|
||||
"provider": "xai",
|
||||
"description": "Fast, flexible, and capable of reasoning."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "nvidia",
|
||||
"displayName": "NVIDIA NIM",
|
||||
"baseUrl": "https://integrate.api.nvidia.com/",
|
||||
"endpoint": "v1/chat/completions",
|
||||
"websiteUrl": "https://build.nvidia.com/explore",
|
||||
"isCustom": false,
|
||||
"models": [
|
||||
{
|
||||
"modelId": "meta/llama-3.3-70b-instruct",
|
||||
"displayName": "Llama 3.3 70B",
|
||||
"provider": "nvidia",
|
||||
"description": "Standard high-performance open model accelerated by NVIDIA."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "cerebras",
|
||||
"displayName": "Cerebras",
|
||||
"baseUrl": "https://api.cerebras.ai/",
|
||||
"endpoint": "v1/chat/completions",
|
||||
"websiteUrl": "https://inference.cerebras.ai/",
|
||||
"isCustom": false,
|
||||
"models": [
|
||||
{
|
||||
"modelId": "llama-3.3-70b",
|
||||
"displayName": "Llama 3.3 70B (Instant)",
|
||||
"provider": "cerebras",
|
||||
"description": "World's fastest inference (2000+ tokens/sec) on Wafer-Scale Engines."
|
||||
},
|
||||
{
|
||||
"modelId": "llama3.1-8b",
|
||||
"displayName": "Llama 3.1 8B",
|
||||
"provider": "cerebras",
|
||||
"description": "Instant speed for simple tasks."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "huggingface",
|
||||
"displayName": "Hugging Face",
|
||||
"baseUrl": "https://router.huggingface.co/",
|
||||
"endpoint": "v1/chat/completions",
|
||||
"websiteUrl": "https://huggingface.co/settings/tokens",
|
||||
"isCustom": false,
|
||||
"models": [
|
||||
{
|
||||
"modelId": "meta-llama/Llama-3.3-70B-Instruct",
|
||||
"displayName": "Llama 3.3 70B",
|
||||
"provider": "huggingface",
|
||||
"description": "Hosted via the Hugging Face serverless router (Free tier limits apply)."
|
||||
},
|
||||
{
|
||||
"modelId": "microsoft/Phi-3.5-mini-instruct",
|
||||
"displayName": "Phi 3.5 Mini",
|
||||
"provider": "huggingface",
|
||||
"description": "Highly capable small model from Microsoft."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
45
app/src/main/java/eu/gaudian/translator/CorrectActivity.kt
Normal file
45
app/src/main/java/eu/gaudian/translator/CorrectActivity.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import eu.gaudian.translator.utils.Log
|
||||
|
||||
class CorrectActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val intent = intent
|
||||
val action = intent.action
|
||||
val type = intent.type
|
||||
|
||||
if (Intent.ACTION_SEND == action && type == "text/plain") {
|
||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (sharedText != null) {
|
||||
Log.d("EditActivity", "Received text: $sharedText")
|
||||
setContent {
|
||||
Text(stringResource(R.string.editing_text, sharedText))
|
||||
|
||||
}
|
||||
} else {
|
||||
Log.e("EditActivity", getString(R.string.no_text_received))
|
||||
setContent {
|
||||
Text(stringResource(R.string.error_no_text_to_edit))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d("EditActivity", "Not launched with ACTION_SEND")
|
||||
setContent {
|
||||
Text(stringResource(R.string.not_launched_with_text_to_edit))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
36
app/src/main/java/eu/gaudian/translator/MyApplication.kt
Normal file
36
app/src/main/java/eu/gaudian/translator/MyApplication.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
package eu.gaudian.translator
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import eu.gaudian.translator.model.repository.ApiRepository
|
||||
import eu.gaudian.translator.model.repository.LanguageRepository
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltAndroidApp
|
||||
class MyApplication : Application() {
|
||||
|
||||
private var _languageRepository: LanguageRepository? = null
|
||||
private var _apiRepository: ApiRepository? = null
|
||||
|
||||
val languageRepository: LanguageRepository
|
||||
get() = _languageRepository ?: synchronized(this) {
|
||||
_languageRepository ?: LanguageRepository(this).also {
|
||||
_languageRepository = it
|
||||
}
|
||||
}
|
||||
|
||||
val apiRepository: ApiRepository
|
||||
get() = _apiRepository ?: synchronized(this) {
|
||||
_apiRepository ?: ApiRepository(this).also {
|
||||
_apiRepository = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package eu.gaudian.translator.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.RawValue
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class DictionaryEntry @OptIn(ExperimentalTime::class) constructor(
|
||||
val id: Int,
|
||||
val word: String,
|
||||
val definition: List<EntryPart>, //list of Word class, declination, origin, etc.
|
||||
var languageCode: Int,
|
||||
var languageName: String,
|
||||
@Contextual val createdAt: kotlin.time.Instant? = Clock.System.now()) :
|
||||
Parcelable
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class EntryPart(
|
||||
val title: String, //e.g. Word class
|
||||
val content: @RawValue JsonElement, //e.g. Noun, Verb, etc.
|
||||
) : Parcelable
|
||||
23
app/src/main/java/eu/gaudian/translator/model/Etymology.kt
Normal file
23
app/src/main/java/eu/gaudian/translator/model/Etymology.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package eu.gaudian.translator.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class EtymologyStep(
|
||||
val year: String,
|
||||
val language: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RelatedWord(
|
||||
val language: String,
|
||||
val word: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EtymologyData(
|
||||
val word: String,
|
||||
val timeline: List<EtymologyStep>,
|
||||
val relatedWords: List<RelatedWord>
|
||||
)
|
||||
122
app/src/main/java/eu/gaudian/translator/model/Exercise.kt
Normal file
122
app/src/main/java/eu/gaudian/translator/model/Exercise.kt
Normal file
@@ -0,0 +1,122 @@
|
||||
package eu.gaudian.translator.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class Exercise(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val questions: List<Int>,
|
||||
val associatedVocabularyIds: List<Int> = emptyList(),
|
||||
val sourceLanguage: String? = null,
|
||||
val targetLanguage: String? = null,
|
||||
val contextTitle: String? = null,
|
||||
val contextText: String? = null,
|
||||
val youtubeUrl: String? = null
|
||||
) : Parcelable
|
||||
|
||||
|
||||
@Serializable
|
||||
sealed class Question : Parcelable {
|
||||
abstract val id: Int
|
||||
abstract val name: String
|
||||
|
||||
companion object {
|
||||
|
||||
val allTypes: List<KClass<out Question>> = listOf(
|
||||
TrueFalseQuestion::class,
|
||||
MultipleChoiceQuestion::class,
|
||||
FillInTheBlankQuestion::class,
|
||||
WordOrderQuestion::class,
|
||||
MatchingPairsQuestion::class,
|
||||
ListeningComprehensionQuestion::class,
|
||||
CategorizationQuestion::class,
|
||||
VocabularyTestQuestion::class
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
@SerialName("TrueFalseQuestion")
|
||||
data class TrueFalseQuestion(
|
||||
override val id: Int,
|
||||
override val name: String,
|
||||
val correctAnswer: Boolean,
|
||||
val explanation: String = ""
|
||||
) : Question()
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
@SerialName("MultipleChoiceQuestion")
|
||||
data class MultipleChoiceQuestion(
|
||||
override val id: Int,
|
||||
override val name: String,
|
||||
val options: List<String>,
|
||||
val correctAnswerIndex: Int
|
||||
) : Question()
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
@SerialName("FillInTheBlankQuestion")
|
||||
data class FillInTheBlankQuestion(
|
||||
override val id: Int,
|
||||
override val name: String, // The sentence with a placeholder like "___"
|
||||
val correctAnswer: String,
|
||||
val hintBaseForm: String = "",
|
||||
val hintOptions: List<String> = emptyList()
|
||||
) : Question()
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
@SerialName("WordOrderQuestion")
|
||||
data class WordOrderQuestion(
|
||||
override val id: Int,
|
||||
override val name: String, // The instruction, e.g., "Form the sentence."
|
||||
val words: List<String>, // The scrambled words
|
||||
val correctOrder: List<String>
|
||||
) : Question()
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
@SerialName("MatchingPairsQuestion")
|
||||
data class MatchingPairsQuestion(
|
||||
override val id: Int,
|
||||
override val name: String, // e.g., "Match the English words to their German translation."
|
||||
val pairs: Map<String, String> // Key-value pairs to be matched
|
||||
) : Question()
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
@SerialName("ListeningComprehensionQuestion")
|
||||
data class ListeningComprehensionQuestion(
|
||||
override val id: Int,
|
||||
override val name: String, // The text to be spoken and transcribed
|
||||
val languageCode: String // e.g., "en-US" for TTS
|
||||
) : Question()
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
@SerialName("CategorizationQuestion")
|
||||
data class CategorizationQuestion(
|
||||
override val id: Int,
|
||||
override val name: String, // e.g., "Sort these into 'Fruit' and 'Vegetable' categories."
|
||||
val items: List<String>,
|
||||
val categories: List<String>,
|
||||
val correctMapping: Map<String, String> // Maps each item to its correct category
|
||||
) : Question()
|
||||
|
||||
@Parcelize
|
||||
@Serializable
|
||||
@SerialName("VocabularyTestQuestion")
|
||||
data class VocabularyTestQuestion(
|
||||
override val id: Int,
|
||||
override val name: String,
|
||||
val correctAnswer: String,
|
||||
val languageDirection: String
|
||||
) : Question()
|
||||
188
app/src/main/java/eu/gaudian/translator/model/Language.kt
Normal file
188
app/src/main/java/eu/gaudian/translator/model/Language.kt
Normal file
@@ -0,0 +1,188 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.Locale
|
||||
import kotlin.random.Random
|
||||
|
||||
@Serializable
|
||||
data class Language(
|
||||
val code: String, //ISO 639-1 code
|
||||
val region: String,
|
||||
val nameResId: Int,
|
||||
val name: String, //the name is context specific and can differ in various languages -> gets loaded when App starts
|
||||
val englishName: String, //to be used internally for requests etc.
|
||||
val nativeName: String = englishName, //the native name of the language (e.g., "Deutsch" for German), defaults to englishName for backward compatibility
|
||||
val isCustom: Boolean? = false, // there is also an option to add custom languages
|
||||
var isSelected: Boolean? = false
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Language
|
||||
|
||||
if (code != other.code) return false
|
||||
if (region != other.region) return false
|
||||
if (nameResId != other.nameResId) return false
|
||||
if (name != other.name) return false
|
||||
if (englishName != other.englishName) return false
|
||||
if (nativeName != other.nativeName) return false
|
||||
if (isCustom != other.isCustom) return false
|
||||
if (isSelected != other.isSelected) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@Suppress("unused")
|
||||
fun isSameAs(other: Language?): Boolean {
|
||||
if (other == null) return false
|
||||
|
||||
if (this.isCustom == true && other.isCustom == true) {
|
||||
return this.name.equals(other.name, ignoreCase = true) &&
|
||||
this.code.equals(other.code, ignoreCase = true)
|
||||
}
|
||||
if (this.isCustom != other.isCustom) {
|
||||
return false
|
||||
}
|
||||
return this.nameResId == other.nameResId
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = code.hashCode()
|
||||
result = 31 * result + region.hashCode()
|
||||
result = 31 * result + nameResId
|
||||
result = 31 * result + name.hashCode()
|
||||
result = 31 * result + englishName.hashCode()
|
||||
result = 31 * result + nativeName.hashCode()
|
||||
result = 31 * result + (isCustom?.hashCode() ?: 0)
|
||||
result = 31 * result + (isSelected?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun parseLanguagesFromResources(context: Context): List<Language> {
|
||||
val languages = mutableListOf<Language>()
|
||||
val languageCodes = context.resources.getStringArray(R.array.language_codes)
|
||||
|
||||
Log.d("LanguageParser", "Starting to parse languages from resources")
|
||||
|
||||
languageCodes.forEach { item ->
|
||||
val parts = item.split(",")
|
||||
if (parts.size == 3) {
|
||||
val code = parts[0].lowercase(Locale.getDefault())
|
||||
val region = parts[1]
|
||||
val nameResId = parts[2].toIntOrNull() ?: 0
|
||||
if (nameResId != 0) {
|
||||
val localizedName = getCapitalizedName(context, nameResId)
|
||||
val englishName = getEnglishName(context, nameResId)
|
||||
Log.d("LanguageParser", "Parsed language: $code, $region, $nameResId")
|
||||
val nativeName = getNativeName(context, nameResId)
|
||||
languages.add(
|
||||
Language(
|
||||
code = code,
|
||||
region = region,
|
||||
nameResId = nameResId,
|
||||
name = localizedName,
|
||||
englishName = englishName,
|
||||
nativeName = nativeName,
|
||||
isCustom = false,
|
||||
isSelected = true
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Log.w("LanguageParser", "Invalid nameResId for language: $code, $region")
|
||||
}
|
||||
} else {
|
||||
Log.e("LanguageParser", "Invalid language code format: $item")
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("LanguageParser", "Finished parsing languages. Total languages: ${languages.size}")
|
||||
return languages
|
||||
}
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
private fun getCapitalizedName(context: Context, nameResId: Int): String {
|
||||
return try {
|
||||
val name = context.getString(
|
||||
context.resources.getIdentifier("language_$nameResId", "string", context.packageName)
|
||||
)
|
||||
name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
} catch (e: Exception) {
|
||||
Log.e("Language", "Resource not found for nameResId: $nameResId", e)
|
||||
context.getString(R.string.text_unknown_language)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("AppBundleLocaleChanges", "DiscouragedApi")
|
||||
private fun getEnglishName(context: Context, nameResId: Int): String {
|
||||
return try {
|
||||
val resName = "language_$nameResId"
|
||||
val id = context.resources.getIdentifier(resName, "string", context.packageName)
|
||||
if (id == 0) return context.getString(R.string.text_unknown_language)
|
||||
|
||||
// Use the application context with English locale for resource lookup
|
||||
val resources = context.applicationContext.resources
|
||||
val configuration = android.content.res.Configuration(resources.configuration)
|
||||
configuration.setLocale(Locale.ENGLISH)
|
||||
val localizedContext = context.applicationContext.createConfigurationContext(configuration)
|
||||
localizedContext.resources.getString(id)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Language", "Failed to get English name for nameResId: $nameResId", e)
|
||||
context.getString(R.string.text_unknown_language)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
fun getNativeName(context: Context, nameResId: Int): String {
|
||||
|
||||
return try {
|
||||
val resName = "native_language_$nameResId"
|
||||
val id = context.resources.getIdentifier(resName, "string", context.packageName)
|
||||
Log.d("Language", "Native name resource ID for nameResId $nameResId: $id")
|
||||
if (id == 0) {
|
||||
Log.w("Language", "Native name resource not found for nameResId: $nameResId")
|
||||
getEnglishName(context, nameResId)
|
||||
} else {
|
||||
|
||||
context.getString(id)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Language", "Failed to get native name for nameResId: $nameResId", e)
|
||||
getEnglishName(context, nameResId)
|
||||
}
|
||||
}
|
||||
|
||||
//meant for creating dummies, testing, etc
|
||||
fun generateRandomLanguage(): Language {
|
||||
return Language(
|
||||
code = "random",
|
||||
region = "",
|
||||
nameResId = 0,
|
||||
name = "Language "+ Random.nextInt(1000, 9999).toString(),
|
||||
englishName = "German",
|
||||
nativeName = "Deutsch",
|
||||
isCustom = false,
|
||||
isSelected = true
|
||||
)
|
||||
}
|
||||
|
||||
fun generateSimpleLanguage(name: String): Language {
|
||||
return Language(
|
||||
code = "random",
|
||||
region = "",
|
||||
nameResId = 0,
|
||||
name = name,
|
||||
englishName = "German",
|
||||
nativeName = "Deutsch",
|
||||
isCustom = false,
|
||||
isSelected = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
package eu.gaudian.translator.model
|
||||
|
||||
import eu.gaudian.translator.R
|
||||
|
||||
|
||||
object LanguageLevels {
|
||||
val all: List<MyAppLanguageLevel> = listOf(
|
||||
MyAppLanguageLevel.Newborn,
|
||||
MyAppLanguageLevel.EchoingEcho,
|
||||
MyAppLanguageLevel.GoldfishMemory,
|
||||
MyAppLanguageLevel.CleverPigeon,
|
||||
MyAppLanguageLevel.KoshikTheElephant,
|
||||
MyAppLanguageLevel.GossipLovingCrow,
|
||||
MyAppLanguageLevel.HoneybeeCartographer,
|
||||
MyAppLanguageLevel.ChattyParrotlet,
|
||||
MyAppLanguageLevel.CuriousToddler,
|
||||
MyAppLanguageLevel.RicoTheDog,
|
||||
MyAppLanguageLevel.AuctioneerInTraining,
|
||||
MyAppLanguageLevel.AlexTheParrot,
|
||||
MyAppLanguageLevel.PilitaTheSeaLion,
|
||||
MyAppLanguageLevel.KanziTheBonobo,
|
||||
MyAppLanguageLevel.KokoTheGorilla,
|
||||
MyAppLanguageLevel.ShakespeareanInsultGenerator,
|
||||
MyAppLanguageLevel.FirstGrader,
|
||||
MyAppLanguageLevel.PuppyInTraining,
|
||||
MyAppLanguageLevel.ChaserTheSuperdog,
|
||||
MyAppLanguageLevel.Bookworm,
|
||||
MyAppLanguageLevel.MiddleSchooler,
|
||||
MyAppLanguageLevel.AvidDebater,
|
||||
MyAppLanguageLevel.HighSchoolGrad,
|
||||
MyAppLanguageLevel.TheJournalist,
|
||||
MyAppLanguageLevel.TheProfessor,
|
||||
MyAppLanguageLevel.TheNovelist,
|
||||
MyAppLanguageLevel.MasterLinguist,
|
||||
MyAppLanguageLevel.ThePolyglotOracle
|
||||
)
|
||||
|
||||
fun getLevelForWords(wordsLearned: Int): MyAppLanguageLevel {
|
||||
return all.lastOrNull { wordsLearned >= it.wordsKnown } ?: MyAppLanguageLevel.Newborn
|
||||
}
|
||||
fun getNextLevel(wordsLearned: Int): MyAppLanguageLevel? {
|
||||
return all.firstOrNull { wordsLearned < it.wordsKnown }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
sealed class MyAppLanguageLevel(
|
||||
val nameResId: Int,
|
||||
val descriptionResId: Int,
|
||||
val wordsKnown: Int,
|
||||
val iconResId: Int
|
||||
) {
|
||||
object Newborn : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_newborn_name,
|
||||
descriptionResId = R.string.level_newborn_description,
|
||||
wordsKnown = 0,
|
||||
iconResId = R.drawable.ic_level_newborn
|
||||
)
|
||||
|
||||
object EchoingEcho : MyAppLanguageLevel( // New
|
||||
nameResId = R.string.level_echo_name,
|
||||
descriptionResId = R.string.level_echo_description,
|
||||
wordsKnown = 3,
|
||||
iconResId = R.drawable.ic_level_echo
|
||||
)
|
||||
|
||||
object GoldfishMemory : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_goldfish_name,
|
||||
descriptionResId = R.string.level_goldfish_description,
|
||||
wordsKnown = 5,
|
||||
iconResId = R.drawable.ic_level_goldfish
|
||||
)
|
||||
|
||||
object CleverPigeon : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_pigeon_name,
|
||||
descriptionResId = R.string.level_pigeon_description,
|
||||
wordsKnown = 10,
|
||||
iconResId = R.drawable.ic_level_pigeon
|
||||
)
|
||||
|
||||
object KoshikTheElephant : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_elephant_name,
|
||||
descriptionResId = R.string.level_elephant_description,
|
||||
wordsKnown = 20,
|
||||
iconResId = R.drawable.ic_level_elephant
|
||||
)
|
||||
|
||||
object GossipLovingCrow : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_crow_name,
|
||||
descriptionResId = R.string.level_crow_description,
|
||||
wordsKnown = 25,
|
||||
iconResId = R.drawable.ic_level_crow
|
||||
)
|
||||
|
||||
object HoneybeeCartographer : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_honeybee_name,
|
||||
descriptionResId = R.string.level_honeybee_description,
|
||||
wordsKnown = 35,
|
||||
iconResId = R.drawable.ic_level_bee
|
||||
)
|
||||
|
||||
object ChattyParrotlet : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_parrotlet_name,
|
||||
descriptionResId = R.string.level_parrotlet_description,
|
||||
wordsKnown = 50,
|
||||
iconResId = R.drawable.ic_level_parrotlet
|
||||
)
|
||||
|
||||
object CuriousToddler : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_toddler_name,
|
||||
descriptionResId = R.string.level_toddler_description,
|
||||
wordsKnown = 75,
|
||||
iconResId = R.drawable.ic_level_toddler
|
||||
)
|
||||
|
||||
object RicoTheDog : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_rico_name,
|
||||
descriptionResId = R.string.level_rico_description,
|
||||
wordsKnown = 100,
|
||||
iconResId = R.drawable.ic_level_rico
|
||||
)
|
||||
|
||||
object AuctioneerInTraining : MyAppLanguageLevel( // New
|
||||
nameResId = R.string.level_auctioneer_name,
|
||||
descriptionResId = R.string.level_auctioneer_description,
|
||||
wordsKnown = 125,
|
||||
iconResId = R.drawable.ic_level_auctioneer
|
||||
)
|
||||
|
||||
object AlexTheParrot : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_alex_name,
|
||||
descriptionResId = R.string.level_alex_description,
|
||||
wordsKnown = 150,
|
||||
iconResId = R.drawable.ic_level_parrot
|
||||
)
|
||||
|
||||
object PilitaTheSeaLion : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_pilita_name,
|
||||
descriptionResId = R.string.level_pilita_description,
|
||||
wordsKnown = 225,
|
||||
iconResId = R.drawable.ic_level_sea_lion
|
||||
)
|
||||
|
||||
object KanziTheBonobo : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_kanzi_name,
|
||||
descriptionResId = R.string.level_kanzi_description,
|
||||
wordsKnown = 350,
|
||||
iconResId = R.drawable.ic_level_bonobo
|
||||
)
|
||||
|
||||
object KokoTheGorilla : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_koko_name,
|
||||
descriptionResId = R.string.level_koko_description,
|
||||
wordsKnown = 500,
|
||||
iconResId = R.drawable.ic_level_gorilla
|
||||
)
|
||||
|
||||
object ShakespeareanInsultGenerator : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_shakespeare_name,
|
||||
descriptionResId = R.string.level_shakespeare_description,
|
||||
wordsKnown = 600,
|
||||
iconResId = R.drawable.ic_level_shakespeare
|
||||
)
|
||||
|
||||
object FirstGrader : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_first_grader_name,
|
||||
descriptionResId = R.string.level_first_grader_description,
|
||||
wordsKnown = 750,
|
||||
iconResId = R.drawable.ic_level_first_grader
|
||||
)
|
||||
|
||||
object PuppyInTraining : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_puppy_in_training_name,
|
||||
descriptionResId = R.string.level_puppy_in_training_description,
|
||||
wordsKnown = 900,
|
||||
iconResId = R.drawable.ic_level_puppy
|
||||
)
|
||||
|
||||
object ChaserTheSuperdog : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_chaser_name,
|
||||
descriptionResId = R.string.level_chaser_description,
|
||||
wordsKnown = 1100,
|
||||
iconResId = R.drawable.ic_level_chaser
|
||||
)
|
||||
|
||||
object Bookworm : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_bookworm_name,
|
||||
descriptionResId = R.string.level_bookworm_description,
|
||||
wordsKnown = 1600,
|
||||
iconResId = R.drawable.ic_level_bookworm
|
||||
)
|
||||
|
||||
object MiddleSchooler : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_middle_schooler_name,
|
||||
descriptionResId = R.string.level_middle_schooler_description,
|
||||
wordsKnown = 2300,
|
||||
iconResId = R.drawable.ic_level_middle_schooler
|
||||
)
|
||||
|
||||
object AvidDebater : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_avid_debater_name,
|
||||
descriptionResId = R.string.level_avid_debater_description,
|
||||
wordsKnown = 3500,
|
||||
iconResId = R.drawable.ic_level_avid_debater
|
||||
)
|
||||
|
||||
object HighSchoolGrad : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_high_school_grad_name,
|
||||
descriptionResId = R.string.level_high_school_grad_description,
|
||||
wordsKnown = 5000,
|
||||
iconResId = R.drawable.ic_level_high_school_grad
|
||||
)
|
||||
|
||||
object TheJournalist : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_journalist_name,
|
||||
descriptionResId = R.string.level_journalist_description,
|
||||
wordsKnown = 7500,
|
||||
iconResId = R.drawable.ic_level_journalist
|
||||
)
|
||||
|
||||
object TheProfessor : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_professor_name,
|
||||
descriptionResId = R.string.level_professor_description,
|
||||
wordsKnown = 12000,
|
||||
iconResId = R.drawable.ic_level_professor
|
||||
)
|
||||
|
||||
object TheNovelist : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_novelist_name,
|
||||
descriptionResId = R.string.level_novelist_description,
|
||||
wordsKnown = 18000,
|
||||
iconResId = R.drawable.ic_level_novelist
|
||||
)
|
||||
|
||||
object MasterLinguist : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_linguist_name,
|
||||
descriptionResId = R.string.level_linguist_description,
|
||||
wordsKnown = 25000,
|
||||
iconResId = R.drawable.ic_level_master_linguist
|
||||
)
|
||||
|
||||
object ThePolyglotOracle : MyAppLanguageLevel(
|
||||
nameResId = R.string.level_oracle_name,
|
||||
descriptionResId = R.string.level_oracle_description,
|
||||
wordsKnown = 50000,
|
||||
iconResId = R.drawable.ic_level_oracle
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
147
app/src/main/java/eu/gaudian/translator/model/Vocabulary.kt
Normal file
147
app/src/main/java/eu/gaudian/translator/model/Vocabulary.kt
Normal file
@@ -0,0 +1,147 @@
|
||||
@file:OptIn(ExperimentalTime::class)
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.model.grammar.VocabularyFeatures
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
val jsonParser = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
@Entity(tableName = "vocabulary_items")
|
||||
data class VocabularyItem(
|
||||
@PrimaryKey val id: Int,
|
||||
val languageFirstId: Int?,
|
||||
val languageSecondId: Int?,
|
||||
val wordFirst: String,
|
||||
val wordSecond: String,
|
||||
@Contextual val createdAt: Instant? = Clock.System.now(),
|
||||
val features: String? = null,
|
||||
val zipfFrequencyFirst: Float? = null,
|
||||
val zipfFrequencySecond: Float? = null
|
||||
) : Parcelable {
|
||||
|
||||
fun isDuplicate(other: VocabularyItem): Boolean {
|
||||
val normalizedWords = setOf(wordFirst.lowercase(), wordSecond.lowercase())
|
||||
val otherNormalizedWords = setOf(other.wordFirst.lowercase(), other.wordSecond.lowercase())
|
||||
val normalizedIds = setOf(languageFirstId, languageSecondId)
|
||||
val otherNormalizedIds = setOf(other.languageFirstId, other.languageSecondId)
|
||||
return normalizedWords == otherNormalizedWords && normalizedIds == otherNormalizedIds
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun switchOrder(): VocabularyItem {
|
||||
val currentFeatures = features?.let { jsonParser.decodeFromString<VocabularyFeatures>(it) }
|
||||
val switchedFeatures = currentFeatures?.copy(first = currentFeatures.second, second = currentFeatures.first)
|
||||
val switchedFeaturesJson = switchedFeatures?.let { jsonParser.encodeToString(it) }
|
||||
|
||||
return this.copy(
|
||||
languageFirstId = this.languageSecondId,
|
||||
languageSecondId = this.languageFirstId,
|
||||
wordFirst = this.wordSecond,
|
||||
wordSecond = this.wordFirst,
|
||||
features = switchedFeaturesJson
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class WordDetails(
|
||||
@SerialName("category")
|
||||
val wordClass: String,
|
||||
val properties: Map<String, String>
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* A container holding the grammatical details for both words in a VocabularyItem.
|
||||
*/
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class VocabularyGrammarDetails(
|
||||
val first: WordDetails? = null,
|
||||
val second: WordDetails? = null
|
||||
) : Parcelable
|
||||
|
||||
|
||||
@Serializable
|
||||
sealed class VocabularyCategory {
|
||||
abstract val id: Int
|
||||
abstract val name: String
|
||||
}
|
||||
|
||||
|
||||
@Serializable
|
||||
@SerialName("FilterCategory")
|
||||
data class VocabularyFilter(
|
||||
override val id: Int,
|
||||
override val name: String,
|
||||
val languages: List<Int>? = null,
|
||||
@Contextual val languagePairs: Pair<Int, Int> ? = null,
|
||||
val stages: List<VocabularyStage>? = null,
|
||||
) : VocabularyCategory()
|
||||
|
||||
@Serializable
|
||||
@SerialName("TagCategory")
|
||||
data class TagCategory(
|
||||
override val id: Int,
|
||||
override val name: String,
|
||||
) : VocabularyCategory()
|
||||
|
||||
|
||||
@Serializable
|
||||
@Entity(tableName = "vocabulary_states")
|
||||
data class VocabularyItemState(
|
||||
@PrimaryKey val vocabularyItemId: Int,
|
||||
@Contextual var lastCorrectAnswer: Instant? = null,
|
||||
@Contextual var lastIncorrectAnswer: Instant? = null,
|
||||
var correctAnswerCount: Int = 0,
|
||||
var incorrectAnswerCount: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class VocabularyStage {
|
||||
NEW,
|
||||
STAGE_1,
|
||||
STAGE_2,
|
||||
STAGE_3,
|
||||
STAGE_4,
|
||||
STAGE_5,
|
||||
LEARNED;
|
||||
|
||||
fun toString(context: Context): String {
|
||||
val res = context.resources
|
||||
return when (this) {
|
||||
NEW -> res.getString(R.string.stage_new)
|
||||
STAGE_1 -> res.getString(R.string.stage_1)
|
||||
STAGE_2 -> res.getString(R.string.stage_2)
|
||||
STAGE_3 -> res.getString(R.string.stage_3)
|
||||
STAGE_4 -> res.getString(R.string.stage_4)
|
||||
STAGE_5 -> res.getString(R.string.stage_5)
|
||||
LEARNED -> res.getString(R.string.stage_learned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class CardSet(
|
||||
val id: Int?=null,
|
||||
val languageFirst: Int?=null,
|
||||
val languageSecond: Int?=null,
|
||||
val cards: List<VocabularyItem>,
|
||||
): Parcelable
|
||||
39
app/src/main/java/eu/gaudian/translator/model/WidgetType.kt
Normal file
39
app/src/main/java/eu/gaudian/translator/model/WidgetType.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import eu.gaudian.translator.R
|
||||
|
||||
|
||||
sealed class WidgetType(val id: String, @param:StringRes val titleRes: Int) {
|
||||
data object Status : WidgetType("status", R.string.label_status)
|
||||
data object Streak : WidgetType("streak", R.string.title_widget_streak)
|
||||
data object StartButtons : WidgetType("start_buttons", R.string.label_start_exercise)
|
||||
data object AllVocabulary : WidgetType("all_vocabulary", R.string.label_all_vocabulary)
|
||||
data object DueToday : WidgetType("due_today", R.string.title_widget_due_today)
|
||||
data object CategoryProgress : WidgetType("category_progress", R.string.title_widget_category_progress)
|
||||
data object WeeklyActivityChart : WidgetType("weekly_activity_chart", R.string.text_widget_title_weekly_activity)
|
||||
data object Levels : WidgetType("category_stats", R.string.levels)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The default order of widgets when the app is first launched or if no order is saved.
|
||||
*/
|
||||
val DEFAULT_ORDER = listOf(
|
||||
Status,
|
||||
Streak,
|
||||
StartButtons,
|
||||
AllVocabulary,
|
||||
DueToday,
|
||||
CategoryProgress ,
|
||||
WeeklyActivityChart,
|
||||
Levels,
|
||||
)
|
||||
|
||||
|
||||
fun fromId(id: String?): WidgetType? {
|
||||
return DEFAULT_ORDER.find { it.id == id }
|
||||
}
|
||||
}
|
||||
}
|
||||
18
app/src/main/java/eu/gaudian/translator/model/YouTubeData.kt
Normal file
18
app/src/main/java/eu/gaudian/translator/model/YouTubeData.kt
Normal file
@@ -0,0 +1,18 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class SubtitleLine(
|
||||
@SerialName("start") val start: Float,
|
||||
@SerialName("duration") val duration: Float,
|
||||
@SerialName("text") val text: String,
|
||||
// Optional translated text to be displayed alongside the original subtitle.
|
||||
// Not provided by the backend; filled by the app after fetching.
|
||||
@SerialName("translatedText") val translatedText: String? = null
|
||||
) {
|
||||
val end: Float get() = start + duration
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,620 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
import android.content.Context
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.model.LanguageModel
|
||||
import eu.gaudian.translator.model.repository.ApiRepository
|
||||
import eu.gaudian.translator.model.repository.SettingsRepository
|
||||
import eu.gaudian.translator.utils.ApiCallback
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import eu.gaudian.translator.utils.StatusAction
|
||||
import eu.gaudian.translator.utils.StatusMessageService
|
||||
import eu.gaudian.translator.viewmodel.MessageAction
|
||||
import eu.gaudian.translator.viewmodel.MessageDisplayType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Dispatchers.Main
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
class ApiManager(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Checks whether a base URL is reachable and the (optional) port is open.
|
||||
* - If no scheme is provided, http is assumed.
|
||||
* - If no port is provided, defaults to 80 for http and 443 for https.
|
||||
* Tries a TCP socket connect first, then a lightweight HTTP GET to the root.
|
||||
* Returns Pair<isAvailable, message>.
|
||||
*/
|
||||
suspend fun checkProviderAvailability(baseUrl: String): Pair<Boolean, String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val normalized = try {
|
||||
|
||||
var url = baseUrl.trim()
|
||||
if (url.isEmpty()) url = "http://localhost/"
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) url = "http://$url"
|
||||
if (!url.endsWith('/')) url += "/"
|
||||
url
|
||||
} catch (_: Exception) {
|
||||
var url = baseUrl.trim()
|
||||
if (url.isEmpty()) url = "http://localhost/"
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) url = "http://$url"
|
||||
if (!url.endsWith('/')) url += "/"
|
||||
url
|
||||
}
|
||||
|
||||
val uri = java.net.URI(normalized)
|
||||
val host = uri.host ?: return@withContext Pair(false, "Invalid host")
|
||||
val scheme = (uri.scheme ?: "http").lowercase()
|
||||
val port = if (uri.port != -1) uri.port else if (scheme == "https") 443 else 80
|
||||
|
||||
// 1) TCP connect test
|
||||
try {
|
||||
java.net.Socket().use { socket ->
|
||||
socket.connect(java.net.InetSocketAddress(host, port), 1500)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return@withContext Pair(false, "Cannot connect to $host:$port (${e.message})")
|
||||
}
|
||||
|
||||
// 2) HTTP GET test (non-fatal if it fails; we already know port is open)
|
||||
return@withContext try {
|
||||
val client = okhttp3.OkHttpClient.Builder()
|
||||
.connectTimeout(2, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
val request = okhttp3.Request.Builder().url(normalized).get().build()
|
||||
client.newCall(request).execute().use { resp ->
|
||||
val code = resp.code
|
||||
if (code in 200..499) {
|
||||
Pair(true, "Reachable ($code)")
|
||||
} else {
|
||||
Pair(true, "Port open; HTTP $code")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Pair(true, "Port open; HTTP check failed: ${e.message}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ApiManager", "Availability check error: ${e.message}", e)
|
||||
Pair(false, e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
private val apiLogRepository = eu.gaudian.translator.model.repository.ApiLogRepository(context)
|
||||
private val gson = com.google.gson.Gson()
|
||||
|
||||
private val apiRepository = ApiRepository(context)
|
||||
private val settingsRepository = SettingsRepository(context)
|
||||
|
||||
/**
|
||||
* Validates a given API key against a specific provider's endpoint.
|
||||
*/
|
||||
suspend fun validateApiKey(apiKey: String, provider: ApiProvider): Pair<Boolean, String?> {
|
||||
val validationMessage = "Validating API key for ${provider.displayName}..."
|
||||
Log.d("ApiManager", validationMessage)
|
||||
|
||||
val cleanApiKey = apiKey.trim()
|
||||
if (cleanApiKey.isEmpty()) {
|
||||
return Pair(false, "API key cannot be empty.")
|
||||
}
|
||||
|
||||
val tempApiService = RetrofitClient.getApiClient(cleanApiKey, provider.baseUrl, provider)
|
||||
.create(LlmApiService::class.java)
|
||||
|
||||
val endpointUrl = provider.endpoint
|
||||
val validationPrompt = "Just respond with the word \"success\" if you received this message."
|
||||
|
||||
// For custom providers or local hosts, allow saving the key without requiring a model.
|
||||
val base = provider.baseUrl.trim()
|
||||
val lower = base.lowercase()
|
||||
val isLocalHost = (
|
||||
lower.contains("localhost") ||
|
||||
lower.contains("127.0.0.1") ||
|
||||
lower.startsWith("192.168.") ||
|
||||
lower.startsWith("http://192.168.") ||
|
||||
lower.startsWith("10.") ||
|
||||
lower.startsWith("http://10.") ||
|
||||
Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower) ||
|
||||
Regex("^http://172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower)
|
||||
)
|
||||
if (provider.isCustom || isLocalHost) {
|
||||
// We cannot reliably validate without a model; accept non-empty key for setup.
|
||||
return Pair(true, "Key accepted for ${provider.displayName}.")
|
||||
}
|
||||
|
||||
val defaultModel = provider.defaultModel ?: return Pair(true, "No default model found; key saved. Configure a model to fully validate.")
|
||||
|
||||
val result = withTimeoutOrNull(10000) { // 10-second timeout
|
||||
try {
|
||||
when (provider.key) {
|
||||
"gemini" -> {
|
||||
val testRequest = GeminiRequest(
|
||||
contents = listOf(GeminiContent("user", listOf(GeminiPart(validationPrompt))))
|
||||
)
|
||||
val response: Response<GeminiResponse> = tempApiService.sendGeminiRequest(endpointUrl, testRequest).execute()
|
||||
Pair(response.isSuccessful, response.body()?.toString() ?: response.errorBody()?.string())
|
||||
}
|
||||
else -> {
|
||||
val testRequest = Request(
|
||||
model = defaultModel,
|
||||
messages = listOf(Request.Message("user", validationPrompt))
|
||||
)
|
||||
val response: Response<ApiResponse> = tempApiService.sendRequest(endpointUrl, testRequest).execute()
|
||||
Pair(response.isSuccessful, response.body()?.toString() ?: response.errorBody()?.string())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ApiManager", "Error validating API key: ${e.message}", e)
|
||||
Pair(false, e.message)
|
||||
}
|
||||
} ?: Pair(false, "Timeout validating API key.")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from provider if supported (e.g., OpenAI/OpenRouter/Mistral-compatible GET v1/models).
|
||||
* Returns Pair<List<LanguageModel>, String?> where second is an error message if any.
|
||||
*/
|
||||
suspend fun fetchAvailableModels(apiKey: String?, provider: ApiProvider): Pair<List<LanguageModel>, String?> {
|
||||
val base = provider.baseUrl.trim()
|
||||
val lower = base.lowercase()
|
||||
val isLocalHost = (
|
||||
lower.contains("localhost") ||
|
||||
lower.contains("127.0.0.1") ||
|
||||
lower.startsWith("192.168.") ||
|
||||
lower.startsWith("http://192.168.") ||
|
||||
lower.startsWith("10.") ||
|
||||
lower.startsWith("http://10.") ||
|
||||
Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower) ||
|
||||
Regex("^http://172\\.(1[6-9]|2[0-9]|3[0-1])\\.").containsMatchIn(lower)
|
||||
)
|
||||
|
||||
if (provider.key == "gemini") {
|
||||
// Gemini uses a different endpoint and response shape for model listing.
|
||||
val key = apiKey?.trim().orEmpty()
|
||||
if (key.isEmpty() && !provider.isCustom) {
|
||||
return Pair(emptyList(), "API key required to list models for this provider.")
|
||||
}
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val service = RetrofitClient.getApiClient(key, provider.baseUrl, provider).create(LlmApiService::class.java)
|
||||
// Gemini model list endpoint (v1beta). We keep it relative to baseUrl like other calls.
|
||||
val url = "v1beta/models"
|
||||
val response = service.listGeminiModels(url).execute()
|
||||
if (!response.isSuccessful) {
|
||||
val err = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
||||
Pair(emptyList(), err)
|
||||
} else {
|
||||
val body = response.body()
|
||||
val items = body?.models ?: emptyList()
|
||||
val mapped = items.map { item ->
|
||||
val niceName = item.displayName ?: item.name ?: "unknown"
|
||||
val parts = mutableListOf<String>()
|
||||
item.description?.let { if (it.isNotBlank()) parts.add(it) }
|
||||
item.inputTokenLimit?.let { if (it > 0) parts.add("in $it ctx") }
|
||||
item.outputTokenLimit?.let { if (it > 0) parts.add("out $it ctx") }
|
||||
item.supportedGenerationMethods?.takeIf { it.isNotEmpty() }?.let { parts.add(it.joinToString("/")) }
|
||||
val desc = parts.joinToString(" • ")
|
||||
val modelIdClean = (item.name ?: niceName).substringAfterLast('/')
|
||||
LanguageModel(
|
||||
modelId = modelIdClean,
|
||||
displayName = niceName,
|
||||
providerKey = provider.key,
|
||||
description = desc,
|
||||
isCustom = provider.isCustom
|
||||
)
|
||||
}
|
||||
Pair(mapped, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ApiManager", "Error fetching Gemini models: ${e.message}", e)
|
||||
Pair(emptyList(), e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val key = apiKey?.trim().orEmpty()
|
||||
// For custom providers (and local endpoints), allow scanning without key by attempting unauthenticated GET v1/models.
|
||||
val allowNoKey = provider.isCustom || isLocalHost
|
||||
val effectiveKey = if (key.isNotEmpty()) key else if (allowNoKey) "" else key
|
||||
|
||||
// Perplexity does not support listing models via /v1/models; fail fast with a clear message
|
||||
if (provider.key.equals("perplexity", ignoreCase = true)) {
|
||||
return Pair(emptyList(), "Perplexity does not support fetching modeles.") //TODO this must be transalted!
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val service = RetrofitClient.getApiClient(effectiveKey, provider.baseUrl, provider).create(LlmApiService::class.java)
|
||||
val url = "v1/models"
|
||||
val response = service.listModels(url).execute()
|
||||
Log.d("ApiManager", "Listing models response success=${response}")
|
||||
if (!response.isSuccessful) {
|
||||
val err = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
||||
Pair(emptyList(), err)
|
||||
} else {
|
||||
val body = response.body()
|
||||
val items = body?.data ?: emptyList()
|
||||
val mapped = items.map { item ->
|
||||
val niceName = item.displayName ?: item.title ?: item.name ?: item.id
|
||||
// Build a compact description with the most useful info
|
||||
val parts = mutableListOf<String>()
|
||||
(item.description ?: item.ownedBy ?: item.organization)?.let { if (it.isNotBlank()) parts.add(it) }
|
||||
val ctx = item.contextLength ?: item.maxContext ?: item.tokenLimit
|
||||
if (ctx != null && ctx > 0) parts.add("$ctx ctx")
|
||||
item.capabilities?.let { cap ->
|
||||
val caps = mutableListOf<String>()
|
||||
if (cap.vision == true) caps.add("vision")
|
||||
if (cap.audio == true) caps.add("audio")
|
||||
if (cap.tools == true) caps.add("tools")
|
||||
if (cap.json == true) caps.add("json")
|
||||
if (cap.reasoning == true) caps.add("reasoning")
|
||||
if (caps.isNotEmpty()) parts.add(caps.joinToString("/"))
|
||||
}
|
||||
item.pricing?.let { p ->
|
||||
val inPrice = p.input ?: p.inputAlt
|
||||
val outPrice = p.output ?: p.outputAlt
|
||||
val currency = p.currency ?: "$"
|
||||
val unit = p.unit ?: "1K tok"
|
||||
if (inPrice != null || outPrice != null) {
|
||||
val priceStr = listOfNotNull(
|
||||
inPrice?.let { "in ${currency}${it}/${unit}" },
|
||||
outPrice?.let { "out ${currency}${it}/${unit}" }
|
||||
).joinToString(" · ")
|
||||
if (priceStr.isNotBlank()) parts.add(priceStr)
|
||||
}
|
||||
}
|
||||
if (item.deprecated == true) parts.add("deprecated")
|
||||
val desc = parts.joinToString(" • ")
|
||||
LanguageModel(
|
||||
modelId = item.id,
|
||||
displayName = niceName,
|
||||
providerKey = provider.key,
|
||||
description = desc,
|
||||
isCustom = provider.isCustom
|
||||
)
|
||||
}
|
||||
Pair(mapped, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ApiManager", "Error fetching models: ${e.message}", e)
|
||||
Pair(emptyList(), e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a specific model for a given provider and API key.
|
||||
*/
|
||||
suspend fun validateModel(apiKey: String, provider: ApiProvider, model: LanguageModel): Pair<Boolean, String?> {
|
||||
val validationMessage = "Validating model '${model.displayName}' for provider '${provider.displayName}'..."
|
||||
Log.d("ApiManager", validationMessage)
|
||||
|
||||
val cleanApiKey = apiKey.trim()
|
||||
if (cleanApiKey.isEmpty()) {
|
||||
return Pair(false, "API key is missing for this provider.")
|
||||
}
|
||||
|
||||
val tempApiService = RetrofitClient.getApiClient(cleanApiKey, provider.baseUrl, provider)
|
||||
.create(LlmApiService::class.java)
|
||||
|
||||
val validationPrompt = "This is a test message to validate the model."
|
||||
|
||||
val result = withTimeoutOrNull(10_000) { // 10-second timeout
|
||||
try {
|
||||
Log.d("ApiManager", "Starting validation for provider=${provider.key}, model=${model.modelId}")
|
||||
|
||||
when (provider.key) {
|
||||
"gemini" -> {
|
||||
val modelSpecificEndpoint = "v1beta/models/${model.modelId}:generateContent"
|
||||
val testRequest = GeminiRequest(
|
||||
contents = listOf(GeminiContent("user", listOf(GeminiPart(validationPrompt))))
|
||||
)
|
||||
|
||||
Log.d("ApiManager", "Sending Gemini request to $modelSpecificEndpoint with prompt=$validationPrompt")
|
||||
|
||||
val response: Response<GeminiResponse> = withContext(Dispatchers.IO) {
|
||||
tempApiService.sendGeminiRequest(modelSpecificEndpoint, testRequest).execute()
|
||||
}
|
||||
|
||||
Log.d("ApiManager", "Gemini response success=${response.isSuccessful}")
|
||||
|
||||
val bodyOrError = try {
|
||||
response.body()?.toString()
|
||||
?: response.errorBody()?.string()
|
||||
?: "Unknown error"
|
||||
} catch (ioe: Exception) {
|
||||
Log.e("ApiManager", "Error extracting Gemini response body: ${ioe.message}", ioe)
|
||||
"Failed to parse error body: ${ioe.message}"
|
||||
}
|
||||
|
||||
Pair(response.isSuccessful, bodyOrError)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val testRequest = Request(
|
||||
model = model.modelId,
|
||||
messages = listOf(Request.Message("user", validationPrompt))
|
||||
)
|
||||
|
||||
Log.d("ApiManager", "Sending generic request to ${provider.endpoint} with model=${model.modelId}")
|
||||
|
||||
val response: Response<ApiResponse> = withContext(Dispatchers.IO) {
|
||||
tempApiService.sendRequest(provider.endpoint, testRequest).execute()
|
||||
}
|
||||
|
||||
Log.d("ApiManager", "Generic response success=${response.isSuccessful}")
|
||||
|
||||
val bodyOrError = try {
|
||||
response.body()?.toString()
|
||||
?: response.errorBody()?.string()
|
||||
?: "Unknown error"
|
||||
} catch (ioe: Exception) {
|
||||
Log.e("ApiManager", "Error extracting generic response body: ${ioe.message}", ioe)
|
||||
"Failed to parse error body: ${ioe.message}"
|
||||
}
|
||||
|
||||
Pair(response.isSuccessful, bodyOrError)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val errorMessage = buildString {
|
||||
append("Exception type=${e::class.java.simpleName}")
|
||||
e.message?.let { append(", message=$it") }
|
||||
if (e is HttpException) {
|
||||
append(", httpCode=${e.code()}")
|
||||
try {
|
||||
append(", errorBody=${e.response()?.errorBody()?.string()} ")
|
||||
} catch (ioe: Exception) {
|
||||
append(", failed to read errorBody: ${ioe.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.e("ApiManager", "Exception while validating model=${model.modelId}: $errorMessage", e)
|
||||
Pair(false, errorMessage)
|
||||
}
|
||||
} ?: Pair(false, "Timeout validating model.")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary method for making API requests for various tasks within the app.
|
||||
*/
|
||||
fun getCompletion(
|
||||
prompt: String,
|
||||
modelType: ModelType,
|
||||
callback: ApiCallback
|
||||
) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val languageModel: LanguageModel? = when (modelType) {
|
||||
ModelType.TRANSLATION -> apiRepository.getTranslationModel().first()
|
||||
ModelType.EXERCISE -> apiRepository.getExerciseModel().first()
|
||||
ModelType.VOCABULARY -> apiRepository.getVocabularyModel().first()
|
||||
ModelType.DICTIONARY -> apiRepository.getDictionaryModel().first()
|
||||
}
|
||||
|
||||
if (languageModel == null) {
|
||||
val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context))
|
||||
StatusMessageService.trigger(StatusAction.ShowActionableMessage(
|
||||
text = errorMsg,
|
||||
type = MessageDisplayType.ACTIONABLE_ERROR,
|
||||
action = MessageAction.NAVIGATE_TO_API_KEYS
|
||||
))
|
||||
callback.onFailure(errorMsg)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val allProviders = apiRepository.getProviders().first()
|
||||
val provider = allProviders.find { it.key == languageModel.providerKey }
|
||||
|
||||
if (provider == null) {
|
||||
val errorMsg = "Provider '${languageModel.providerKey}' not found for the selected model."
|
||||
StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5))
|
||||
callback.onFailure(errorMsg)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val allApiKeys = settingsRepository.getAllApiKeys().first()
|
||||
val apiKey = allApiKeys[provider.key] ?: ""
|
||||
|
||||
if (apiKey.isBlank() && !provider.isCustom) {
|
||||
val errorMsg = "API key for ${provider.displayName} is missing."
|
||||
StatusMessageService.trigger(StatusAction.ShowActionableMessage(
|
||||
text = errorMsg,
|
||||
type = MessageDisplayType.ACTIONABLE_ERROR,
|
||||
action = MessageAction.NAVIGATE_TO_API_KEYS
|
||||
))
|
||||
callback.onFailure(errorMsg)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val apiService = RetrofitClient.getApiClient(apiKey, provider.baseUrl, provider)
|
||||
.create(LlmApiService::class.java)
|
||||
|
||||
val endpointUrl = provider.endpoint
|
||||
Log.d("ApiManager", "Sending request to ${provider.displayName} with model ${languageModel.modelId} for task $modelType and URL ${provider.baseUrl}$endpointUrl")
|
||||
|
||||
when(provider.key) {
|
||||
"gemini" -> {
|
||||
val request = GeminiRequest(contents = listOf(GeminiContent("user", listOf(GeminiPart(prompt)))))
|
||||
val requestJson = try { gson.toJson(request) } catch (_: Exception) { null }
|
||||
val logId = java.util.UUID.randomUUID().toString()
|
||||
val logTimestamp = System.currentTimeMillis()
|
||||
val startTime = System.nanoTime()
|
||||
apiService.sendGeminiRequest(endpointUrl, request).enqueue(object: Callback<GeminiResponse> {
|
||||
override fun onResponse(call: Call<GeminiResponse>, response: Response<GeminiResponse>) {
|
||||
val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0)
|
||||
var parseError: String? = null
|
||||
val responseJson = try {
|
||||
gson.toJson(response.body())
|
||||
} catch (e: Exception) {
|
||||
parseError = "JSON parse error: ${e.message}"
|
||||
try { response.errorBody()?.string() } catch (_: Exception) { null }
|
||||
}
|
||||
val entry = ApiLogEntry(
|
||||
id = logId,
|
||||
timestamp = logTimestamp,
|
||||
providerKey = provider.key,
|
||||
endpoint = endpointUrl,
|
||||
method = "POST",
|
||||
model = languageModel.modelId,
|
||||
requestJson = requestJson,
|
||||
responseCode = response.code(),
|
||||
responseMessage = response.message(),
|
||||
responseJson = responseJson,
|
||||
errorMessage = if (response.isSuccessful) null else (parseError ?: response.message()),
|
||||
durationMs = durationMs,
|
||||
exceptionType = null,
|
||||
isTimeout = null,
|
||||
parseErrorMessage = parseError,
|
||||
url = endpointUrl
|
||||
)
|
||||
CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) }
|
||||
|
||||
if (response.isSuccessful && parseError == null) {
|
||||
try {
|
||||
callback.onSuccess(response.body()?.getResponse())
|
||||
} catch (e: Exception) {
|
||||
val err = "Response processing failed: ${e.message}"
|
||||
CoroutineScope(Main).launch {
|
||||
StatusMessageService.trigger(StatusAction.ShowMessage(err, MessageDisplayType.ERROR, 5))
|
||||
}
|
||||
callback.onFailure(err)
|
||||
}
|
||||
} else {
|
||||
val errorMsg = parseError ?: "Response error: ${response.code()} ${response.message()}"
|
||||
CoroutineScope(Main).launch {
|
||||
StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5))
|
||||
}
|
||||
callback.onFailure(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<GeminiResponse>, t: Throwable) {
|
||||
val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0)
|
||||
val isTimeout = t is java.net.SocketTimeoutException || t is java.io.InterruptedIOException
|
||||
val entry = ApiLogEntry(
|
||||
id = logId,
|
||||
timestamp = logTimestamp,
|
||||
providerKey = provider.key,
|
||||
endpoint = endpointUrl,
|
||||
method = "POST",
|
||||
model = languageModel.modelId,
|
||||
requestJson = requestJson,
|
||||
responseCode = null,
|
||||
responseMessage = null,
|
||||
responseJson = null,
|
||||
errorMessage = t.message,
|
||||
durationMs = durationMs,
|
||||
exceptionType = t::class.java.simpleName,
|
||||
isTimeout = isTimeout,
|
||||
parseErrorMessage = null,
|
||||
url = endpointUrl
|
||||
)
|
||||
CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) }
|
||||
|
||||
val errorPrefix = if (isTimeout) "Request timeout" else "Request failed"
|
||||
val errorMsg = "$errorPrefix: ${t.message}"
|
||||
CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5)) }
|
||||
callback.onFailure(errorMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
else -> {
|
||||
val request = Request(languageModel.modelId, listOf(Request.Message("user", prompt)))
|
||||
val requestJson = try { gson.toJson(request) } catch (_: Exception) { null }
|
||||
val logId = java.util.UUID.randomUUID().toString()
|
||||
val logTimestamp = System.currentTimeMillis()
|
||||
val startTime = System.nanoTime()
|
||||
apiService.sendRequest(endpointUrl, request).enqueue(object : Callback<ApiResponse> {
|
||||
override fun onResponse(call: Call<ApiResponse>, response: Response<ApiResponse>) {
|
||||
val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0)
|
||||
var parseError: String? = null
|
||||
val responseJson = try {
|
||||
gson.toJson(response.body())
|
||||
} catch (e: Exception) {
|
||||
parseError = "JSON parse error: ${e.message}"
|
||||
try { response.errorBody()?.string() } catch (_: Exception) { null }
|
||||
}
|
||||
val entry = ApiLogEntry(
|
||||
id = logId,
|
||||
timestamp = logTimestamp,
|
||||
providerKey = provider.key,
|
||||
endpoint = endpointUrl,
|
||||
method = "POST",
|
||||
model = languageModel.modelId,
|
||||
requestJson = requestJson,
|
||||
responseCode = response.code(),
|
||||
responseMessage = response.message(),
|
||||
responseJson = responseJson,
|
||||
errorMessage = if (response.isSuccessful) null else (parseError ?: response.message()),
|
||||
durationMs = durationMs,
|
||||
exceptionType = null,
|
||||
isTimeout = null,
|
||||
parseErrorMessage = parseError,
|
||||
url = endpointUrl
|
||||
)
|
||||
CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) }
|
||||
|
||||
if (response.isSuccessful && parseError == null) {
|
||||
try {
|
||||
callback.onSuccess(response.body()?.getResponse())
|
||||
} catch (e: Exception) {
|
||||
val err = "Response processing failed: ${e.message}"
|
||||
CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage(err, MessageDisplayType.ERROR, 5)) }
|
||||
callback.onFailure(err)
|
||||
}
|
||||
} else {
|
||||
val errorMsg = parseError ?: "Response error: ${response.code()} ${response.message()}"
|
||||
CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage(
|
||||
errorMsg, MessageDisplayType.ERROR, 5)) }
|
||||
callback.onFailure(errorMsg)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<ApiResponse>, t: Throwable) {
|
||||
val durationMs = ((System.nanoTime() - startTime) / 1_000_000).coerceAtLeast(0)
|
||||
val isTimeout = t is java.net.SocketTimeoutException || t is java.io.InterruptedIOException
|
||||
val entry = ApiLogEntry(
|
||||
id = logId,
|
||||
timestamp = logTimestamp,
|
||||
providerKey = provider.key,
|
||||
endpoint = endpointUrl,
|
||||
method = "POST",
|
||||
model = languageModel.modelId,
|
||||
requestJson = requestJson,
|
||||
responseCode = null,
|
||||
responseMessage = null,
|
||||
responseJson = null,
|
||||
errorMessage = t.message,
|
||||
durationMs = durationMs,
|
||||
exceptionType = t::class.java.simpleName,
|
||||
isTimeout = isTimeout,
|
||||
parseErrorMessage = null,
|
||||
url = endpointUrl
|
||||
)
|
||||
CoroutineScope(Dispatchers.IO).launch { apiLogRepository.addLog(entry) }
|
||||
|
||||
val errorPrefix = if (isTimeout) "Request timeout" else "Request failed"
|
||||
val errorMsg = "$errorPrefix: ${t.message}"
|
||||
CoroutineScope(Main).launch { StatusMessageService.trigger(StatusAction.ShowMessage(errorMsg, MessageDisplayType.ERROR, 5)) }
|
||||
callback.onFailure(errorMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
import android.content.Context
|
||||
import eu.gaudian.translator.model.LanguageModel
|
||||
import eu.gaudian.translator.utils.ProviderConfigParser
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import java.nio.file.NoSuchFileException
|
||||
|
||||
@Serializable
|
||||
data class ApiProvider(
|
||||
val key: String,
|
||||
val displayName: String,
|
||||
val baseUrl: String,
|
||||
val endpoint: String,
|
||||
val websiteUrl: String,
|
||||
val models: List<LanguageModel>,
|
||||
val isCustom: Boolean = false
|
||||
) {
|
||||
@Transient
|
||||
var hasValidKey: Boolean = false
|
||||
val defaultModel: String? get() = models.firstOrNull()?.modelId
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Loads providers from the JSON configuration file with fallback to hardcoded providers
|
||||
* @param context Android context for accessing assets
|
||||
* @return List of ApiProvider instances
|
||||
*/
|
||||
fun loadProviders(context: Context): List<ApiProvider> {
|
||||
val providersFromJson = ProviderConfigParser.loadProvidersFromAssets(context)
|
||||
return providersFromJson.ifEmpty {
|
||||
// Fallback to hardcoded providers if JSON loading fails
|
||||
getHardcodedProviders()
|
||||
throw NoSuchFileException("providers.json not found in assets")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hardcoded default providers list
|
||||
* @return List of ApiProvider instances
|
||||
*/
|
||||
private fun getHardcodedProviders(): List<ApiProvider> {
|
||||
return listOf(
|
||||
ApiProvider(
|
||||
key = "mistral",
|
||||
displayName = "Mistral AI",
|
||||
baseUrl = "https://api.mistral.ai/",
|
||||
endpoint = "v1/chat/completions",
|
||||
websiteUrl = "https://mistral.ai",
|
||||
models = listOf(
|
||||
LanguageModel("mistral-small-latest", "Mistral Small Latest", "mistral", "Fast and efficient for simple tasks."),
|
||||
LanguageModel("open-mistral-nemo", "Mistral Nemo", "mistral", "Advanced model with native function calling."),
|
||||
)
|
||||
),
|
||||
ApiProvider(
|
||||
key = "openai",
|
||||
displayName = "OpenAI",
|
||||
baseUrl = "https://api.openai.com/",
|
||||
endpoint = "v1/chat/completions",
|
||||
websiteUrl = "https://platform.openai.com/",
|
||||
models = listOf(
|
||||
LanguageModel("gpt-5-nano", "GPT 5 Nano", "openai", "Fast and cheap model sufficient for most tasks."),
|
||||
)
|
||||
),
|
||||
ApiProvider(
|
||||
key = "anthropic",
|
||||
displayName = "Anthropic",
|
||||
baseUrl = "https://api.anthropic.com/",
|
||||
endpoint = "v1/messages",
|
||||
websiteUrl = "https://www.anthropic.com/",
|
||||
models = listOf(
|
||||
)
|
||||
),
|
||||
ApiProvider(
|
||||
key = "deepseek",
|
||||
displayName = "DeepSeek",
|
||||
baseUrl = "https://api.deepseek.com/",
|
||||
endpoint = "chat/completions",
|
||||
websiteUrl = "https://www.deepseek.com/",
|
||||
models = listOf(
|
||||
LanguageModel("deepseek-chat", "DeepSeek Chat", "deepseek", "Specialized in code and reasoning.")
|
||||
)
|
||||
),
|
||||
ApiProvider(
|
||||
key = "gemini",
|
||||
displayName = "Google Gemini",
|
||||
baseUrl = "https://generativelanguage.googleapis.com/",
|
||||
endpoint = "v1beta/models/gemini-2.5-flash:generateContent",
|
||||
websiteUrl = "https://ai.google/",
|
||||
models = listOf(
|
||||
LanguageModel("gemini-2.5-flash", "Gemini 2.5 Flash", "gemini", "Current default: Fast, grounded, strong at conversational and search tasks."),
|
||||
LanguageModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash Lite", "gemini", "Fastest and most cost-efficient Gemini model for high throughput and low-latency needs.")
|
||||
)
|
||||
),
|
||||
ApiProvider(
|
||||
key = "openrouter",
|
||||
displayName = "OpenRouter",
|
||||
baseUrl = "https://openrouter.ai/api/",
|
||||
endpoint = "v1/chat/completions",
|
||||
websiteUrl = "https://openrouter.ai",
|
||||
models = listOf(
|
||||
)
|
||||
),
|
||||
ApiProvider(
|
||||
key = "groq",
|
||||
displayName = "Groq",
|
||||
baseUrl = "https://api.groq.com/openai/",
|
||||
endpoint = "v1/chat/completions",
|
||||
websiteUrl = "https://groq.com/",
|
||||
models = listOf(
|
||||
LanguageModel("llama-3.1-8b-instant", "Llama 3.1 8B", "groq", "Powerful Llama 3 model running at extreme speed."),
|
||||
)
|
||||
),
|
||||
ApiProvider(
|
||||
key = "perplexity",
|
||||
displayName = "Perplexity",
|
||||
baseUrl = "https://api.perplexity.ai/",
|
||||
endpoint = "chat/completions",
|
||||
websiteUrl = "https://www.perplexity.ai/",
|
||||
models = listOf(
|
||||
LanguageModel("sonar", "Sonar Small Online", "perplexity", "A faster online model for quick, up-to-date answers."), // default
|
||||
LanguageModel("sonar-pro", "Sonar Pro", "perplexity", "Advanced search-focused model for richer context and longer answers."),
|
||||
)
|
||||
),
|
||||
ApiProvider(
|
||||
key = "xai",
|
||||
displayName = "xAI Grok",
|
||||
baseUrl = "https://api.x.ai/",
|
||||
endpoint = "v1/chat/completions",
|
||||
websiteUrl = "https://x.ai",
|
||||
models = listOf(
|
||||
LanguageModel("grok-4-fast", "Grok 4 Fast", "xai", "Fast and flexible model from xAI.")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.create
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Manages downloading files from the server, verifying checksums, and checking versions.
|
||||
*/
|
||||
class FileDownloadManager(private val context: Context) {
|
||||
|
||||
private val baseUrl = "http://23.88.48.47/"
|
||||
|
||||
private val retrofit = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.client(OkHttpClient.Builder().build())
|
||||
.build()
|
||||
|
||||
private val manifestApiService = retrofit.create<ManifestApiService>()
|
||||
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* Fetches the manifest from the server.
|
||||
*/
|
||||
suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = manifestApiService.getManifest().execute()
|
||||
if (response.isSuccessful) {
|
||||
response.body()
|
||||
} else {
|
||||
@Suppress("HardCodedStringLiteral") val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
||||
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
|
||||
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
Log.e("FileDownloadManager", "Error fetching manifest", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads all assets for a file and verifies their checksums.
|
||||
*/
|
||||
suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
|
||||
val totalAssets = fileInfo.assets.size
|
||||
|
||||
for ((completedAssets, asset) in fileInfo.assets.withIndex()) {
|
||||
val success = downloadAsset(asset) { assetProgress ->
|
||||
// Calculate overall progress
|
||||
val assetContribution = assetProgress / totalAssets
|
||||
val previousAssetsProgress = completedAssets.toFloat() / totalAssets
|
||||
onProgress(previousAssetsProgress + assetContribution)
|
||||
}
|
||||
if (!success) {
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
// Save version after all assets are downloaded successfully
|
||||
sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a specific asset and verifies its checksum.
|
||||
*/
|
||||
private suspend fun downloadAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
|
||||
val fileUrl = "${baseUrl}${asset.filename}"
|
||||
val localFile = File(context.filesDir, asset.filename)
|
||||
|
||||
try {
|
||||
val client = OkHttpClient()
|
||||
val request = Request.Builder().url(fileUrl).build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
val errorMessage = context.getString(
|
||||
R.string.text_download_failed_http,
|
||||
response.code,
|
||||
response.message
|
||||
)
|
||||
Log.e("FileDownloadManager", errorMessage)
|
||||
throw Exception(errorMessage)
|
||||
}
|
||||
|
||||
val body = response.body
|
||||
|
||||
val contentLength = body.contentLength()
|
||||
if (contentLength <= 0) {
|
||||
throw Exception("Invalid file size: $contentLength")
|
||||
}
|
||||
|
||||
FileOutputStream(localFile).use { output ->
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
var totalBytesRead: Long = 0
|
||||
@Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
output.write(buffer, 0, bytesRead)
|
||||
digest.update(buffer, 0, bytesRead)
|
||||
totalBytesRead += bytesRead
|
||||
onProgress((totalBytesRead.toFloat() / contentLength))
|
||||
}
|
||||
|
||||
output.flush()
|
||||
|
||||
// Compute checksum
|
||||
val computedChecksum = digest.digest().joinToString("") {
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
"%02X".format(it)
|
||||
}
|
||||
|
||||
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
Log.d("FileDownloadManager", "Download successful for ${asset.filename}")
|
||||
true
|
||||
} else {
|
||||
Log.e("FileDownloadManager",
|
||||
context.getString(
|
||||
R.string.text_checksum_mismatch_for_expected_got,
|
||||
asset.filename,
|
||||
asset.checksumSha256,
|
||||
computedChecksum
|
||||
))
|
||||
localFile.delete() // Delete corrupted file
|
||||
throw Exception("Checksum verification failed for ${asset.filename}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
Log.e("FileDownloadManager", "Error downloading asset", e)
|
||||
// Clean up partial download
|
||||
if (localFile.exists()) {
|
||||
localFile.delete()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a newer version is available for a file.
|
||||
*/
|
||||
fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
|
||||
val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0"
|
||||
return compareVersions(fileInfo.version, localVersion) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two version strings (assuming semantic versioning).
|
||||
*/
|
||||
private fun compareVersions(version1: String, version2: String): Int {
|
||||
val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
|
||||
for (i in 0 until maxOf(parts1.size, parts2.size)) {
|
||||
val part1 = parts1.getOrElse(i) { 0 }
|
||||
val part2 = parts2.getOrElse(i) { 0 }
|
||||
if (part1 != part2) {
|
||||
return part1.compareTo(part2)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the local version of a file.
|
||||
*/
|
||||
fun getLocalVersion(fileId: String): String {
|
||||
return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
|
||||
|
||||
data class GeminiModelsListResponse(
|
||||
val models: List<GeminiModelItem>?
|
||||
)
|
||||
|
||||
data class GeminiModelItem(
|
||||
val name: String?,
|
||||
val displayName: String?,
|
||||
val description: String?,
|
||||
val inputTokenLimit: Int?,
|
||||
val outputTokenLimit: Int?,
|
||||
val supportedGenerationMethods: List<String>? // e.g., ["generateContent"]
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Url
|
||||
|
||||
interface LlmApiService {
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST
|
||||
fun sendRequest(@Url url: String, @Body request: Request): Call<ApiResponse>
|
||||
|
||||
@Headers("Content-Type: application/json")
|
||||
@POST
|
||||
fun sendGeminiRequest(@Url url: String, @Body request: GeminiRequest): Call<GeminiResponse>
|
||||
|
||||
// Generic models listing (e.g., OpenAI-compatible: GET v1/models)
|
||||
@GET
|
||||
fun listModels(@Url url: String): Call<ModelsListResponse>
|
||||
|
||||
// Gemini models listing (GET v1beta/models)
|
||||
@GET
|
||||
fun listGeminiModels(@Url url: String): Call<GeminiModelsListResponse>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.GET
|
||||
|
||||
/**
|
||||
* API service for fetching the manifest and downloading files.
|
||||
*/
|
||||
interface ManifestApiService {
|
||||
|
||||
/**
|
||||
* Fetches the manifest from the server.
|
||||
*/
|
||||
@GET("manifest.json")
|
||||
fun getManifest(): Call<ManifestResponse>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Data class representing the manifest response from the server.
|
||||
*/
|
||||
data class ManifestResponse(
|
||||
@SerializedName("files")
|
||||
val files: List<FileInfo>
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class representing information about a downloadable file.
|
||||
*/
|
||||
data class FileInfo(
|
||||
@SerializedName("id")
|
||||
val id: String,
|
||||
@SerializedName("name")
|
||||
val name: String,
|
||||
@SerializedName("description")
|
||||
val description: String,
|
||||
@SerializedName("version")
|
||||
val version: String,
|
||||
@SerializedName("assets")
|
||||
val assets: List<Asset>
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class representing an asset file within a downloadable file.
|
||||
*/
|
||||
data class Asset(
|
||||
@SerializedName("filename")
|
||||
val filename: String,
|
||||
@SerializedName("size_bytes")
|
||||
val sizeBytes: Long,
|
||||
@SerializedName("checksum_sha256")
|
||||
val checksumSha256: String
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Schema for OpenAI-compatible list models response, extended with optional fields
|
||||
* that some providers (OpenRouter, Mistral, etc.) may include. All extra fields are
|
||||
* nullable so unknown providers won't break deserialization.
|
||||
*/
|
||||
data class ModelsListResponse(
|
||||
val data: List<ModelItem> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* A conservative union of known fields from various providers.
|
||||
*/
|
||||
data class ModelItem(
|
||||
val id: String,
|
||||
// Owner / organization
|
||||
@SerializedName("owned_by") val ownedBy: String? = null,
|
||||
@SerializedName("organization") val organization: String? = null,
|
||||
|
||||
// Object type (OpenAI: "model")
|
||||
@SerializedName("object") val objectType: String? = null,
|
||||
|
||||
// Human-friendly name
|
||||
@SerializedName("display_name") val displayName: String? = null,
|
||||
@SerializedName("title") val title: String? = null,
|
||||
@SerializedName("name") val name: String? = null,
|
||||
|
||||
// Description and tags
|
||||
@SerializedName("description") val description: String? = null,
|
||||
@SerializedName("tags") val tags: List<String>? = null,
|
||||
|
||||
// Capabilities / features
|
||||
@SerializedName("capabilities") val capabilities: Capabilities? = null,
|
||||
|
||||
// Context window (various naming across providers)
|
||||
@SerializedName("context_length") val contextLength: Int? = null,
|
||||
@SerializedName("max_context") val maxContext: Int? = null,
|
||||
@SerializedName("token_limit") val tokenLimit: Int? = null,
|
||||
|
||||
// Pricing information (if provided)
|
||||
@SerializedName("pricing") val pricing: Pricing? = null,
|
||||
|
||||
// Lifecycle
|
||||
@SerializedName("created") val created: Long? = null,
|
||||
@SerializedName("deprecated") val deprecated: Boolean? = null,
|
||||
@SerializedName("deprecation_date") val deprecationDate: String? = null,
|
||||
|
||||
// Family / type
|
||||
@SerializedName("family") val family: String? = null,
|
||||
@SerializedName("type") val type: String? = null,
|
||||
|
||||
// Aliases that may point to canonical models
|
||||
@SerializedName("aliases") val aliases: List<String>? = null
|
||||
)
|
||||
|
||||
// Nested shapes that some providers use
|
||||
|
||||
data class Capabilities(
|
||||
val vision: Boolean? = null,
|
||||
val audio: Boolean? = null,
|
||||
val tools: Boolean? = null,
|
||||
val json: Boolean? = null,
|
||||
val reasoning: Boolean? = null
|
||||
)
|
||||
|
||||
data class Pricing(
|
||||
// Prices may come in different units; commonly per 1K or 1M tokens. We store raw doubles.
|
||||
@SerializedName("prompt") val input: Double? = null,
|
||||
@SerializedName("completion") val output: Double? = null,
|
||||
@SerializedName("image") val image: Double? = null,
|
||||
@SerializedName("request") val request: Double? = null,
|
||||
// Alternative naming sometimes used
|
||||
@SerializedName("input") val inputAlt: Double? = null,
|
||||
@SerializedName("output") val outputAlt: Double? = null,
|
||||
@SerializedName("unit") val unit: String? = null,
|
||||
@SerializedName("currency") val currency: String? = null
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
data class Request(
|
||||
val model: String,
|
||||
val messages: List<Message>
|
||||
) {
|
||||
data class Message(
|
||||
val role: String,
|
||||
val content: String
|
||||
)
|
||||
}
|
||||
|
||||
data class ApiResponse(
|
||||
val choices: List<Choice>
|
||||
) {
|
||||
data class Choice(
|
||||
val message: Message
|
||||
) {
|
||||
data class Message(
|
||||
val content: String
|
||||
)
|
||||
}
|
||||
|
||||
fun getResponse(): String {
|
||||
return choices.firstOrNull()?.message?.content ?: "No response available"
|
||||
}
|
||||
}
|
||||
|
||||
data class GeminiRequest(
|
||||
val contents: List<GeminiContent>
|
||||
)
|
||||
|
||||
data class GeminiResponse(
|
||||
val candidates: List<GeminiCandidate>?
|
||||
) {
|
||||
fun getResponse(): String {
|
||||
return candidates?.firstOrNull()?.content?.parts?.firstOrNull()?.text ?: "No response available"
|
||||
}
|
||||
}
|
||||
|
||||
data class GeminiCandidate(
|
||||
val content: GeminiContent
|
||||
)
|
||||
|
||||
data class GeminiContent(
|
||||
val role: String?,
|
||||
val parts: List<GeminiPart>
|
||||
)
|
||||
|
||||
data class GeminiPart(
|
||||
val text: String
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object RetrofitClient {
|
||||
|
||||
private const val W_BASE_URL = "https://en.wiktionary.org/"
|
||||
|
||||
private val okHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val pkg = try { eu.gaudian.translator.BuildConfig.APPLICATION_ID } catch (_: Exception) { "eu.gaudian.translator" }
|
||||
val ver = try { eu.gaudian.translator.BuildConfig.VERSION_NAME } catch (_: Exception) { "unknown" }
|
||||
val ua = "GaudianTranslator/$ver ($pkg; contact: jonas@gaudian.eu)"
|
||||
val request = chain.request().newBuilder()
|
||||
.header("User-Agent", ua)
|
||||
.build()
|
||||
chain.proceed(request)
|
||||
}
|
||||
.build()
|
||||
|
||||
val api: WiktionaryApiService by lazy {
|
||||
Log.d("RetrofitClient", "Creating Wiktionary API with baseUrl=$W_BASE_URL")
|
||||
// TODO: Ensure User-Agent complies with Wiktionary API policy; consider including app version.
|
||||
val gson = com.google.gson.GsonBuilder()
|
||||
.registerTypeAdapter(TextField::class.java, TextFieldDeserializer())
|
||||
.create()
|
||||
Retrofit.Builder()
|
||||
.baseUrl(W_BASE_URL)
|
||||
.client(okHttpClient)
|
||||
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||
.build()
|
||||
.create(WiktionaryApiService::class.java)
|
||||
}
|
||||
|
||||
private fun normalizeBaseUrl(input: String): String {
|
||||
var url = input.trim()
|
||||
if (url.isEmpty()) return "http://localhost/"
|
||||
// If scheme missing, prepend http://
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "http://$url"
|
||||
}
|
||||
// If it's just host:port or IP without trailing slash, ensure trailing slash
|
||||
if (!url.endsWith('/')) {
|
||||
url += "/"
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private val logCollector = InMemoryLogCollector()
|
||||
|
||||
/**
|
||||
* Creates a Retrofit instance for a specific API provider.
|
||||
* @param apiKey The API key for authorization.
|
||||
* @param baseUrl The base URL of the API provider (e.g., "https://api.openai.com/").
|
||||
* @param provider The ApiProvider object, used to determine which headers to add.
|
||||
* @return A configured Retrofit instance.
|
||||
*/
|
||||
fun getApiClient(apiKey: String, baseUrl: String, provider: ApiProvider): Retrofit {
|
||||
Log.d("RetrofitClient", "Creating API client with baseUrl=$baseUrl")
|
||||
|
||||
val loggingInterceptor = UserVisibleLoggingInterceptor(logCollector)
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.addInterceptor { chain ->
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
Log.d("RetrofitClient", "Adding headers to request: $requestBuilder")
|
||||
|
||||
val trimmedKey = apiKey.trim()
|
||||
when (provider.key) {
|
||||
"gemini" -> {
|
||||
if (trimmedKey.isNotEmpty()) {
|
||||
// Google Generative Language API typically accepts API key as query param `key`.
|
||||
// We also send the x-goog-api-key header for compatibility.
|
||||
requestBuilder.addHeader("x-goog-api-key", trimmedKey)
|
||||
|
||||
try {
|
||||
val originalUrl = chain.request().url
|
||||
val hasKeyParam = (0 until originalUrl.querySize).any { i ->
|
||||
originalUrl.queryParameterName(i).equals("key", ignoreCase = true)
|
||||
}
|
||||
val newUrl = if (!hasKeyParam) {
|
||||
originalUrl.newBuilder().addQueryParameter("key", trimmedKey).build()
|
||||
} else {
|
||||
originalUrl
|
||||
}
|
||||
requestBuilder.url(newUrl)
|
||||
} catch (_: Exception) {
|
||||
// Fail-safe: if URL manipulation fails, rely on header only.
|
||||
}
|
||||
}
|
||||
}
|
||||
"openrouter" -> {
|
||||
if (trimmedKey.isNotEmpty()) {
|
||||
// Use Bearer token for OpenRouter and add custom headers.
|
||||
requestBuilder.addHeader("Authorization", "Bearer $trimmedKey")
|
||||
requestBuilder.addHeader("HTTP-Referer", "https://gaudian.eu/translator")
|
||||
requestBuilder.addHeader("X-Title", "Gaudian Translator")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Default to the standard Bearer token for most other APIs, but only if key is present.
|
||||
if (trimmedKey.isNotEmpty()) {
|
||||
requestBuilder.addHeader("Authorization", "Bearer $trimmedKey")
|
||||
}
|
||||
}
|
||||
}
|
||||
chain.proceed(requestBuilder.build())
|
||||
}
|
||||
.build()
|
||||
|
||||
val normalizedBaseUrl = normalizeBaseUrl(baseUrl)
|
||||
Log.d("RetrofitClient", "Normalized baseUrl: $normalizedBaseUrl")
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(normalizedBaseUrl)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.client(client)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
class UserVisibleLoggingInterceptor(private val logCollector: LogCollector) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
val requestLog = StringBuilder().apply {
|
||||
append("Request: ${request.method} ${request.url}\n")
|
||||
request.headers.forEach { header ->
|
||||
append("${header.first}: ${header.second}\n")
|
||||
}
|
||||
}.toString()
|
||||
logCollector.addLog(requestLog)
|
||||
|
||||
val response = chain.proceed(request)
|
||||
|
||||
val responseLog = StringBuilder().apply {
|
||||
append("Response: ${response.code} ${response.message}\n")
|
||||
response.headers.forEach { header ->
|
||||
append("${header.first}: ${header.second}\n")
|
||||
}
|
||||
}.toString()
|
||||
logCollector.addLog(responseLog)
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
interface LogCollector {
|
||||
fun addLog(message: String)
|
||||
fun getLogs(): List<String>
|
||||
}
|
||||
|
||||
class InMemoryLogCollector : LogCollector {
|
||||
private val logs = mutableListOf<String>()
|
||||
private val maxLogs = 100 // Keep last 100 messages
|
||||
|
||||
override fun addLog(message: String) {
|
||||
synchronized(logs) {
|
||||
logs.add(message)
|
||||
if (logs.size > maxLogs) {
|
||||
logs.removeAt(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLogs(): List<String> {
|
||||
return synchronized(logs) {
|
||||
logs.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import java.lang.reflect.Type
|
||||
|
||||
/**
|
||||
* Robust models for Wiktionary parse API that accept either an object {"*": "<html>"}
|
||||
* or a raw string for the `text` field.
|
||||
*/
|
||||
data class WiktionaryResponse(
|
||||
val parse: ParseData?
|
||||
)
|
||||
|
||||
data class ParseData(
|
||||
val title: String?,
|
||||
val text: TextField?
|
||||
)
|
||||
|
||||
/** Wrapper for the flexible `text` payload. */
|
||||
data class TextField(
|
||||
val html: String?
|
||||
)
|
||||
|
||||
/** Gson deserializer that accepts either object-with-star or raw string. */
|
||||
class TextFieldDeserializer : JsonDeserializer<TextField> {
|
||||
override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): TextField {
|
||||
if (json == null || json.isJsonNull) return TextField(null)
|
||||
return try {
|
||||
when {
|
||||
json.isJsonPrimitive && json.asJsonPrimitive.isString -> TextField(json.asString)
|
||||
json.isJsonObject -> {
|
||||
val obj = json.asJsonObject
|
||||
val star = obj.get("*")
|
||||
val html = if (star != null && !star.isJsonNull) star.asString else null
|
||||
TextField(html)
|
||||
}
|
||||
else -> TextField(null)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
TextField(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
@file:OptIn(ExperimentalTime::class)
|
||||
|
||||
package eu.gaudian.translator.model.db
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import eu.gaudian.translator.model.Language
|
||||
import eu.gaudian.translator.model.VocabularyStage
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
@Suppress("HardCodedStringLiteral", "unused")
|
||||
class Converters {
|
||||
@TypeConverter
|
||||
fun fromTimestamp(value: Long?): Instant? {
|
||||
return value?.let { Instant.fromEpochMilliseconds(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun dateToTimestamp(date: Instant?): Long? {
|
||||
return date?.toEpochMilliseconds()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromLocalDate(value: String?): LocalDate? {
|
||||
return value?.let { LocalDate.parse(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toLocalDate(date: LocalDate?): String? {
|
||||
return date?.toString()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromLanguageList(languages: List<Language>?): String? {
|
||||
return languages?.let { Json.encodeToString(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toLanguageList(json: String?): List<Language>? {
|
||||
return json?.let { Json.decodeFromString<List<Language>>(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromStageList(stages: List<VocabularyStage>?): String? {
|
||||
return stages?.let { Json.encodeToString(it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toStageList(json: String?): List<VocabularyStage>? {
|
||||
return json?.let { Json.decodeFromString<List<VocabularyStage>>(it) }
|
||||
}
|
||||
}
|
||||
216
app/src/main/java/eu/gaudian/translator/model/db/Daos.kt
Normal file
216
app/src/main/java/eu/gaudian/translator/model/db/Daos.kt
Normal file
@@ -0,0 +1,216 @@
|
||||
@file:OptIn(ExperimentalTime::class)
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.db
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Upsert
|
||||
import eu.gaudian.translator.model.VocabularyItem
|
||||
import eu.gaudian.translator.model.VocabularyItemState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
|
||||
@Dao
|
||||
interface VocabularyItemDao {
|
||||
@Query("DELETE FROM vocabulary_items")
|
||||
suspend fun clearAllItems()
|
||||
@Query("SELECT * FROM vocabulary_items")
|
||||
fun getAllItemsFlow(): Flow<List<VocabularyItem>>
|
||||
|
||||
@Query("SELECT * FROM vocabulary_items")
|
||||
suspend fun getAllItems(): List<VocabularyItem>
|
||||
|
||||
@Query("SELECT * FROM vocabulary_items WHERE id = :id")
|
||||
suspend fun getItemById(id: Int): VocabularyItem?
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM vocabulary_items WHERE (LOWER(wordFirst) = LOWER(:word) AND languageFirstId IS :languageId) OR (LOWER(wordSecond) = LOWER(:word) AND languageSecondId IS :languageId))")
|
||||
suspend fun itemExists(word: String, languageId: Int?): Boolean
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertItem(item: VocabularyItem)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertAll(items: List<VocabularyItem>)
|
||||
|
||||
@Query("DELETE FROM vocabulary_items WHERE id = :itemId")
|
||||
suspend fun deleteItemById(itemId: Int)
|
||||
|
||||
@Query("DELETE FROM vocabulary_items WHERE id IN (:itemIds)")
|
||||
suspend fun deleteItemsByIds(itemIds: List<Int>)
|
||||
|
||||
@Query("SELECT * FROM vocabulary_items WHERE id IN (:ids)")
|
||||
suspend fun getItemsByIds(ids: List<Int>): List<VocabularyItem>
|
||||
|
||||
@Query("SELECT MAX(id) FROM vocabulary_items")
|
||||
suspend fun getMaxItemId(): Int?
|
||||
|
||||
@Query("""
|
||||
SELECT i.* FROM vocabulary_items i
|
||||
INNER JOIN category_mappings cm ON i.id = cm.vocabularyItemId
|
||||
WHERE cm.categoryId = :categoryId
|
||||
"""
|
||||
)
|
||||
suspend fun getItemsByCategoryId(categoryId: Int): List<VocabularyItem>
|
||||
|
||||
@Query("""
|
||||
SELECT i.* FROM vocabulary_items AS i
|
||||
LEFT JOIN stage_mappings AS sm ON i.id = sm.vocabularyItemId
|
||||
LEFT JOIN vocabulary_states AS vs ON i.id = vs.vocabularyItemId
|
||||
WHERE
|
||||
-- Condition 1: Item is NEW
|
||||
sm.stage IS NULL OR sm.stage = 'NEW' OR
|
||||
-- Condition 2: Item has a due date that is in the past
|
||||
(
|
||||
-- Use last correct or incorrect answer as the base time
|
||||
(COALESCE(vs.lastCorrectAnswer, vs.lastIncorrectAnswer) / 1000) +
|
||||
(
|
||||
CASE IFNULL(sm.stage, 'NEW')
|
||||
WHEN 'STAGE_1' THEN :intervalStage1 * 86400
|
||||
WHEN 'STAGE_2' THEN :intervalStage2 * 86400
|
||||
WHEN 'STAGE_3' THEN :intervalStage3 * 86400
|
||||
WHEN 'STAGE_4' THEN :intervalStage4 * 86400
|
||||
WHEN 'STAGE_5' THEN :intervalStage5 * 86400
|
||||
WHEN 'LEARNED' THEN :intervalLearned * 86400
|
||||
ELSE 0
|
||||
END
|
||||
) <= :now
|
||||
)
|
||||
"""
|
||||
)
|
||||
fun getDueTodayItemsFlow(
|
||||
now: Long,
|
||||
intervalStage1: Int,
|
||||
intervalStage2: Int,
|
||||
intervalStage3: Int,
|
||||
intervalStage4: Int,
|
||||
intervalStage5: Int,
|
||||
intervalLearned: Int
|
||||
): Flow<List<VocabularyItem>>
|
||||
|
||||
@Query("""
|
||||
SELECT * FROM vocabulary_items
|
||||
WHERE id != :excludeId AND (
|
||||
((languageFirstId = :lang1 AND languageSecondId = :lang2) OR (languageFirstId = :lang2 AND languageSecondId = :lang1))
|
||||
AND (wordFirst = :wordFirst OR wordSecond = :wordSecond)
|
||||
)
|
||||
""")
|
||||
suspend fun getSynonyms(excludeId: Int, lang1: Int, lang2: Int, wordFirst: String, wordSecond: String): List<VocabularyItem>
|
||||
}
|
||||
|
||||
data class DailyCount(val date: LocalDate, val count: Int)
|
||||
|
||||
@Dao
|
||||
interface VocabularyStateDao {
|
||||
|
||||
@Query("DELETE FROM vocabulary_states")
|
||||
suspend fun clearAllStates()
|
||||
|
||||
@Query("""
|
||||
SELECT DATE(lastCorrectAnswer / 1000, 'unixepoch') as date, COUNT(vocabularyItemId) as count
|
||||
FROM vocabulary_states
|
||||
WHERE lastCorrectAnswer IS NOT NULL AND date BETWEEN :startDate AND :endDate
|
||||
GROUP BY date
|
||||
""")
|
||||
suspend fun getCorrectAnswerCountsByDate(startDate: LocalDate, endDate: LocalDate): List<DailyCount>
|
||||
@Query("SELECT * FROM vocabulary_states")
|
||||
fun getAllStatesFlow(): Flow<List<VocabularyItemState>>
|
||||
|
||||
@Query("SELECT * FROM vocabulary_states")
|
||||
suspend fun getAllStates(): List<VocabularyItemState>
|
||||
|
||||
@Query("SELECT * FROM vocabulary_states WHERE vocabularyItemId = :itemId")
|
||||
suspend fun getStateById(itemId: Int): VocabularyItemState?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertAll(states: List<VocabularyItemState>)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertState(state: VocabularyItemState)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface CategoryDao {
|
||||
@Query("DELETE FROM categories")
|
||||
suspend fun clearAllCategories()
|
||||
@Query("SELECT * FROM categories")
|
||||
fun getAllCategoriesFlow(): Flow<List<VocabularyCategoryEntity>>
|
||||
|
||||
@Query("SELECT * FROM categories")
|
||||
suspend fun getAllCategories(): List<VocabularyCategoryEntity>
|
||||
|
||||
@Query("SELECT * FROM categories WHERE id = :id")
|
||||
suspend fun getCategoryById(id: Int): VocabularyCategoryEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertAll(categories: List<VocabularyCategoryEntity>)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertCategory(category: VocabularyCategoryEntity)
|
||||
|
||||
@Query("DELETE FROM categories WHERE id = :categoryId")
|
||||
suspend fun deleteCategoryById(categoryId: Int)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface MappingDao {
|
||||
@Query("DELETE FROM stage_mappings")
|
||||
suspend fun clearStageMappings()
|
||||
@Query("DELETE FROM stage_mappings WHERE vocabularyItemId NOT IN (:itemIds)")
|
||||
suspend fun deleteStageMappingsNotIn(itemIds: List<Int>)
|
||||
@Query("SELECT * FROM category_mappings")
|
||||
fun getCategoryMappingsFlow(): Flow<List<CategoryMappingEntity>>
|
||||
|
||||
@Query("SELECT * FROM category_mappings")
|
||||
suspend fun getCategoryMappings(): List<CategoryMappingEntity>
|
||||
|
||||
|
||||
@Query("DELETE FROM category_mappings")
|
||||
suspend fun clearCategoryMappings()
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun insertCategoryMappings(mappings: List<CategoryMappingEntity>)
|
||||
|
||||
@Transaction
|
||||
suspend fun setAllCategoryMappings(mappings: List<CategoryMappingEntity>) {
|
||||
clearCategoryMappings()
|
||||
if (mappings.isNotEmpty()) {
|
||||
insertCategoryMappings(mappings)
|
||||
}
|
||||
}
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
suspend fun addCategoryMapping(mapping: CategoryMappingEntity)
|
||||
|
||||
@Query("DELETE FROM category_mappings WHERE vocabularyItemId = :itemId AND categoryId = :listId")
|
||||
suspend fun removeCategoryMapping(itemId: Int, listId: Int)
|
||||
|
||||
@Query("SELECT * FROM stage_mappings")
|
||||
fun getStageMappingsFlow(): Flow<List<StageMappingEntity>>
|
||||
|
||||
@Query("SELECT * FROM stage_mappings")
|
||||
suspend fun getStageMappings(): List<StageMappingEntity>
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertStageMapping(mapping: StageMappingEntity)
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertStageMappings(mappings: List<StageMappingEntity>)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface DailyStatDao {
|
||||
@Query("DELETE FROM daily_stats")
|
||||
suspend fun clearAll()
|
||||
@Query("SELECT * FROM daily_stats WHERE date = :date")
|
||||
suspend fun getStatForDate(date: LocalDate): DailyStatEntity?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertStat(stat: DailyStatEntity)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.grammar
|
||||
|
||||
/**
|
||||
* Parser for extracting adjective variations from raw forms data.
|
||||
*
|
||||
* This class is responsible for parsing FormData into structured AdjectiveVariation
|
||||
* objects that can be easily displayed in the UI. It separates the parsing logic
|
||||
* from the UI components for better testability and maintainability.
|
||||
*/
|
||||
object AdjectiveVariationsParser {
|
||||
|
||||
/**
|
||||
* Supported languages for adjective variations.
|
||||
*/
|
||||
private val SUPPORTED_LANGUAGES = setOf("fr", "pt", "de")
|
||||
|
||||
/**
|
||||
* Gender tags that we recognize.
|
||||
*/
|
||||
private val GENDER_TAGS = setOf("masculine", "feminine", "neuter")
|
||||
|
||||
/**
|
||||
* Number tags that we recognize.
|
||||
*/
|
||||
private val NUMBER_TAGS = setOf("singular", "plural")
|
||||
|
||||
/**
|
||||
* Standard gender-number combinations to display in the table.
|
||||
*/
|
||||
private val STANDARD_COMBINATIONS = listOf(
|
||||
GenderNumberCombination("masculine", "singular"),
|
||||
GenderNumberCombination("masculine", "plural"),
|
||||
GenderNumberCombination("feminine", "singular"),
|
||||
GenderNumberCombination("feminine", "plural")
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class representing a gender-number combination.
|
||||
*/
|
||||
data class GenderNumberCombination(
|
||||
val gender: String,
|
||||
val number: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Parsed adjective variation ready for UI display.
|
||||
*/
|
||||
data class ParsedAdjectiveVariation(
|
||||
val form: String,
|
||||
val gender: String,
|
||||
val number: String,
|
||||
val tags: List<String>,
|
||||
val ipas: List<String>
|
||||
)
|
||||
|
||||
/**
|
||||
* Result of parsing adjective variations.
|
||||
*/
|
||||
data class AdjectiveVariationsResult(
|
||||
val variations: List<ParsedAdjectiveVariation>,
|
||||
val hasCompleteData: Boolean // Whether all 4 standard combinations are present
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if the given language and forms data represents an adjective with variations.
|
||||
*
|
||||
* @param langCode Language code (e.g., "fr", "pt", "de")
|
||||
* @param pos Part of speech (optional)
|
||||
* @param forms List of FormData from the dictionary entry
|
||||
* @return true if this appears to be an adjective with variations
|
||||
*/
|
||||
fun isAdjectiveWithVariations(
|
||||
langCode: String,
|
||||
pos: String?,
|
||||
forms: List<FormData>
|
||||
): Boolean {
|
||||
if (langCode !in SUPPORTED_LANGUAGES || forms.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if POS explicitly indicates adjective
|
||||
val isAdjectiveByPos = pos?.equals("adjective", ignoreCase = true) == true
|
||||
if (isAdjectiveByPos) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if forms contain gender and number tags (indicating adjective variations)
|
||||
return hasGenderNumberTags(forms)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse adjective variations from forms data.
|
||||
*
|
||||
* @param forms List of FormData from the dictionary entry
|
||||
* @param lemma The base form of the adjective (used as fallback for missing combinations)
|
||||
* @return AdjectiveVariationsResult containing parsed variations
|
||||
*/
|
||||
fun parseVariations(
|
||||
forms: List<FormData>,
|
||||
lemma: String? = null
|
||||
): AdjectiveVariationsResult {
|
||||
val variations = mutableListOf<ParsedAdjectiveVariation>()
|
||||
|
||||
// Parse each standard combination
|
||||
STANDARD_COMBINATIONS.forEach { combination ->
|
||||
val form = findFormForCombination(forms, combination)
|
||||
if (form != null) {
|
||||
variations.add(ParsedAdjectiveVariation(
|
||||
form = form.form,
|
||||
gender = combination.gender,
|
||||
number = combination.number,
|
||||
tags = form.tags,
|
||||
ipas = form.ipas
|
||||
))
|
||||
} else if (lemma != null && combination.gender == "masculine" && combination.number == "singular") {
|
||||
// Use lemma as fallback for masculine singular
|
||||
variations.add(ParsedAdjectiveVariation(
|
||||
form = lemma,
|
||||
gender = combination.gender,
|
||||
number = combination.number,
|
||||
tags = listOf("masculine", "singular"),
|
||||
ipas = emptyList()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
val hasCompleteData = variations.size == STANDARD_COMBINATIONS.size
|
||||
|
||||
return AdjectiveVariationsResult(
|
||||
variations = variations,
|
||||
hasCompleteData = hasCompleteData
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display labels for the supported languages.
|
||||
*/
|
||||
fun getLanguageDisplayLabels(langCode: String): Map<String, String> {
|
||||
return when (langCode) {
|
||||
"fr" -> mapOf(
|
||||
"masculine" to "Masculin",
|
||||
"feminine" to "Féminin",
|
||||
"singular" to "Singulier",
|
||||
"plural" to "Pluriel"
|
||||
)
|
||||
"pt" -> mapOf(
|
||||
"masculine" to "Masculino",
|
||||
"feminine" to "Feminino",
|
||||
"singular" to "Singular",
|
||||
"plural" to "Plural"
|
||||
)
|
||||
"de" -> mapOf(
|
||||
"masculine" to "Maskulin",
|
||||
"feminine" to "Feminin",
|
||||
"neuter" to "Neutrum",
|
||||
"singular" to "Singular",
|
||||
"plural" to "Plural"
|
||||
)
|
||||
else -> mapOf(
|
||||
"masculine" to "Masculine",
|
||||
"feminine" to "Feminine",
|
||||
"neuter" to "Neuter",
|
||||
"singular" to "Singular",
|
||||
"plural" to "Plural"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if forms contain gender and number tags.
|
||||
*/
|
||||
private fun hasGenderNumberTags(forms: List<FormData>): Boolean {
|
||||
return forms.any { formData ->
|
||||
val tags = formData.tags.map { it.lowercase() }
|
||||
val hasGender = tags.any { it in GENDER_TAGS }
|
||||
val hasNumber = tags.any { it in NUMBER_TAGS }
|
||||
hasGender && hasNumber
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the form that matches a specific gender-number combination.
|
||||
*/
|
||||
private fun findFormForCombination(
|
||||
forms: List<FormData>,
|
||||
combination: GenderNumberCombination
|
||||
): FormData? {
|
||||
return forms.find { formData ->
|
||||
val tags = formData.tags.map { it.lowercase() }
|
||||
tags.contains(combination.gender) && tags.contains(combination.number)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.grammar
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Comprehensive JSON parser for local dictionary entries.
|
||||
* * Enhanced to support detailed phonetics, homophones, form variations,
|
||||
* and raw tags for granular linguistic data.
|
||||
* * NOW SUPPORTS: Structured Verb Objects (e.g. German "forms": { "present": [...] })
|
||||
*/
|
||||
object DictionaryJsonParser {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* Parse raw JSON string into a structured DictionaryEntryData object.
|
||||
*/
|
||||
fun parseJson(jsonString: String): DictionaryEntryData? {
|
||||
val root: JsonElement = try {
|
||||
json.parseToJsonElement(jsonString)
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val obj = root as? JsonObject ?: return null
|
||||
|
||||
return DictionaryEntryData(
|
||||
translations = parseTranslations(obj),
|
||||
relations = parseRelations(obj),
|
||||
phonetics = parsePhonetics(obj),
|
||||
hyphenation = parseHyphenation(obj),
|
||||
etymology = parseEtymology(obj),
|
||||
senses = parseSenses(obj),
|
||||
grammaticalFeatures = parseGrammaticalFeatures(obj),
|
||||
grammaticalProperties = parseGrammaticalProperties(obj),
|
||||
pronunciation = parsePronunciation(obj),
|
||||
inflections = parseInflections(obj),
|
||||
forms = parseForms(obj) // Updated to handle Object structures
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTranslations(obj: JsonObject): List<TranslationData> {
|
||||
val translationsElement = obj["translations"]
|
||||
val array = translationsElement as? JsonArray ?: return emptyList()
|
||||
|
||||
return array.mapNotNull { element ->
|
||||
val o = element.jsonObject
|
||||
val langCode = o["lang_code"]?.jsonPrimitive?.contentOrNull
|
||||
?: o["language_code"]?.jsonPrimitive?.contentOrNull
|
||||
val word = o["word"]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
if (langCode.isNullOrBlank() || word.isNullOrBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
val sense = o["sense"]?.jsonPrimitive?.contentOrNull
|
||||
?: o["sense_index"]?.jsonPrimitive?.contentOrNull
|
||||
val tags = (o["tags"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
|
||||
TranslationData(
|
||||
languageCode = langCode,
|
||||
word = word,
|
||||
sense = sense,
|
||||
tags = tags
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRelations(obj: JsonObject): Map<String, List<RelationData>> {
|
||||
val relationsElement = obj["relations"] as? JsonObject ?: return emptyMap()
|
||||
|
||||
val result = mutableMapOf<String, List<RelationData>>()
|
||||
for ((relationType, value) in relationsElement) {
|
||||
val array = value as? JsonArray ?: continue
|
||||
val relations = array.mapNotNull { element ->
|
||||
val o = element.jsonObject
|
||||
val word = o["word"]?.jsonPrimitive?.contentOrNull
|
||||
if (word.isNullOrBlank()) return@mapNotNull null
|
||||
|
||||
val senseIndex = o["sense_index"]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
// Parse raw_tags
|
||||
val rawTags = (o["raw_tags"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
|
||||
RelationData(
|
||||
word = word,
|
||||
senseIndex = senseIndex,
|
||||
rawTags = rawTags
|
||||
)
|
||||
}
|
||||
result[relationType] = relations
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parsePhonetics(obj: JsonObject): PhoneticsData? {
|
||||
val phoneticsElement = obj["phonetics"] as? JsonObject ?: return null
|
||||
|
||||
// standard IPA list
|
||||
val ipaList = (phoneticsElement["ipa"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull?.replace(Regex("[\\[\\]]"), "") }
|
||||
?: emptyList()
|
||||
|
||||
// homophones
|
||||
val homophones = (phoneticsElement["homophones"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
|
||||
// IPA variations (e.g. regional pronunciations)
|
||||
val variationsArray = phoneticsElement["ipa_variations"] as? JsonArray
|
||||
val variations = variationsArray?.mapNotNull { element ->
|
||||
val vObj = element.jsonObject
|
||||
val ipa = vObj["ipa"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
|
||||
val rawTags = (vObj["raw_tags"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
|
||||
IpaVariationData(ipa = ipa, rawTags = rawTags)
|
||||
} ?: emptyList()
|
||||
|
||||
return PhoneticsData(
|
||||
ipa = ipaList,
|
||||
homophones = homophones,
|
||||
variations = variations
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseHyphenation(obj: JsonObject): List<String> {
|
||||
val hyphenationElement = obj["hyphenation"] as? JsonArray ?: return emptyList()
|
||||
return hyphenationElement.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
}
|
||||
|
||||
private fun parseEtymology(obj: JsonObject): EtymologyData {
|
||||
val element = obj["etymology"] ?: return EtymologyData(texts = emptyList())
|
||||
val texts = when (element) {
|
||||
is JsonArray -> element.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
is JsonObject -> {
|
||||
val array = element["texts"] as? JsonArray
|
||||
if (array != null) {
|
||||
array.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
} else {
|
||||
val singleText = element["text"]?.jsonPrimitive?.contentOrNull
|
||||
if (singleText != null) listOf(singleText) else emptyList()
|
||||
}
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
return EtymologyData(texts = texts)
|
||||
}
|
||||
|
||||
private fun parseSenses(obj: JsonObject): List<SenseData> {
|
||||
val sensesElement = obj["senses"] as? JsonArray ?: return emptyList()
|
||||
|
||||
return sensesElement.mapNotNull { element ->
|
||||
val senseObj = element.jsonObject
|
||||
|
||||
// Handle both "gloss" (string) and "glosses" (array) formats
|
||||
val glosses = when {
|
||||
senseObj["glosses"] is JsonArray -> {
|
||||
(senseObj["glosses"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
}
|
||||
!senseObj["gloss"]?.jsonPrimitive?.contentOrNull.isNullOrBlank() -> {
|
||||
listOf(senseObj["gloss"]!!.jsonPrimitive.content)
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
if (glosses.isEmpty()) return@mapNotNull null
|
||||
|
||||
val topics = (senseObj["topics"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
|
||||
val examples = (senseObj["examples"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
|
||||
val tags = (senseObj["tags"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
|
||||
// Capture raw_tags
|
||||
val rawTags = (senseObj["raw_tags"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
|
||||
val categories = (senseObj["categories"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
|
||||
SenseData(
|
||||
glosses = glosses,
|
||||
topics = topics,
|
||||
examples = examples,
|
||||
tags = tags,
|
||||
rawTags = rawTags,
|
||||
categories = categories
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseGrammaticalFeatures(obj: JsonObject): GrammaticalFeaturesData? {
|
||||
val featuresElement = obj["grammatical_features"] as? JsonObject ?: return null
|
||||
val tags = (featuresElement["tags"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
|
||||
val gender = featuresElement["gender"]?.jsonPrimitive?.contentOrNull
|
||||
val number = featuresElement["number"]?.jsonPrimitive?.contentOrNull
|
||||
return GrammaticalFeaturesData(tags = tags, gender = gender, number = number)
|
||||
}
|
||||
|
||||
private fun parseGrammaticalProperties(obj: JsonObject): GrammaticalPropertiesData? {
|
||||
val propsElement = obj["grammatical_properties"] as? JsonObject ?: return null
|
||||
val otherTags = (propsElement["other_tags"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
|
||||
return GrammaticalPropertiesData(otherTags = otherTags)
|
||||
}
|
||||
|
||||
private fun parsePronunciation(obj: JsonObject): List<PronunciationData> {
|
||||
val pronunciationElement = obj["pronunciation"] as? JsonArray ?: return emptyList()
|
||||
return pronunciationElement.mapNotNull { element ->
|
||||
val pronObj = element.jsonObject
|
||||
val ipa = pronObj["ipa"]?.jsonPrimitive?.contentOrNull
|
||||
val rhymes = pronObj["rhymes"]?.jsonPrimitive?.contentOrNull
|
||||
if (ipa.isNullOrBlank()) return@mapNotNull null
|
||||
PronunciationData(ipa = ipa, rhymes = rhymes)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseInflections(obj: JsonObject): List<InflectionData> {
|
||||
val inflectionElement = obj["inflections"] as? JsonArray ?: return emptyList()
|
||||
return inflectionElement.mapNotNull { element ->
|
||||
val infObj = element.jsonObject
|
||||
val form = infObj["form"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
|
||||
val grammaticalFeatures = (infObj["grammatical_features"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
|
||||
InflectionData(form = form, grammaticalFeatures = grammaticalFeatures)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse forms array from JSON.
|
||||
* Handles BOTH standard Arrays and structured Objects (common in German verbs).
|
||||
*/
|
||||
private fun parseForms(obj: JsonObject): List<FormData> {
|
||||
val formsElement = obj["forms"] ?: return emptyList()
|
||||
|
||||
return when (formsElement) {
|
||||
is JsonArray -> parseFormsArray(formsElement)
|
||||
is JsonObject -> parseFormsObject(formsElement)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Case 1: Standard Array of Form Objects
|
||||
*/
|
||||
private fun parseFormsArray(array: JsonArray): List<FormData> {
|
||||
return array.mapNotNull { element ->
|
||||
val formObj = element.jsonObject
|
||||
val form = formObj["form"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
|
||||
|
||||
val tags = (formObj["tags"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
?: emptyList()
|
||||
|
||||
// Capture IPAs specific to this form (often escaped like "\\ʃo\\")
|
||||
val ipas = (formObj["ipas"] as? JsonArray)
|
||||
?.mapNotNull {
|
||||
it.jsonPrimitive.contentOrNull?.replace("\\", "") // cleanup escapes
|
||||
}
|
||||
?: emptyList()
|
||||
|
||||
FormData(
|
||||
form = form,
|
||||
tags = tags,
|
||||
ipas = ipas
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Case 2: Structured Object (German Verbs in "laufen" style)
|
||||
* Flattens the object keys (present, past, etc.) into FormData objects tagged with that key.
|
||||
*/
|
||||
private fun parseFormsObject(obj: JsonObject): List<FormData> {
|
||||
val result = mutableListOf<FormData>()
|
||||
|
||||
// 1. Handle Array Tenses (e.g. "present": ["laufe", "läufst"...])
|
||||
val tenseKeys = listOf(
|
||||
"present", "past", "future",
|
||||
"subjunctive_i", "subjunctive_ii",
|
||||
"imperative", "conditional"
|
||||
)
|
||||
|
||||
tenseKeys.forEach { key ->
|
||||
val array = obj[key] as? JsonArray
|
||||
array?.forEach { formElement ->
|
||||
val form = formElement.jsonPrimitive.contentOrNull
|
||||
if (!form.isNullOrBlank()) {
|
||||
// We tag it with the key (e.g., "present") so the Parser can find it.
|
||||
// The UnifiedMorphologyParser relies on list order for persons (1st->3rd),
|
||||
// so we simply add them in order.
|
||||
result.add(FormData(
|
||||
form = form,
|
||||
tags = listOf(key),
|
||||
ipas = emptyList()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle Single String Properties (e.g. "auxiliary": "sein")
|
||||
val singleKeys = listOf("auxiliary", "infinitive", "participle_perfect", "participle_present")
|
||||
|
||||
singleKeys.forEach { key ->
|
||||
val value = obj[key]?.jsonPrimitive?.contentOrNull
|
||||
if (!value.isNullOrBlank()) {
|
||||
result.add(FormData(
|
||||
form = value,
|
||||
tags = listOf(key),
|
||||
ipas = emptyList()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data Classes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
data class DictionaryEntryData(
|
||||
val translations: List<TranslationData>,
|
||||
val relations: Map<String, List<RelationData>>,
|
||||
val phonetics: PhoneticsData?,
|
||||
val hyphenation: List<String>,
|
||||
val etymology: EtymologyData,
|
||||
val senses: List<SenseData>,
|
||||
val grammaticalFeatures: GrammaticalFeaturesData?,
|
||||
val grammaticalProperties: GrammaticalPropertiesData?,
|
||||
val pronunciation: List<PronunciationData>,
|
||||
val inflections: List<InflectionData>,
|
||||
val forms: List<FormData>
|
||||
) {
|
||||
val synonyms: List<RelationData>
|
||||
get() = relations["synonyms"] ?: emptyList()
|
||||
|
||||
val hyponyms: List<RelationData>
|
||||
get() = relations["hyponyms"] ?: emptyList()
|
||||
|
||||
val allRelatedWords: List<RelationData>
|
||||
get() = relations.values.flatten()
|
||||
|
||||
val allTags: List<String>
|
||||
get() = (grammaticalFeatures?.tags.orEmpty() + grammaticalProperties?.otherTags.orEmpty()).distinct()
|
||||
|
||||
}
|
||||
|
||||
data class TranslationData(
|
||||
val languageCode: String,
|
||||
val word: String,
|
||||
val sense: String?,
|
||||
val tags: List<String>
|
||||
)
|
||||
|
||||
data class RelationData(
|
||||
val word: String,
|
||||
val senseIndex: String?,
|
||||
val rawTags: List<String> = emptyList()
|
||||
)
|
||||
|
||||
data class EtymologyData(
|
||||
val texts: List<String>
|
||||
)
|
||||
|
||||
data class SenseData(
|
||||
val glosses: List<String>,
|
||||
val topics: List<String> = emptyList(),
|
||||
val examples: List<String> = emptyList(),
|
||||
val tags: List<String> = emptyList(),
|
||||
val rawTags: List<String> = emptyList(),
|
||||
val categories: List<String> = emptyList()
|
||||
) {
|
||||
val gloss: String
|
||||
get() = glosses.firstOrNull() ?: ""
|
||||
}
|
||||
|
||||
data class GrammaticalPropertiesData(
|
||||
val otherTags: List<String>
|
||||
)
|
||||
|
||||
data class GrammaticalFeaturesData(
|
||||
val tags: List<String>,
|
||||
val gender: String? = null,
|
||||
val number: String? = null
|
||||
)
|
||||
|
||||
data class PronunciationData(
|
||||
val ipa: String,
|
||||
val rhymes: String?
|
||||
)
|
||||
|
||||
data class PhoneticsData(
|
||||
val ipa: List<String>,
|
||||
val homophones: List<String>,
|
||||
val variations: List<IpaVariationData>
|
||||
)
|
||||
|
||||
data class IpaVariationData(
|
||||
val ipa: String,
|
||||
val rawTags: List<String>
|
||||
)
|
||||
|
||||
data class InflectionData(
|
||||
val form: String,
|
||||
val grammaticalFeatures: List<String>
|
||||
)
|
||||
|
||||
data class FormData(
|
||||
val form: String,
|
||||
val tags: List<String>,
|
||||
val ipas: List<String>
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package eu.gaudian.translator.model.grammar
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* A container holding the grammatical features for BOTH words in a VocabularyItem.
|
||||
* This entire object is serialized into the single `features` string.
|
||||
*/
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class VocabularyFeatures(
|
||||
val first: GrammaticalFeature? = null,
|
||||
val second: GrammaticalFeature? = null
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* A generic, serializable container for the grammatical information of a SINGLE word.
|
||||
*
|
||||
* @param category The type of word, e.g., "noun", "verb". This key corresponds
|
||||
* to a category in the language configuration JSON.
|
||||
* @param properties A flexible map to hold any language-specific properties,
|
||||
* e.g., "gender" -> "masculine", "plural" -> "die Männer".
|
||||
*/
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class GrammaticalFeature(
|
||||
val category: String,
|
||||
val properties: Map<String, String>
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,50 @@
|
||||
Unified Morphology Architecture & Data Standard1. System OverviewThe app uses a Declarative, Config-Driven Architecture to parse and display grammatical forms (morphology). Instead of writing language-specific code (e.g., GermanNounParser, FrenchVerbParser), the system uses a single generic engine driven by JSON configuration.The Engine (Kotlin): UnifiedMorphologyParser knows how to build data structures (Grids, Lists, Verb Paradigms) but not what to build.The Blueprint (JSON Config): Language files (e.g., fr.json, de.json) define the dimensions (Rows/Columns for grids, Tenses for verbs).The Data (Dictionary JSON): The raw word forms tagged with grammatical features.2. Configuration Standard (LanguageConfig)The configuration file defines the "rules" for the parser. It tells the engine which strategy to use for a given Part of Speech (POS).A. The Grid Strategy (Nouns & Adjectives)Used for any 2D data structure (Case × Number, Gender × Number).Requirement: The category object MUST contain declension_display.Example (fr.json - Adjectives):"adjective": {
|
||||
"declension_display": {
|
||||
"cases_order": ["masculine", "feminine"], // <--- Defined ROWS
|
||||
"numbers_order": ["singular", "plural"] // <--- Defined COLUMNS
|
||||
}
|
||||
}
|
||||
Note: The keys cases_order and numbers_order are generic. For adjectives, they conceptually represent "Genders" and "Numbers".B. The Verb Strategy (Conjugations)Used for verb paradigms.Requirement: The category object MUST contain conjugation_display.Example (de.json - Verbs):"verb": {
|
||||
"conjugation_display": {
|
||||
"pronouns": ["ich", "du", "er/sie/es", "wir", "ihr", "sie"], // Required for mapping ordered forms
|
||||
"tense_labels": {
|
||||
"present": "Präsens", // Internal Tag -> Display Label
|
||||
"past": "Präteritum",
|
||||
"subjunctive_i": "Konjunktiv I"
|
||||
}
|
||||
}
|
||||
}
|
||||
3. Data Input Standard (DictionaryEntryData)The dictionary entry provides the raw forms. The parser supports two input formats.Format A: The Standard List (Recommended)A flat list of objects. Each object contains the form string and a list of tags.Constraint: The tags values MUST match the keys defined in the Configuration (e.g., "masculine", "present")."forms": [
|
||||
{ "form": "beau", "tags": ["masculine", "singular"] },
|
||||
{ "form": "belle", "tags": ["feminine", "singular"] },
|
||||
{ "form": "beaux", "tags": ["masculine", "plural"] },
|
||||
{ "form": "belles", "tags": ["feminine", "plural"] }
|
||||
]
|
||||
Format B: The Structured Object (Legacy/German Verbs)Some entries group forms by tense keys instead of tags.Parser Behavior: The DictionaryJsonParser detects this object and flattens it into Format A automatically.Mapping: Keys like present become tags. Arrays are preserved in order.// Input (Dictionary JSON)
|
||||
"forms": {
|
||||
"present": ["laufe", "läufst", "läuft", "laufen", "lauft", "laufen"],
|
||||
"auxiliary": "sein"
|
||||
}
|
||||
|
||||
// Internal Representation after Flattening:
|
||||
// [
|
||||
// { "form": "laufe", "tags": ["present"] },
|
||||
// { "form": "läufst", "tags": ["present"] },
|
||||
// ...
|
||||
// { "form": "sein", "tags": ["auxiliary"] }
|
||||
// ]
|
||||
4. The Parsing AlgorithmThe UnifiedMorphologyParser follows this specific decision tree:Step 1: NormalizationInputs: Lemma (e.g., "beau"), POS (e.g., "adj"), Config.Normalize POS: Maps short tags to standard keys:adj, adjectif -> adjectivenom, substantive -> nounverbe -> verbAbort: If forms list is empty.Step 2: Rule SelectionCheck JSON Config: Does config.categories[pos] exist?Yes: Does it have declension_display? -> Build Grid Rule.Yes: Does it have conjugation_display? -> Build Verb Rule.Fallback: If JSON config is missing, check MorphologyRegistry.kt for a hardcoded default rule (e.g., generic fallback).Step 3: Execution (Grid Strategy)Applies to Nouns and Adjectives.Index Forms: Creates a fast lookup map: Set<Tags> -> Form.Iterate: Loops through row_tags (from Config) × col_tags (from Config).Match: For cell [row, col], looks for a form containing BOTH tags.Example: Looking for form with tags ["masculine", "plural"].Fallback (The Lemma Rule): If a match is not found:Adjective Special Case: If the cell being built is masculine + singular, the parser defaults to using the Lemma (Headword). This handles dictionary data that omits the base form.Output: Returns UnifiedMorphology.Grid (Rendered as a table).Step 4: Execution (Verb Strategy)Applies to Verbs.Iterate: Loops through tense_labels keys (from Config).Filter: Finds all forms tagged with that tense key (e.g., "present").Truncate: Takes the first $N$ forms, where $N$ is the length of the pronouns list in Config.Auxiliary: Scans for a form tagged auxiliary.Output: Returns UnifiedMorphology.Verb (Rendered as conjugation lists).5. Validating Your JSONTo ensure your JSON renders correctly in the UI, check these constraints:ComponentConstraintWhy?Configcases_order must be non-null for Nouns/Adjectives.Without row definitions, the grid cannot be built.Configtense_labels keys must match Dictionary tags."present" in config must match "present" tag in data.DataTags must be lowercase in forms.The parser normalizes tags to lowercase, but consistency prevents bugs.DataVerb arrays must be ordered by person (1st->3rd).The parser maps forms to pronouns by index position (0=I, 1=You, etc.).Example: A Valid Adjective Setuppt.json (Config):"adjective": {
|
||||
"declension_display": {
|
||||
"cases_order": ["masculine", "feminine"],
|
||||
"numbers_order": ["singular", "plural"]
|
||||
}
|
||||
}
|
||||
Dictionary Entry (Data):"word": "lindo",
|
||||
"forms": [
|
||||
// Note: "lindo" (masc/sing) is MISSING here.
|
||||
// The Parser will automatically insert the word "lindo" into the [masculine, singular] cell.
|
||||
{ "form": "linda", "tags": ["feminine", "singular"] },
|
||||
{ "form": "lindos", "tags": ["masculine", "plural"] },
|
||||
{ "form": "lindas", "tags": ["feminine", "plural"] }
|
||||
]
|
||||
Resulting UI:| | Singular | Plural || :--- | :--- | :--- || Masculine | lindo (Lemma) | lindos || Feminine | linda | lindas |
|
||||
@@ -0,0 +1,60 @@
|
||||
@file:Suppress("PropertyName")
|
||||
|
||||
package eu.gaudian.translator.model.grammar
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents the entire language configuration file (e.g., de.json).
|
||||
*
|
||||
* @param language_code The ISO code for the language (e.g., "de").
|
||||
* @param articles A list of articles for the language (e.g., "der", "die"). This is optional.
|
||||
* @param categories A map where the key is the category name (e.g., "noun")
|
||||
* and the value contains the details for that category.
|
||||
*/
|
||||
@Serializable
|
||||
data class LanguageConfig(
|
||||
val language_code: String,
|
||||
val articles: List<String>? = null,
|
||||
val categories: Map<String, CategoryConfig>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CategoryConfig(
|
||||
@Suppress("PropertyName") val display_key: String,
|
||||
val fields: List<FieldConfig>,
|
||||
val formatter: String? = null,
|
||||
val mappings: Map<String, Map<String, String>>? = null,
|
||||
/** Optional configuration for how verb conjugations should be displayed. */
|
||||
val conjugation_display: VerbConjugationDisplayConfig? = null,
|
||||
/** Optional configuration for how noun declension tables should be displayed. */
|
||||
val declension_display: NounDeclensionDisplayConfig? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VerbConjugationDisplayConfig(
|
||||
/** Default pronoun order for this language's verb tables. */
|
||||
val pronouns: List<String>? = null,
|
||||
/** Mapping from internal tense keys (e.g., "present") to display labels (e.g., "Präsens"). */
|
||||
val tense_labels: Map<String, String>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NounDeclensionDisplayConfig(
|
||||
/** Ordered list of case keys to display as rows. */
|
||||
val cases_order: List<String>? = null,
|
||||
/** Mapping from case keys to display labels (e.g., "nominative" -> "Nom."). */
|
||||
val case_labels: Map<String, String>? = null,
|
||||
/** Ordered list of number keys to display as columns (e.g., ["singular", "plural"]). */
|
||||
val numbers_order: List<String>? = null,
|
||||
/** Mapping from number keys to display labels (e.g., "singular" -> "Sing."). */
|
||||
val number_labels: Map<String, String>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class FieldConfig(
|
||||
val key: String,
|
||||
val display_key: String,
|
||||
val type: String,
|
||||
val options: List<String>? = null
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.grammar
|
||||
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Maps raw local dictionary JSON into grammar models used by the UI.
|
||||
*
|
||||
* This implementation uses the Strategy Pattern to delegate parsing logic
|
||||
* based on the language code.
|
||||
*/
|
||||
object LocalDictionaryMorphologyMapper {
|
||||
|
||||
private val strategies = mapOf(
|
||||
"de" to GermanMorphologyStrategy(),
|
||||
"fr" to FrenchMorphologyStrategy()
|
||||
)
|
||||
|
||||
private val genericStrategy = GenericMorphologyStrategy()
|
||||
|
||||
/**
|
||||
* Parse morphology information (conjugation/declension/inflections) from a
|
||||
* local dictionary entry.
|
||||
*
|
||||
* @param langCode ISO language code, e.g. "de".
|
||||
* @param pos Part of speech as stored with the entry, if available.
|
||||
* @param lemma The base form / headword, used as infinitive for verbs.
|
||||
* @param data Root JSON object of the dictionary entry.
|
||||
* @param languageConfig Optional language configuration, used for display
|
||||
* metadata like pronoun order and tense/case labels when available.
|
||||
*/
|
||||
fun parseMorphology(
|
||||
langCode: String,
|
||||
pos: String?,
|
||||
lemma: String,
|
||||
data: JsonObject,
|
||||
languageConfig: LanguageConfig? = null
|
||||
): WordMorphology? {
|
||||
Log.d("LocalDictionaryMorphologyMapper", "Parsing morphology for $langCode, word: $lemma")
|
||||
|
||||
// 1. Try specific language strategy
|
||||
val specificStrategy = strategies[langCode]
|
||||
if (specificStrategy != null) {
|
||||
val result = specificStrategy.parse(lemma, pos, data, languageConfig)
|
||||
if (result != null) return result
|
||||
}
|
||||
|
||||
// 2. Fallback to generic strategy (valid for any language with 'inflections' array)
|
||||
return genericStrategy.parse(lemma, pos, data, languageConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overload that uses parsed [DictionaryEntryData] instead of raw JSON.
|
||||
*/
|
||||
fun parseMorphology(
|
||||
langCode: String,
|
||||
pos: String?,
|
||||
lemma: String,
|
||||
entryData: DictionaryEntryData,
|
||||
languageConfig: LanguageConfig? = null
|
||||
): WordMorphology? {
|
||||
Log.d("LocalDictionaryMorphologyMapper", "Parsing morphology (structured) for $langCode, word: $lemma")
|
||||
|
||||
// 1. Handle adjective forms for applicable languages (check this first)
|
||||
Log.d("LocalDictionaryMorphologyMapper", "Checking adjective: langCode=$langCode, pos=$pos, formsCount=${entryData.forms.size}")
|
||||
|
||||
if (AdjectiveVariationsParser.isAdjectiveWithVariations(langCode, pos, entryData.forms)) {
|
||||
Log.d("LocalDictionaryMorphologyMapper", "Creating adjective morphology for $lemma")
|
||||
val comparison = AdjectiveComparison() // Empty comparison, forms will be accessed directly from entryData
|
||||
return WordMorphology.Adjective(comparison)
|
||||
}
|
||||
|
||||
// 2. Try specific language strategy if 'forms' data is available
|
||||
val specificStrategy = strategies[langCode]
|
||||
// Check isNotEmpty() because forms is now a List<FormData>, not nullable JsonElement
|
||||
if (specificStrategy != null && entryData.forms.isNotEmpty()) {
|
||||
|
||||
// Reconstruct the JsonArray for forms because the strategy expects raw JSON structures
|
||||
val formsArray = JsonArray(entryData.forms.map { formData ->
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"form" to JsonPrimitive(formData.form),
|
||||
"tags" to JsonArray(formData.tags.map { JsonPrimitive(it) }),
|
||||
"ipas" to JsonArray(formData.ipas.map { JsonPrimitive(it) })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
// Construct a minimal JsonObject for the strategy which expects "forms" key
|
||||
val syntheticData = JsonObject(mapOf("forms" to formsArray))
|
||||
|
||||
val result = specificStrategy.parse(lemma, pos, syntheticData, languageConfig)
|
||||
if (result != null) return result
|
||||
}
|
||||
|
||||
// 3. Fallback to generic strategy using parsed inflections
|
||||
if (entryData.inflections.isNotEmpty()) {
|
||||
val inflections = entryData.inflections.map {
|
||||
Inflection(it.form, it.grammaticalFeatures)
|
||||
}
|
||||
return WordMorphology.GenericInflections(inflections)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package eu.gaudian.translator.model.grammar
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Generic morphology models derived from local dictionary data.
|
||||
*
|
||||
* These models are UI-agnostic and can be rendered by different screens
|
||||
* (dictionary results, vocabulary cards, etc.).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Conjugation data for a single verb.
|
||||
*
|
||||
* @param infinitive The base form of the verb.
|
||||
* @param conjugations Map from an internal or display tense key to the list of
|
||||
* forms for each person (same order as [pronouns]).
|
||||
* @param pronouns List of pronouns corresponding to the persons in [conjugations] values.
|
||||
* @param auxiliary Optional auxiliary verb used by this verb (e.g., "haben/sein").
|
||||
*/
|
||||
@Serializable
|
||||
data class VerbConjugation(
|
||||
val infinitive: String,
|
||||
val conjugations: Map<String, List<String>>, // tense -> list of forms
|
||||
val pronouns: List<String> = emptyList(),
|
||||
val auxiliary: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Declension table for noun-like words.
|
||||
*
|
||||
* @param cases Ordered list of case keys (e.g., "nominative", "genitive", ...).
|
||||
* @param numbers Ordered list of number keys (e.g., "singular", "plural").
|
||||
* @param forms Mapping from (case, number) pair to the inflected form.
|
||||
*/
|
||||
@Serializable
|
||||
data class NounDeclensionTable(
|
||||
val cases: List<String>,
|
||||
val numbers: List<String>,
|
||||
val forms: Map<Pair<String, String>, String>
|
||||
)
|
||||
|
||||
/**
|
||||
* Comparison degrees for adjectives.
|
||||
*/
|
||||
@Serializable
|
||||
data class AdjectiveComparison(
|
||||
val positive: String? = null,
|
||||
val comparative: String? = null,
|
||||
val superlative: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* High-level morphology classification for a word.
|
||||
*/
|
||||
sealed class WordMorphology {
|
||||
data class Verb(val conjugations: List<VerbConjugation>) : WordMorphology()
|
||||
data class Noun(val declension: NounDeclensionTable) : WordMorphology()
|
||||
data class Adjective(val comparison: AdjectiveComparison) : WordMorphology()
|
||||
|
||||
/**
|
||||
* Generic inflections as provided by some dictionaries (e.g. Portuguese).
|
||||
*/
|
||||
data class GenericInflections(val inflections: List<Inflection>) : WordMorphology()
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package eu.gaudian.translator.model.grammar
|
||||
|
||||
/**
|
||||
* Base class for all morphology rules.
|
||||
*/
|
||||
sealed class MorphologyRule {
|
||||
|
||||
/**
|
||||
* RULE: Extract a 2D Grid (e.g., German Nouns).
|
||||
* @param rowTags The tags defining the rows (e.g., Nominative, Genitive).
|
||||
* @param colTags The tags defining the columns (e.g., Singular, Plural).
|
||||
* @param title Display title for the table.
|
||||
* @param fallbackStrategy Optional: If cell is missing, how to generate it (e.g., use Lemma).
|
||||
*/
|
||||
data class GridRule(
|
||||
val title: String,
|
||||
val rowTags: List<String>,
|
||||
val colTags: List<String>,
|
||||
val fallbackStrategy: ((row: String, col: String, lemma: String) -> String?)? = null
|
||||
) : MorphologyRule()
|
||||
|
||||
/**
|
||||
* RULE: Extract a Verb Paradigm.
|
||||
* @param tenses Map of "Internal Tag" -> "Display Label".
|
||||
* @param pronouns List of pronouns to display.
|
||||
*/
|
||||
data class VerbRule(
|
||||
val tenses: Map<String, String>,
|
||||
val pronouns: List<String>
|
||||
) : MorphologyRule()
|
||||
|
||||
/**
|
||||
* RULE: Just show everything generic.
|
||||
*/
|
||||
object GenericRule : MorphologyRule()
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.grammar
|
||||
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.CONDITIONAL_PRESENT
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.IMPERATIVE
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_FUTURE
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_IMPERFECT
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_PRESENT
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.INDICATIVE_SIMPLE_PAST
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.INFINITIVE
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_DATA
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_FORMS
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_INFLECTIONS
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_TYPE
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.PARTICIPLE_PAST
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.PARTICIPLE_PRESENT
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.PAST
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.POS_NOUN
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.POS_VERB
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.PRESENT
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.SUBJUNCTIVE_I
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.SUBJUNCTIVE_II
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.SUBJUNCTIVE_PRESENT
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.TYPE_DE_ADJ
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.TYPE_DE_VERB
|
||||
import eu.gaudian.translator.model.grammar.WordMorphology.Adjective
|
||||
import eu.gaudian.translator.model.grammar.WordMorphology.GenericInflections
|
||||
import eu.gaudian.translator.model.grammar.WordMorphology.Noun
|
||||
import eu.gaudian.translator.model.grammar.WordMorphology.Verb
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Interface for language-specific morphology parsing.
|
||||
*/
|
||||
interface MorphologyStrategy {
|
||||
fun parse(
|
||||
lemma: String,
|
||||
pos: String?,
|
||||
rootData: JsonObject,
|
||||
config: LanguageConfig?
|
||||
): WordMorphology?
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy for German (de).
|
||||
*/
|
||||
class GermanMorphologyStrategy : MorphologyStrategy {
|
||||
override fun parse(
|
||||
lemma: String,
|
||||
pos: String?,
|
||||
rootData: JsonObject,
|
||||
config: LanguageConfig?
|
||||
): WordMorphology? {
|
||||
val formsElement = rootData[KEY_FORMS] ?: return null
|
||||
val categories = config?.categories
|
||||
val normalizedPos = pos?.lowercase()?.trim()
|
||||
|
||||
return when (formsElement) {
|
||||
is JsonObject -> {
|
||||
val type = formsElement[KEY_TYPE]?.jsonPrimitive?.contentOrNull
|
||||
val innerData = formsElement[KEY_DATA]?.jsonObject
|
||||
|
||||
when (type) {
|
||||
TYPE_DE_VERB if innerData != null -> {
|
||||
val verbConfig = categories?.get(POS_VERB)
|
||||
val conjugation = mapJsonToVerbConjugation(lemma, innerData, verbConfig)
|
||||
Verb(listOf(conjugation))
|
||||
}
|
||||
TYPE_DE_ADJ if innerData != null -> {
|
||||
val comparison = parseAdjectiveComparison(innerData)
|
||||
Adjective(comparison)
|
||||
}
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
is JsonArray -> {
|
||||
if (formsElement.isEmpty()) return null
|
||||
|
||||
if (SharedMorphologyUtils.isNounLikeStructure(formsElement, normalizedPos)) {
|
||||
val nounConfig = categories?.get(POS_NOUN)
|
||||
val declension = SharedMorphologyUtils.parseNounDeclension(formsElement, nounConfig)
|
||||
return Noun(declension)
|
||||
}
|
||||
|
||||
// Handle array of verb blocks
|
||||
val verbBlocks = formsElement.mapNotNull { element ->
|
||||
val obj = element.jsonObject
|
||||
val type = obj[KEY_TYPE]?.jsonPrimitive?.contentOrNull
|
||||
val innerData = obj[KEY_DATA]?.jsonObject
|
||||
if (innerData != null && (type == TYPE_DE_VERB || obj.containsKey(KEY_DATA))) innerData else null
|
||||
}
|
||||
|
||||
if (verbBlocks.isNotEmpty()) {
|
||||
val verbConfig = categories?.get(POS_VERB)
|
||||
val conjugations = verbBlocks.map { innerData ->
|
||||
mapJsonToVerbConjugation(lemma, innerData, verbConfig)
|
||||
}
|
||||
return Verb(conjugations)
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapJsonToVerbConjugation(
|
||||
infinitive: String,
|
||||
data: JsonObject,
|
||||
categoryConfig: CategoryConfig?
|
||||
): VerbConjugation {
|
||||
fun getForms(key: String): List<String> =
|
||||
data[key]?.jsonArray?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList()
|
||||
|
||||
val conjugationMap = mutableMapOf<String, List<String>>()
|
||||
val tenseLabels = categoryConfig?.conjugation_display?.tense_labels
|
||||
|
||||
fun addTense(key: String) {
|
||||
val forms = getForms(key)
|
||||
if (forms.isNotEmpty()) {
|
||||
val label = tenseLabels?.get(key) ?: key.replaceFirstChar { it.titlecase() }
|
||||
conjugationMap[label] = forms
|
||||
}
|
||||
}
|
||||
|
||||
addTense(PRESENT)
|
||||
addTense(PAST)
|
||||
addTense(SUBJUNCTIVE_I)
|
||||
addTense(SUBJUNCTIVE_II)
|
||||
|
||||
val pronouns = categoryConfig?.conjugation_display?.pronouns
|
||||
?: listOf("ich", "du", "er/sie/es", "wir", "ihr", "sie")
|
||||
|
||||
val aux = data["aux"]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
return VerbConjugation(infinitive, conjugationMap, pronouns, aux)
|
||||
}
|
||||
|
||||
private fun parseAdjectiveComparison(data: JsonObject): AdjectiveComparison {
|
||||
return AdjectiveComparison(
|
||||
positive = data["positive_masculine"]?.jsonPrimitive?.contentOrNull
|
||||
?: data["positive_feminine"]?.jsonPrimitive?.contentOrNull,
|
||||
comparative = data["comparative"]?.jsonPrimitive?.contentOrNull,
|
||||
superlative = data["superlative"]?.jsonPrimitive?.contentOrNull
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy for French (fr).
|
||||
*/
|
||||
class FrenchMorphologyStrategy : MorphologyStrategy {
|
||||
override fun parse(
|
||||
lemma: String,
|
||||
pos: String?,
|
||||
rootData: JsonObject,
|
||||
config: LanguageConfig?
|
||||
): WordMorphology? {
|
||||
val formsElement = rootData[KEY_FORMS] ?: return null
|
||||
val categories = config?.categories
|
||||
val normalizedPos = pos?.lowercase()?.trim()
|
||||
|
||||
return when (formsElement) {
|
||||
is JsonObject -> {
|
||||
// Heuristic: Check for French verb keys
|
||||
val hasVerbForms = formsElement.keys.any { key ->
|
||||
key.startsWith("indicative_") || key.startsWith("subjunctive_") ||
|
||||
key.startsWith(IMPERATIVE) || key.startsWith("conditional_") ||
|
||||
key == INFINITIVE || key == PARTICIPLE_PRESENT || key == PARTICIPLE_PAST
|
||||
}
|
||||
|
||||
if (hasVerbForms) {
|
||||
val verbConfig = categories?.get(POS_VERB)
|
||||
val conjugation = mapFrenchJsonToVerbConjugation(lemma, formsElement, verbConfig)
|
||||
Verb(listOf(conjugation))
|
||||
} else null
|
||||
}
|
||||
is JsonArray -> {
|
||||
if (SharedMorphologyUtils.isNounLikeStructure(formsElement, normalizedPos)) {
|
||||
val nounConfig = categories?.get(POS_NOUN)
|
||||
val declension = SharedMorphologyUtils.parseNounDeclension(formsElement, nounConfig)
|
||||
return Noun(declension)
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapFrenchJsonToVerbConjugation(
|
||||
infinitive: String,
|
||||
data: JsonObject,
|
||||
categoryConfig: CategoryConfig?
|
||||
): VerbConjugation {
|
||||
fun getForms(key: String): List<String> =
|
||||
data[key]?.jsonArray?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } ?: emptyList()
|
||||
|
||||
val conjugationMap = mutableMapOf<String, List<String>>()
|
||||
val tenseLabels = categoryConfig?.conjugation_display?.tense_labels
|
||||
|
||||
fun addFrenchTense(frenchKey: String, displayKey: String) {
|
||||
val forms = getForms(frenchKey)
|
||||
if (forms.isNotEmpty()) {
|
||||
val label = tenseLabels?.get(frenchKey) ?: displayKey
|
||||
conjugationMap[label] = forms
|
||||
}
|
||||
}
|
||||
|
||||
addFrenchTense(INDICATIVE_PRESENT, "Indicative Present")
|
||||
addFrenchTense(INDICATIVE_IMPERFECT, "Indicative Imperfect")
|
||||
addFrenchTense(INDICATIVE_SIMPLE_PAST, "Indicative Simple Past")
|
||||
addFrenchTense(INDICATIVE_FUTURE, "Indicative Future")
|
||||
addFrenchTense(SUBJUNCTIVE_PRESENT, "Subjunctive Present")
|
||||
addFrenchTense(CONDITIONAL_PRESENT, "Conditional Present")
|
||||
addFrenchTense(IMPERATIVE, "Imperative")
|
||||
|
||||
val pronouns = categoryConfig?.conjugation_display?.pronouns
|
||||
?: listOf("je", "tu", "il/elle", "nous", "vous", "ils/elles")
|
||||
|
||||
// French data sometimes puts aux in an array
|
||||
val aux = data["auxiliary"]?.jsonArray?.firstOrNull()?.jsonPrimitive?.contentOrNull
|
||||
?: data["aux"]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
return VerbConjugation(infinitive, conjugationMap, pronouns, aux)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback Strategy (Portuguese, Generic, etc.).
|
||||
* Handles flat list of inflections.
|
||||
*/
|
||||
class GenericMorphologyStrategy : MorphologyStrategy {
|
||||
override fun parse(
|
||||
lemma: String,
|
||||
pos: String?,
|
||||
rootData: JsonObject,
|
||||
config: LanguageConfig?
|
||||
): WordMorphology? {
|
||||
val inflectionsElement = rootData[KEY_INFLECTIONS] as? JsonArray
|
||||
|
||||
if (!inflectionsElement.isNullOrEmpty()) {
|
||||
val inflections = inflectionsElement.mapNotNull { element ->
|
||||
val obj = element.jsonObject
|
||||
val form = obj["form"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
|
||||
|
||||
val features = (obj["grammatical_features"] as? JsonArray)
|
||||
?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
|
||||
// FIX: Use 'tags' instead of 'grammatical_features'
|
||||
// FIX: Handle nullability with '?: emptyList()'
|
||||
Inflection(form = form, tags = features ?: emptyList())
|
||||
}
|
||||
|
||||
// Note: Ensure GenericInflections accepts List<Inflection> if you haven't updated it yet
|
||||
if (inflections.isNotEmpty()) {
|
||||
return GenericInflections(inflections)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package eu.gaudian.translator.model.grammar
|
||||
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.ACCUSATIVE
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.DATIVE
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.GENITIVE
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_FORM
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.KEY_TAGS
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.NOMINATIVE
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.PLURAL
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.SINGULAR
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
object SharedMorphologyUtils {
|
||||
|
||||
/**
|
||||
* Parses a standard noun declension table from a list of forms.
|
||||
* Used by both German and French strategies.
|
||||
*/
|
||||
fun parseNounDeclension(
|
||||
forms: JsonArray,
|
||||
categoryConfig: CategoryConfig?
|
||||
): NounDeclensionTable {
|
||||
val nestedMap = mutableMapOf<String, MutableMap<String, String>>()
|
||||
|
||||
forms.forEach { item ->
|
||||
val obj = item.jsonObject
|
||||
val form = obj[KEY_FORM]?.jsonPrimitive?.contentOrNull ?: return@forEach
|
||||
val tags = obj[KEY_TAGS]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
|
||||
|
||||
val case = tags.firstOrNull { it in listOf(NOMINATIVE, GENITIVE, DATIVE, ACCUSATIVE) }
|
||||
val number = tags.firstOrNull { it in listOf(SINGULAR, PLURAL) }
|
||||
|
||||
if (case != null && number != null) {
|
||||
nestedMap.getOrPut(case) { mutableMapOf() }[number] = form
|
||||
}
|
||||
}
|
||||
|
||||
val displayConfig = categoryConfig?.declension_display
|
||||
|
||||
val cases = displayConfig?.cases_order ?: listOf(NOMINATIVE, GENITIVE, DATIVE, ACCUSATIVE)
|
||||
val numbers = displayConfig?.numbers_order ?: listOf(SINGULAR, PLURAL)
|
||||
|
||||
val flatMap = mutableMapOf<Pair<String, String>, String>()
|
||||
cases.forEach { caseKey ->
|
||||
numbers.forEach { numberKey ->
|
||||
val form = nestedMap[caseKey]?.get(numberKey)
|
||||
if (form != null) {
|
||||
flatMap[caseKey to numberKey] = form
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NounDeclensionTable(
|
||||
cases = cases,
|
||||
numbers = numbers,
|
||||
forms = flatMap
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a JSON array contains noun-like structures (Case + Number).
|
||||
*/
|
||||
fun isNounLikeStructure(forms: JsonArray, normalizedPos: String?): Boolean {
|
||||
if (forms.isEmpty()) return false
|
||||
|
||||
val hasCaseAndNumber = forms.any { formElement ->
|
||||
val formObj = formElement.jsonObject
|
||||
val tags = formObj[KEY_TAGS]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList()
|
||||
val hasCase = tags.any { it in listOf(NOMINATIVE, GENITIVE, DATIVE, ACCUSATIVE) }
|
||||
val hasNumber = tags.any { it in listOf(SINGULAR, PLURAL) }
|
||||
hasCase && hasNumber
|
||||
}
|
||||
|
||||
return hasCaseAndNumber && (normalizedPos == null || normalizedPos.startsWith(GrammarConstants.POS_NOUN))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package eu.gaudian.translator.model.grammar
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class UnifiedMorphology {
|
||||
|
||||
/**
|
||||
* 2D Grid for Nouns (Case x Number) and Adjectives (Gender x Number).
|
||||
*/
|
||||
data class Grid(
|
||||
val title: String,
|
||||
val rowLabels: List<String>,
|
||||
val colLabels: List<String>,
|
||||
val cells: Map<String, String>
|
||||
) : UnifiedMorphology()
|
||||
|
||||
/**
|
||||
* Verb Paradigm (Flattened - no intermediate 'paradigm' object).
|
||||
*/
|
||||
data class Verb(
|
||||
val infinitive: String,
|
||||
val auxiliary: String?,
|
||||
val tenses: Map<String, List<String>>,
|
||||
val pronouns: List<String>
|
||||
) : UnifiedMorphology()
|
||||
|
||||
/**
|
||||
* Generic List. Now holds 'Inflection' objects to keep tags visible.
|
||||
*/
|
||||
data class ListForms(
|
||||
val forms: List<Inflection>
|
||||
) : UnifiedMorphology()
|
||||
}
|
||||
|
||||
/**
|
||||
* Moved here to be shared. Represents a single form with its tags.
|
||||
*/
|
||||
@Serializable
|
||||
data class Inflection(
|
||||
val form: String,
|
||||
val tags: List<String>
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.grammar
|
||||
|
||||
import android.content.Context
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.POS_ADJ
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.POS_NOUN
|
||||
import eu.gaudian.translator.model.grammar.GrammarConstants.POS_VERB
|
||||
import eu.gaudian.translator.utils.Log
|
||||
|
||||
object UnifiedMorphologyParser {
|
||||
|
||||
private const val TAG = "UnifiedMorphologyParser"
|
||||
|
||||
fun parse(
|
||||
entry: DictionaryEntryData,
|
||||
lemma: String,
|
||||
pos: String?,
|
||||
langCode: String,
|
||||
config: LanguageConfig,
|
||||
context: Context
|
||||
): UnifiedMorphology? {
|
||||
// 1. Log Input
|
||||
Log.d(TAG, "Request: lemma='$lemma', lang='$langCode', pos='$pos'")
|
||||
|
||||
if (entry.forms.isEmpty()) {
|
||||
Log.d(TAG, "Aborting: No forms data available for '$lemma'")
|
||||
return null
|
||||
}
|
||||
|
||||
// FIX 1: Normalize POS tags (map "adj" -> "adjective", etc.)
|
||||
val normalizedPos = normalizePos(pos)
|
||||
|
||||
if (normalizedPos == null) {
|
||||
Log.w(TAG, "Aborting: POS could not be normalized for '$lemma' (raw: $pos)")
|
||||
return null
|
||||
}
|
||||
|
||||
// 2. Determine Rule Source
|
||||
// First, check JSON Config
|
||||
var rule = buildRuleFromConfig(normalizedPos, config, context)
|
||||
val ruleSource = if (rule != null) "JSON Config" else "Registry Fallback"
|
||||
|
||||
// If not in JSON, check Registry
|
||||
if (rule == null) {
|
||||
rule = MorphologyRegistry.getRule(langCode, normalizedPos)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Strategy: Using $ruleSource for $langCode|$normalizedPos")
|
||||
|
||||
// 3. Execute Extraction
|
||||
val result = try {
|
||||
when (rule) {
|
||||
is MorphologyRule.GridRule -> {
|
||||
Log.d(TAG, "Extracting Grid: ${rule.title}")
|
||||
parseGrid(entry.forms, rule, lemma)
|
||||
}
|
||||
is MorphologyRule.VerbRule -> {
|
||||
Log.d(TAG, "Extracting Verb Paradigm")
|
||||
parseVerb(entry.forms, rule, lemma)
|
||||
}
|
||||
is MorphologyRule.GenericRule -> {
|
||||
Log.d(TAG, "Extracting Generic List")
|
||||
parseGeneric(entry.forms)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error extracting morphology for '$lemma': ${e.message}")
|
||||
return null
|
||||
}
|
||||
|
||||
// 4. Log Outcome
|
||||
if (result != null) {
|
||||
Log.d(TAG, "Success: Generated ${result::class.java.simpleName} for '$lemma'")
|
||||
} else {
|
||||
Log.w(TAG, "Result was null for '$lemma'")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps short/raw POS tags to the standard GrammarConstants keys.
|
||||
*/
|
||||
private fun normalizePos(pos: String?): String? {
|
||||
val raw = pos?.lowercase()?.trim() ?: return null
|
||||
return when (raw) {
|
||||
"adj", "adjective", "adjectif" -> POS_ADJ // "adjective"
|
||||
"noun", "substantive", "nom" -> POS_NOUN // "noun"
|
||||
"verb", "verbe" -> POS_VERB // "verb"
|
||||
else -> raw // Return as-is if no map found (e.g. "adverb")
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRuleFromConfig(pos: String, config: LanguageConfig, context: Context): MorphologyRule? {
|
||||
val category = config.categories[pos] ?: return null
|
||||
return when (pos) {
|
||||
// MERGED: Nouns AND Adjectives both use Grid Logic
|
||||
POS_NOUN, POS_ADJ -> {
|
||||
val display = category.declension_display ?: return null
|
||||
if (display.cases_order != null && display.numbers_order != null) {
|
||||
|
||||
// FIX 2: Define a fallback strategy for Adjectives.
|
||||
// If a cell is missing (e.g. Masc/Sing), use the lemma.
|
||||
val fallback: ((String, String, String) -> String?)? = if (pos == POS_ADJ) {
|
||||
{ row, col, lemma ->
|
||||
// If looking for Masculine Singular, assume it's the Lemma
|
||||
if (row.startsWith("masc") && col.startsWith("sing")) lemma else null
|
||||
}
|
||||
} else null
|
||||
|
||||
MorphologyRule.GridRule(
|
||||
title = if (pos == POS_ADJ) context.getString(R.string.label_variations) else context.getString(R.string.label_declension),
|
||||
rowTags = display.cases_order,
|
||||
colTags = display.numbers_order,
|
||||
fallbackStrategy = fallback
|
||||
)
|
||||
} else null
|
||||
}
|
||||
POS_VERB -> {
|
||||
val display = category.conjugation_display ?: return null
|
||||
if (display.tense_labels != null && display.pronouns != null) {
|
||||
MorphologyRule.VerbRule(
|
||||
tenses = display.tense_labels,
|
||||
pronouns = display.pronouns
|
||||
)
|
||||
} else null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// --- EXECUTION LOGIC ---
|
||||
|
||||
private fun parseGrid(
|
||||
forms: List<FormData>,
|
||||
rule: MorphologyRule.GridRule,
|
||||
lemma: String
|
||||
): UnifiedMorphology.Grid {
|
||||
val cells = mutableMapOf<String, String>()
|
||||
|
||||
// Optimization: Lookup map
|
||||
val formLookup = forms.associate { formData ->
|
||||
formData.tags.map { it.lowercase() }.toSet() to formData.form
|
||||
}
|
||||
|
||||
rule.rowTags.forEach { rowTag ->
|
||||
rule.colTags.forEach { colTag ->
|
||||
val requiredTags = listOf(rowTag, colTag)
|
||||
.filter { it.isNotEmpty() }
|
||||
.map { it.lowercase() }
|
||||
|
||||
val matchEntry = formLookup.entries.find { (tags, _) ->
|
||||
tags.containsAll(requiredTags)
|
||||
}
|
||||
|
||||
// Try to get value from match, OR use fallback (Lemma)
|
||||
val cellValue = matchEntry?.value
|
||||
?: rule.fallbackStrategy?.invoke(rowTag, colTag, lemma)
|
||||
|
||||
if (cellValue != null) {
|
||||
val key = if (colTag.isEmpty()) rowTag else "$rowTag|$colTag"
|
||||
cells[key] = cellValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return UnifiedMorphology.Grid(
|
||||
title = rule.title,
|
||||
rowLabels = rule.rowTags,
|
||||
colLabels = rule.colTags.filter { it.isNotEmpty() },
|
||||
cells = cells
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseVerb(
|
||||
forms: List<FormData>,
|
||||
rule: MorphologyRule.VerbRule,
|
||||
lemma: String
|
||||
): UnifiedMorphology.Verb {
|
||||
val tenseResults = mutableMapOf<String, List<String>>()
|
||||
|
||||
rule.tenses.forEach { (tenseKey, displayLabel) ->
|
||||
val relevantForms = forms.filter { form ->
|
||||
form.tags.any { tag -> tag.equals(tenseKey, ignoreCase = true) }
|
||||
}
|
||||
|
||||
if (relevantForms.isNotEmpty()) {
|
||||
val truncated = relevantForms.take(rule.pronouns.size).map { it.form }
|
||||
tenseResults[displayLabel] = truncated
|
||||
}
|
||||
}
|
||||
|
||||
val aux = forms.find { it.tags.contains("auxiliary") }?.form
|
||||
|
||||
return UnifiedMorphology.Verb(
|
||||
infinitive = lemma,
|
||||
auxiliary = aux,
|
||||
tenses = tenseResults,
|
||||
pronouns = rule.pronouns
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseGeneric(forms: List<FormData>): UnifiedMorphology.ListForms {
|
||||
return UnifiedMorphology.ListForms(
|
||||
forms.map { Inflection(it.form, it.tags) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.content.Context
|
||||
import eu.gaudian.translator.model.communication.ApiLogEntry
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ApiLogRepository(context: Context) {
|
||||
private val dataStore = context.dataStore
|
||||
|
||||
fun getLogs(): Flow<List<ApiLogEntry>> = dataStore.loadObjectList(ApiStoreKeys.API_LOGS_KEY)
|
||||
|
||||
suspend fun addLog(entry: ApiLogEntry, maxKeep: Int = 200) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val current = getLogs().first()
|
||||
val updated = (current + entry).takeLast(maxKeep)
|
||||
dataStore.saveObjectList(ApiStoreKeys.API_LOGS_KEY, updated)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ApiLogRepository", "Failed to add log", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clear() {
|
||||
withContext(Dispatchers.IO) {
|
||||
dataStore.clear(ApiStoreKeys.API_LOGS_KEY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Separate object to avoid cluttering the global DataStoreKeys if needed
|
||||
object ApiStoreKeys {
|
||||
val API_LOGS_KEY = DataStoreKeys.API_LOGS_KEY
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import eu.gaudian.translator.model.LanguageModel
|
||||
import eu.gaudian.translator.model.communication.ApiProvider
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class ApiRepository(private val context: Context) {
|
||||
|
||||
private val settingsRepository = SettingsRepository(context)
|
||||
private val dataStore: DataStore<Preferences> = context.dataStore
|
||||
|
||||
@Suppress("PrivatePropertyName")
|
||||
private val TAG = "ApiRepository"
|
||||
|
||||
/**
|
||||
* Checks and sets fallback models.
|
||||
* Enforces consistency: If no key is present, default models are removed.
|
||||
*/
|
||||
suspend fun initialInit() {
|
||||
Log.i(TAG, "Starting initial model check...")
|
||||
|
||||
val jsonProviders = ApiProvider.loadProviders(context.applicationContext)
|
||||
val jsonProvidersByKey = jsonProviders.associateBy { it.key }
|
||||
|
||||
// Load currently saved providers
|
||||
var storedProviders = dataStore.loadObjectList<ApiProvider>(DataStoreKeys.PROVIDERS_KEY).first()
|
||||
|
||||
// 1. First Run Handling
|
||||
if (storedProviders.isEmpty()) {
|
||||
Log.i(TAG, "DataStore empty. Initializing with JSON defaults.")
|
||||
val initial = jsonProviders.map {
|
||||
if (!it.isCustom) it.copy(models = emptyList()) else it
|
||||
}
|
||||
saveProviders(initial)
|
||||
storedProviders = initial
|
||||
}
|
||||
|
||||
val apiKeys = getAllApiKeys().first()
|
||||
var needsSync = false
|
||||
|
||||
// --- NEW STEP: CLEANUP ---
|
||||
// Filter out providers that are NOT custom AND are NO LONGER in the JSON
|
||||
val validStoredProviders = storedProviders.filter { stored ->
|
||||
val stillExistsInJson = jsonProvidersByKey.containsKey(stored.key)
|
||||
val isCustom = stored.isCustom
|
||||
|
||||
// Keep it if it's custom OR if it still exists in the JSON source
|
||||
if (isCustom || stillExistsInJson) {
|
||||
true
|
||||
} else {
|
||||
Log.i(TAG, "Removing obsolete provider: ${stored.displayName} (Key: ${stored.key})")
|
||||
needsSync = true
|
||||
false // Drop this provider
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync Existing Providers (Run logic on the CLEANED list)
|
||||
val syncedStoredProviders = validStoredProviders.map { stored ->
|
||||
if (!stored.isCustom) {
|
||||
val latestDefault = jsonProvidersByKey[stored.key]
|
||||
if (latestDefault != null) {
|
||||
val hasKey = apiKeys[stored.key]?.isNotBlank() ?: false
|
||||
val customModels = stored.models.filter { it.isCustom }
|
||||
|
||||
val targetModels = if (hasKey) {
|
||||
val newDefaultModels = latestDefault.models.filter { !it.isCustom }
|
||||
(customModels + newDefaultModels).distinctBy { it.modelId }
|
||||
} else {
|
||||
customModels
|
||||
}
|
||||
|
||||
val needsUpdate = stored.displayName != latestDefault.displayName ||
|
||||
stored.baseUrl != latestDefault.baseUrl ||
|
||||
stored.endpoint != latestDefault.endpoint ||
|
||||
stored.websiteUrl != latestDefault.websiteUrl ||
|
||||
stored.models.size != targetModels.size ||
|
||||
stored.models.map { it.modelId }.toSet() != targetModels.map { it.modelId }.toSet()
|
||||
|
||||
if (needsUpdate) {
|
||||
needsSync = true
|
||||
stored.copy(
|
||||
displayName = latestDefault.displayName,
|
||||
baseUrl = latestDefault.baseUrl,
|
||||
endpoint = latestDefault.endpoint,
|
||||
websiteUrl = latestDefault.websiteUrl,
|
||||
models = targetModels,
|
||||
isCustom = false
|
||||
)
|
||||
} else {
|
||||
stored
|
||||
}
|
||||
} else {
|
||||
stored
|
||||
}
|
||||
} else {
|
||||
stored
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Detect & Add NEW Providers
|
||||
val existingKeys = syncedStoredProviders.map { it.key }.toSet()
|
||||
val newProvidersFromJson = jsonProviders.filter { it.key !in existingKeys }
|
||||
|
||||
val newProvidersInitialized = newProvidersFromJson.map {
|
||||
if (!it.isCustom) it.copy(models = emptyList()) else it
|
||||
}
|
||||
|
||||
if (newProvidersInitialized.isNotEmpty()) {
|
||||
Log.i(TAG, "Found ${newProvidersInitialized.size} new providers in JSON. Adding them.")
|
||||
needsSync = true
|
||||
}
|
||||
|
||||
// 4. Save and Apply
|
||||
val finalProviderList = syncedStoredProviders + newProvidersInitialized
|
||||
|
||||
if (needsSync) {
|
||||
Log.i(TAG, "Syncing providers...")
|
||||
saveProviders(finalProviderList)
|
||||
storedProviders = finalProviderList
|
||||
}
|
||||
|
||||
// 5. Fallback Selection Logic
|
||||
val currentTrans = getTranslationModel().first()
|
||||
val currentExer = getExerciseModel().first()
|
||||
val currentVocab = getVocabularyModel().first()
|
||||
val currentDict = getDictionaryModel().first()
|
||||
|
||||
val validProviders = storedProviders.filter { provider ->
|
||||
val hasKey = apiKeys[provider.key]?.isNotBlank() ?: false
|
||||
val isLocalHost = provider.baseUrl.contains("localhost") || provider.baseUrl.contains("127.0.0.1")
|
||||
hasKey || isLocalHost || provider.isCustom
|
||||
}
|
||||
val availableModels = validProviders.flatMap { it.models }
|
||||
|
||||
var configurationValid = true
|
||||
|
||||
// (Helper function to reduce repetition)
|
||||
fun checkAndFallback(current: LanguageModel?, setter: suspend (LanguageModel) -> Unit) {
|
||||
val isValid = current != null && availableModels.any { it.modelId == current.modelId && it.providerKey == current.providerKey }
|
||||
if (!isValid) {
|
||||
val fallback = findFallbackModel(availableModels)
|
||||
if (fallback != null) {
|
||||
// We must use a blocking call or scope here because we can't easily pass a suspend function to a lambda
|
||||
// But since we are inside a suspend function, we can just call the setter directly if we unroll the loop.
|
||||
// For simplicity, I'll keep the unrolled logic below.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback checks
|
||||
if (currentTrans == null || !availableModels.any { it.modelId == currentTrans.modelId && it.providerKey == currentTrans.providerKey }) {
|
||||
findFallbackModel(availableModels)?.let { setTranslationModel(it) } ?: run { configurationValid = false }
|
||||
}
|
||||
|
||||
if (currentExer == null || !availableModels.any { it.modelId == currentExer.modelId && it.providerKey == currentExer.providerKey }) {
|
||||
findFallbackModel(availableModels)?.let { setExerciseModel(it) } ?: run { configurationValid = false }
|
||||
}
|
||||
|
||||
if (currentVocab == null || !availableModels.any { it.modelId == currentVocab.modelId && it.providerKey == currentVocab.providerKey }) {
|
||||
findFallbackModel(availableModels)?.let { setVocabularyModel(it) } ?: run { configurationValid = false }
|
||||
}
|
||||
|
||||
if (currentDict == null || !availableModels.any { it.modelId == currentDict.modelId && it.providerKey == currentDict.providerKey }) {
|
||||
findFallbackModel(availableModels)?.let { setDictionaryModel(it) } ?: run { configurationValid = false }
|
||||
}
|
||||
|
||||
settingsRepository.connectionConfigured.set(configurationValid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually adds default models for a provider (Triggered on Key Activation).
|
||||
*/
|
||||
suspend fun addDefaultModels(providerKey: String) {
|
||||
val currentProviders = dataStore.loadObjectList<ApiProvider>(DataStoreKeys.PROVIDERS_KEY).first().toMutableList()
|
||||
val index = currentProviders.indexOfFirst { it.key == providerKey }
|
||||
if (index == -1) return
|
||||
|
||||
val stored = currentProviders[index]
|
||||
if (stored.isCustom) return // Don't touch custom providers
|
||||
|
||||
val jsonProviders = ApiProvider.loadProviders(context.applicationContext)
|
||||
val default = jsonProviders.find { it.key == providerKey } ?: return
|
||||
|
||||
// Merge default models into existing (preserving customs)
|
||||
val customModels = stored.models.filter { it.isCustom }
|
||||
val defaultModels = default.models.filter { !it.isCustom }
|
||||
val merged = (customModels + defaultModels).distinctBy { it.modelId }
|
||||
|
||||
currentProviders[index] = stored.copy(models = merged)
|
||||
saveProviders(currentProviders)
|
||||
Log.i(TAG, "Added default models for $providerKey. Count: ${defaultModels.size}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually removes default models for a provider (Triggered on Key Deactivation).
|
||||
*/
|
||||
suspend fun removeDefaultModels(providerKey: String) {
|
||||
val currentProviders = dataStore.loadObjectList<ApiProvider>(DataStoreKeys.PROVIDERS_KEY).first().toMutableList()
|
||||
val index = currentProviders.indexOfFirst { it.key == providerKey }
|
||||
if (index == -1) return
|
||||
|
||||
val stored = currentProviders[index]
|
||||
if (stored.isCustom) return
|
||||
|
||||
// Remove all non-custom models
|
||||
val customOnly = stored.models.filter { it.isCustom }
|
||||
|
||||
currentProviders[index] = stored.copy(models = customOnly)
|
||||
saveProviders(currentProviders)
|
||||
Log.i(TAG, "Removed default models for $providerKey.")
|
||||
}
|
||||
|
||||
fun getProviders(): Flow<List<ApiProvider>> {
|
||||
val providersFlow = dataStore.loadObjectList<ApiProvider>(DataStoreKeys.PROVIDERS_KEY).map { list ->
|
||||
list.ifEmpty {
|
||||
ApiProvider.loadProviders(context.applicationContext).map {
|
||||
// Initial state: No key = No models
|
||||
if (!it.isCustom) it.copy(models = emptyList()) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
val apiKeysFlow = getAllApiKeys()
|
||||
|
||||
return combine(providersFlow, apiKeysFlow) { providers, apiKeys ->
|
||||
providers.map { provider ->
|
||||
val hasKey = apiKeys[provider.key]?.isNotBlank() ?: false
|
||||
val base = provider.baseUrl.trim().lowercase()
|
||||
val isLocalHost = (base.contains("localhost") || base.contains("127.0.0.1") || base.startsWith("10."))
|
||||
|
||||
provider.copy().apply {
|
||||
hasValidKey = hasKey || isLocalHost || isCustom
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllApiKeys(): Flow<Map<String, String>> {
|
||||
val apiKeySuffix = "_api_key"
|
||||
return dataStore.data.map { preferences ->
|
||||
preferences.asMap().mapNotNull { (key, value) ->
|
||||
if (key.name.endsWith(apiKeySuffix) && value is String) {
|
||||
val providerKey = key.name.removeSuffix(apiKeySuffix)
|
||||
providerKey to value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFallbackModel(allModels: List<LanguageModel>): LanguageModel? {
|
||||
val preferredProviderOrder = listOf("mistral", "openai", "gemini", "deepseek", "openrouter")
|
||||
for (providerKey in preferredProviderOrder) {
|
||||
val model = allModels.firstOrNull { it.providerKey == providerKey }
|
||||
if (model != null) return model
|
||||
}
|
||||
return allModels.firstOrNull()
|
||||
}
|
||||
|
||||
suspend fun saveProviders(providers: List<ApiProvider>) {
|
||||
try {
|
||||
dataStore.saveObjectList(DataStoreKeys.PROVIDERS_KEY, providers)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error saving providers: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addProvider(provider: ApiProvider) {
|
||||
val currentProviders = dataStore.loadObjectList<ApiProvider>(DataStoreKeys.PROVIDERS_KEY).first().toMutableList()
|
||||
if (currentProviders.none { it.key == provider.key }) {
|
||||
currentProviders.add(provider)
|
||||
saveProviders(currentProviders)
|
||||
initialInit()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateProvider(updatedProvider: ApiProvider) {
|
||||
val currentProviders = dataStore.loadObjectList<ApiProvider>(DataStoreKeys.PROVIDERS_KEY).first().toMutableList()
|
||||
val index = currentProviders.indexOfFirst { it.key == updatedProvider.key }
|
||||
if (index != -1) {
|
||||
currentProviders[index] = updatedProvider
|
||||
saveProviders(currentProviders)
|
||||
initialInit()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteProvider(providerKey: String) {
|
||||
val currentProviders = dataStore.loadObjectList<ApiProvider>(DataStoreKeys.PROVIDERS_KEY).first().toMutableList()
|
||||
val removed = currentProviders.removeAll { it.key == providerKey && it.isCustom }
|
||||
if (removed) {
|
||||
saveProviders(currentProviders)
|
||||
initialInit()
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Model Selection Setters --- */
|
||||
|
||||
suspend fun setTranslationModel(model: LanguageModel) {
|
||||
dataStore.saveObject(DataStoreKeys.SELECTED_TRANSLATION_MODEL_KEY, model)
|
||||
settingsRepository.connectionConfigured.set(true)
|
||||
}
|
||||
fun getTranslationModel(): Flow<LanguageModel?> = dataStore.loadObject(DataStoreKeys.SELECTED_TRANSLATION_MODEL_KEY)
|
||||
|
||||
suspend fun setExerciseModel(model: LanguageModel) {
|
||||
dataStore.saveObject(DataStoreKeys.SELECTED_EXERCISE_MODEL_KEY, model)
|
||||
settingsRepository.connectionConfigured.set(true)
|
||||
}
|
||||
fun getExerciseModel(): Flow<LanguageModel?> = dataStore.loadObject(DataStoreKeys.SELECTED_EXERCISE_MODEL_KEY)
|
||||
|
||||
suspend fun setVocabularyModel(model: LanguageModel) {
|
||||
dataStore.saveObject(DataStoreKeys.SELECTED_VOCABULARY_MODEL_KEY, model)
|
||||
settingsRepository.connectionConfigured.set(true)
|
||||
}
|
||||
fun getVocabularyModel(): Flow<LanguageModel?> = dataStore.loadObject(DataStoreKeys.SELECTED_VOCABULARY_MODEL_KEY)
|
||||
|
||||
suspend fun setDictionaryModel(model: LanguageModel) {
|
||||
dataStore.saveObject(DataStoreKeys.SELECTED_DICTIONARY_MODEL_KEY, model)
|
||||
settingsRepository.connectionConfigured.set(true)
|
||||
}
|
||||
fun getDictionaryModel(): Flow<LanguageModel?> = dataStore.loadObject(DataStoreKeys.SELECTED_DICTIONARY_MODEL_KEY)
|
||||
|
||||
suspend fun wipeAll() {
|
||||
Log.w(TAG, "Executing wipeAll()")
|
||||
settingsRepository.connectionConfigured.set(false)
|
||||
try {
|
||||
dataStore.edit { prefs ->
|
||||
prefs.remove(DataStoreKeys.PROVIDERS_KEY)
|
||||
prefs.remove(DataStoreKeys.SELECTED_TRANSLATION_MODEL_KEY)
|
||||
prefs.remove(DataStoreKeys.SELECTED_EXERCISE_MODEL_KEY)
|
||||
prefs.remove(DataStoreKeys.SELECTED_VOCABULARY_MODEL_KEY)
|
||||
prefs.remove(DataStoreKeys.SELECTED_DICTIONARY_MODEL_KEY)
|
||||
|
||||
val apiKeySuffix = "_api_key"
|
||||
val keysToRemove = prefs.asMap().keys.filter { it.name.endsWith(apiKeySuffix) }
|
||||
for (k in keysToRemove) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val anyKey = k as Preferences.Key<Any>
|
||||
prefs.remove(anyKey)
|
||||
}
|
||||
}
|
||||
// initialInit will be called, see empty DataStore, and reload default providers (with EMPTY models)
|
||||
initialInit()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during wipeAll: ${e.message}", e)
|
||||
}
|
||||
}}
|
||||
@@ -0,0 +1,162 @@
|
||||
@file:Suppress("HardCodedStringLiteral", "unused")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
object DataStoreKeys {
|
||||
val PROVIDERS_KEY = stringPreferencesKey("providers")
|
||||
|
||||
val EXERCISES_KEY = stringPreferencesKey("exercises")
|
||||
|
||||
|
||||
|
||||
val QUESTIONS_KEY = stringPreferencesKey("questions")
|
||||
|
||||
// Language initialization metadata key
|
||||
val LANGUAGE_INIT_METADATA_KEY = stringPreferencesKey("language_init_metadata")
|
||||
|
||||
// Language-related keys
|
||||
val SELECTED_SOURCE_LANGUAGE_KEY = stringPreferencesKey("selected_source_language")
|
||||
val SELECTED_TARGET_LANGUAGE_KEY = stringPreferencesKey("selected_target_language")
|
||||
|
||||
val DEFAULT_LANGUAGES_KEY = stringPreferencesKey("default_languages")
|
||||
val CUSTOM_LANGUAGES_KEY = stringPreferencesKey("custom_language")
|
||||
val ALL_LANGUAGES_KEY = stringPreferencesKey("all_languages")
|
||||
val LANGUAGE_HISTORY_KEY = stringPreferencesKey("language_history")
|
||||
val FAVORITE_LANGUAGES_KEY = stringPreferencesKey("favorite_languages")
|
||||
val SELECTED_DICTIONARY_LANGUAGE_KEY = stringPreferencesKey("selected_dictionary_language")
|
||||
|
||||
val SELECTED_TRANSLATION_MODEL_KEY = stringPreferencesKey("selected_translation_model")
|
||||
val SELECTED_EXERCISE_MODEL_KEY = stringPreferencesKey("selected_exercise_model")
|
||||
val SELECTED_VOCABULARY_MODEL_KEY = stringPreferencesKey("selected_vocabulary_model")
|
||||
val SELECTED_DICTIONARY_MODEL_KEY = stringPreferencesKey("selected_dictionary_model")
|
||||
|
||||
val TRANSLATION_HISTORY_KEY = stringPreferencesKey("translation_history")
|
||||
val API_LOGS_KEY = stringPreferencesKey("api_logs")
|
||||
}
|
||||
|
||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "app_data")
|
||||
|
||||
suspend inline fun <reified T> DataStore<Preferences>.saveObject(key: Preferences.Key<String>, obj: T) {
|
||||
edit { preferences ->
|
||||
val jsonString = Json.encodeToString(obj)
|
||||
preferences[key] = jsonString
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DataStore<Preferences>.saveStringSet(key: Preferences.Key<Set<String>>, set: Set<String>) {
|
||||
edit { preferences ->
|
||||
preferences[key] = set
|
||||
}
|
||||
}
|
||||
|
||||
fun DataStore<Preferences>.loadStringSet(key: Preferences.Key<Set<String>>): Flow<Set<String>> {
|
||||
return data.map { preferences ->
|
||||
preferences[key] ?: emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T> DataStore<Preferences>.loadObject(key: Preferences.Key<String>): Flow<T?> {
|
||||
return data.map { preferences ->
|
||||
val jsonString = preferences[key]
|
||||
if (jsonString != null) {
|
||||
Json.decodeFromString<T>(jsonString)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.catch { exception ->
|
||||
if (exception is SerializationException) {
|
||||
Log.w("DataStore: Failed to decode object for key '$key', clearing it.", exception)
|
||||
this@loadObject.edit { it.remove(key) }
|
||||
emit(null)
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SuspiciousIndentation")
|
||||
suspend inline fun <reified T> DataStore<Preferences>.saveObjectList(key: Preferences.Key<String>, list: List<T>) {
|
||||
try {
|
||||
edit { preferences ->
|
||||
try {
|
||||
val jsonString = Json.encodeToString(list)
|
||||
jsonString.also { preferences[key] = it }
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataStore", "Failed to encode list to JSON", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("DataStore", "Failed to save list to DataStore", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
inline fun <reified T> DataStore<Preferences>.loadObjectList(key: Preferences.Key<String>): Flow<List<T>> {
|
||||
return data.map { preferences ->
|
||||
val jsonString = preferences[key]
|
||||
if (jsonString != null) {
|
||||
Json.decodeFromString<List<T>>(jsonString)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}.catch { exception ->
|
||||
if (exception is SerializationException) {
|
||||
Log.w("DataStore", "Failed to decode list for key '$key', clearing it.", exception)
|
||||
this@loadObjectList.edit { it.remove(key) } // Clears the bad data
|
||||
emit(emptyList()) // Provides a default value to continue
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun DataStore<Preferences>.clear(key: Preferences.Key<String>) {
|
||||
edit { preferences ->
|
||||
preferences.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
data class LanguageInitializationMetadata(
|
||||
val appVersion: String?,
|
||||
val systemLocale: String,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
object InstantSerializer : KSerializer<Instant> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Instant) {
|
||||
encoder.encodeString(value.toString())
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): Instant {
|
||||
return Instant.parse(decoder.decodeString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
@file:Suppress("HardCodedStringLiteral", "unused")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import eu.gaudian.translator.model.communication.FileInfo
|
||||
import eu.gaudian.translator.utils.Log
|
||||
|
||||
/**
|
||||
* Repository for generic access and reading of downloaded dictionary .db files.
|
||||
* All operations are read-only. The .db files are assumed to be SQLite databases.
|
||||
*/
|
||||
class DictionaryDatabaseRepository(private val context: Context) {
|
||||
|
||||
@Suppress("PrivatePropertyName")
|
||||
private val TAG = "DictionaryDatabaseRepository"
|
||||
|
||||
/**
|
||||
* Opens a read-only SQLite database for the given file info.
|
||||
* Returns null if the file does not exist.
|
||||
*/
|
||||
internal fun getDatabase(fileInfo: FileInfo): SQLiteDatabase? {
|
||||
val dbAsset = fileInfo.assets.firstOrNull { it.filename.endsWith(".db") }
|
||||
if (dbAsset == null) {
|
||||
Log.e(TAG, "No database asset found for ${fileInfo.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
val file = java.io.File(context.filesDir, dbAsset.filename)
|
||||
return if (file.exists()) {
|
||||
try {
|
||||
// Use URI mode with immutable=1 to reduce locking noise
|
||||
val path = "file:${file.absolutePath}?mode=ro&immutable=1"
|
||||
SQLiteDatabase.openDatabase(
|
||||
path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error opening database for ${dbAsset.filename}", e)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Database file does not exist: ${dbAsset.filename}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of table names in the database.
|
||||
*/
|
||||
fun getTables(fileInfo: FileInfo): List<String> {
|
||||
val db = getDatabase(fileInfo) ?: return emptyList()
|
||||
val tables = mutableListOf<String>()
|
||||
db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
tables.add(cursor.getString(0))
|
||||
}
|
||||
}
|
||||
db.close()
|
||||
return tables
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of column names for a specific table.
|
||||
*/
|
||||
fun getColumns(fileInfo: FileInfo, table: String): List<String> {
|
||||
val db = getDatabase(fileInfo) ?: return emptyList()
|
||||
val columns = mutableListOf<String>()
|
||||
db.rawQuery("PRAGMA table_info($table)", null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
columns.add(cursor.getString(1)) // name column index is 1
|
||||
}
|
||||
}
|
||||
db.close()
|
||||
return columns
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves up to [limit] rows from a table as a list of maps (column name to value).
|
||||
* Defaults to first 1000 rows if limit is not specified.
|
||||
*/
|
||||
fun getTableData(fileInfo: FileInfo, table: String, limit: Int = 1000): List<Map<String, Any>> {
|
||||
val db = getDatabase(fileInfo) ?: return emptyList()
|
||||
val data = mutableListOf<Map<String, Any>>()
|
||||
db.rawQuery("SELECT * FROM $table LIMIT $limit", null).use { cursor ->
|
||||
val columnCount = cursor.columnCount
|
||||
val columnNames = Array(columnCount) { i -> cursor.getColumnName(i) }
|
||||
while (cursor.moveToNext()) {
|
||||
val row = mutableMapOf<String, Any>()
|
||||
for (i in 0 until columnCount) {
|
||||
val name = columnNames[i]
|
||||
val value = when (cursor.getType(i)) {
|
||||
Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(i)
|
||||
Cursor.FIELD_TYPE_FLOAT -> cursor.getDouble(i)
|
||||
Cursor.FIELD_TYPE_STRING -> cursor.getString(i)
|
||||
Cursor.FIELD_TYPE_BLOB -> "BLOB (${cursor.getBlob(i)?.size ?: 0} bytes)"
|
||||
else -> null
|
||||
}
|
||||
value?.let { row[name] = it }
|
||||
}
|
||||
data.add(row)
|
||||
}
|
||||
}
|
||||
db.close()
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a table exists in the database.
|
||||
*/
|
||||
fun tableExists(fileInfo: FileInfo, table: String): Boolean {
|
||||
val db = getDatabase(fileInfo) ?: return false
|
||||
val exists = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table' AND name='$table'", null).use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
}
|
||||
db.close()
|
||||
return exists
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the row count for a specific table.
|
||||
*/
|
||||
fun getRowCount(fileInfo: FileInfo, table: String): Int {
|
||||
val db = getDatabase(fileInfo) ?: return 0
|
||||
var count = 0
|
||||
db.rawQuery("SELECT COUNT(*) FROM $table", null).use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
count = cursor.getInt(0)
|
||||
}
|
||||
}
|
||||
db.close()
|
||||
return count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.edit
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.model.communication.Asset
|
||||
import eu.gaudian.translator.model.communication.FileDownloadManager
|
||||
import eu.gaudian.translator.model.communication.FileInfo
|
||||
import eu.gaudian.translator.model.communication.ManifestResponse
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Repository for managing downloaded dictionary files.
|
||||
*/
|
||||
class DictionaryFileRepository(private val context: Context) {
|
||||
|
||||
private val fileDownloadManager = FileDownloadManager(context)
|
||||
private val _downloadedDictionaries = MutableStateFlow<List<FileInfo>>(emptyList())
|
||||
val downloadedDictionaries: Flow<List<FileInfo>> = _downloadedDictionaries.asStateFlow()
|
||||
|
||||
private val _orphanedFiles = MutableStateFlow<List<FileInfo>>(emptyList())
|
||||
val orphanedFiles: Flow<List<FileInfo>> = _orphanedFiles.asStateFlow()
|
||||
|
||||
private val _manifest = MutableStateFlow<ManifestResponse?>(null)
|
||||
val manifest: Flow<ManifestResponse?> = _manifest.asStateFlow()
|
||||
|
||||
init {
|
||||
loadDownloadedDictionaries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the manifest and updates the state.
|
||||
*/
|
||||
suspend fun fetchManifest() {
|
||||
try {
|
||||
val manifestResponse = fileDownloadManager.fetchManifest()
|
||||
if (manifestResponse != null) {
|
||||
_manifest.value = manifestResponse
|
||||
loadDownloadedDictionaries() // Refresh both downloaded and orphaned lists
|
||||
} else {
|
||||
throw Exception("Manifest response is null")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("DictionaryFileRepository", "Error fetching manifest", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a dictionary file.
|
||||
*/
|
||||
suspend fun downloadDictionary(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean {
|
||||
try {
|
||||
val success = fileDownloadManager.downloadFile(fileInfo, onProgress)
|
||||
if (success) {
|
||||
loadDownloadedDictionaries()
|
||||
}
|
||||
return success
|
||||
} catch (e: Exception) {
|
||||
Log.e("DictionaryFileRepository", "Error downloading dictionary", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a newer version is available for a dictionary.
|
||||
*/
|
||||
fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
|
||||
return fileDownloadManager.isNewerVersionAvailable(fileInfo)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the local version of a dictionary.
|
||||
*/
|
||||
fun getLocalVersion(fileId: String): String {
|
||||
return fileDownloadManager.getLocalVersion(fileId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all assets of a downloaded dictionary.
|
||||
*/
|
||||
@Suppress("SameReturnValue", "SameReturnValue")
|
||||
fun deleteDictionary(fileInfo: FileInfo): Boolean {
|
||||
try {
|
||||
var allDeleted = true
|
||||
val failedFiles = mutableListOf<String>()
|
||||
|
||||
for (asset in fileInfo.assets) {
|
||||
val localFile = File(context.filesDir, asset.filename)
|
||||
if (localFile.exists()) {
|
||||
val deleted = localFile.delete()
|
||||
if (!deleted) {
|
||||
allDeleted = false
|
||||
failedFiles.add(asset.filename)
|
||||
Log.e("DictionaryFileRepository", "Failed to delete asset: ${asset.filename}")
|
||||
} else {
|
||||
Log.d("DictionaryFileRepository", "Deleted asset: ${asset.filename}")
|
||||
}
|
||||
} else {
|
||||
Log.w("DictionaryFileRepository", "Asset file not found: ${asset.filename}")
|
||||
}
|
||||
}
|
||||
|
||||
if (allDeleted) {
|
||||
// Remove version from SharedPreferences
|
||||
val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
|
||||
sharedPreferences.edit { remove(fileInfo.id) }
|
||||
loadDownloadedDictionaries()
|
||||
Log.d("DictionaryFileRepository", "Deleted all assets for dictionary: ${fileInfo.name}")
|
||||
} else {
|
||||
throw Exception("Failed to delete some assets: ${failedFiles.joinToString(", ")}")
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e("DictionaryFileRepository", "Error deleting dictionary", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all downloaded dictionary files and their assets.
|
||||
*/
|
||||
@Suppress("SameReturnValue")
|
||||
fun deleteAllDictionaries(): Boolean {
|
||||
try {
|
||||
val filesDir = context.filesDir
|
||||
// Find all dictionary-related files (.db and .zstdict)
|
||||
val dictionaryFiles = filesDir.listFiles { file ->
|
||||
file.isFile && (
|
||||
file.name.endsWith(".db") ||
|
||||
file.name.endsWith(".zstdict") ||
|
||||
file.name.startsWith("dictionary")
|
||||
) && !file.name.endsWith(".db-wal") &&
|
||||
!file.name.endsWith(".db-shm")
|
||||
}
|
||||
|
||||
if (dictionaryFiles.isNullOrEmpty()) {
|
||||
Log.d("DictionaryFileRepository", "No dictionary files found to delete")
|
||||
return true
|
||||
}
|
||||
|
||||
var allDeleted = true
|
||||
val failedFiles = mutableListOf<String>()
|
||||
|
||||
dictionaryFiles.forEach { file ->
|
||||
val deleted = file.delete()
|
||||
if (!deleted) {
|
||||
allDeleted = false
|
||||
failedFiles.add(file.name)
|
||||
Log.e("DictionaryFileRepository", "Failed to delete file: ${file.name}")
|
||||
}
|
||||
}
|
||||
|
||||
if (allDeleted) {
|
||||
// Clear all versions from SharedPreferences
|
||||
val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
|
||||
sharedPreferences.edit { clear() }
|
||||
loadDownloadedDictionaries()
|
||||
Log.d("DictionaryFileRepository", "Deleted all dictionary files")
|
||||
} else {
|
||||
throw Exception("Failed to delete some files: ${failedFiles.joinToString(", ")}")
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e("DictionaryFileRepository", "Error deleting all dictionaries", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an orphaned file.
|
||||
*/
|
||||
fun deleteOrphanedFile(fileInfo: FileInfo): Boolean {
|
||||
try {
|
||||
// For orphaned files, we only have one asset (the .db file)
|
||||
val asset = fileInfo.assets.firstOrNull()
|
||||
?: throw Exception("No asset found for orphaned file: ${fileInfo.id}")
|
||||
|
||||
val localFile = File(context.filesDir, asset.filename)
|
||||
if (!localFile.exists()) {
|
||||
throw Exception("Orphaned file not found: ${asset.filename}")
|
||||
}
|
||||
|
||||
val deleted = localFile.delete()
|
||||
if (deleted) {
|
||||
loadDownloadedDictionaries()
|
||||
Log.d("DictionaryFileRepository", "Deleted orphaned file: ${asset.filename}")
|
||||
} else {
|
||||
throw Exception("Failed to delete orphaned file: ${asset.filename}")
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
Log.e("DictionaryFileRepository", "Error deleting orphaned file", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total size of all assets for a downloaded dictionary.
|
||||
*/
|
||||
fun getDictionarySize(fileInfo: FileInfo): Long {
|
||||
return fileInfo.assets.sumOf { asset ->
|
||||
val localFile = File(context.filesDir, asset.filename)
|
||||
if (localFile.exists()) localFile.length() else 0L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the list of downloaded dictionaries and orphaned files based on local files.
|
||||
*/
|
||||
private fun loadDownloadedDictionaries() {
|
||||
val filesDir = context.filesDir
|
||||
val allDictionaryFiles = filesDir.listFiles { file ->
|
||||
file.isFile && (
|
||||
file.name.endsWith(".db") ||
|
||||
file.name.endsWith(".zstdict") ||
|
||||
file.name.endsWith(".db.corrupt") ||
|
||||
file.name.startsWith("dictionary")
|
||||
) &&
|
||||
!file.name.endsWith(".db-wal") &&
|
||||
!file.name.endsWith(".db-shm")
|
||||
|
||||
} ?: emptyArray()
|
||||
|
||||
val manifestFiles = _manifest.value?.files ?: emptyList()
|
||||
|
||||
// Find downloaded dictionaries (files where all assets exist locally)
|
||||
val downloadedFiles = manifestFiles.filter { fileInfo ->
|
||||
fileInfo.assets.all { asset ->
|
||||
File(context.filesDir, asset.filename).exists()
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all asset filenames from downloaded dictionaries
|
||||
val downloadedAssetFilenames = downloadedFiles.flatMap { it.assets }.map { it.filename }.toSet()
|
||||
|
||||
// Find orphaned files (dictionary files that are not assets of any downloaded dictionary)
|
||||
val orphanedFiles = allDictionaryFiles
|
||||
.filter { file -> file.name !in downloadedAssetFilenames }
|
||||
.map { file ->
|
||||
// Create a FileInfo for orphaned files with minimal data
|
||||
FileInfo(
|
||||
id = "orphaned_${file.name}",
|
||||
name = context.getString(R.string.label_unknown_dictionary_d, file.name),
|
||||
description = context.getString(R.string.text_orphaned_file_description),
|
||||
version = context.getString(R.string.label_unknown),
|
||||
assets = listOf(
|
||||
Asset(
|
||||
filename = file.name,
|
||||
sizeBytes = file.length(),
|
||||
checksumSha256 = context.getString(R.string.label_unknown),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
_downloadedDictionaries.value = downloadedFiles
|
||||
_orphanedFiles.value = orphanedFiles
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import eu.gaudian.translator.model.grammar.DictionaryEntryData
|
||||
import eu.gaudian.translator.model.grammar.DictionaryJsonParser
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Service layer for accessing parsed dictionary JSON data.
|
||||
*
|
||||
* This service provides a clean interface between the repository layer
|
||||
* and the JSON parsing logic, making it easy to use structured dictionary
|
||||
* data throughout the application.
|
||||
*
|
||||
* The service handles caching and error handling, ensuring that
|
||||
* UI components and other parts of the app can safely access
|
||||
* parsed dictionary data without worrying about JSON parsing details.
|
||||
*/
|
||||
class DictionaryJsonService @Inject constructor() {
|
||||
|
||||
private val parseCache = mutableMapOf<String, DictionaryEntryData?>()
|
||||
@Suppress("PrivatePropertyName")
|
||||
private val TAG = "DictionaryJsonService"
|
||||
|
||||
/**
|
||||
* Parse a dictionary entry's JSON data into a structured format.
|
||||
*
|
||||
* @param entry The dictionary entry from the local database
|
||||
* @return Structured dictionary data or null if parsing fails
|
||||
*/
|
||||
suspend fun parseEntry(entry: DictionaryWordEntry): DictionaryEntryData? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
// Check cache first
|
||||
parseCache[entry.json]?.let { return@withContext it }
|
||||
|
||||
try {
|
||||
val parsed = DictionaryJsonParser.parseJson(entry.json)
|
||||
parseCache[entry.json] = parsed
|
||||
parsed
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse dictionary entry for '${entry.word}': ${e.message}", e)
|
||||
parseCache[entry.json] = null
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multiple dictionary entries efficiently.
|
||||
*
|
||||
* @param entries List of dictionary entries to parse
|
||||
* @return Map of entry word to parsed data (null for failed parses)
|
||||
*/
|
||||
suspend fun parseEntries(entries: List<DictionaryWordEntry>): Map<String, DictionaryEntryData?> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
entries.associate { entry ->
|
||||
entry.word to parseEntry(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translations for a specific dictionary entry.
|
||||
*
|
||||
* @param entry The dictionary entry
|
||||
* @return List of translations or empty list if parsing fails
|
||||
*/
|
||||
suspend fun getTranslations(entry: DictionaryWordEntry) =
|
||||
parseEntry(entry)?.translations ?: emptyList()
|
||||
|
||||
/**
|
||||
* Get synonyms for a specific dictionary entry.
|
||||
*
|
||||
* @param entry The dictionary entry
|
||||
* @return List of synonyms or empty list if parsing fails
|
||||
*/
|
||||
suspend fun getSynonyms(entry: DictionaryWordEntry) =
|
||||
parseEntry(entry)?.synonyms ?: emptyList()
|
||||
|
||||
/**
|
||||
* Get hyponyms for a specific dictionary entry.
|
||||
*
|
||||
* @param entry The dictionary entry
|
||||
* @return List of hyponyms or empty list if parsing fails
|
||||
*/
|
||||
suspend fun getHyponyms(entry: DictionaryWordEntry) =
|
||||
parseEntry(entry)?.hyponyms ?: emptyList()
|
||||
|
||||
/**
|
||||
* Get all related words for a specific dictionary entry.
|
||||
*
|
||||
* @param entry The dictionary entry
|
||||
* @return List of all related words or empty list if parsing fails
|
||||
*/
|
||||
suspend fun getAllRelatedWords(entry: DictionaryWordEntry) =
|
||||
parseEntry(entry)?.allRelatedWords ?: emptyList()
|
||||
|
||||
suspend fun getPhonetics(entry: DictionaryWordEntry): List<String> =
|
||||
parseEntry(entry)?.phonetics?.ipa ?: emptyList()
|
||||
|
||||
/**
|
||||
* Get hyphenation data for a specific dictionary entry.
|
||||
*
|
||||
* @param entry The dictionary entry
|
||||
* @return List of hyphenation parts or empty list if parsing fails
|
||||
*/
|
||||
suspend fun getHyphenation(entry: DictionaryWordEntry) =
|
||||
parseEntry(entry)?.hyphenation ?: emptyList()
|
||||
|
||||
/**
|
||||
* Get etymology data for a specific dictionary entry.
|
||||
*
|
||||
* @param entry The dictionary entry
|
||||
* @return Etymology data or empty data if parsing fails
|
||||
*/
|
||||
suspend fun getEtymology(entry: DictionaryWordEntry) =
|
||||
parseEntry(entry)?.etymology ?: eu.gaudian.translator.model.grammar.EtymologyData(emptyList())
|
||||
|
||||
/**
|
||||
* Get senses/definitions for a specific dictionary entry.
|
||||
*
|
||||
* @param entry The dictionary entry
|
||||
* @return List of senses or empty list if parsing fails
|
||||
*/
|
||||
suspend fun getSenses(entry: DictionaryWordEntry) =
|
||||
parseEntry(entry)?.senses ?: emptyList()
|
||||
|
||||
/**
|
||||
* Get grammatical properties for a specific dictionary entry.
|
||||
*
|
||||
* @param entry The dictionary entry
|
||||
* @return Grammatical properties or null if parsing fails
|
||||
*/
|
||||
suspend fun getGrammaticalProperties(entry: DictionaryWordEntry) =
|
||||
parseEntry(entry)?.grammaticalProperties
|
||||
|
||||
/**
|
||||
* Get pronunciation data for a specific dictionary entry.
|
||||
*
|
||||
* @param entry The dictionary entry
|
||||
* @return List of pronunciation data or empty list if parsing fails
|
||||
*/
|
||||
suspend fun getPronunciation(entry: DictionaryWordEntry) =
|
||||
parseEntry(entry)?.pronunciation ?: emptyList()
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get inflection data for a specific dictionary entry.
|
||||
*
|
||||
* @param entry The dictionary entry
|
||||
* @return List of inflection data or empty list if parsing fails
|
||||
*/
|
||||
suspend fun getInflections(entry: DictionaryWordEntry) =
|
||||
parseEntry(entry)?.inflections ?: emptyList()
|
||||
|
||||
/**
|
||||
* Clear the internal cache. Useful for testing or when memory is constrained.
|
||||
*/
|
||||
fun clearCache() {
|
||||
parseCache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for debugging purposes.
|
||||
*/
|
||||
fun getCacheStats(): CacheStats {
|
||||
return CacheStats(
|
||||
totalEntries = parseCache.size,
|
||||
successfulParses = parseCache.values.count { it != null },
|
||||
failedParses = parseCache.values.count { it == null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache statistics for debugging and monitoring.
|
||||
*/
|
||||
data class CacheStats(
|
||||
val totalEntries: Int,
|
||||
val successfulParses: Int,
|
||||
val failedParses: Int
|
||||
)
|
||||
@@ -0,0 +1,334 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.github.luben.zstd.Zstd
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import eu.gaudian.translator.model.communication.Asset
|
||||
import eu.gaudian.translator.model.communication.FileInfo
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.text.Normalizer
|
||||
|
||||
/**
|
||||
* Data class representing a dictionary word entry with parsed fields where possible.
|
||||
*/
|
||||
data class DictionaryWordEntry(
|
||||
val word: String,
|
||||
val langCode: String,
|
||||
val pos: String?,
|
||||
val json: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Repository for performing dictionary lookups using the DictionaryDatabaseRepository.
|
||||
* Handles specific dictionary files named "dictionary_<langCode>.db".
|
||||
*/
|
||||
class DictionaryLookupRepository(private val context: Context) {
|
||||
|
||||
private val databaseRepository = DictionaryDatabaseRepository(context)
|
||||
private val gson = Gson()
|
||||
@Suppress("PrivatePropertyName")
|
||||
private val TAG = "DictionaryLookupRepository"
|
||||
private val dictionaryCache = mutableMapOf<String, ByteArray>()
|
||||
|
||||
/**
|
||||
* Developer helper: returns a list of ALL words in the local dictionary for a language.
|
||||
* This can be large and is intended for debugging / cycling through entries only.
|
||||
*/
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
fun getAllWords(langCode: String, limit: Int = 10_000, offset: Int = 0): List<String> {
|
||||
if (!hasDictionaryForLanguage(langCode)) {
|
||||
Log.w(TAG, "No dictionary available for language: $langCode")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val dbFilename = "dictionary_$langCode.db"
|
||||
val dictFilename = "dictionary_$langCode.zstdict"
|
||||
val fileInfo = FileInfo(
|
||||
id = "dictionary_$langCode",
|
||||
name = "Dictionary for $langCode",
|
||||
description = "",
|
||||
version = "",
|
||||
assets = listOf(
|
||||
Asset(filename = dbFilename, sizeBytes = 0L, checksumSha256 = ""),
|
||||
Asset(filename = dictFilename, sizeBytes = 0L, checksumSha256 = "")
|
||||
)
|
||||
)
|
||||
|
||||
val db = databaseRepository.getDatabase(fileInfo) ?: return emptyList()
|
||||
val sql = "SELECT word FROM dictionary_data ORDER BY word LIMIT ? OFFSET ?"
|
||||
Log.d(TAG, "Developer getAllWords for '$langCode' with limit=$limit, offset=$offset")
|
||||
return try {
|
||||
db.rawQuery(sql, arrayOf(limit.toString(), offset.toString())).use { cursor ->
|
||||
val words = mutableListOf<String>()
|
||||
val wordIndex = cursor.getColumnIndex("word")
|
||||
while (cursor.moveToNext()) {
|
||||
val wordValue = cursor.getString(wordIndex)
|
||||
if (!wordValue.isNullOrBlank()) {
|
||||
words.add(wordValue)
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Developer getAllWords fetched ${words.size} words")
|
||||
words
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Developer getAllWords failed for lang '$langCode': ${e.message}", e)
|
||||
emptyList()
|
||||
}.also { db.close() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a dictionary file exists for the given language code.
|
||||
* Language codes are the first part in languages.xml strings, e.g., "de" for German.
|
||||
*/
|
||||
fun hasDictionaryForLanguage(langCode: String): Boolean {
|
||||
val filename = "dictionary_$langCode.db"
|
||||
val exists = File(context.filesDir, filename).exists()
|
||||
return exists
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific word exists in the dictionary for the given language code.
|
||||
* This performs an actual lookup to verify the word is available, not just that
|
||||
* the dictionary file exists.
|
||||
*/
|
||||
fun hasWordInDictionary(word: String, langCode: String): Boolean {
|
||||
if (!hasDictionaryForLanguage(langCode)) {
|
||||
return false
|
||||
}
|
||||
|
||||
val dbFilename = "dictionary_$langCode.db"
|
||||
val dictFilename = "dictionary_$langCode.zstdict"
|
||||
val fileInfo = FileInfo(
|
||||
id = "dictionary_$langCode",
|
||||
name = "Dictionary for $langCode",
|
||||
description = "",
|
||||
version = "",
|
||||
assets = listOf(
|
||||
Asset(filename = dbFilename, sizeBytes = 0L, checksumSha256 = ""),
|
||||
Asset(filename = dictFilename, sizeBytes = 0L, checksumSha256 = "")
|
||||
)
|
||||
)
|
||||
|
||||
val db = databaseRepository.getDatabase(fileInfo) ?: return false
|
||||
val sql = "SELECT 1 FROM dictionary_data WHERE word = ? COLLATE NOCASE LIMIT 1"
|
||||
|
||||
return try {
|
||||
db.rawQuery(sql, arrayOf(word)).use { cursor ->
|
||||
val found = cursor.moveToFirst()
|
||||
found
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to check word '$word' in dictionary '$langCode': ${e.message}", e)
|
||||
false
|
||||
}.also { db.close() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for words in the dictionary for the specified language code.
|
||||
* Returns a list of DictionaryWordEntry matching the exact word.
|
||||
* Uses case-insensitive exact match on the word column.
|
||||
*/
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
fun searchWord(word: String, langCode: String): List<DictionaryWordEntry> {
|
||||
if (!hasDictionaryForLanguage(langCode)) {
|
||||
Log.w(TAG, "No dictionary available for language: $langCode")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val dbFilename = "dictionary_$langCode.db"
|
||||
val dictFilename = "dictionary_$langCode.zstdict"
|
||||
val fileInfo = FileInfo(
|
||||
id = "dictionary_$langCode",
|
||||
name = "Dictionary for $langCode",
|
||||
description = "",
|
||||
version = "",
|
||||
assets = listOf(
|
||||
Asset(filename = dbFilename, sizeBytes = 0L, checksumSha256 = ""),
|
||||
Asset(filename = dictFilename, sizeBytes = 0L, checksumSha256 = "")
|
||||
)
|
||||
)
|
||||
|
||||
val db = databaseRepository.getDatabase(fileInfo) ?: return emptyList()
|
||||
val sql = "SELECT word, pos, data_blob, uncompressed_size FROM dictionary_data WHERE word = ? COLLATE NOCASE LIMIT 100"
|
||||
//Log.d(TAG, "Attempting query for exact word match '$word': $sql")
|
||||
return try {
|
||||
db.rawQuery(sql, arrayOf(word)).use { cursor ->
|
||||
val entries = mutableListOf<DictionaryWordEntry>()
|
||||
val wordIndex = cursor.getColumnIndex("word")
|
||||
val posIndex = cursor.getColumnIndex("pos")
|
||||
val blobIndex = cursor.getColumnIndex("data_blob")
|
||||
val sizeIndex = cursor.getColumnIndex("uncompressed_size")
|
||||
|
||||
if(cursor.count != 1){
|
||||
Log.d(TAG, "Cursor has ${cursor.count} rows")}
|
||||
while (cursor.moveToNext()) {
|
||||
val wordValue = cursor.getString(wordIndex)
|
||||
val posValue = if (posIndex >= 0) cursor.getString(posIndex) else null
|
||||
val blob = cursor.getBlob(blobIndex)
|
||||
val uncompressedSize = cursor.getInt(sizeIndex)
|
||||
|
||||
// Decompress the blob to get the JSON data
|
||||
val dict = getDecompressionDict(langCode)
|
||||
val jsonData = if (dict != null && blob != null) {
|
||||
try {
|
||||
String(Zstd.decompress(blob, dict, uncompressedSize))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to decompress data for word '$word'", e)
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val entry = DictionaryWordEntry(
|
||||
word = wordValue,
|
||||
langCode = langCode,
|
||||
pos = posValue,
|
||||
json = jsonData
|
||||
)
|
||||
entries.add(entry)
|
||||
}
|
||||
if(entries.size != 1){
|
||||
Log.d(TAG, "Processed ${entries.size} results")}
|
||||
entries
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Query failed for word '$word' in dictionary '$langCode': ${e.message}", e)
|
||||
Log.d(TAG, "Attemped query for exact word match '$word': $sql")
|
||||
emptyList()
|
||||
}.also { db.close() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses JSON string into a list of strings, or null if parsing fails.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
private fun parseJsonList(json: String?): List<String>? {
|
||||
return json?.let {
|
||||
try {
|
||||
gson.fromJson(it, object : TypeToken<List<String>>() {}.type)
|
||||
} catch (e: Exception) {
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
Log.w(TAG, "Failed to parse JSON list: $json", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves word suggestions based on prefix match.
|
||||
* The match is accent-insensitive, so e.g. "haufig" will still suggest "häufig".
|
||||
*/
|
||||
fun getSuggestions(prefix: String, langCode: String, limit: Int): List<String> {
|
||||
if (!hasDictionaryForLanguage(langCode)) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Normalize the prefix: lowercase and strip diacritics (accents)
|
||||
val normalizedPrefix = prefix.trim().lowercase().removeDiacritics()
|
||||
if (normalizedPrefix.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val dbFilename = "dictionary_$langCode.db"
|
||||
val dictFilename = "dictionary_$langCode.zstdict"
|
||||
@Suppress("HardCodedStringLiteral") val fileInfo = FileInfo(
|
||||
id = "dictionary_$langCode",
|
||||
name = "Dictionary for $langCode",
|
||||
description = "",
|
||||
version = "",
|
||||
assets = listOf(
|
||||
Asset(filename = dbFilename, sizeBytes = 0L, checksumSha256 = ""),
|
||||
Asset(filename = dictFilename, sizeBytes = 0L, checksumSha256 = "")
|
||||
)
|
||||
)
|
||||
|
||||
val db = databaseRepository.getDatabase(fileInfo) ?: return emptyList()
|
||||
|
||||
// To support accent-insensitive matching, query a broader set of candidates
|
||||
// (all words starting with the same first base letter), then filter in Kotlin.
|
||||
val broadPrefix = normalizedPrefix[0].toString()
|
||||
val sql = "SELECT word FROM dictionary_fts WHERE word MATCH ? LIMIT ?"
|
||||
val matchQuery = "$broadPrefix*"
|
||||
|
||||
return try {
|
||||
db.rawQuery(sql, arrayOf(matchQuery, (limit * 20).toString())).use { cursor ->
|
||||
val suggestions = mutableListOf<String>()
|
||||
val wordIndex = cursor.getColumnIndex("word")
|
||||
while (cursor.moveToNext()) {
|
||||
val word = cursor.getString(wordIndex)
|
||||
if (!word.isNullOrBlank()) {
|
||||
suggestions.add(word)
|
||||
}
|
||||
}
|
||||
|
||||
suggestions
|
||||
.distinct() // Remove duplicates
|
||||
.filter { candidate ->
|
||||
val candidateNorm = candidate.lowercase().removeDiacritics()
|
||||
candidateNorm.startsWith(normalizedPrefix)
|
||||
}
|
||||
.take(limit)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Suggestion query failed: ${e.message}", e)
|
||||
emptyList()
|
||||
}.also { db.close() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses translations JSON into a map of language code to list of translations, or null if parsing fails.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
private fun parseTranslationsJson(json: String?): Map<String, List<String>>? {
|
||||
return json?.let {
|
||||
try {
|
||||
gson.fromJson(it, object : TypeToken<Map<String, List<String>>>() {}.type)
|
||||
} catch (e: Exception) {
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
Log.w(TAG, "Failed to parse translations JSON: $json", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a cached decompression dictionary for the given language, creating it if necessary.
|
||||
*/
|
||||
private fun getDecompressionDict(langCode: String): ByteArray? {
|
||||
// Return the cached dictionary if it's already loaded
|
||||
if (dictionaryCache.containsKey(langCode)) {
|
||||
return dictionaryCache[langCode]
|
||||
}
|
||||
|
||||
return try {
|
||||
val dictFileName = "dictionary_${langCode}.zstdict"
|
||||
Log.d(TAG, "Loading compression dictionary: $dictFileName")
|
||||
|
||||
// Read the dictionary file from filesDir (consistent with database location)
|
||||
val dictFile = File(context.filesDir, dictFileName)
|
||||
if (dictFile.exists()) {
|
||||
val dictBytes = dictFile.readBytes()
|
||||
// Cache the dictionary bytes for later use
|
||||
dictionaryCache[langCode] = dictBytes
|
||||
dictBytes
|
||||
} else {
|
||||
Log.e(TAG, "Zstd dictionary file does not exist: $dictFileName")
|
||||
null
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Failed to load Zstd dictionary file for lang '$langCode'", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.removeDiacritics(): String {
|
||||
val normalized = Normalizer.normalize(this, Normalizer.Form.NFD)
|
||||
return normalized.replace("\\p{Mn}+".toRegex(), "")
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import eu.gaudian.translator.model.DictionaryEntry
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.SerializationException
|
||||
|
||||
class DictionaryRepository(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private val DICTIONARY_ENTRY_KEY = stringPreferencesKey("dictionary_entry")
|
||||
private val WORD_OF_THE_DAY_KEY = stringPreferencesKey("word_of_the_day")
|
||||
}
|
||||
|
||||
init {
|
||||
CoroutineScope(context = Dispatchers.IO).launch {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveDictionaryEntry(entry: DictionaryEntry) {
|
||||
val history = loadDictionaryEntry().first().toMutableList()
|
||||
history.removeAll { it.word.equals(entry.word, ignoreCase = true) && it.languageCode == entry.languageCode }
|
||||
history.add(0, entry)
|
||||
val updatedHistory = history.take(20)
|
||||
context.dataStore.saveObjectList(DICTIONARY_ENTRY_KEY, updatedHistory)
|
||||
}
|
||||
|
||||
|
||||
fun loadDictionaryEntry(): Flow<List<DictionaryEntry>> {
|
||||
return context.dataStore.loadObjectList<DictionaryEntry>(DICTIONARY_ENTRY_KEY)
|
||||
.catch { exception ->
|
||||
if (exception is SerializationException) {
|
||||
Log.w("DictionaryRepo", "Could not parse old dictionary history. Clearing it.", exception)
|
||||
clearHistory()
|
||||
emit(emptyList())
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateDictionaryEntry(entry: DictionaryEntry) {
|
||||
val history = loadDictionaryEntry().first().toMutableList()
|
||||
val index = history.indexOfFirst { it.id == entry.id }
|
||||
|
||||
if (index != -1) {
|
||||
history[index] = entry
|
||||
context.dataStore.saveObjectList(DICTIONARY_ENTRY_KEY, history)
|
||||
Log.d("DictionaryRepo", "Entry with id ${entry.id} updated.")
|
||||
} else {
|
||||
Log.w("DictionaryRepo", "Attempted to update non-existent entry with id ${entry.id}.")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearHistory() {
|
||||
context.dataStore.saveObjectList(DICTIONARY_ENTRY_KEY, emptyList<DictionaryEntry>())
|
||||
}
|
||||
|
||||
suspend fun saveWordOfTheDay(entry: DictionaryEntry) {
|
||||
context.dataStore.saveObject(WORD_OF_THE_DAY_KEY, entry)
|
||||
}
|
||||
|
||||
|
||||
fun loadWordOfTheDay(): Flow<DictionaryEntry?> {
|
||||
return context.dataStore.loadObject<DictionaryEntry>(WORD_OF_THE_DAY_KEY)
|
||||
.catch { exception ->
|
||||
if (exception is SerializationException) {
|
||||
Log.w("DictionaryRepo", "Could not parse old Word of the Day. Clearing it.", exception)
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences.remove(WORD_OF_THE_DAY_KEY)
|
||||
}
|
||||
emit(null)
|
||||
} else {
|
||||
throw exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import eu.gaudian.translator.model.Exercise
|
||||
import eu.gaudian.translator.model.Question
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
||||
private const val TAG = "ExerciseRepository"
|
||||
|
||||
class ExerciseRepository {
|
||||
|
||||
constructor(context: Context) {
|
||||
this.dataStore = context.dataStore
|
||||
}
|
||||
|
||||
private val dataStore: DataStore<Preferences>
|
||||
|
||||
|
||||
fun getAllExercisesFlow(): Flow<List<Exercise>> {
|
||||
return dataStore.loadObjectList(DataStoreKeys.EXERCISES_KEY)
|
||||
}
|
||||
|
||||
private suspend fun getAllExercises(): List<Exercise> {
|
||||
return getAllExercisesFlow().firstOrNull() ?: emptyList()
|
||||
}
|
||||
|
||||
suspend fun saveExercise(exercise: Exercise) {
|
||||
try {
|
||||
val currentExercises = getAllExercises().toMutableList()
|
||||
val existingIndex = currentExercises.indexOfFirst { it.id == exercise.id }
|
||||
|
||||
if (existingIndex != -1) {
|
||||
currentExercises[existingIndex] = exercise
|
||||
} else {
|
||||
currentExercises.add(exercise)
|
||||
}
|
||||
dataStore.saveObjectList(DataStoreKeys.EXERCISES_KEY, currentExercises)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save exercise", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteExercise(exerciseId: String) {
|
||||
try {
|
||||
val currentExercises = getAllExercises().toMutableList()
|
||||
if (currentExercises.removeAll { it.id == exerciseId }) {
|
||||
dataStore.saveObjectList(DataStoreKeys.EXERCISES_KEY, currentExercises)
|
||||
Log.d(TAG, "Successfully deleted exercise with ID: $exerciseId")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to delete exercise with ID: $exerciseId", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
fun getAllQuestionsFlow(): Flow<List<Question>> {
|
||||
return dataStore.loadObjectList(DataStoreKeys.QUESTIONS_KEY)
|
||||
}
|
||||
|
||||
private suspend fun getAllQuestions(): List<Question> {
|
||||
return getAllQuestionsFlow().firstOrNull() ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a list of questions to the DataStore, replacing any existing ones with the same ID.
|
||||
*/
|
||||
private suspend fun saveQuestions(questions: List<Question>) {
|
||||
try {
|
||||
val currentQuestions = getAllQuestions().toMutableList()
|
||||
questions.forEach { newQuestion ->
|
||||
val index = currentQuestions.indexOfFirst { it.id == newQuestion.id }
|
||||
if (index != -1) {
|
||||
currentQuestions[index] = newQuestion
|
||||
} else {
|
||||
currentQuestions.add(newQuestion)
|
||||
}
|
||||
}
|
||||
dataStore.saveObjectList(DataStoreKeys.QUESTIONS_KEY, currentQuestions)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save questions", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveNewExerciseWithQuestions(exercise: Exercise, questions: List<Question>) {
|
||||
saveQuestions(questions)
|
||||
saveExercise(exercise)
|
||||
Log.d(TAG, "Successfully saved new exercise '${exercise.title}' with ${questions.size} questions.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import eu.gaudian.translator.model.Language
|
||||
import eu.gaudian.translator.model.parseLanguagesFromResources
|
||||
import eu.gaudian.translator.model.repository.DataStoreKeys.LANGUAGE_INIT_METADATA_KEY
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
|
||||
enum class LanguageListType {
|
||||
DEFAULT,
|
||||
CUSTOM,
|
||||
ALL,
|
||||
FAVORITE,
|
||||
HISTORY
|
||||
}
|
||||
|
||||
class LanguageRepository(private val context: Context) {
|
||||
|
||||
private val dataStore = context.dataStore
|
||||
|
||||
private fun getKeyForType(type: LanguageListType) = when (type) {
|
||||
// Note: For ALL we will now store only the enabled language IDs (Int) instead of full Language objects
|
||||
LanguageListType.ALL -> DataStoreKeys.ALL_LANGUAGES_KEY
|
||||
LanguageListType.DEFAULT -> DataStoreKeys.DEFAULT_LANGUAGES_KEY
|
||||
LanguageListType.CUSTOM -> DataStoreKeys.CUSTOM_LANGUAGES_KEY
|
||||
LanguageListType.FAVORITE -> DataStoreKeys.FAVORITE_LANGUAGES_KEY
|
||||
LanguageListType.HISTORY -> DataStoreKeys.LANGUAGE_HISTORY_KEY
|
||||
}
|
||||
|
||||
// Returns a flow of the master catalog = DEFAULT + CUSTOM with conflict disambiguation applied
|
||||
private fun masterLanguagesFlow(): Flow<List<Language>> {
|
||||
return kotlinx.coroutines.flow.combine(
|
||||
loadLanguages(LanguageListType.DEFAULT),
|
||||
loadLanguages(LanguageListType.CUSTOM)
|
||||
) { defaults, customs ->
|
||||
val master = (defaults + customs)
|
||||
disambiguateConflictingNames(master)
|
||||
}
|
||||
}
|
||||
|
||||
// Suffix region codes for languages with duplicate names within the provided list
|
||||
private fun disambiguateConflictingNames(list: List<Language>): List<Language> {
|
||||
if (list.isEmpty()) return list
|
||||
// Define a regex that matches a trailing region suffix we add, e.g. " (DE)" or " (PT)"
|
||||
val suffixRegex = " \\([A-Z]{2,}\\)$".toRegex()
|
||||
|
||||
// Compute base names by stripping any existing suffix first
|
||||
val baseNames = list.associate { lang ->
|
||||
lang.name to lang.name.replace(suffixRegex, "")
|
||||
}
|
||||
// Count occurrences by base name
|
||||
val countsByBase = list
|
||||
.map { baseNames[it.name] ?: it.name }
|
||||
.groupingBy { it }
|
||||
.eachCount()
|
||||
|
||||
// Map each language to a display name based on base name conflict
|
||||
return list.map { lang ->
|
||||
val base = baseNames[lang.name] ?: lang.name
|
||||
val count = countsByBase[base] ?: 0
|
||||
if (count > 1 && lang.region.isNotEmpty()) {
|
||||
val suffix = lang.region.uppercase()
|
||||
// Always construct from base to prevent duplicate/chained suffixes
|
||||
lang.copy(name = "$base ($suffix)")
|
||||
} else {
|
||||
// No conflict: ensure we show the clean base
|
||||
lang.copy(name = base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun wipeHistoryAndFavorites() {
|
||||
clearLanguages(LanguageListType.HISTORY)
|
||||
clearLanguages(LanguageListType.FAVORITE)
|
||||
saveSelectedSourceLanguage(null)
|
||||
saveSelectedTargetLanguage(null)
|
||||
}
|
||||
|
||||
suspend fun initializeDefaultLanguages() {
|
||||
Log.d("LanguageRepository", "Initializing default languages")
|
||||
try {
|
||||
// Check if we already have default languages saved
|
||||
val savedDefaultLanguages = loadLanguages(LanguageListType.DEFAULT).firstOrNull() ?: emptyList()
|
||||
|
||||
// Check if we need to re-parse languages (first run, version change, or language change)
|
||||
val shouldReparse = shouldReparseLanguages(savedDefaultLanguages)
|
||||
|
||||
if (shouldReparse) {
|
||||
Log.d("LanguageRepository", "Parsing languages from resources")
|
||||
val parsedLanguages = parseLanguagesFromResources(context)
|
||||
wipeHistoryAndFavorites()
|
||||
saveLanguages(LanguageListType.DEFAULT, parsedLanguages)
|
||||
// Save the current app version and locale to detect changes next time
|
||||
saveLanguageInitializationMetadata()
|
||||
} else {
|
||||
Log.d("LanguageRepository", "Using cached default languages")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("LanguageRepository", "Error initializing default languages: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun shouldReparseLanguages(savedLanguages: List<Language>): Boolean {
|
||||
// Always reparse if no languages are saved
|
||||
if (savedLanguages.isEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if the number of languages matches expected count (51)
|
||||
if (savedLanguages.size != 51) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if app version has changed (indicating possible new languages)
|
||||
val metadata = getLanguageInitializationMetadata()
|
||||
val currentVersion = try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
// If we can't get the package info, assume version changed to trigger reparse
|
||||
return true
|
||||
}
|
||||
if (metadata?.appVersion != currentVersion) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if system locale has changed (affecting localized language names)
|
||||
val currentLocale = context.resources.configuration.locales.get(0)?.toLanguageTag()
|
||||
return metadata?.systemLocale != currentLocale
|
||||
}
|
||||
|
||||
private suspend fun saveLanguageInitializationMetadata() {
|
||||
val currentVersion = try {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
// If we can't get the package info, use a default value
|
||||
"unknown"
|
||||
}
|
||||
val currentLocale = context.resources.configuration.locales.get(0)?.toLanguageTag() ?: ""
|
||||
|
||||
val metadata = LanguageInitializationMetadata(
|
||||
appVersion = currentVersion,
|
||||
systemLocale = currentLocale,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
dataStore.saveObject(LANGUAGE_INIT_METADATA_KEY, metadata)
|
||||
}
|
||||
|
||||
private suspend fun getLanguageInitializationMetadata(): LanguageInitializationMetadata? {
|
||||
return try {
|
||||
dataStore.loadObject<LanguageInitializationMetadata>(LANGUAGE_INIT_METADATA_KEY).firstOrNull()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun initializeAllLanguages() {
|
||||
Log.d("LanguageRepository", "Initializing enabled languages (ALL as IDs)")
|
||||
try {
|
||||
val defaultLanguages = loadLanguages(LanguageListType.DEFAULT).first()
|
||||
val customLanguages = loadLanguages(LanguageListType.CUSTOM).first()
|
||||
val master = (defaultLanguages + customLanguages).distinctBy { it.nameResId }
|
||||
|
||||
// Sanitize existing enabled IDs and initialize if empty
|
||||
val existingEnabled: List<Int> = try {
|
||||
context.dataStore.loadObjectList<Int>(DataStoreKeys.ALL_LANGUAGES_KEY).firstOrNull() ?: emptyList()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
val masterIds = master.map { it.nameResId }.toSet()
|
||||
val sanitized = existingEnabled.filter { it in masterIds }
|
||||
val finalIds = sanitized.ifEmpty { master.filter { it.isSelected == true }.map { it.nameResId } }
|
||||
|
||||
context.dataStore.saveObjectList(DataStoreKeys.ALL_LANGUAGES_KEY, finalIds)
|
||||
|
||||
val historyLanguages = loadLanguages(LanguageListType.HISTORY).firstOrNull() ?: emptyList()
|
||||
if (historyLanguages.size > 5) {
|
||||
saveLanguages(LanguageListType.HISTORY, historyLanguages.takeLast(5))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("LanguageRepository", "Error initializing enabled languages: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLanguagesByResourceIds(ids: Set<Int>): List<Language> {
|
||||
val master = masterLanguagesFlow().first()
|
||||
return master.filter { it.nameResId in ids }
|
||||
}
|
||||
|
||||
fun loadMasterLanguages(): Flow<List<Language>> = masterLanguagesFlow()
|
||||
|
||||
suspend fun setEnabledLanguagesByIds(ids: List<Int>) {
|
||||
dataStore.saveObjectList(DataStoreKeys.ALL_LANGUAGES_KEY, ids)
|
||||
}
|
||||
|
||||
suspend fun editCustomLanguage(languageId: Int, newName: String?, newCode: String, newRegion: String) {
|
||||
// Update in DEFAULT or CUSTOM by id (nameResId)
|
||||
val defaults = loadLanguages(LanguageListType.DEFAULT).first().toMutableList()
|
||||
val customs = loadLanguages(LanguageListType.CUSTOM).first().toMutableList()
|
||||
var updated = false
|
||||
for (i in defaults.indices) {
|
||||
if (defaults[i].nameResId == languageId) {
|
||||
val l = defaults[i]
|
||||
// Default languages: do not change name
|
||||
defaults[i] = l.copy(code = newCode, region = newRegion)
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!updated) {
|
||||
for (i in customs.indices) {
|
||||
if (customs[i].nameResId == languageId) {
|
||||
val l = customs[i]
|
||||
// Custom languages: allow name editing if provided
|
||||
val targetName = newName ?: l.name
|
||||
// Prevent exact duplicates by name+region+code with other customs
|
||||
val duplicate = customs.withIndex().any { (idx, other) ->
|
||||
idx != i && other.name.equals(targetName, true) && other.region.equals(newRegion, true) && other.code.equals(newCode, true)
|
||||
}
|
||||
if (!duplicate) {
|
||||
customs[i] = l.copy(name = targetName, code = newCode, region = newRegion)
|
||||
updated = true
|
||||
} else {
|
||||
Log.w("LanguageRepository", "Skipping update to avoid duplicate custom language")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updated) {
|
||||
saveLanguages(LanguageListType.DEFAULT, defaults)
|
||||
saveLanguages(LanguageListType.CUSTOM, customs)
|
||||
// Update selected languages if necessary
|
||||
val src = loadSelectedSourceLanguage().first()
|
||||
if (src?.nameResId == languageId) saveSelectedSourceLanguage(src.copy(name = newName ?: src.name, code = newCode, region = newRegion))
|
||||
val tgt = loadSelectedTargetLanguage().first()
|
||||
if (tgt?.nameResId == languageId) saveSelectedTargetLanguage(tgt.copy(name = newName ?: tgt.name, code = newCode, region = newRegion))
|
||||
val dict = loadSelectedDictionaryLanguage().first()
|
||||
if (dict?.nameResId == languageId) saveSelectedDictionaryLanguage(dict.copy(name = newName ?: dict.name, code = newCode, region = newRegion))
|
||||
initializeAllLanguages()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLanguages(type: LanguageListType): Flow<List<Language>> {
|
||||
return when (type) {
|
||||
LanguageListType.ALL -> {
|
||||
// Enabled languages (IDs) mapped to actual Language objects from master catalog
|
||||
kotlinx.coroutines.flow.combine(
|
||||
dataStore.loadObjectList<Int>(getKeyForType(type)),
|
||||
masterLanguagesFlow()
|
||||
) { ids, master ->
|
||||
val idSet = ids.toSet()
|
||||
disambiguateConflictingNames(master.filter { it.nameResId in idSet })
|
||||
}
|
||||
}
|
||||
LanguageListType.FAVORITE, LanguageListType.HISTORY -> {
|
||||
// Internally store only the language keys (nameResId) to avoid duplicate Language instances
|
||||
kotlinx.coroutines.flow.combine(
|
||||
dataStore.loadObjectList<Int>(getKeyForType(type)),
|
||||
masterLanguagesFlow()
|
||||
) { ids, master ->
|
||||
val idSet = ids.toSet()
|
||||
master.filter { it.nameResId in idSet }
|
||||
}
|
||||
}
|
||||
else -> dataStore.loadObjectList(getKeyForType(type))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveLanguages(type: LanguageListType, languages: List<Language>) {
|
||||
when (type) {
|
||||
LanguageListType.ALL, LanguageListType.FAVORITE, LanguageListType.HISTORY -> {
|
||||
val ids = languages.map { it.nameResId }
|
||||
dataStore.saveObjectList(getKeyForType(type), ids)
|
||||
}
|
||||
else -> dataStore.saveObjectList(getKeyForType(type), languages)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearLanguages(type: LanguageListType) {
|
||||
dataStore.clear(getKeyForType(type))
|
||||
}
|
||||
|
||||
fun loadSelectedSourceLanguage(): Flow<Language?> {
|
||||
return dataStore.loadObject(DataStoreKeys.SELECTED_SOURCE_LANGUAGE_KEY)
|
||||
}
|
||||
|
||||
suspend fun saveSelectedSourceLanguage(language: Language?) {
|
||||
dataStore.saveObject(DataStoreKeys.SELECTED_SOURCE_LANGUAGE_KEY, language)
|
||||
}
|
||||
|
||||
fun loadSelectedTargetLanguage(): Flow<Language?> {
|
||||
return dataStore.loadObject(DataStoreKeys.SELECTED_TARGET_LANGUAGE_KEY)
|
||||
}
|
||||
|
||||
suspend fun saveSelectedTargetLanguage(language: Language?) {
|
||||
dataStore.saveObject(DataStoreKeys.SELECTED_TARGET_LANGUAGE_KEY, language)
|
||||
}
|
||||
|
||||
fun loadSelectedDictionaryLanguage(): Flow<Language?> {
|
||||
return dataStore.loadObject(DataStoreKeys.SELECTED_DICTIONARY_LANGUAGE_KEY)
|
||||
}
|
||||
|
||||
suspend fun saveSelectedDictionaryLanguage(language: Language?) {
|
||||
dataStore.saveObject(DataStoreKeys.SELECTED_DICTIONARY_LANGUAGE_KEY, language)
|
||||
}
|
||||
|
||||
suspend fun addCustomLanguage(language: Language) {
|
||||
val customLanguages = loadLanguages(LanguageListType.CUSTOM).first().toMutableList()
|
||||
|
||||
val newId = -(System.currentTimeMillis().toInt())
|
||||
|
||||
val newLanguage = language.copy(nameResId = newId, isCustom = true)
|
||||
|
||||
// Allow same names with different regions; prevent exact duplicates by name+region+code
|
||||
if (!customLanguages.any { it.name.equals(newLanguage.name, true) && it.region.equals(newLanguage.region, true) && it.code.equals(newLanguage.code, true) }) {
|
||||
customLanguages.add(newLanguage)
|
||||
saveLanguages(LanguageListType.CUSTOM, customLanguages)
|
||||
initializeAllLanguages()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteCustomLanguage(language: Language) {
|
||||
// Read all lists needed first
|
||||
val customLanguages = loadLanguages(LanguageListType.CUSTOM).first().toMutableList()
|
||||
val historyLanguages = loadLanguages(LanguageListType.HISTORY).first().toMutableList()
|
||||
val favoriteLanguages = loadLanguages(LanguageListType.FAVORITE).first().toMutableList()
|
||||
val enabledIds = context.dataStore.loadObjectList<Int>(DataStoreKeys.ALL_LANGUAGES_KEY).firstOrNull()?.toMutableList() ?: mutableListOf()
|
||||
|
||||
// Perform removals
|
||||
val wasCustomRemoved = customLanguages.removeIf { it.nameResId == language.nameResId }
|
||||
val wasHistoryRemoved = historyLanguages.removeIf { it.nameResId == language.nameResId }
|
||||
val wasFavoriteRemoved = favoriteLanguages.removeIf { it.nameResId == language.nameResId }
|
||||
val wasEnabledRemoved = enabledIds.removeIf { it == language.nameResId }
|
||||
|
||||
// Write back to DataStore only if changes were made
|
||||
if (wasCustomRemoved) {
|
||||
saveLanguages(LanguageListType.CUSTOM, customLanguages)
|
||||
// Re-run initialization to update the enabled list
|
||||
initializeAllLanguages()
|
||||
}
|
||||
if (wasHistoryRemoved) {
|
||||
saveLanguages(LanguageListType.HISTORY, historyLanguages)
|
||||
}
|
||||
if (wasFavoriteRemoved) {
|
||||
saveLanguages(LanguageListType.FAVORITE, favoriteLanguages)
|
||||
}
|
||||
if (wasEnabledRemoved) {
|
||||
context.dataStore.saveObjectList(DataStoreKeys.ALL_LANGUAGES_KEY, enabledIds)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLanguageById(id: Int): Language? {
|
||||
if (id == 0) return null
|
||||
return try {
|
||||
masterLanguagesFlow().first().find { it.nameResId == id }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* A generic wrapper for a single setting in DataStore.
|
||||
*
|
||||
* @param T The type of the setting value.
|
||||
* @param dataStore The DataStore instance.
|
||||
* @param key The Preferences.Key for this setting.
|
||||
* @param defaultValue The default value to return if none is set.
|
||||
*/
|
||||
class Setting<T>(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val key: Preferences.Key<T>,
|
||||
private val defaultValue: T
|
||||
) {
|
||||
/**
|
||||
* A Flow that emits the current value of the setting.
|
||||
*/
|
||||
val flow: Flow<T> = dataStore.data.map { preferences ->
|
||||
preferences[key] ?: defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or updates the value of the setting.
|
||||
*/
|
||||
suspend fun set(value: T) {
|
||||
Log.d("Setting", "Setting value for key $key to $value")
|
||||
dataStore.edit { settings ->
|
||||
settings[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import eu.gaudian.translator.model.communication.ApiProvider
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class SettingsRepository(private val context: Context) {
|
||||
|
||||
private object PrefKeys {
|
||||
const val API_KEY_SUFFIX = "_api_key"
|
||||
|
||||
val CUSTOM_PROMPT_TRANSLATION = stringPreferencesKey("custom_prompt_translation")
|
||||
val CUSTOM_PROMPT_VOCABULARY = stringPreferencesKey("custom_prompt_vocabulary")
|
||||
val CUSTOM_PROMPT_DICTIONARY = stringPreferencesKey("custom_prompt_dictionary")
|
||||
val CUSTOM_PROMPT_EXERCISE = stringPreferencesKey("custom_prompt_exercise")
|
||||
val SPEAKING_SPEED = intPreferencesKey("SPEAKING_SPEED")
|
||||
val DEVELOPER_MODE = booleanPreferencesKey("developer_mode")
|
||||
val DAILY_GOAL = intPreferencesKey("daily_goal")
|
||||
val SELECTED_CATEGORIES = stringSetPreferencesKey("selectedCategories")
|
||||
val DICTIONARY_SWITCHES = stringSetPreferencesKey("dictionary_switches")
|
||||
val THEME = stringPreferencesKey("selected_theme")
|
||||
val DARK_MODE_PREFERENCE = stringPreferencesKey("dark_mode_preference")
|
||||
val FONT_PREFERENCE = stringPreferencesKey("font_preference")
|
||||
val INTRO_COMPLETED = booleanPreferencesKey("intro_completed")
|
||||
val INTERVAL_NEW = intPreferencesKey("interval_new")
|
||||
val INTERVAL_STAGE_1 = intPreferencesKey("interval_stage_1")
|
||||
val INTERVAL_STAGE_2 = intPreferencesKey("interval_stage_2")
|
||||
val INTERVAL_STAGE_3 = intPreferencesKey("interval_stage_3")
|
||||
val INTERVAL_STAGE_4 = intPreferencesKey("interval_stage_4")
|
||||
val INTERVAL_STAGE_5 = intPreferencesKey("interval_stage_5")
|
||||
val INTERVAL_LEARNED = intPreferencesKey("interval_learned")
|
||||
val CRITERIA_CORRECT = intPreferencesKey("criteria_correct")
|
||||
val CRITERIA_WRONG = intPreferencesKey("criteria_wrong")
|
||||
val SHOW_HINTS = booleanPreferencesKey("show_hints")
|
||||
val EXPERIMENTAL_FEATURES = booleanPreferencesKey("experimental_features")
|
||||
val TRY_WIKTIONARY_FIRST = booleanPreferencesKey("try_wiktionary_first")
|
||||
val SHOW_BOTTOM_NAV_LABELS = booleanPreferencesKey("show_bottom_nav_labels")
|
||||
val CONNECTION_CONFIGURED = booleanPreferencesKey("connection_configured")
|
||||
val USE_LIBRE_TRANSLATE = booleanPreferencesKey("use_libretranslate")
|
||||
val LAST_SEEN_VERSION = stringPreferencesKey("last_seen_version")
|
||||
|
||||
fun getTtsVoiceKey(code: String, region: String): androidx.datastore.preferences.core.Preferences.Key<String> {
|
||||
val c = code.lowercase()
|
||||
val r = region.trim()
|
||||
return stringPreferencesKey("tts_voice_" + if (r.isBlank()) c else "${c}_${r.uppercase()}")
|
||||
}
|
||||
fun getLegacyTtsVoiceKey(code: String) = stringPreferencesKey("tts_voice_" + code.lowercase())
|
||||
fun getApiKeyPrefKey(providerKey: String) = stringPreferencesKey("${providerKey}$API_KEY_SUFFIX")
|
||||
}
|
||||
|
||||
suspend fun setTtsVoiceForLanguage(code: String, region: String, voiceName: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val key = PrefKeys.getTtsVoiceKey(code, region)
|
||||
if (voiceName.isNullOrBlank()) {
|
||||
prefs.remove(key)
|
||||
} else {
|
||||
prefs[key] = voiceName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getTtsVoiceForLanguage(code: String, region: String): Flow<String?> {
|
||||
val key = PrefKeys.getTtsVoiceKey(code, region)
|
||||
val legacy = PrefKeys.getLegacyTtsVoiceKey(code)
|
||||
return context.dataStore.data.map { prefs ->
|
||||
prefs[key] ?: prefs[legacy]
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Use region-aware overload", ReplaceWith("getTtsVoiceForLanguage(code, region)"))
|
||||
fun getTtsVoiceForLanguage(code: String): Flow<String?> = getTtsVoiceForLanguage(code, "")
|
||||
|
||||
@Deprecated("Use region-aware overload", ReplaceWith("setTtsVoiceForLanguage(code, region, voiceName)"))
|
||||
suspend fun setTtsVoiceForLanguage(code: String, voiceName: String?) = setTtsVoiceForLanguage(code, "", voiceName)
|
||||
|
||||
val theme = Setting(context.dataStore, PrefKeys.THEME, "Default")
|
||||
val darkModePreference = Setting(context.dataStore, PrefKeys.DARK_MODE_PREFERENCE, "System")
|
||||
val fontPreference = Setting(context.dataStore, PrefKeys.FONT_PREFERENCE, "Default")
|
||||
val introCompleted = Setting(context.dataStore, PrefKeys.INTRO_COMPLETED, false)
|
||||
val developerMode = Setting(context.dataStore, PrefKeys.DEVELOPER_MODE, false)
|
||||
val dailyGoal = Setting(context.dataStore, PrefKeys.DAILY_GOAL, 10)
|
||||
val selectedCategories = Setting(context.dataStore, PrefKeys.SELECTED_CATEGORIES, emptySet())
|
||||
val dictionarySwitches = Setting(context.dataStore, PrefKeys.DICTIONARY_SWITCHES, emptySet())
|
||||
val customPromptTranslation = Setting(context.dataStore, PrefKeys.CUSTOM_PROMPT_TRANSLATION, "")
|
||||
val customPromptVocabulary = Setting(context.dataStore, PrefKeys.CUSTOM_PROMPT_VOCABULARY, "")
|
||||
val customPromptDictionary = Setting(context.dataStore, PrefKeys.CUSTOM_PROMPT_DICTIONARY, "")
|
||||
val customPromptExercise = Setting(context.dataStore, PrefKeys.CUSTOM_PROMPT_EXERCISE, "")
|
||||
val speakingSpeed = Setting(context.dataStore, PrefKeys.SPEAKING_SPEED, 100)
|
||||
val intervalNew = Setting(context.dataStore, PrefKeys.INTERVAL_NEW, 1)
|
||||
val intervalStage1 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_1, 3)
|
||||
val intervalStage2 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_2, 7)
|
||||
val intervalStage3 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_3, 14)
|
||||
val intervalStage4 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_4, 30)
|
||||
val intervalStage5 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_5, 60)
|
||||
val intervalLearned = Setting(context.dataStore, PrefKeys.INTERVAL_LEARNED, 90)
|
||||
val criteriaCorrect = Setting(context.dataStore, PrefKeys.CRITERIA_CORRECT, 3)
|
||||
val criteriaWrong = Setting(context.dataStore, PrefKeys.CRITERIA_WRONG, 2)
|
||||
val showHints = Setting(context.dataStore, PrefKeys.SHOW_HINTS, true)
|
||||
val experimentalFeatures = Setting(context.dataStore, PrefKeys.EXPERIMENTAL_FEATURES, false)
|
||||
val tryWiktionaryFirst = Setting(context.dataStore, PrefKeys.TRY_WIKTIONARY_FIRST, false)
|
||||
val showBottomNavLabels = Setting(context.dataStore, PrefKeys.SHOW_BOTTOM_NAV_LABELS, true)
|
||||
val connectionConfigured = Setting(context.dataStore, PrefKeys.CONNECTION_CONFIGURED, true)
|
||||
val useLibreTranslate = Setting(context.dataStore, PrefKeys.USE_LIBRE_TRANSLATE, false)
|
||||
val lastSeenVersion = Setting(context.dataStore, PrefKeys.LAST_SEEN_VERSION, "")
|
||||
|
||||
|
||||
|
||||
fun getAllApiKeys(): Flow<Map<String, String>> {
|
||||
return context.dataStore.data.map { preferences ->
|
||||
preferences.asMap().mapNotNull { (key, value) ->
|
||||
if (key.name.endsWith(PrefKeys.API_KEY_SUFFIX) && value is String) {
|
||||
val providerKey = key.name.removeSuffix(PrefKeys.API_KEY_SUFFIX)
|
||||
providerKey to value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has seen the "what's new" dialog for the current version
|
||||
*/
|
||||
suspend fun hasSeenCurrentVersion(currentVersion: String): Boolean {
|
||||
val lastSeen = lastSeenVersion.flow.first()
|
||||
return lastSeen == currentVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the current version as seen by the user
|
||||
*/
|
||||
suspend fun markVersionAsSeen(version: String) {
|
||||
lastSeenVersion.set(version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves an API key for a given provider.
|
||||
* The provider object is used to get the key, but the preference is stored dynamically.
|
||||
*/
|
||||
suspend fun saveApiKey(provider: ApiProvider, apiKey: String) {
|
||||
context.dataStore.edit { settings ->
|
||||
settings[PrefKeys.getApiKeyPrefKey(provider.key)] = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an API key for a given provider.
|
||||
*/
|
||||
suspend fun deleteApiKey(providerKey: String) {
|
||||
context.dataStore.edit { settings ->
|
||||
settings.remove(PrefKeys.getApiKeyPrefKey(providerKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
@file:OptIn(ExperimentalTime::class)
|
||||
@file:Suppress("HardCodedStringLiteral", "unused")
|
||||
|
||||
package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.withTransaction
|
||||
import eu.gaudian.translator.model.Language
|
||||
import eu.gaudian.translator.model.TagCategory
|
||||
import eu.gaudian.translator.model.VocabularyCategory
|
||||
import eu.gaudian.translator.model.VocabularyFilter
|
||||
import eu.gaudian.translator.model.VocabularyItem
|
||||
import eu.gaudian.translator.model.VocabularyItemState
|
||||
import eu.gaudian.translator.model.VocabularyStage
|
||||
import eu.gaudian.translator.model.db.AppDatabase
|
||||
import eu.gaudian.translator.model.db.CategoryMappingEntity
|
||||
import eu.gaudian.translator.model.db.DailyStatEntity
|
||||
import eu.gaudian.translator.model.db.StageMappingEntity
|
||||
import eu.gaudian.translator.model.db.VocabularyCategoryEntity
|
||||
import eu.gaudian.translator.utils.Log
|
||||
import eu.gaudian.translator.utils.VocabularyService
|
||||
import eu.gaudian.translator.viewmodel.CategoryProgress
|
||||
import eu.gaudian.translator.viewmodel.StageStats
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
private const val TAG = "VocabularyRepository"
|
||||
|
||||
@Serializable
|
||||
data class CategoryMapping(
|
||||
val vocabularyItemId: Int,
|
||||
val categoryId: Int,
|
||||
)
|
||||
|
||||
class VocabularyRepository private constructor(context: Context) {
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: VocabularyRepository? = null
|
||||
fun getInstance(context: Context): VocabularyRepository = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: VocabularyRepository(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
|
||||
val settingsRepository = SettingsRepository(context)
|
||||
private val vocabularyItemService = VocabularyService(context)
|
||||
private val updateMappingsMutex = Mutex()
|
||||
|
||||
// Coalescing scheduler for updateMappings to avoid heavy bursts
|
||||
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val debounceMs = 1500L
|
||||
@Volatile private var isRunning = false
|
||||
@Volatile private var pendingRequest = false
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
// DAOs from Room are the new data source
|
||||
private val db = AppDatabase.getDatabase(context)
|
||||
private val itemDao = db.vocabularyItemDao()
|
||||
private val stateDao = db.vocabularyStateDao()
|
||||
private val categoryDao = db.categoryDao()
|
||||
private val mappingDao = db.mappingDao()
|
||||
private val dailyStatDao = db.dailyStatDao()
|
||||
|
||||
fun initializeRepository() {
|
||||
Log.d(TAG, "Initializing repository...")
|
||||
requestUpdateMappings()
|
||||
}
|
||||
|
||||
suspend fun getDailyVocabularyStats(startDate: LocalDate, endDate: LocalDate): Map<LocalDate, Int> {
|
||||
// The DAO query does all the hard work
|
||||
val statsFromDb = stateDao.getCorrectAnswerCountsByDate(startDate, endDate)
|
||||
val dailyStats = statsFromDb.associate { it.date to it.count }.toMutableMap()
|
||||
|
||||
// Ensure all dates in the range are present, even if their count is 0
|
||||
var currentDate = startDate
|
||||
while (currentDate <= endDate) {
|
||||
dailyStats.putIfAbsent(currentDate, 0)
|
||||
currentDate = currentDate.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
return dailyStats
|
||||
}
|
||||
|
||||
fun getCategoryMappingsFlow(): Flow<List<CategoryMapping>> {
|
||||
return mappingDao.getCategoryMappingsFlow().map { list ->
|
||||
list.map { CategoryMapping(it.vocabularyItemId, it.categoryId) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getWordsLearnedByDate(startDate: LocalDate, endDate: LocalDate): Map<LocalDate, Int> {
|
||||
val allStates = getAllVocabularyItemStates()
|
||||
val dailyStats = mutableMapOf<LocalDate, Int>().withDefault { 0 }
|
||||
|
||||
allStates.forEach { state ->
|
||||
if (state.correctAnswerCount >= settingsRepository.criteriaCorrect.flow.first()) {
|
||||
state.lastCorrectAnswer?.let { learnedTime ->
|
||||
val learnedDate = learnedTime.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
if (learnedDate in startDate..endDate) {
|
||||
dailyStats[learnedDate] = dailyStats.getValue(learnedDate) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var currentDate = startDate
|
||||
while (currentDate <= endDate) {
|
||||
dailyStats.putIfAbsent(currentDate, 0)
|
||||
currentDate = currentDate.plus(1, DateTimeUnit.DAY)
|
||||
}
|
||||
return dailyStats
|
||||
}
|
||||
|
||||
private suspend fun runUpdateMappingsInternal() = updateMappingsMutex.withLock {
|
||||
coroutineScope {
|
||||
val stageUpdateJob = async { actualizeVocabularyStageMappings() }
|
||||
stageUpdateJob.await()
|
||||
|
||||
val listMappings = async { calculateListMappings() }.await()
|
||||
val filterMappings = async { calculateFilterMappings() }.await()
|
||||
|
||||
val allMappings = (listMappings + filterMappings).distinct()
|
||||
|
||||
// Atomically replace all mappings
|
||||
mappingDao.setAllCategoryMappings(allMappings.map { CategoryMappingEntity(it.vocabularyItemId, it.categoryId) })
|
||||
}
|
||||
}
|
||||
|
||||
// Public scheduler entry
|
||||
private fun requestUpdateMappings() {
|
||||
// Mark that a request is pending
|
||||
pendingRequest = true
|
||||
// Restart debounce timer
|
||||
debounceJob?.cancel()
|
||||
debounceJob = repoScope.launch {
|
||||
delay(debounceMs)
|
||||
triggerIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerIfNeeded() {
|
||||
// Only one runner at a time; if already running, we'll run again afterwards
|
||||
if (isRunning) return
|
||||
if (!pendingRequest) return
|
||||
pendingRequest = false
|
||||
isRunning = true
|
||||
repoScope.launch {
|
||||
try {
|
||||
runUpdateMappingsInternal()
|
||||
} finally {
|
||||
isRunning = false
|
||||
// If more requests arrived while running, schedule next run after small debounce
|
||||
if (pendingRequest) {
|
||||
debounceJob?.cancel()
|
||||
debounceJob = repoScope.launch {
|
||||
delay(debounceMs)
|
||||
triggerIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun editVocabularyItem(vocabularyItem: VocabularyItem) {
|
||||
Log.d(TAG, "editVocabularyItem: Editing item id=${vocabularyItem.id}")
|
||||
itemDao.upsertItem(vocabularyItem)
|
||||
requestUpdateMappings()
|
||||
}
|
||||
|
||||
suspend fun updateVocabularyItems(items: List<VocabularyItem>) {
|
||||
Log.d(TAG, "updateVocabularyItems: Updating ${items.size} items.")
|
||||
items.forEach { itemDao.upsertItem(it) }
|
||||
requestUpdateMappings()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun getDueTodayItemsFlow(): Flow<List<VocabularyItem>> {
|
||||
return combine(
|
||||
settingsRepository.intervalStage1.flow, settingsRepository.intervalStage2.flow,
|
||||
settingsRepository.intervalStage3.flow, settingsRepository.intervalStage4.flow,
|
||||
settingsRepository.intervalStage5.flow, settingsRepository.intervalLearned.flow
|
||||
) { intervals ->
|
||||
// This is a list of integers [stage1, stage2, ...]
|
||||
intervals.toList()
|
||||
}.flatMapLatest { intervals ->
|
||||
// flatMapLatest ensures the DB query is re-triggered when intervals change
|
||||
itemDao.getDueTodayItemsFlow(
|
||||
now = Clock.System.now().epochSeconds,
|
||||
intervalStage1 = intervals[0],
|
||||
intervalStage2 = intervals[1],
|
||||
intervalStage3 = intervals[2],
|
||||
intervalStage4 = intervals[3],
|
||||
intervalStage5 = intervals[4],
|
||||
intervalLearned = intervals[5]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllVocabularyItemsFlow(): Flow<List<VocabularyItem>> = itemDao.getAllItemsFlow()
|
||||
|
||||
suspend fun getAllVocabularyItems(): List<VocabularyItem> = itemDao.getAllItems()
|
||||
|
||||
suspend fun getVocabularyItemById(vocabularyItemId: Int): VocabularyItem? = itemDao.getItemById(vocabularyItemId)
|
||||
|
||||
suspend fun deleteVocabularyItemById(vocabularyItemId: Int) {
|
||||
Log.w(TAG, "deleteVocabularyItemById: Deleting item id=$vocabularyItemId")
|
||||
itemDao.deleteItemById(vocabularyItemId)
|
||||
requestUpdateMappings()
|
||||
}
|
||||
|
||||
suspend fun deleteVocabularyItemsByIds(vocabularyItemIds: List<Int>) {
|
||||
Log.w(TAG, "deleteVocabularyItemsByIds: Deleting ${vocabularyItemIds.size} items.")
|
||||
itemDao.deleteItemsByIds(vocabularyItemIds)
|
||||
requestUpdateMappings()
|
||||
}
|
||||
|
||||
suspend fun generateVocabularyItems(
|
||||
category: String, languageFirst: Language, languageSecond: Language, amount: Int
|
||||
): Result<List<VocabularyItem>> {
|
||||
return vocabularyItemService.generateVocabularyItems(category, languageFirst, languageSecond, amount)
|
||||
}
|
||||
|
||||
suspend fun introduceVocabularyItems(newItems: List<VocabularyItem>, categoryIds: List<Int> = emptyList()) {
|
||||
Log.i(TAG, "introduceVocabularyItems: Adding ${newItems.size} new items to categories: $categoryIds")
|
||||
val maxId = itemDao.getMaxItemId() ?: 0
|
||||
val updatedItems = newItems.mapIndexed { index, item -> item.copy(id = maxId + index + 1) }
|
||||
itemDao.insertAll(updatedItems)
|
||||
|
||||
if (categoryIds.isNotEmpty()) {
|
||||
val newMappings = updatedItems.flatMap { item ->
|
||||
categoryIds.map { categoryId ->
|
||||
CategoryMappingEntity(vocabularyItemId = item.id, categoryId = categoryId)
|
||||
}
|
||||
}
|
||||
newMappings.forEach { mappingDao.addCategoryMapping(it) }
|
||||
}
|
||||
requestUpdateMappings()
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
suspend fun findDuplicates(): List<VocabularyItem> {
|
||||
Log.d(TAG, "findDuplicates: Searching for duplicate items.")
|
||||
return getAllVocabularyItems()
|
||||
.groupBy { item -> item.wordFirst.lowercase() to item.wordSecond.lowercase() }
|
||||
.values
|
||||
.filter { it.size > 1 }
|
||||
.flatten()
|
||||
}
|
||||
|
||||
suspend fun cleanDuplicates() {
|
||||
Log.i(TAG, "cleanDuplicates: Starting duplicate cleanup.")
|
||||
val allItems = getAllVocabularyItems()
|
||||
val uniqueItems = allItems.distinctBy { item -> item.wordFirst.lowercase() to item.wordSecond.lowercase() }
|
||||
if (allItems.size != uniqueItems.size) {
|
||||
val itemsToDelete = allItems.filterNot { uniqueItem -> uniqueItems.any { it.id == uniqueItem.id } }
|
||||
Log.w(TAG, "cleanDuplicates: Found and deleting ${itemsToDelete.size} duplicates.")
|
||||
itemDao.deleteItemsByIds(itemsToDelete.map { it.id })
|
||||
requestUpdateMappings()
|
||||
} else {
|
||||
Log.d(TAG, "cleanDuplicates: No duplicates found.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapEntityToCategory(entity: VocabularyCategoryEntity): VocabularyCategory {
|
||||
return when(entity.type) {
|
||||
"LIST", "TAG" -> TagCategory(entity.id, entity.name)
|
||||
"FILTER" -> {
|
||||
val langsJson = entity.filterLanguages
|
||||
val langs: List<Int>? = langsJson?.let { Json.decodeFromString(it) }
|
||||
val pair: Pair<Int, Int>? = if (entity.dictLangFirst != null && entity.dictLangSecond != null) {
|
||||
Pair(entity.dictLangFirst, entity.dictLangSecond)
|
||||
} else null
|
||||
VocabularyFilter(
|
||||
id = entity.id,
|
||||
name = entity.name,
|
||||
languages = langs,
|
||||
languagePairs = pair,
|
||||
stages = entity.filterStages?.let { Json.decodeFromString(it) }
|
||||
)
|
||||
}
|
||||
"DICTIONARY" -> VocabularyFilter(
|
||||
id = entity.id,
|
||||
name = entity.name,
|
||||
languages = null,
|
||||
languagePairs = Pair(entity.dictLangFirst!!, entity.dictLangSecond!!),
|
||||
stages = null
|
||||
)
|
||||
else -> TagCategory(entity.id, entity.name)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mapCategoryToEntity(category: VocabularyCategory): VocabularyCategoryEntity {
|
||||
val id = if (category.id == 0) (categoryDao.getAllCategories().maxOfOrNull { it.id } ?: 0) + 1 else category.id
|
||||
return when(category) {
|
||||
is TagCategory -> VocabularyCategoryEntity(id, category.name, "TAG", null, null, null, null)
|
||||
is VocabularyFilter -> {
|
||||
Log.d(TAG, "mapCategoryToEntity: $category, id=$id")
|
||||
val hasPair = category.languagePairs != null
|
||||
VocabularyCategoryEntity(
|
||||
id = id,
|
||||
name = category.name,
|
||||
type = "FILTER",
|
||||
filterLanguages = if (!hasPair) category.languages?.let { Json.encodeToString(it) } else null,
|
||||
filterStages = category.stages?.let { if (it.isEmpty()) null else Json.encodeToString(it) },
|
||||
dictLangFirst = category.languagePairs?.first,
|
||||
dictLangSecond = category.languagePairs?.second
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAllCategories(): List<VocabularyCategory> {
|
||||
return categoryDao.getAllCategories().map { mapEntityToCategory(it) }
|
||||
}
|
||||
|
||||
fun getAllCategoriesFlow(): Flow<List<VocabularyCategory>> {
|
||||
return categoryDao.getAllCategoriesFlow().map { list -> list.map { mapEntityToCategory(it) } }
|
||||
}
|
||||
|
||||
suspend fun saveCategory(category: VocabularyCategory) {
|
||||
val entity = mapCategoryToEntity(category)
|
||||
Log.i(TAG, "saveCategory: Upserting category='${category.name}' -> entity=${entity}")
|
||||
categoryDao.upsertCategory(entity)
|
||||
// Recalculate mappings after saving the updated category to ensure filters (languages/stages/pair) take effect immediately
|
||||
requestUpdateMappings()
|
||||
}
|
||||
|
||||
suspend fun deleteCategoryById(categoryId: Int) {
|
||||
Log.w(TAG, "deleteCategoryById: Deleting category id=$categoryId")
|
||||
categoryDao.deleteCategoryById(categoryId)
|
||||
requestUpdateMappings()
|
||||
}
|
||||
|
||||
suspend fun getCategoryMappings(): List<CategoryMapping> {
|
||||
return mappingDao.getCategoryMappings().map { CategoryMapping(it.vocabularyItemId, it.categoryId) }
|
||||
}
|
||||
|
||||
suspend fun addVocabularyItemToList(vocabularyItemId: Int, listId: Int) {
|
||||
Log.d(TAG, "addVocabularyItemToList: Adding item $vocabularyItemId to list $listId")
|
||||
mappingDao.addCategoryMapping(CategoryMappingEntity(vocabularyItemId, listId))
|
||||
}
|
||||
|
||||
suspend fun removeVocabularyItemFromList(vocabularyItemId: Int, listId: Int) {
|
||||
Log.d(TAG, "removeVocabularyItemFromList: Removing item $vocabularyItemId from list $listId")
|
||||
mappingDao.removeCategoryMapping(vocabularyItemId, listId)
|
||||
}
|
||||
|
||||
suspend fun getVocabularyItemsByCategory(categoryId: Int): List<VocabularyItem> {
|
||||
return itemDao.getItemsByCategoryId(categoryId)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun getAllVocabularyItemStatesFlow(): Flow<List<VocabularyItemState>> = stateDao.getAllStatesFlow()
|
||||
|
||||
suspend fun getAllVocabularyItemStates(): List<VocabularyItemState> = stateDao.getAllStates()
|
||||
|
||||
suspend fun getVocabularyItemStateById(vocabularyItemId: Int): VocabularyItemState? = stateDao.getStateById(vocabularyItemId)
|
||||
|
||||
suspend fun saveVocabularyItemState(vocabularyItemState: VocabularyItemState) {
|
||||
Log.d(TAG, "saveVocabularyItemState: Saving state for item ${vocabularyItemState.vocabularyItemId}")
|
||||
stateDao.upsertState(vocabularyItemState)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
suspend fun itemExists(word: String, languageID: Int?): Boolean {
|
||||
// Avoid loading all items into memory; delegate existence check to the DB
|
||||
return itemDao.itemExists(word, languageID)
|
||||
}
|
||||
|
||||
suspend fun getLastCorrectAnswer(vocabularyItemId: Int): Instant? {
|
||||
return getVocabularyItemStateById(vocabularyItemId)?.lastCorrectAnswer
|
||||
}
|
||||
|
||||
suspend fun getLastIncorrectAnswer(vocabularyItemId: Int): Instant? {
|
||||
return getVocabularyItemStateById(vocabularyItemId)?.lastIncorrectAnswer
|
||||
}
|
||||
|
||||
suspend fun getCorrectAnswerCount(vocabularyItemId: Int): Int {
|
||||
return getVocabularyItemStateById(vocabularyItemId)?.correctAnswerCount ?: 0
|
||||
}
|
||||
|
||||
suspend fun getIncorrectAnswerCount(vocabularyItemId: Int): Int {
|
||||
return getVocabularyItemStateById(vocabularyItemId)?.incorrectAnswerCount ?: 0
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
suspend fun getVocabularyItemsByStage(stage: VocabularyStage): List<VocabularyItem> {
|
||||
val stageMapping = loadStageMapping().first()
|
||||
val idsForStage = stageMapping.filterValues { it == stage }.keys
|
||||
if (idsForStage.isEmpty()) return emptyList()
|
||||
return itemDao.getItemsByIds(idsForStage.toList())
|
||||
}
|
||||
|
||||
suspend fun changeVocabularyItemStage(item: VocabularyItem?, newStage: VocabularyStage) {
|
||||
val itemId = item?.id ?: return
|
||||
Log.d(TAG, "changeVocabularyItemStage: Changing item $itemId to stage $newStage")
|
||||
mappingDao.upsertStageMapping(StageMappingEntity(itemId, newStage))
|
||||
}
|
||||
|
||||
suspend fun changeVocabularyItemsStage(items: List<VocabularyItem>?, newStage: VocabularyStage) {
|
||||
if (items.isNullOrEmpty()) return
|
||||
Log.d(TAG, "changeVocabularyItemsStage: Changing ${items.size} items to stage $newStage")
|
||||
val newMappings = items.map { StageMappingEntity(it.id, newStage) }
|
||||
mappingDao.upsertStageMappings(newMappings)
|
||||
}
|
||||
|
||||
fun getVocabularyItemStage(itemId: Int): Flow<VocabularyStage> {
|
||||
return loadStageMapping().map { stageMap ->
|
||||
stageMap[itemId] ?: VocabularyStage.NEW
|
||||
}
|
||||
}
|
||||
|
||||
fun loadStageMapping(): Flow<Map<Int, VocabularyStage>> {
|
||||
return mappingDao.getStageMappingsFlow().map { list ->
|
||||
list.associate { it.vocabularyItemId to it.stage }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveStageMapping(mapping: Map<Int, VocabularyStage>) {
|
||||
val entities = mapping.map { StageMappingEntity(it.key, it.value) }
|
||||
mappingDao.upsertStageMappings(entities)
|
||||
}
|
||||
|
||||
suspend fun updateFlashcardStage(item: VocabularyItem, isCorrect: Boolean) {
|
||||
val vocabularyItemId = item.id
|
||||
val currentStage = getVocabularyItemStage(item.id).first()
|
||||
val vocabularyItemState = getVocabularyItemStateById(vocabularyItemId) ?: VocabularyItemState(vocabularyItemId)
|
||||
val criteriaCorrect = settingsRepository.criteriaCorrect.flow.first()
|
||||
val criteriaWrong = settingsRepository.criteriaWrong.flow.first()
|
||||
val now = Clock.System.now()
|
||||
Log.d(TAG, "updateFlashcardStage: Item=${item.id}, Correct=$isCorrect, CurrentStage=$currentStage")
|
||||
|
||||
|
||||
if (isCorrect) {
|
||||
vocabularyItemState.correctAnswerCount++
|
||||
vocabularyItemState.lastCorrectAnswer = now
|
||||
} else {
|
||||
vocabularyItemState.incorrectAnswerCount++
|
||||
vocabularyItemState.lastIncorrectAnswer = now
|
||||
}
|
||||
|
||||
val nextStage = calculateNextStage(
|
||||
currentStage, isCorrect,
|
||||
vocabularyItemState.correctAnswerCount, vocabularyItemState.incorrectAnswerCount,
|
||||
criteriaCorrect, criteriaWrong
|
||||
)
|
||||
|
||||
if (nextStage != currentStage) {
|
||||
Log.i(TAG, "updateFlashcardStage: Item ${item.id} moved from $currentStage to $nextStage")
|
||||
vocabularyItemState.correctAnswerCount = 0
|
||||
vocabularyItemState.incorrectAnswerCount = 0
|
||||
changeVocabularyItemStage(item, nextStage)
|
||||
}
|
||||
saveVocabularyItemState(vocabularyItemState)
|
||||
}
|
||||
|
||||
private fun calculateNextStage(
|
||||
currentStage: VocabularyStage, isCorrect: Boolean,
|
||||
correctCount: Int, incorrectCount: Int,
|
||||
criteriaCorrect: Int, criteriaWrong: Int
|
||||
): VocabularyStage {
|
||||
val readyToAdvance = if (isCorrect) correctCount >= criteriaCorrect else incorrectCount >= criteriaWrong
|
||||
if (!readyToAdvance) return currentStage
|
||||
return when (currentStage) {
|
||||
VocabularyStage.NEW -> VocabularyStage.STAGE_1
|
||||
VocabularyStage.STAGE_1 -> VocabularyStage.STAGE_2
|
||||
VocabularyStage.STAGE_2 -> VocabularyStage.STAGE_3
|
||||
VocabularyStage.STAGE_3 -> VocabularyStage.STAGE_4
|
||||
VocabularyStage.STAGE_4 -> VocabularyStage.STAGE_5
|
||||
VocabularyStage.STAGE_5 -> VocabularyStage.LEARNED
|
||||
VocabularyStage.LEARNED -> VocabularyStage.LEARNED
|
||||
}
|
||||
}
|
||||
|
||||
private fun isItemFitForCategory(
|
||||
item: VocabularyItem,
|
||||
filter: VocabularyFilter,
|
||||
stageMapping: Map<Int, VocabularyStage>
|
||||
): Boolean {
|
||||
val stage = stageMapping[item.id] ?: VocabularyStage.NEW
|
||||
val stageFilter = filter.stages
|
||||
val stageMatches = stageFilter.isNullOrEmpty() || stageFilter.contains(stage)
|
||||
|
||||
// Language filter precedence: dictionary pair > languages list > no language filter
|
||||
val firstId = item.languageFirstId
|
||||
val secondId = item.languageSecondId
|
||||
val languageMatches = when {
|
||||
// Pair specified: both item language IDs must be non-null and match the pair (in any order)
|
||||
filter.languagePairs != null -> {
|
||||
val (a, b) = filter.languagePairs
|
||||
(firstId != null && secondId != null) &&
|
||||
((firstId == a && secondId == b) || (firstId == b && secondId == a))
|
||||
}
|
||||
// Languages list specified: any of the item language IDs must be in the list
|
||||
!filter.languages.isNullOrEmpty() -> {
|
||||
val ids = filter.languages
|
||||
(firstId != null && ids.contains(firstId)) || (secondId != null && ids.contains(secondId))
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
val matches = stageMatches && languageMatches
|
||||
return matches
|
||||
}
|
||||
|
||||
suspend fun calcAvailableDictionaries(): Set<Pair<Int, Int>> {
|
||||
return getAllVocabularyItems().mapNotNull {
|
||||
val lang1 = it.languageFirstId
|
||||
val lang2 = it.languageSecondId
|
||||
if (lang1 != null && lang2 != null) {
|
||||
if (lang1 < lang2) lang1 to lang2 else lang2 to lang1
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
suspend fun getCategoryById(categoryId: Int): VocabularyCategory? {
|
||||
return categoryDao.getCategoryById(categoryId)?.let { mapEntityToCategory(it) }
|
||||
}
|
||||
|
||||
private suspend fun calculateListMappings(): List<CategoryMapping> {
|
||||
val allItemIds = getAllVocabularyItems().map { it.id }.toSet()
|
||||
val listIds = getAllCategories().filterIsInstance<TagCategory>().map { it.id }.toSet()
|
||||
val listMappings = getCategoryMappings().filter { it.categoryId in listIds && it.vocabularyItemId in allItemIds }
|
||||
Log.d(TAG, "calculateListMappings: Found ${listMappings.size} manual list mappings.")
|
||||
return listMappings
|
||||
}
|
||||
|
||||
private suspend fun calculateFilterMappings(): List<CategoryMapping> {
|
||||
val vocabularyItems = getAllVocabularyItems()
|
||||
val autoFilters = getAllCategories().filterIsInstance<VocabularyFilter>()
|
||||
val stageMapping = loadStageMapping().first()
|
||||
val newMappings = mutableListOf<CategoryMapping>()
|
||||
|
||||
for (item in vocabularyItems) {
|
||||
for (filter in autoFilters) {
|
||||
if (isItemFitForCategory(item, filter, stageMapping)) {
|
||||
newMappings.add(CategoryMapping(item.id, filter.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "calculateFilterMappings: Generated ${newMappings.size} filter mappings.")
|
||||
return newMappings
|
||||
}
|
||||
|
||||
|
||||
suspend fun actualizeVocabularyStageMappings() {
|
||||
val allItems = getAllVocabularyItems()
|
||||
val currentStageMapping = loadStageMapping().first()
|
||||
val newStageMappings = allItems.associate { item ->
|
||||
item.id to (currentStageMapping[item.id] ?: VocabularyStage.NEW)
|
||||
}
|
||||
val allIds = allItems.map { it.id }
|
||||
mappingDao.deleteStageMappingsNotIn(allIds)
|
||||
saveStageMapping(newStageMappings)
|
||||
calculateCategoryProgress()
|
||||
}
|
||||
|
||||
suspend fun getDueTodayItems(): List<VocabularyItem> {
|
||||
return getDueTodayItemsFlow().first()
|
||||
}
|
||||
|
||||
suspend fun calculateStageStatistics(): List<StageStats> {
|
||||
val stageMapping = loadStageMapping().first()
|
||||
val counts = stageMapping.values.groupingBy { it }.eachCount()
|
||||
return VocabularyStage.entries.map { stage ->
|
||||
StageStats(stage, counts[stage] ?: 0)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getDailyCorrectCount(date: LocalDate): Int {
|
||||
return dailyStatDao.getStatForDate(date)?.correctCount ?: 0
|
||||
}
|
||||
|
||||
suspend fun updateDailyCorrectCount(date: LocalDate, count: Int) {
|
||||
dailyStatDao.upsertStat(DailyStatEntity(date, count))
|
||||
}
|
||||
|
||||
suspend fun getCorrectCountsForLastDays(days: Int): Map<LocalDate, Int> {
|
||||
val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||
val counts = mutableMapOf<LocalDate, Int>()
|
||||
for (i in 0 until days) {
|
||||
val date = today.minus(i, DateTimeUnit.DAY)
|
||||
counts[date] = getDailyCorrectCount(date)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
suspend fun isTargetMetForDate(date: LocalDate): Boolean {
|
||||
val dailyCorrectCount = getDailyCorrectCount(date)
|
||||
val target = 10 // TODO: Replace with settingsRepository.dailyGoal.flow.first()
|
||||
return dailyCorrectCount >= target
|
||||
}
|
||||
|
||||
suspend fun getAllLanguageIdsFromVocabulary(): Set<Int?> {
|
||||
Log.d(TAG, "getAllLanguageIdsFromVocabulary: Fetching all language IDs.")
|
||||
val languageIds = getAllVocabularyItems()
|
||||
.asSequence()
|
||||
.flatMap { sequenceOf(it.languageFirstId, it.languageSecondId) }
|
||||
.toSet()
|
||||
Log.d(TAG, "getAllLanguageIdsFromVocabulary: Found ${languageIds.size} unique language IDs: $languageIds")
|
||||
return languageIds
|
||||
}
|
||||
|
||||
suspend fun calculateCategoryProgress(): List<CategoryProgress> = coroutineScope {
|
||||
val stageMappingDeferred = async { loadStageMapping().first() }
|
||||
val itemsDeferred = async { getAllVocabularyItems() }
|
||||
val mappingsDeferred = async { getCategoryMappings() }
|
||||
|
||||
val categories = getAllCategories()
|
||||
val stageMapping = stageMappingDeferred.await()
|
||||
val allItems = itemsDeferred.await()
|
||||
val allMappings = mappingsDeferred.await()
|
||||
|
||||
// Create maps for efficient lookups
|
||||
val itemsById = allItems.associateBy { it.id }
|
||||
val itemIdsByCategoryId = allMappings.groupBy(
|
||||
keySelector = { it.categoryId },
|
||||
valueTransform = { it.vocabularyItemId }
|
||||
)
|
||||
|
||||
categories.map { category ->
|
||||
val itemIdsForCategory = itemIdsByCategoryId[category.id] ?: emptyList()
|
||||
val itemsInCategory = itemIdsForCategory.mapNotNull { itemsById[it] }
|
||||
|
||||
val totalItems = itemsInCategory.size
|
||||
val itemsCompleted = itemsInCategory.count { stageMapping[it.id] == VocabularyStage.LEARNED }
|
||||
val itemsInStages = itemsInCategory.count {
|
||||
val stage = stageMapping[it.id]
|
||||
stage != VocabularyStage.NEW && stage != VocabularyStage.LEARNED
|
||||
}
|
||||
val newItems = itemsInCategory.count { stageMapping[it.id] == VocabularyStage.NEW || stageMapping[it.id] == null }
|
||||
|
||||
CategoryProgress(
|
||||
vocabularyCategory = category,
|
||||
totalItems = totalItems,
|
||||
itemsCompleted = itemsCompleted,
|
||||
itemsInStages = itemsInStages,
|
||||
newItems = newItems,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSynonymsForItem(itemId: Int): List<VocabularyItem> {
|
||||
val item = getVocabularyItemById(itemId) ?: return emptyList()
|
||||
val lang1 = item.languageFirstId
|
||||
val lang2 = item.languageSecondId
|
||||
return if (lang1 != null && lang2 != null) {
|
||||
itemDao.getSynonyms(
|
||||
excludeId = item.id,
|
||||
lang1 = lang1,
|
||||
lang2 = lang2,
|
||||
wordFirst = item.wordFirst,
|
||||
wordSecond = item.wordSecond
|
||||
)
|
||||
} else {
|
||||
// Fallback: if we don't have both language IDs, do a minimal in-memory filter
|
||||
getAllVocabularyItems().asSequence()
|
||||
.filter { it.id != item.id }
|
||||
.filter { it.wordFirst == item.wordFirst || it.wordSecond == item.wordSecond }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getNewlyAddedCountForDate(date: LocalDate): Int {
|
||||
return getAllVocabularyItems().count { item ->
|
||||
item.createdAt?.toLocalDateTime(TimeZone.currentSystemDefault())?.date == date
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCompletedCountForDate(date: LocalDate): Int {
|
||||
val criteria = settingsRepository.criteriaCorrect.flow.first()
|
||||
return getAllVocabularyItemStates().count { state ->
|
||||
val completedDate = state.lastCorrectAnswer?.toLocalDateTime(TimeZone.currentSystemDefault())?.date
|
||||
completedDate == date && state.correctAnswerCount >= criteria
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCorrectAnswerCountForDate(date: LocalDate): Int {
|
||||
return getDailyCorrectCount(date)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
suspend fun backupRepositoryState(): String {
|
||||
Log.i(TAG, "backupRepositoryState: Creating repository backup string.")
|
||||
val backupData = RepositoryBackup(
|
||||
items = getAllVocabularyItems(),
|
||||
categories = getAllCategories(),
|
||||
states = getAllVocabularyItemStates(),
|
||||
categoryMappings = getCategoryMappings(),
|
||||
stageMappings = loadStageMapping().first().toList()
|
||||
)
|
||||
return Json.encodeToString(RepositoryBackup.serializer(), backupData)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
suspend fun restoreRepositoryState(backupJson: String) {
|
||||
Log.w(TAG, "restoreRepositoryState: Restoring repository from backup. This will replace all existing data.")
|
||||
val backupData = Json.decodeFromString(RepositoryBackup.serializer(), backupJson)
|
||||
|
||||
db.withTransaction {
|
||||
itemDao.insertAll(backupData.items)
|
||||
stateDao.insertAll(backupData.states)
|
||||
val categoryEntities = backupData.categories.map { mapCategoryToEntity(it) }
|
||||
categoryDao.insertAll(categoryEntities)
|
||||
val categoryMappingEntities = backupData.categoryMappings.map { CategoryMappingEntity(it.vocabularyItemId, it.categoryId) }
|
||||
mappingDao.setAllCategoryMappings(categoryMappingEntities)
|
||||
val stageMappingEntities = backupData.stageMappings.map { StageMappingEntity(it.first, it.second) }
|
||||
mappingDao.upsertStageMappings(stageMappingEntities)
|
||||
}
|
||||
requestUpdateMappings()
|
||||
Log.i(TAG, "restoreRepositoryState: Restore complete.")
|
||||
}
|
||||
|
||||
suspend fun wipeRepository() {
|
||||
Log.e(TAG, "wipeRepository: Deleting all repository data!")
|
||||
db.withTransaction {
|
||||
mappingDao.clearCategoryMappings()
|
||||
mappingDao.clearStageMappings()
|
||||
categoryDao.clearAllCategories()
|
||||
stateDao.clearAllStates()
|
||||
itemDao.clearAllItems()
|
||||
dailyStatDao.clearAll()
|
||||
}
|
||||
requestUpdateMappings()
|
||||
Log.i(TAG, "wipeRepository: All data deleted.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints a detailed summary of the current repository state to the debug log.
|
||||
* This function is intended for debugging purposes.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
suspend fun printRepoState() {
|
||||
val allItems = getAllVocabularyItems()
|
||||
val allCategories = getAllCategories()
|
||||
val allStates = getAllVocabularyItemStates()
|
||||
val categoryMappings = getCategoryMappings()
|
||||
val stageMapping = loadStageMapping().first()
|
||||
|
||||
val itemsPerCategory = categoryMappings.groupBy { it.categoryId }
|
||||
.mapValues { it.value.size }
|
||||
|
||||
val itemsPerStage = stageMapping.values.groupingBy { it }.eachCount()
|
||||
|
||||
Log.d(TAG, "--- REPOSITORY STATE ---")
|
||||
Log.d(TAG, "Total Items: ${allItems.size}")
|
||||
Log.d(TAG, "Total Categories: ${allCategories.size} (${allCategories.filterIsInstance<TagCategory>().size} Tags, ${allCategories.filterIsInstance<VocabularyFilter>().size} Filters)")
|
||||
Log.d(TAG, "Total Item States: ${allStates.size}")
|
||||
Log.d(TAG, "Total Category Mappings: ${categoryMappings.size}")
|
||||
Log.d(TAG, "Total Stage Mappings: ${stageMapping.size}")
|
||||
Log.d(TAG, "--- Items per Category ---")
|
||||
allCategories.forEach { category ->
|
||||
Log.d(TAG, " - ${category.name} (ID: ${category.id}, Type: ${category::class.simpleName}): ${itemsPerCategory[category.id] ?: 0} items")
|
||||
}
|
||||
Log.d(TAG, "--- Items per Stage ---")
|
||||
VocabularyStage.entries.forEach { stage ->
|
||||
Log.d(TAG, " - ${stage.name}: ${itemsPerStage[stage] ?: 0} items")
|
||||
}
|
||||
Log.d(TAG, "--- END REPOSITORY STATE ---")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RepositoryBackup(
|
||||
val items: List<VocabularyItem>,
|
||||
val categories: List<VocabularyCategory>,
|
||||
val states: List<VocabularyItemState>,
|
||||
val categoryMappings: List<CategoryMapping>,
|
||||
val stageMappings: List<Pair<Int, VocabularyStage>>
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
206
app/src/main/java/eu/gaudian/translator/ui/theme/Theme.kt
Normal file
206
app/src/main/java/eu/gaudian/translator/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,206 @@
|
||||
package eu.gaudian.translator.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import eu.gaudian.translator.ui.theme.themes.AutumnSpiceTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.CitrusSplashTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.CoffeeTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.CrimsonTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.CyberpunkTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.DefaultTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.ForestTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.NordTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.OceanicCalmTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.PixelTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.SakuraTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.SlateAndStoneTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.SpaceTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.SynthwaveTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.TealTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.TwilightSerenityTheme
|
||||
|
||||
/**
|
||||
* A data class to hold the core colors for a theme variation (light or dark).
|
||||
* This makes defining new themes as simple as providing these color values.
|
||||
*/
|
||||
data class ThemeColorSet(
|
||||
// Main colors
|
||||
val primary: Color,
|
||||
val secondary: Color,
|
||||
val tertiary: Color,
|
||||
|
||||
// Container colors
|
||||
val primaryContainer: Color,
|
||||
val secondaryContainer: Color,
|
||||
val tertiaryContainer: Color,
|
||||
|
||||
// On colors (text/icons on top of main colors)
|
||||
val onPrimary: Color,
|
||||
val onSecondary: Color,
|
||||
val onTertiary: Color,
|
||||
val onPrimaryContainer: Color,
|
||||
val onSecondaryContainer: Color,
|
||||
val onTertiaryContainer: Color,
|
||||
|
||||
// Error colors
|
||||
val error: Color,
|
||||
val onError: Color,
|
||||
val errorContainer: Color,
|
||||
val onErrorContainer: Color,
|
||||
|
||||
// Background/surface colors
|
||||
val background: Color,
|
||||
val onBackground: Color,
|
||||
val surface: Color,
|
||||
val onSurface: Color,
|
||||
val surfaceVariant: Color,
|
||||
val onSurfaceVariant: Color,
|
||||
|
||||
// Outline colors
|
||||
val outline: Color,
|
||||
val outlineVariant: Color,
|
||||
|
||||
// Scrim
|
||||
val scrim: Color,
|
||||
|
||||
// Inverse colors
|
||||
val inverseSurface: Color,
|
||||
val inverseOnSurface: Color,
|
||||
val inversePrimary: Color,
|
||||
|
||||
// Surface container colors
|
||||
val surfaceDim: Color,
|
||||
val surfaceBright: Color,
|
||||
val surfaceContainerLowest: Color,
|
||||
val surfaceContainerLow: Color,
|
||||
val surfaceContainer: Color,
|
||||
val surfaceContainerHigh: Color,
|
||||
val surfaceContainerHighest: Color
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a complete, named theme in the app, containing both its light and dark color sets.
|
||||
*/
|
||||
data class AppTheme(
|
||||
val name: String,
|
||||
val lightColors: ThemeColorSet,
|
||||
val darkColors: ThemeColorSet
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a font style, including its display name and the filename of the font file.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
val AllThemes = listOf(
|
||||
|
||||
DefaultTheme,
|
||||
PixelTheme,
|
||||
CrimsonTheme,
|
||||
SakuraTheme,
|
||||
AutumnSpiceTheme,
|
||||
TealTheme,
|
||||
ForestTheme,
|
||||
CoffeeTheme,
|
||||
CitrusSplashTheme,
|
||||
OceanicCalmTheme,
|
||||
SlateAndStoneTheme,
|
||||
NordTheme,
|
||||
TwilightSerenityTheme,
|
||||
SpaceTheme,
|
||||
CyberpunkTheme,
|
||||
SynthwaveTheme,
|
||||
|
||||
|
||||
)
|
||||
|
||||
/**
|
||||
* A helper function that dynamically builds a Material [ColorScheme]
|
||||
* from our generic [ThemeColorSet].
|
||||
*
|
||||
* @param colorSet The set of colors to use.
|
||||
* @param isDark Whether to build a dark or light color scheme.
|
||||
* @return A complete Material 3 [ColorScheme].
|
||||
*/
|
||||
fun buildColorScheme(colorSet: ThemeColorSet, isDark: Boolean): ColorScheme {
|
||||
return if (isDark) {
|
||||
darkColorScheme(
|
||||
primary = colorSet.primary,
|
||||
onPrimary = colorSet.onPrimary,
|
||||
primaryContainer = colorSet.primaryContainer,
|
||||
onPrimaryContainer = colorSet.onPrimaryContainer,
|
||||
inversePrimary = colorSet.inversePrimary,
|
||||
secondary = colorSet.secondary,
|
||||
onSecondary = colorSet.onSecondary,
|
||||
secondaryContainer = colorSet.secondaryContainer,
|
||||
onSecondaryContainer = colorSet.onSecondaryContainer,
|
||||
tertiary = colorSet.tertiary,
|
||||
onTertiary = colorSet.onTertiary,
|
||||
tertiaryContainer = colorSet.tertiaryContainer,
|
||||
onTertiaryContainer = colorSet.onTertiaryContainer,
|
||||
background = colorSet.background,
|
||||
onBackground = colorSet.onBackground,
|
||||
surface = colorSet.surface,
|
||||
onSurface = colorSet.onSurface,
|
||||
surfaceVariant = colorSet.surfaceVariant,
|
||||
onSurfaceVariant = colorSet.onSurfaceVariant,
|
||||
surfaceDim = colorSet.surfaceDim,
|
||||
surfaceBright = colorSet.surfaceBright,
|
||||
surfaceContainerLowest = colorSet.surfaceContainerLowest,
|
||||
surfaceContainerLow = colorSet.surfaceContainerLow,
|
||||
surfaceContainer = colorSet.surfaceContainer,
|
||||
surfaceContainerHigh = colorSet.surfaceContainerHigh,
|
||||
surfaceContainerHighest = colorSet.surfaceContainerHighest,
|
||||
error = colorSet.error,
|
||||
onError = colorSet.onError,
|
||||
errorContainer = colorSet.errorContainer,
|
||||
onErrorContainer = colorSet.onErrorContainer,
|
||||
outline = colorSet.outline,
|
||||
outlineVariant = colorSet.outlineVariant,
|
||||
scrim = colorSet.scrim,
|
||||
inverseSurface = colorSet.inverseSurface,
|
||||
inverseOnSurface = colorSet.inverseOnSurface
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
primary = colorSet.primary,
|
||||
onPrimary = colorSet.onPrimary,
|
||||
primaryContainer = colorSet.primaryContainer,
|
||||
onPrimaryContainer = colorSet.onPrimaryContainer,
|
||||
inversePrimary = colorSet.inversePrimary,
|
||||
secondary = colorSet.secondary,
|
||||
onSecondary = colorSet.onSecondary,
|
||||
secondaryContainer = colorSet.secondaryContainer,
|
||||
onSecondaryContainer = colorSet.onSecondaryContainer,
|
||||
tertiary = colorSet.tertiary,
|
||||
onTertiary = colorSet.onTertiary,
|
||||
tertiaryContainer = colorSet.tertiaryContainer,
|
||||
onTertiaryContainer = colorSet.onTertiaryContainer,
|
||||
background = colorSet.background,
|
||||
onBackground = colorSet.onBackground,
|
||||
surface = colorSet.surface,
|
||||
onSurface = colorSet.onSurface,
|
||||
surfaceVariant = colorSet.surfaceVariant,
|
||||
onSurfaceVariant = colorSet.onSurfaceVariant,
|
||||
surfaceDim = colorSet.surfaceDim,
|
||||
surfaceBright = colorSet.surfaceBright,
|
||||
surfaceContainerLowest = colorSet.surfaceContainerLowest,
|
||||
surfaceContainerLow = colorSet.surfaceContainerLow,
|
||||
surfaceContainer = colorSet.surfaceContainer,
|
||||
surfaceContainerHigh = colorSet.surfaceContainerHigh,
|
||||
surfaceContainerHighest = colorSet.surfaceContainerHighest,
|
||||
error = colorSet.error,
|
||||
onError = colorSet.onError,
|
||||
errorContainer = colorSet.errorContainer,
|
||||
onErrorContainer = colorSet.onErrorContainer,
|
||||
outline = colorSet.outline,
|
||||
outlineVariant = colorSet.outlineVariant,
|
||||
scrim = colorSet.scrim,
|
||||
inverseSurface = colorSet.inverseSurface,
|
||||
inverseOnSurface = colorSet.inverseOnSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user