Compare commits

...

3 Commits

26 changed files with 813 additions and 923 deletions

View File

@@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-02-16T10:13:39.492968600Z"> <DropdownSelection timestamp="2026-02-20T17:14:10.736481200Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCW11ML1WV" /> <DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\jonas\.android\avd\Pixel_6.avd" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View File

@@ -10,6 +10,7 @@ import eu.gaudian.translator.model.repository.SettingsRepository
import eu.gaudian.translator.utils.ApiCallback import eu.gaudian.translator.utils.ApiCallback
import eu.gaudian.translator.utils.Log import eu.gaudian.translator.utils.Log
import eu.gaudian.translator.utils.StatusAction import eu.gaudian.translator.utils.StatusAction
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.viewmodel.MessageAction import eu.gaudian.translator.viewmodel.MessageAction
import eu.gaudian.translator.viewmodel.MessageDisplayType import eu.gaudian.translator.viewmodel.MessageDisplayType
@@ -403,11 +404,7 @@ class ApiManager(private val context: Context) {
if (languageModel == null) { if (languageModel == null) {
val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context)) val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context))
StatusMessageService.trigger(StatusAction.ShowActionableMessage( StatusMessageService.showErrorById(StatusMessageId.ERROR_NO_MODEL_CONFIGURED)
text = errorMsg,
type = MessageDisplayType.ACTIONABLE_ERROR,
action = MessageAction.NAVIGATE_TO_API_KEYS
))
callback.onFailure(errorMsg) callback.onFailure(errorMsg)
return@launch return@launch
} }

View File

@@ -0,0 +1,62 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.communication.files_download
/**
* Centralized configuration for all download-related URLs and paths.
* Update this file when server configuration changes.
*/
object DownloadConfig {
// ===== BASE URLS =====
/** Base URL for all polly content */
const val POLLY_BASE_URL = "http://pollyapp.b-cdn.net/"
// ===== DICTIONARIES CONFIG =====
/** Subdirectory for dictionary files on the server */
const val DICTIONARIES_SUBDIRECTORY = "dictionaries"
/** Full URL for dictionary files (baseUrl + subdirectory) */
const val DICTIONARIES_BASE_URL = "$POLLY_BASE_URL$DICTIONARIES_SUBDIRECTORY/"
/** Manifest file name for dictionaries */
const val DICTIONARIES_MANIFEST_FILE = "manifest.json"
/** Full URL for the dictionary manifest */
const val DICTIONARIES_MANIFEST_URL = "$DICTIONARIES_BASE_URL$DICTIONARIES_MANIFEST_FILE"
// ===== FLASHCARDS CONFIG =====
/** Subdirectory for flashcard/vocab files on the server */
const val FLASHCARDS_SUBDIRECTORY = "flashcards"
/** Full URL for flashcard files (baseUrl + subdirectory) */
const val FLASHCARDS_BASE_URL = "$POLLY_BASE_URL$FLASHCARDS_SUBDIRECTORY/"
/** Manifest file name for flashcards/vocab packs */
const val FLASHCARDS_MANIFEST_FILE = "vocab_manifest.json"
/** Full URL for the flashcard manifest */
const val FLASHCARDS_MANIFEST_URL = "$FLASHCARDS_BASE_URL$FLASHCARDS_MANIFEST_FILE"
// ===== LOCAL STORAGE PATHS =====
/** Local subdirectory for storing flashcard files (relative to filesDir) */
const val LOCAL_FLASHCARDS_PATH = FLASHCARDS_SUBDIRECTORY
// ===== HELPER METHODS =====
/**
* Returns the full remote URL for a dictionary asset.
* @param filename The asset filename (e.g., "dictionary_de.db")
*/
fun getDictionaryAssetUrl(filename: String): String = "$DICTIONARIES_BASE_URL$filename"
/**
* Returns the full remote URL for a flashcard asset.
* @param filename The asset filename (e.g., "2026_02_20_verbs_beginners_zh_pl_A1.json")
*/
fun getFlashcardAssetUrl(filename: String): String = "$FLASHCARDS_BASE_URL$filename"
}

View File

@@ -1,11 +1,13 @@
package eu.gaudian.translator.model.communication @file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.communication.files_download
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.communication.files_download.FlashcardCollectionInfo import eu.gaudian.translator.model.communication.Asset
import eu.gaudian.translator.model.communication.files_download.FlashcardManifestResponse import eu.gaudian.translator.model.communication.FileInfo
import eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo import eu.gaudian.translator.model.communication.ManifestResponse
import eu.gaudian.translator.utils.Log import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -18,91 +20,122 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.security.MessageDigest import java.security.MessageDigest
/**
* Enum representing different download sources.
*/
enum class DownloadSource(val baseUrl: String, val subdirectory: String) {
DICTIONARIES("http://23.88.48.47/", "dictionaries"),
FLASHCARDS("http://23.88.48.47/", "flashcard-collections")
}
/** /**
* Manages downloading files from the server, verifying checksums, and checking versions. * Manages downloading files from the server, verifying checksums, and checking versions.
* All URLs and paths are centralized in [DownloadConfig].
*/ */
class FileDownloadManager(private val context: Context) { class FileDownloadManager(private val context: Context) {
private val baseUrl = "http://23.88.48.47/" init {
Log.d("FileDownloadManager", "=== FileDownloadManager initialized ===")
Log.d("FileDownloadManager", "Context filesDir: ${context.filesDir.absolutePath}")
Log.d("FileDownloadManager", "Polly base URL: ${DownloadConfig.POLLY_BASE_URL}")
Log.d("FileDownloadManager", "Dictionaries URL: ${DownloadConfig.DICTIONARIES_BASE_URL}")
Log.d("FileDownloadManager", "Flashcards URL: ${DownloadConfig.FLASHCARDS_BASE_URL}")
}
private val retrofit = Retrofit.Builder() // ===== Retrofit Services =====
.baseUrl(baseUrl)
private val dictionaryRetrofit = Retrofit.Builder()
.baseUrl(DownloadConfig.DICTIONARIES_BASE_URL)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().build()) .client(OkHttpClient.Builder().build())
.build() .build()
private val manifestApiService = retrofit.create<ManifestApiService>() private val manifestApiService = dictionaryRetrofit.create<ManifestApiService>()
private val flashcardRetrofit = Retrofit.Builder()
.baseUrl(DownloadConfig.POLLY_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val flashcardApiService = flashcardRetrofit.create<FlashcardApiService>()
@Suppress("HardCodedStringLiteral")
private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE) private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
// ===== Dictionary Manifest =====
/** /**
* Fetches the manifest from the server. * Fetches the manifest from the server.
*/ */
suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) { suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== fetchManifest() called ===")
Log.d("FileDownloadManager", "Fetching manifest from: ${DownloadConfig.DICTIONARIES_MANIFEST_URL}")
try { try {
val response = manifestApiService.getManifest().execute() val response = manifestApiService.getManifest().execute()
Log.d("FileDownloadManager", "Manifest response received - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
if (response.isSuccessful) { if (response.isSuccessful) {
response.body() val manifest = response.body()
Log.d("FileDownloadManager", "Manifest parsed successfully, files count: ${manifest?.files?.size ?: 0}")
manifest?.files?.forEach { file ->
Log.d("FileDownloadManager", " - File: ${file.id}, name: ${file.name}, version: ${file.version}, assets: ${file.assets.size}")
}
manifest
} else { } else {
@Suppress("HardCodedStringLiteral") val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}" val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
@Suppress("HardCodedStringLiteral")
Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage") Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage)) throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
} }
} catch (e: Exception) { } catch (e: Exception) {
@Suppress("HardCodedStringLiteral")
Log.e("FileDownloadManager", "Error fetching manifest", e) Log.e("FileDownloadManager", "Error fetching manifest", e)
throw e throw e
} }
} }
// ===== Dictionary Downloads =====
/** /**
* Downloads all assets for a file and verifies their checksums. * Downloads all assets for a file and verifies their checksums.
*/ */
suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) { suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== downloadFile() called ===")
Log.d("FileDownloadManager", "File info - id: ${fileInfo.id}, name: ${fileInfo.name}, version: ${fileInfo.version}")
Log.d("FileDownloadManager", "Total assets to download: ${fileInfo.assets.size}")
val totalAssets = fileInfo.assets.size val totalAssets = fileInfo.assets.size
for ((completedAssets, asset) in fileInfo.assets.withIndex()) { for ((completedAssets, asset) in fileInfo.assets.withIndex()) {
val success = downloadAsset(asset) { assetProgress -> Log.d("FileDownloadManager", "Processing asset ${completedAssets + 1}/$totalAssets: ${asset.filename}")
val success = downloadDictionaryAsset(asset) { assetProgress ->
// Calculate overall progress // Calculate overall progress
val assetContribution = assetProgress / totalAssets val assetContribution = assetProgress / totalAssets
val previousAssetsProgress = completedAssets.toFloat() / totalAssets val previousAssetsProgress = completedAssets.toFloat() / totalAssets
onProgress(previousAssetsProgress + assetContribution) onProgress(previousAssetsProgress + assetContribution)
} }
if (!success) { if (!success) {
Log.e("FileDownloadManager", "Failed to download asset: ${asset.filename}")
return@withContext false return@withContext false
} }
} }
// Save version after all assets are downloaded successfully // Save version after all assets are downloaded successfully
sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) } sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
@Suppress("HardCodedStringLiteral")
Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}") Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
Log.d("FileDownloadManager", "Saved version ${fileInfo.version} for id ${fileInfo.id}")
true true
} }
/** /**
* Downloads a specific asset and verifies its checksum. * Downloads a specific dictionary asset and verifies its checksum.
*/ */
private suspend fun downloadAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) { private suspend fun downloadDictionaryAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
val fileUrl = "${baseUrl}${asset.filename}" Log.d("FileDownloadManager", "=== downloadDictionaryAsset() called ===")
val fileUrl = DownloadConfig.getDictionaryAssetUrl(asset.filename)
val localFile = File(context.filesDir, asset.filename) val localFile = File(context.filesDir, asset.filename)
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
try { try {
Log.d("FileDownloadManager", "Creating HTTP request...")
val client = OkHttpClient() val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build() val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) { if (!response.isSuccessful) {
val errorMessage = context.getString( val errorMessage = context.getString(
R.string.text_download_failed_http, R.string.text_download_failed_http,
@@ -116,16 +149,19 @@ class FileDownloadManager(private val context: Context) {
val body = response.body val body = response.body
val contentLength = body.contentLength() val contentLength = body.contentLength()
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
if (contentLength <= 0) { if (contentLength <= 0) {
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
throw Exception("Invalid file size: $contentLength") throw Exception("Invalid file size: $contentLength")
} }
Log.d("FileDownloadManager", "Starting file download to: ${localFile.absolutePath}")
FileOutputStream(localFile).use { output -> FileOutputStream(localFile).use { output ->
body.byteStream().use { input -> body.byteStream().use { input ->
val buffer = ByteArray(8192) val buffer = ByteArray(8192)
var bytesRead: Int var bytesRead: Int
var totalBytesRead: Long = 0 var totalBytesRead: Long = 0
@Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) { while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead) output.write(buffer, 0, bytesRead)
@@ -137,16 +173,19 @@ class FileDownloadManager(private val context: Context) {
output.flush() output.flush()
// Compute checksum // Compute checksum
val computedChecksum = digest.digest().joinToString("") { val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
@Suppress("HardCodedStringLiteral")
"%02X".format(it) Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
} Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) { if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
@Suppress("HardCodedStringLiteral") Log.d("FileDownloadManager", "Checksum VERIFIED for ${asset.filename}")
Log.d("FileDownloadManager", "Download successful for ${asset.filename}") Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
true true
} else { } else {
Log.e("FileDownloadManager", "Checksum MISMATCH for ${asset.filename}")
Log.e("FileDownloadManager", Log.e("FileDownloadManager",
context.getString( context.getString(
R.string.text_checksum_mismatch_for_expected_got, R.string.text_checksum_mismatch_for_expected_got,
@@ -154,34 +193,44 @@ class FileDownloadManager(private val context: Context) {
asset.checksumSha256, asset.checksumSha256,
computedChecksum computedChecksum
)) ))
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete() // Delete corrupted file localFile.delete() // Delete corrupted file
throw Exception("Checksum verification failed for ${asset.filename}") throw Exception("Checksum verification failed for ${asset.filename}")
} }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@Suppress("HardCodedStringLiteral") Log.e("FileDownloadManager", "Error downloading asset from $fileUrl", e)
Log.e("FileDownloadManager", "Error downloading asset", e) Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
// Clean up partial download // Clean up partial download
if (localFile.exists()) { if (localFile.exists()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete() localFile.delete()
} }
throw e throw e
} }
} }
// ===== Version Management =====
/** /**
* Checks if a newer version is available for a file. * Checks if a newer version is available for a file.
*/ */
fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean { fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
Log.d("FileDownloadManager", "=== isNewerVersionAvailable() called ===")
Log.d("FileDownloadManager", "Checking file: ${fileInfo.id} (${fileInfo.name})")
val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0" val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0"
return compareVersions(fileInfo.version, localVersion) > 0 Log.d("FileDownloadManager", "Local version: $localVersion, Server version: ${fileInfo.version}")
val result = compareVersions(fileInfo.version, localVersion) > 0
Log.d("FileDownloadManager", "Newer version available: $result")
return result
} }
/** /**
* Compares two version strings (assuming semantic versioning). * Compares two version strings (assuming semantic versioning).
*/ */
private fun compareVersions(version1: String, version2: String): Int { private fun compareVersions(version1: String, version2: String): Int {
Log.d("FileDownloadManager", "Comparing versions: '$version1' vs '$version2'")
val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 } val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 } val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
@@ -189,9 +238,12 @@ class FileDownloadManager(private val context: Context) {
val part1 = parts1.getOrElse(i) { 0 } val part1 = parts1.getOrElse(i) { 0 }
val part2 = parts2.getOrElse(i) { 0 } val part2 = parts2.getOrElse(i) { 0 }
if (part1 != part2) { if (part1 != part2) {
return part1.compareTo(part2) val result = part1.compareTo(part2)
Log.d("FileDownloadManager", "Version comparison result: $result (at part $i: $part1 vs $part2)")
return result
} }
} }
Log.d("FileDownloadManager", "Versions are equal, returning 0")
return 0 return 0
} }
@@ -199,37 +251,12 @@ class FileDownloadManager(private val context: Context) {
* Gets the local version of a file. * Gets the local version of a file.
*/ */
fun getLocalVersion(fileId: String): String { fun getLocalVersion(fileId: String): String {
return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0" val version = sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
Log.d("FileDownloadManager", "getLocalVersion($fileId) = $version")
return version
} }
// ===== Flashcard Collections Support ===== // ===== Flashcard Collections =====
private val flashcardRetrofit = Retrofit.Builder()
.baseUrl(DownloadSource.FLASHCARDS.baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val flashcardApiService = flashcardRetrofit.create<FlashcardApiService>()
/**
* Fetches the flashcard collection manifest from the server.
*/
suspend fun fetchFlashcardManifest(): FlashcardManifestResponse? = withContext(Dispatchers.IO) {
try {
val response = flashcardApiService.getFlashcardManifest().execute()
if (response.isSuccessful) {
response.body()
} else {
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
Log.e("FileDownloadManager", "Failed to fetch flashcard manifest: $errorMessage")
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error fetching flashcard manifest", e)
throw e
}
}
/** /**
* Downloads a flashcard collection file with checksum verification. * Downloads a flashcard collection file with checksum verification.
@@ -238,19 +265,34 @@ class FileDownloadManager(private val context: Context) {
flashcardInfo: FlashcardCollectionInfo, flashcardInfo: FlashcardCollectionInfo,
onProgress: (Float) -> Unit = {} onProgress: (Float) -> Unit = {}
): Boolean = withContext(Dispatchers.IO) { ): Boolean = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== downloadFlashcardCollection() called ===")
Log.d("FileDownloadManager", "Flashcard info - id: ${flashcardInfo.id}, version: ${flashcardInfo.version}")
val asset = flashcardInfo.asset val asset = flashcardInfo.asset
val source = DownloadSource.FLASHCARDS val fileUrl = DownloadConfig.getFlashcardAssetUrl(asset.filename)
val fileUrl = "${source.baseUrl}${source.subdirectory}/${asset.filename}" val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${asset.filename}")
val localFile = File(context.filesDir, "${source.subdirectory}/${asset.filename}")
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
// Create subdirectory if it doesn't exist // Create subdirectory if it doesn't exist
localFile.parentFile?.mkdirs() val parentDir = localFile.parentFile
if (parentDir != null && !parentDir.exists()) {
Log.d("FileDownloadManager", "Creating subdirectory: ${parentDir.absolutePath}")
val created = parentDir.mkdirs()
Log.d("FileDownloadManager", "Subdirectory created: $created")
}
try { try {
Log.d("FileDownloadManager", "Creating HTTP request for flashcard...")
val client = OkHttpClient() val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build() val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) { if (!response.isSuccessful) {
val errorMessage = context.getString( val errorMessage = context.getString(
R.string.text_download_failed_http, R.string.text_download_failed_http,
@@ -263,10 +305,13 @@ class FileDownloadManager(private val context: Context) {
val body = response.body val body = response.body
val contentLength = body.contentLength() val contentLength = body.contentLength()
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
if (contentLength <= 0) { if (contentLength <= 0) {
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
throw Exception("Invalid file size: $contentLength") throw Exception("Invalid file size: $contentLength")
} }
Log.d("FileDownloadManager", "Starting flashcard download to: ${localFile.absolutePath}")
FileOutputStream(localFile).use { output -> FileOutputStream(localFile).use { output ->
body.byteStream().use { input -> body.byteStream().use { input ->
val buffer = ByteArray(8192) val buffer = ByteArray(8192)
@@ -285,26 +330,37 @@ class FileDownloadManager(private val context: Context) {
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) } val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) { if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
Log.d("FileDownloadManager", "Flashcard download successful for ${asset.filename}") Log.d("FileDownloadManager", "Checksum VERIFIED for flashcard ${asset.filename}")
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
// Save version // Save version
sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) } sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) }
Log.d("FileDownloadManager", "Saved version ${flashcardInfo.version} for id ${flashcardInfo.id}")
true true
} else { } else {
Log.e("FileDownloadManager", "Checksum MISMATCH for flashcard ${asset.filename}")
Log.e("FileDownloadManager", context.getString( Log.e("FileDownloadManager", context.getString(
R.string.text_checksum_mismatch_for_expected_got, R.string.text_checksum_mismatch_for_expected_got,
asset.filename, asset.filename,
asset.checksumSha256, asset.checksumSha256,
computedChecksum computedChecksum
)) ))
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete() localFile.delete()
throw Exception("Checksum verification failed for ${asset.filename}") throw Exception("Checksum verification failed for ${asset.filename}")
} }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("FileDownloadManager", "Error downloading flashcard collection", e) Log.e("FileDownloadManager", "Error downloading flashcard collection from $fileUrl", e)
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
if (localFile.exists()) { if (localFile.exists()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete() localFile.delete()
} }
throw e throw e
@@ -315,28 +371,44 @@ class FileDownloadManager(private val context: Context) {
* Checks if a newer version is available for a flashcard collection. * Checks if a newer version is available for a flashcard collection.
*/ */
fun isNewerFlashcardVersionAvailable(flashcardInfo: FlashcardCollectionInfo): Boolean { fun isNewerFlashcardVersionAvailable(flashcardInfo: FlashcardCollectionInfo): Boolean {
Log.d("FileDownloadManager", "=== isNewerFlashcardVersionAvailable() called ===")
Log.d("FileDownloadManager", "Checking flashcard: ${flashcardInfo.id}")
val localVersion = sharedPreferences.getString(flashcardInfo.id, "0.0.0") ?: "0.0.0" val localVersion = sharedPreferences.getString(flashcardInfo.id, "0.0.0") ?: "0.0.0"
return compareVersions(flashcardInfo.version, localVersion) > 0 Log.d("FileDownloadManager", "Local version: $localVersion, Server version: ${flashcardInfo.version}")
val result = compareVersions(flashcardInfo.version, localVersion) > 0
Log.d("FileDownloadManager", "Newer version available: $result")
return result
} }
/** /**
* Gets the local version of a flashcard collection. * Gets the local version of a flashcard collection.
*/ */
fun getFlashcardLocalVersion(collectionId: String): String { fun getFlashcardLocalVersion(collectionId: String): String {
return sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0" val version = sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
Log.d("FileDownloadManager", "getFlashcardLocalVersion($collectionId) = $version")
return version
} }
// ===== Vocab Packs (vocab_manifest.json) ===== // ===== Vocab Packs =====
/** /**
* Fetches the vocabulary-pack manifest (vocab_manifest.json). * Fetches the vocabulary-pack manifest (vocab_manifest.json).
* Unwraps the top-level [eu.gaudian.translator.model.communication.files_download.VocabManifestResponse] and returns the `lists` array. * Unwraps the top-level [VocabManifestResponse] and returns the `lists` array.
*/ */
suspend fun fetchVocabManifest(): List<VocabCollectionInfo>? = withContext(Dispatchers.IO) { suspend fun fetchVocabManifest(): List<VocabCollectionInfo>? = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== fetchVocabManifest() called ===")
Log.d("FileDownloadManager", "Fetching vocab manifest from: ${DownloadConfig.FLASHCARDS_MANIFEST_URL}")
try { try {
val response = flashcardApiService.getVocabManifest().execute() val response = flashcardApiService.getVocabManifest().execute()
Log.d("FileDownloadManager", "Vocab manifest response - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
if (response.isSuccessful) { if (response.isSuccessful) {
response.body()?.lists val manifest = response.body()
val lists = manifest?.lists
Log.d("FileDownloadManager", "Vocab manifest parsed successfully, lists count: ${lists?.size ?: 0}")
lists?.forEach { list ->
Log.d("FileDownloadManager", " - Vocab list: ${list.id}, name: ${list.name}, version: ${list.version}, filename: ${list.filename}")
}
lists
} else { } else {
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}" val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
Log.e("FileDownloadManager", "Failed to fetch vocab manifest: $errorMessage") Log.e("FileDownloadManager", "Failed to fetch vocab manifest: $errorMessage")
@@ -350,7 +422,7 @@ class FileDownloadManager(private val context: Context) {
/** /**
* Downloads a single vocabulary pack file and verifies its SHA-256 checksum. * Downloads a single vocabulary pack file and verifies its SHA-256 checksum.
* The file is stored at [filesDir]/flashcard-collections/[filename]. * The file is stored at [filesDir]/[DownloadConfig.LOCAL_FLASHCARDS_PATH]/[filename].
* *
* @return true on success, false (or throws) on failure. * @return true on success, false (or throws) on failure.
*/ */
@@ -358,16 +430,34 @@ class FileDownloadManager(private val context: Context) {
info: VocabCollectionInfo, info: VocabCollectionInfo,
onProgress: (Float) -> Unit = {} onProgress: (Float) -> Unit = {}
): Boolean = withContext(Dispatchers.IO) { ): Boolean = withContext(Dispatchers.IO) {
val subdirectory = DownloadSource.FLASHCARDS.subdirectory Log.d("FileDownloadManager", "=== downloadVocabCollection() called ===")
val fileUrl = "${DownloadSource.FLASHCARDS.baseUrl}$subdirectory/${info.filename}" Log.d("FileDownloadManager", "Vocab info - id: ${info.id}, name: ${info.name}, version: ${info.version}")
val localFile = File(context.filesDir, "$subdirectory/${info.filename}") Log.d("FileDownloadManager", "Vocab filename: ${info.filename}, size: ${info.sizeBytes} bytes")
localFile.parentFile?.mkdirs()
val fileUrl = DownloadConfig.getFlashcardAssetUrl(info.filename)
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
Log.d("FileDownloadManager", "Expected checksum: ${info.checksumSha256}")
// Create subdirectory if it doesn't exist
val parentDir = localFile.parentFile
if (parentDir != null && !parentDir.exists()) {
Log.d("FileDownloadManager", "Creating subdirectory: ${parentDir.absolutePath}")
val created = parentDir.mkdirs()
Log.d("FileDownloadManager", "Subdirectory created: $created")
}
try { try {
Log.d("FileDownloadManager", "Creating HTTP request for vocab pack...")
val client = OkHttpClient() val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build() val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) { if (!response.isSuccessful) {
val errorMessage = context.getString( val errorMessage = context.getString(
R.string.text_download_failed_http, R.string.text_download_failed_http,
@@ -380,13 +470,15 @@ class FileDownloadManager(private val context: Context) {
val body = response.body val body = response.body
val contentLength = body.contentLength().takeIf { it > 0 } ?: info.sizeBytes val contentLength = body.contentLength().takeIf { it > 0 } ?: info.sizeBytes
Log.d("FileDownloadManager", "Content length from header: ${body.contentLength()}, using: $contentLength bytes")
Log.d("FileDownloadManager", "Starting vocab pack download to: ${localFile.absolutePath}")
FileOutputStream(localFile).use { output -> FileOutputStream(localFile).use { output ->
body.byteStream().use { input -> body.byteStream().use { input ->
val buffer = ByteArray(8192) val buffer = ByteArray(8192)
var bytesRead: Int var bytesRead: Int
var totalBytesRead: Long = 0 var totalBytesRead: Long = 0
@Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256") val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) { while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead) output.write(buffer, 0, bytesRead)
@@ -396,17 +488,23 @@ class FileDownloadManager(private val context: Context) {
} }
output.flush() output.flush()
val computedChecksum = digest.digest().joinToString("") { val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
@Suppress("HardCodedStringLiteral") "%02X".format(it)
} Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
Log.d("FileDownloadManager", "Expected checksum: ${info.checksumSha256}")
if (computedChecksum.equals(info.checksumSha256, ignoreCase = true)) { if (computedChecksum.equals(info.checksumSha256, ignoreCase = true)) {
Log.d("FileDownloadManager", "Vocab pack downloaded: ${info.filename}") Log.d("FileDownloadManager", "Checksum VERIFIED for vocab pack ${info.filename}")
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
sharedPreferences.edit(commit = true) { sharedPreferences.edit(commit = true) {
putString("vocab_${info.id}", info.version.toString()) putString("vocab_${info.id}", info.version.toString())
} }
Log.d("FileDownloadManager", "Saved version ${info.version} for vocab_${info.id}")
true true
} else { } else {
Log.e("FileDownloadManager", "Checksum MISMATCH for vocab pack ${info.filename}")
Log.e("FileDownloadManager", Log.e("FileDownloadManager",
context.getString( context.getString(
R.string.text_checksum_mismatch_for_expected_got, R.string.text_checksum_mismatch_for_expected_got,
@@ -415,29 +513,48 @@ class FileDownloadManager(private val context: Context) {
computedChecksum computedChecksum
) )
) )
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete() localFile.delete()
throw Exception("Checksum verification failed for ${info.filename}") throw Exception("Checksum verification failed for ${info.filename}")
} }
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("FileDownloadManager", "Error downloading vocab pack", e) Log.e("FileDownloadManager", "Error downloading vocab pack from $fileUrl", e)
if (localFile.exists()) localFile.delete() Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
if (localFile.exists()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete()
}
throw e throw e
} }
} }
/** Returns true if the local file for this collection exists. */ /** Returns true if the local file for this collection exists. */
fun isVocabCollectionDownloaded(info: VocabCollectionInfo): Boolean = fun isVocabCollectionDownloaded(info: VocabCollectionInfo): Boolean {
File(context.filesDir, "${DownloadSource.FLASHCARDS.subdirectory}/${info.filename}").exists() val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
val exists = localFile.exists()
Log.d("FileDownloadManager", "isVocabCollectionDownloaded(${info.id}): $exists (path: ${localFile.absolutePath})")
return exists
}
/** Returns true if the server version is newer than the locally saved version. */ /** Returns true if the server version is newer than the locally saved version. */
fun isNewerVocabVersionAvailable(info: VocabCollectionInfo): Boolean { fun isNewerVocabVersionAvailable(info: VocabCollectionInfo): Boolean {
Log.d("FileDownloadManager", "=== isNewerVocabVersionAvailable() called ===")
Log.d("FileDownloadManager", "Checking vocab: ${info.id} (${info.name})")
val localVersion = sharedPreferences.getString("vocab_${info.id}", "0") ?: "0" val localVersion = sharedPreferences.getString("vocab_${info.id}", "0") ?: "0"
return (info.version.toString().toIntOrNull() ?: 0) > (localVersion.toIntOrNull() ?: 0) val serverVersion = info.version.toString().toIntOrNull() ?: 0
val localVersionInt = localVersion.toIntOrNull() ?: 0
Log.d("FileDownloadManager", "Local version: $localVersionInt, Server version: $serverVersion")
val result = serverVersion > localVersionInt
Log.d("FileDownloadManager", "Newer version available: $result")
return result
} }
/** Returns the locally saved version number string for a vocab pack (default "0"). */ /** Returns the locally saved version number string for a vocab pack (default "0"). */
fun getVocabLocalVersion(packId: String): String = fun getVocabLocalVersion(packId: String): String {
sharedPreferences.getString("vocab_$packId", "0") ?: "0" val version = sharedPreferences.getString("vocab_$packId", "0") ?: "0"
Log.d("FileDownloadManager", "getVocabLocalVersion($packId) = $version")
return version
}
} }

View File

@@ -1,25 +1,18 @@
package eu.gaudian.translator.model.communication package eu.gaudian.translator.model.communication.files_download
import eu.gaudian.translator.model.communication.files_download.FlashcardManifestResponse
import eu.gaudian.translator.model.communication.files_download.VocabManifestResponse
import retrofit2.Call import retrofit2.Call
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Url
/** /**
* API service for flashcard / vocabulary-pack downloads. * API service for flashcard / vocabulary-pack downloads.
* Base URL should be set to DownloadConfig.POLLY_BASE_URL
*/ */
interface FlashcardApiService { interface FlashcardApiService {
// ── Legacy endpoint (old manifest schema) ────────────────────────────────
@GET("flashcard-collections/manifest.json")
fun getFlashcardManifest(): Call<FlashcardManifestResponse>
// ── New vocab packs endpoint ──────────────────────────────────────────────
/** /**
* Fetches the vocabulary-pack manifest. * Fetches the vocab manifest using the full URL from DownloadConfig.
* Returns a JSON object { manifest_version, updated_at, lists: [...] }.
* URL: http://23.88.48.47/flashcard-collections/vocab_manifest.json
*/ */
@GET("flashcard-collections/vocab_manifest.json") @GET
fun getVocabManifest(): Call<VocabManifestResponse> fun getVocabManifest(@Url url: String = DownloadConfig.FLASHCARDS_MANIFEST_URL): Call<VocabManifestResponse>
} }

View File

@@ -1,17 +1,20 @@
package eu.gaudian.translator.model.communication package eu.gaudian.translator.model.communication.files_download
import eu.gaudian.translator.model.communication.ManifestResponse
import retrofit2.Call import retrofit2.Call
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Url
/** /**
* API service for fetching the manifest and downloading files. * API service for fetching the dictionary manifest.
* Base URL should be set to DownloadConfig.DICTIONARIES_BASE_URL
*/ */
interface ManifestApiService { interface ManifestApiService {
/** /**
* Fetches the manifest from the server. * Fetches the manifest from the server using the full URL.
*/ */
@GET("manifest.json") @GET
fun getManifest(): Call<ManifestResponse> fun getManifest(@Url url: String = DownloadConfig.DICTIONARIES_MANIFEST_URL): Call<ManifestResponse>
} }

View File

@@ -6,9 +6,9 @@ import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.communication.Asset 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.FileInfo
import eu.gaudian.translator.model.communication.ManifestResponse import eu.gaudian.translator.model.communication.ManifestResponse
import eu.gaudian.translator.model.communication.files_download.FileDownloadManager
import eu.gaudian.translator.utils.Log import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow

View File

@@ -37,6 +37,7 @@ enum class StatusMessageId(
ERROR_VOCABULARY_IMPORT_FAILED(R.string.message_error_vocabulary_import_failed, MessageDisplayType.ERROR, 5), ERROR_VOCABULARY_IMPORT_FAILED(R.string.message_error_vocabulary_import_failed, MessageDisplayType.ERROR, 5),
SUCCESS_ITEMS_MERGED(R.string.message_success_items_merged, MessageDisplayType.SUCCESS, 3), SUCCESS_ITEMS_MERGED(R.string.message_success_items_merged, MessageDisplayType.SUCCESS, 3),
SUCCESS_ITEMS_ADDED(R.string.message_success_items_added, MessageDisplayType.SUCCESS, 3), SUCCESS_ITEMS_ADDED(R.string.message_success_items_added, MessageDisplayType.SUCCESS, 3),
SUCCESS_All_ITEMS_IMPORTED(R.string.message_success_all_items_imported, MessageDisplayType.SUCCESS, 3),
ERROR_ITEMS_ADD_FAILED(R.string.message_error_items_add_failed, MessageDisplayType.ERROR, 5), ERROR_ITEMS_ADD_FAILED(R.string.message_error_items_add_failed, MessageDisplayType.ERROR, 5),
SUCCESS_ITEMS_DELETED(R.string.message_success_items_deleted, MessageDisplayType.SUCCESS, 3), SUCCESS_ITEMS_DELETED(R.string.message_success_items_deleted, MessageDisplayType.SUCCESS, 3),
ERROR_ITEMS_DELETE_FAILED(R.string.message_error_items_delete_failed, MessageDisplayType.ERROR, 5), ERROR_ITEMS_DELETE_FAILED(R.string.message_error_items_delete_failed, MessageDisplayType.ERROR, 5),
@@ -66,6 +67,7 @@ enum class StatusMessageId(
// API Key related // API Key related
ERROR_API_KEY_MISSING(R.string.message_error_api_key_missing, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS), ERROR_API_KEY_MISSING(R.string.message_error_api_key_missing, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
ERROR_API_KEY_INVALID(R.string.message_error_api_key_invalid, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS), ERROR_API_KEY_INVALID(R.string.message_error_api_key_invalid, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
ERROR_NO_MODEL_CONFIGURED(R.string.message_error_no_model_configured, MessageDisplayType.ERROR, 5),
// Translation related // Translation related
LOADING_TRANSLATING(R.string.message_loading_translating, MessageDisplayType.LOADING, 0), LOADING_TRANSLATING(R.string.message_loading_translating, MessageDisplayType.LOADING, 0),

View File

@@ -82,19 +82,14 @@ val LocalConnectionConfigured = compositionLocalOf { true }
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels() private val settingsViewModel: SettingsViewModel by viewModels()
private var isReady = false private var isReady = false
private var isUiLoaded = false private var isUiLoaded = false
private var isInitializing = true
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().apply { installSplashScreen().apply {
// The splash screen will now correctly wait until isReady is true
setKeepOnScreenCondition { !isReady } setKeepOnScreenCondition { !isReady }
} }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
lifecycleScope.launch { lifecycleScope.launch {
@@ -104,28 +99,22 @@ class MainActivity : ComponentActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
// Show UI immediately and load data in background
setContent { setContent {
AppTheme(settingsViewModel = settingsViewModel) { AppTheme(settingsViewModel = settingsViewModel) {
TranslatorApp(settingsViewModel = settingsViewModel) TranslatorApp(settingsViewModel = settingsViewModel)
} }
} }
// Mark UI as loaded immediately after setContent
isUiLoaded = true isUiLoaded = true
// Start initialization in background without blocking UI
initializeData() initializeData()
} }
private fun initializeData() { private fun initializeData() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
// Get repositories from the Application instance (lazy initialization)
val myApp = application as MyApplication val myApp = application as MyApplication
val languageRepository = myApp.languageRepository val languageRepository = myApp.languageRepository
val apiRepository = myApp.apiRepository val apiRepository = myApp.apiRepository
// Perform initialization in parallel where possible
val languageJob = launch { val languageJob = launch {
languageRepository.initializeDefaultLanguages() languageRepository.initializeDefaultLanguages()
languageRepository.initializeAllLanguages() languageRepository.initializeAllLanguages()
@@ -135,13 +124,10 @@ class MainActivity : ComponentActivity() {
apiRepository.initialInit() apiRepository.initialInit()
} }
// Wait for both to complete
languageJob.join() languageJob.join()
apiJob.join() apiJob.join()
// Signal readiness after all work is done.
isReady = true isReady = true
isInitializing = false
} }
} }
} }
@@ -149,10 +135,7 @@ class MainActivity : ComponentActivity() {
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
@SuppressLint("LocalContextResourcesRead") @SuppressLint("LocalContextResourcesRead")
@Composable @Composable
fun TranslatorApp( fun TranslatorApp(settingsViewModel: SettingsViewModel) {
settingsViewModel: SettingsViewModel
) {
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val statusViewModel: StatusViewModel = hiltViewModel(activity) val statusViewModel: StatusViewModel = hiltViewModel(activity)
val statusMessageService = StatusMessageService val statusMessageService = StatusMessageService
@@ -179,7 +162,6 @@ fun TranslatorApp(
showExitDialog = true showExitDialog = true
} }
if (showExitDialog) { if (showExitDialog) {
AppAlertDialog( AppAlertDialog(
onDismissRequest = { showExitDialog = false }, onDismissRequest = { showExitDialog = false },
@@ -188,7 +170,6 @@ fun TranslatorApp(
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
showExitDialog = false showExitDialog = false
// Minimize the app similar to default back at root behavior
activity.moveTaskToBack(true) activity.moveTaskToBack(true)
}) { }) {
Text(stringResource(R.string.quit)) Text(stringResource(R.string.quit))
@@ -202,7 +183,6 @@ fun TranslatorApp(
) )
} }
// Check for app updates and show "What's New" dialog if needed
var showWhatsNewDialog by remember { mutableStateOf(false) } var showWhatsNewDialog by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
val changelogEntries = context.resources.getStringArray(R.array.changelog_entries) val changelogEntries = context.resources.getStringArray(R.array.changelog_entries)
@@ -210,7 +190,6 @@ fun TranslatorApp(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
// Only check for updates if the intro is completed
if (introCompleted) { if (introCompleted) {
val currentVersion = BuildConfig.VERSION_NAME val currentVersion = BuildConfig.VERSION_NAME
val hasSeenCurrentVersion = settingsViewModel.hasSeenCurrentVersion(currentVersion) val hasSeenCurrentVersion = settingsViewModel.hasSeenCurrentVersion(currentVersion)
@@ -260,7 +239,8 @@ fun TranslatorApp(
Screen.Translation.route, Screen.Translation.route,
Screen.Dictionary.route, Screen.Dictionary.route,
Screen.Exercises.route, Screen.Exercises.route,
Screen.Settings.route Screen.Settings.route,
Screen.Corrector.route
) )
} == true } == true
val isBottomBarHidden = isHiddenByHierarchy || currentRoute in setOf( val isBottomBarHidden = isHiddenByHierarchy || currentRoute in setOf(
@@ -283,12 +263,11 @@ fun TranslatorApp(
Screen.Translation, Screen.Translation,
Screen.Dictionary, Screen.Dictionary,
Screen.Settings, Screen.Settings,
Screen.Exercises Screen.Exercises,
Screen.Corrector
) )
// Always reset the selected section to its root and clear back stack between sections
if (inSameSection) { if (inSameSection) {
// If already within the same section, ensure we are at its graph root
navController.navigate(screen.route) { navController.navigate(screen.route) {
popUpTo(screen.route) { popUpTo(screen.route) {
inclusive = false inclusive = false
@@ -303,9 +282,8 @@ fun TranslatorApp(
restoreState = false restoreState = false
} }
} else { } else {
// Switching sections: clear entire back stack to start to avoid back navigation results
navController.navigate(screen.route) { navController.navigate(screen.route) {
popUpTo(0) { // Pop everything popUpTo(0) {
inclusive = true inclusive = true
saveState = false saveState = false
} }
@@ -340,8 +318,7 @@ fun TranslatorApp(
statusState = statusState, statusState = statusState,
navController = navController, navController = navController,
onDismiss = { statusMessageService.trigger(StatusAction.HideMessageBar) }, onDismiss = { statusMessageService.trigger(StatusAction.HideMessageBar) },
modifier = Modifier modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
) )
AppNavHost( AppNavHost(
navController = navController, navController = navController,
@@ -393,10 +370,8 @@ private fun AppTheme(
val window = (view.context as Activity).window val window = (view.context as Activity).window
val windowInsetsController = WindowInsetsControllerCompat(window, view) val windowInsetsController = WindowInsetsControllerCompat(window, view)
// We must keep this for older Android version!!!
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
window.statusBarColor = colorScheme.surface.toArgb() window.statusBarColor = colorScheme.surface.toArgb()
//Elevation must be the same as BottomNavigationBar
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
window.navigationBarColor = colorScheme.surfaceColorAtElevation(8.dp).toArgb() window.navigationBarColor = colorScheme.surfaceColorAtElevation(8.dp).toArgb()
@@ -443,6 +418,4 @@ private fun AppTheme(
content() content()
} }
} }
} }

View File

@@ -23,9 +23,10 @@ import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.view.categories.CategoryDetailScreen import eu.gaudian.translator.view.categories.CategoryDetailScreen
import eu.gaudian.translator.view.categories.CategoryListScreen import eu.gaudian.translator.view.categories.CategoryListScreen
import eu.gaudian.translator.view.composable.Screen import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.dictionary.CorrectionScreen
import eu.gaudian.translator.view.dictionary.DictionaryResultScreen import eu.gaudian.translator.view.dictionary.DictionaryResultScreen
import eu.gaudian.translator.view.dictionary.DictionaryScreen
import eu.gaudian.translator.view.dictionary.EtymologyResultScreen import eu.gaudian.translator.view.dictionary.EtymologyResultScreen
import eu.gaudian.translator.view.dictionary.MainDictionaryScreen
import eu.gaudian.translator.view.home.DailyReviewScreen import eu.gaudian.translator.view.home.DailyReviewScreen
import eu.gaudian.translator.view.home.HomeScreen import eu.gaudian.translator.view.home.HomeScreen
import eu.gaudian.translator.view.library.LibraryScreen import eu.gaudian.translator.view.library.LibraryScreen
@@ -78,22 +79,14 @@ fun AppNavHost(
navController: NavHostController, navController: NavHostController,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// 1. Define your Main Tab "Leaf" Routes (the actual start destinations of your graphs)
val mainTabRoutes = setOf( val mainTabRoutes = setOf(
Screen.Home.route, Screen.Home.route,
Screen.Library.route, Screen.Library.route,
Screen.Stats.route, Screen.Stats.route,
) )
// Helper to check if a route is a top-level tab
// Note: Routes can be "main_home", "main_library" etc. but mainTabRoutes contains parent routes
fun isTabTransition(initial: String?, target: String?): Boolean { fun isTabTransition(initial: String?, target: String?): Boolean {
if (initial == null || target == null) return false if (initial == null || target == null) return false
// Check if either the direct route OR a "main_*" variant is in mainTabRoutes
val initialIsTab = mainTabRoutes.contains(initial) || val initialIsTab = mainTabRoutes.contains(initial) ||
mainTabRoutes.any { route -> mainTabRoutes.any { route ->
initial == "main_${route}" || initial.startsWith("${route}_") initial == "main_${route}" || initial.startsWith("${route}_")
@@ -109,45 +102,33 @@ fun AppNavHost(
navController = navController, navController = navController,
startDestination = Screen.Home.route, startDestination = Screen.Home.route,
modifier = modifier, modifier = modifier,
// ENTER TRANSITION
enterTransition = { enterTransition = {
if (isTabTransition(initialState.destination.route, targetState.destination.route)) { if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
// Tab Switch: Just Fade In (Subtle Scale for modern feel)
fadeIn(animationSpec = tween(TRANSITION_DURATION)) + fadeIn(animationSpec = tween(TRANSITION_DURATION)) +
scaleIn(initialScale = 0.96f, animationSpec = tween(TRANSITION_DURATION)) scaleIn(initialScale = 0.96f, animationSpec = tween(TRANSITION_DURATION))
} else { } else {
// Detail Screen: Slide in from Right
slideInHorizontally( slideInHorizontally(
initialOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() }, initialOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing) animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(TRANSITION_DURATION)) ) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
} }
}, },
// EXIT TRANSITION
exitTransition = { exitTransition = {
if (isTabTransition(initialState.destination.route, targetState.destination.route)) { if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
// Tab Switch: Just Fade Out
fadeOut(animationSpec = tween(TRANSITION_DURATION)) fadeOut(animationSpec = tween(TRANSITION_DURATION))
} else { } else {
// Detail Screen: Slide out to Left
slideOutHorizontally( slideOutHorizontally(
targetOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() }, targetOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing) animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(TRANSITION_DURATION)) ) + fadeOut(animationSpec = tween(TRANSITION_DURATION))
} }
}, },
// POP ENTER (Pressing Back) -> Always Slide back from left
popEnterTransition = { popEnterTransition = {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() }, initialOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing) animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(TRANSITION_DURATION)) ) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
}, },
// POP EXIT (Pressing Back) -> Always Slide away to right
popExitTransition = { popExitTransition = {
slideOutHorizontally( slideOutHorizontally(
targetOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() }, targetOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
@@ -158,23 +139,18 @@ fun AppNavHost(
composable(Screen.Home.route) { composable(Screen.Home.route) {
HomeScreen(navController = navController) HomeScreen(navController = navController)
} }
composable(NavigationRoutes.DAILY_REVIEW) { composable(NavigationRoutes.DAILY_REVIEW) {
DailyReviewScreen(navController = navController) DailyReviewScreen(navController = navController)
} }
composable(NavigationRoutes.NEW_WORD) { composable(NavigationRoutes.NEW_WORD) {
NewWordScreen(navController = navController) NewWordScreen(navController = navController)
} }
composable(NavigationRoutes.NEW_WORD_REVIEW) { composable(NavigationRoutes.NEW_WORD_REVIEW) {
NewWordReviewScreen(navController = navController) NewWordReviewScreen(navController = navController)
} }
composable(NavigationRoutes.EXPLORE_PACKS) { composable(NavigationRoutes.EXPLORE_PACKS) {
ExplorePacksScreen(navController = navController) ExplorePacksScreen(navController = navController)
} }
composable( composable(
route = "${NavigationRoutes.START_EXERCISE}?categoryId={categoryId}", route = "${NavigationRoutes.START_EXERCISE}?categoryId={categoryId}",
arguments = listOf( arguments = listOf(
@@ -193,7 +169,6 @@ fun AppNavHost(
dueTodayOnly = false dueTodayOnly = false
) )
} }
composable(NavigationRoutes.START_EXERCISE_DAILY) { composable(NavigationRoutes.START_EXERCISE_DAILY) {
StartExerciseScreen( StartExerciseScreen(
navController = navController, navController = navController,
@@ -201,13 +176,12 @@ fun AppNavHost(
dueTodayOnly = true dueTodayOnly = true
) )
} }
// Define all other navigation graphs at the same top level.
homeGraph(navController) homeGraph(navController)
libraryGraph(navController) libraryGraph(navController)
statsGraph(navController) statsGraph(navController)
translationGraph(navController) translationGraph(navController)
dictionaryGraph(navController) dictionaryGraph(navController)
correctorGraph(navController)
exerciseGraph(navController) exerciseGraph(navController)
settingsGraph(navController) settingsGraph(navController)
} }
@@ -233,9 +207,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
LibraryScreen(navController = navController) LibraryScreen(navController = navController)
} }
composable("vocabulary_sorting") { composable("vocabulary_sorting") {
VocabularySortingScreen( VocabularySortingScreen(navController = navController)
navController = navController
)
} }
composable("vocabulary_detail/{itemId}") { backStackEntry -> composable("vocabulary_detail/{itemId}") { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId")?.toIntOrNull() val itemId = backStackEntry.arguments?.getString("itemId")?.toIntOrNull()
@@ -252,10 +224,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
composable("dictionary_result/{entryId}") { backStackEntry -> composable("dictionary_result/{entryId}") { backStackEntry ->
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull() val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
if (entryId != null) { if (entryId != null) {
DictionaryResultScreen( DictionaryResultScreen(entryId = entryId, navController = navController)
entryId = entryId,
navController = navController,
)
} else { } else {
Text("Error: Invalid Entry ID") Text("Error: Invalid Entry ID")
} }
@@ -267,23 +236,16 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
navController = navController, navController = navController,
showDueTodayOnly = showDueTodayOnly, showDueTodayOnly = showDueTodayOnly,
categoryId = categoryId, categoryId = categoryId,
onNavigateToItem = { item -> onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
enableNavigationButtons = true enableNavigationButtons = true
) )
} }
composable("language_progress") { composable("language_progress") {
LanguageJourneyScreen( LanguageJourneyScreen(navController = navController)
navController = navController
)
} }
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) { composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
VocabularyHeatmapScreen( VocabularyHeatmapScreen(navController = navController)
navController = navController,
)
} }
composable("vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry -> composable("vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
@@ -291,14 +253,11 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
val stage = stageString?.let { val stage = stageString?.let {
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it) if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
} }
AllCardsListScreen( AllCardsListScreen(
navController = navController, navController = navController,
showDueTodayOnly = showDueTodayOnly, showDueTodayOnly = showDueTodayOnly,
stage = stage, stage = stage,
onNavigateToItem = { item -> onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
categoryId = 0, categoryId = 0,
enableNavigationButtons = true enableNavigationButtons = true
@@ -311,22 +270,15 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
navArgument("categories") { type = NavType.StringType; nullable = true }, navArgument("categories") { type = NavType.StringType; nullable = true },
navArgument("stages") { type = NavType.StringType; nullable = true }, navArgument("stages") { type = NavType.StringType; nullable = true },
navArgument("languages") { type = NavType.StringType; nullable = true }, navArgument("languages") { type = NavType.StringType; nullable = true },
navArgument("dailyOnly") { navArgument("dailyOnly") { type = NavType.BoolType; defaultValue = false }
type = NavType.BoolType
defaultValue = false
},
) )
) { backStackEntry -> ) { backStackEntry ->
val arguments = backStackEntry.arguments val arguments = backStackEntry.arguments
val dailyOnly = arguments?.getBoolean("dailyOnly") ?: false val dailyOnly = arguments?.getBoolean("dailyOnly") ?: false
val categoryIds = arguments?.getString("categories") val categoryIds = arguments?.getString("categories")
val stageNames = arguments?.getString("stages") val stageNames = arguments?.getString("stages")
val languageIds = arguments?.getString("languages") val languageIds = arguments?.getString("languages")
val dailyOnlyJson = "{\"dailyOnly\": $dailyOnly}" val dailyOnlyJson = "{\"dailyOnly\": $dailyOnly}"
VocabularyExerciseHostScreen( VocabularyExerciseHostScreen(
categoryIdsAsJson = categoryIds, categoryIdsAsJson = categoryIds,
stageNamesAsJson = stageNames, stageNamesAsJson = stageNames,
@@ -336,13 +288,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
navController = navController navController = navController
) )
} }
composable( composable("vocabulary_exercise/{dailyOnly}?", arguments = listOf(navArgument("dailyOnly") { type = NavType.BoolType })) { _ ->
route = "vocabulary_exercise/{dailyOnly}?",
arguments = listOf(
navArgument("dailyOnly") { type = NavType.BoolType },
)
) { _ ->
VocabularyExerciseHostScreen( VocabularyExerciseHostScreen(
categoryIdsAsJson = null, categoryIdsAsJson = null,
stageNamesAsJson = null, stageNamesAsJson = null,
@@ -352,34 +298,18 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
dailyOnlyAsJson = "{\"dailyOnly\": true}" dailyOnlyAsJson = "{\"dailyOnly\": true}"
) )
} }
composable( composable("stage_detail/{stage}", arguments = listOf(navArgument("stage") { type = NavType.EnumType(VocabularyStage::class.java) })) { backStackEntry ->
"stage_detail/{stage}", @Suppress("DEPRECATION")
arguments = listOf( val stage = backStackEntry.arguments?.getSerializable("stage") as VocabularyStage
navArgument("stage") { StageDetailScreen(navController = navController, stage = stage)
type = NavType.EnumType(VocabularyStage::class.java)
}
)
)
{ backStackEntry ->
@Suppress("DEPRECATION") val stage = backStackEntry.arguments?.getSerializable("stage") as VocabularyStage
//NOTE: can ignore warning for now, once moved away from min SDK 28, use:
// val stage = backStackEntry.arguments?.getSerializable("stage", VocabularyStage::class.java)
StageDetailScreen(
navController = navController,
stage = stage
)
} }
composable("category_detail/{categoryId}") { backStackEntry -> composable("category_detail/{categoryId}") { backStackEntry ->
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull() val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
if (categoryId != null) { if (categoryId != null) {
CategoryDetailScreen( CategoryDetailScreen(
categoryId = categoryId, categoryId = categoryId,
onBackClick = { navController.popBackStack() }, onBackClick = { navController.popBackStack() },
onNavigateToItem = { item -> onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
navController.navigate("vocabulary_detail/${item.id}")
},
navController = navController navController = navController
) )
} }
@@ -387,37 +317,22 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
composable("category_list_screen") { composable("category_list_screen") {
CategoryListScreen( CategoryListScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onCategoryClicked = { categoryId -> onCategoryClicked = { categoryId -> navController.navigate("category_detail/$categoryId") }
navController.navigate("category_detail/$categoryId")
}
) )
} }
composable( composable("vocabulary_sorting?mode={mode}", arguments = listOf(navArgument("mode") { type = NavType.StringType; nullable = true })) { backStackEntry ->
route = "vocabulary_sorting?mode={mode}", // Route now accepts an optional 'mode'
arguments = listOf(
navArgument("mode") { // Define the argument
type = NavType.StringType
nullable = true
}
)
) { backStackEntry ->
VocabularySortingScreen( VocabularySortingScreen(
navController = navController, navController = navController,
// Pass the argument to the screen
initialFilterMode = backStackEntry.arguments?.getString("mode") initialFilterMode = backStackEntry.arguments?.getString("mode")
) )
} }
composable("no_grammar_items") { composable("no_grammar_items") {
NoGrammarItemsScreen( NoGrammarItemsScreen(navController = navController)
navController = navController
)
} }
} }
} }
fun NavGraphBuilder.statsGraph( fun NavGraphBuilder.statsGraph(navController: NavHostController) {
navController: NavHostController,
) {
navigation( navigation(
startDestination = "main_stats", startDestination = "main_stats",
route = Screen.Stats.route route = Screen.Stats.route
@@ -426,9 +341,7 @@ fun NavGraphBuilder.statsGraph(
StatsScreen(navController = navController) StatsScreen(navController = navController)
} }
composable("stats/vocabulary_sorting") { composable("stats/vocabulary_sorting") {
VocabularySortingScreen( VocabularySortingScreen(navController = navController)
navController = navController
)
} }
composable("stats/vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry -> composable("stats/vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry ->
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
@@ -437,22 +350,16 @@ fun NavGraphBuilder.statsGraph(
navController = navController, navController = navController,
showDueTodayOnly = showDueTodayOnly, showDueTodayOnly = showDueTodayOnly,
categoryId = categoryId, categoryId = categoryId,
onNavigateToItem = { item -> onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
enableNavigationButtons = true enableNavigationButtons = true
) )
} }
composable(NavigationRoutes.STATS_LANGUAGE_PROGRESS) { composable(NavigationRoutes.STATS_LANGUAGE_PROGRESS) {
LanguageJourneyScreen( LanguageJourneyScreen(navController = navController)
navController = navController
)
} }
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) { composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
VocabularyHeatmapScreen( VocabularyHeatmapScreen(navController = navController)
navController = navController,
)
} }
composable("stats/vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry -> composable("stats/vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
@@ -460,14 +367,11 @@ fun NavGraphBuilder.statsGraph(
val stage = stageString?.let { val stage = stageString?.let {
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it) if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
} }
AllCardsListScreen( AllCardsListScreen(
navController = navController, navController = navController,
showDueTodayOnly = showDueTodayOnly, showDueTodayOnly = showDueTodayOnly,
stage = stage, stage = stage,
onNavigateToItem = { item -> onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
categoryId = 0, categoryId = 0,
enableNavigationButtons = true enableNavigationButtons = true
@@ -475,14 +379,11 @@ fun NavGraphBuilder.statsGraph(
} }
composable("stats/category_detail/{categoryId}") { backStackEntry -> composable("stats/category_detail/{categoryId}") { backStackEntry ->
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull() val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
if (categoryId != null) { if (categoryId != null) {
CategoryDetailScreen( CategoryDetailScreen(
categoryId = categoryId, categoryId = categoryId,
onBackClick = { navController.popBackStack() }, onBackClick = { navController.popBackStack() },
onNavigateToItem = { item -> onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
navController.navigate("vocabulary_detail/${item.id}")
},
navController = navController navController = navController
) )
} }
@@ -490,29 +391,14 @@ fun NavGraphBuilder.statsGraph(
composable("stats/category_list_screen") { composable("stats/category_list_screen") {
CategoryListScreen( CategoryListScreen(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onCategoryClicked = { categoryId -> onCategoryClicked = { categoryId -> navController.navigate("stats/category_detail/$categoryId") }
navController.navigate("stats/category_detail/$categoryId")
}
) )
} }
composable( composable("stats/vocabulary_sorting?mode={mode}", arguments = listOf(navArgument("mode") { type = NavType.StringType; nullable = true })) { backStackEntry ->
route = "stats/vocabulary_sorting?mode={mode}", VocabularySortingScreen(navController = navController, initialFilterMode = backStackEntry.arguments?.getString("mode"))
arguments = listOf(
navArgument("mode") {
type = NavType.StringType
nullable = true
}
)
) { backStackEntry ->
VocabularySortingScreen(
navController = navController,
initialFilterMode = backStackEntry.arguments?.getString("mode")
)
} }
composable("stats/no_grammar_items") { composable("stats/no_grammar_items") {
NoGrammarItemsScreen( NoGrammarItemsScreen(navController = navController)
navController = navController
)
} }
} }
} }
@@ -539,15 +425,16 @@ fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
route = Screen.Dictionary.route route = Screen.Dictionary.route
) { ) {
composable("main_dictionary") { composable("main_dictionary") {
MainDictionaryScreen(navController = navController) DictionaryScreen(
navController = navController,
onEntryClick = { entry -> navController.navigate("dictionary_result/${entry.id}") },
onNavigateToOptions = { navController.navigate("dictionary_options") }
)
} }
composable("dictionary_result/{entryId}") { backStackEntry -> composable("dictionary_result/{entryId}") { backStackEntry ->
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull() val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
if (entryId != null) { if (entryId != null) {
DictionaryResultScreen( DictionaryResultScreen(entryId = entryId, navController = navController)
entryId = entryId,
navController = navController,
)
} else { } else {
Text("Error: Invalid Entry ID") Text("Error: Invalid Entry ID")
} }
@@ -558,43 +445,39 @@ fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
composable("etymology_result/{word}/{languageCode}") { backStackEntry -> composable("etymology_result/{word}/{languageCode}") { backStackEntry ->
val word = backStackEntry.arguments?.getString("word") ?: "" val word = backStackEntry.arguments?.getString("word") ?: ""
val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1 val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1
EtymologyResultScreen( EtymologyResultScreen(navController = navController, word = word, languageCode = languageCode)
navController = navController,
word = word,
languageCode = languageCode
)
} }
}
}
fun NavGraphBuilder.correctorGraph(navController: NavHostController) {
navigation(
startDestination = "main_corrector",
route = Screen.Corrector.route
) {
composable("main_corrector") {
CorrectionScreen(navController = navController)
}
} }
} }
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class) @OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
fun NavGraphBuilder.exerciseGraph( fun NavGraphBuilder.exerciseGraph(navController: NavHostController) {
navController: NavHostController,
) {
navigation( navigation(
startDestination = "main_exercise", startDestination = "main_exercise",
route = Screen.Exercises.route route = Screen.Exercises.route
) { ) {
composable("main_exercise") { composable("main_exercise") {
MainExerciseScreen( MainExerciseScreen(navController = navController)
navController = navController,
)
} }
composable("exercise_session") { composable("exercise_session") {
ExerciseSessionScreen( ExerciseSessionScreen(navController = navController)
navController = navController,
)
} }
composable("youtube_exercise") { composable("youtube_exercise") {
YouTubeExerciseScreen( YouTubeExerciseScreen(navController = navController)
navController = navController
)
} }
composable("youtube_browse") { composable("youtube_browse") {
YouTubeBrowserScreen( YouTubeBrowserScreen(navController = navController)
navController = navController,
)
} }
} }
} }

View File

@@ -16,13 +16,18 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppTopAppBar
@Composable @Composable
fun NoConnectionScreen(onSettingsClick: () -> Unit) { fun NoConnectionScreen(onSettingsClick: () -> Unit, navController: NavController) {
AppTopAppBar(
title = "No Connection",
onNavigateBack = {navController.popBackStack()},
)
Column( Column(
modifier = Modifier.fillMaxSize().padding(24.dp), modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@@ -41,7 +46,7 @@ fun NoConnectionScreen(onSettingsClick: () -> Unit) {
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
AppButton(onClick = onSettingsClick, modifier = Modifier.padding(top = 16.dp)) { AppButton(onClick = onSettingsClick, modifier = Modifier.padding(top = 16.dp)) {
Text(text = stringResource(id = R.string.settings_title_connection)) Text(text = "Configure Connection")
} }
} }
} }

View File

@@ -11,6 +11,7 @@ import androidx.compose.animation.core.spring
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -77,6 +78,7 @@ sealed class Screen(
object Settings : Screen("settings", R.string.title_settings, AppIcons.SettingsFilled, AppIcons.SettingsOutlined) object Settings : Screen("settings", R.string.title_settings, AppIcons.SettingsFilled, AppIcons.SettingsOutlined)
object More : Screen("more", R.string.label_more, AppIcons.MoreVert, AppIcons.MoreHorizontal) object More : Screen("more", R.string.label_more, AppIcons.MoreVert, AppIcons.MoreHorizontal)
object Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined) object Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
object Corrector : Screen("corrector", R.string.title_corrector, AppIcons.SpellCheck, AppIcons.SpellCheck)
object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined) object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
companion object { companion object {
@@ -88,6 +90,7 @@ sealed class Screen(
val items = mutableListOf<Screen>() val items = mutableListOf<Screen>()
items.add(Translation) items.add(Translation)
items.add(Dictionary) items.add(Dictionary)
items.add(Corrector)
items.add(Settings) items.add(Settings)
if (showExperimental) { if (showExperimental) {
items.add(Exercises) items.add(Exercises)
@@ -258,7 +261,7 @@ fun BottomNavigationBar(
.background( .background(
brush = Brush.radialGradient( brush = Brush.radialGradient(
colors = listOf( colors = listOf(
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
Color.Transparent Color.Transparent
) )
), ),
@@ -271,6 +274,12 @@ fun BottomNavigationBar(
modifier = Modifier modifier = Modifier
.size(playButtonSize) .size(playButtonSize)
.clip(CircleShape) .clip(CircleShape)
// CHANGED: Added a border to give the button definition
.border(
width = 4.dp, // Adjust this thickness to your liking
color = MaterialTheme.colorScheme.surfaceVariant, // Creates a nice "cutout" separation
shape = CircleShape
)
.background(MaterialTheme.colorScheme.primaryContainer) .background(MaterialTheme.colorScheme.primaryContainer)
.clickable { .clickable {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -281,7 +290,7 @@ fun BottomNavigationBar(
Icon( Icon(
imageVector = Icons.Filled.PlayArrow, imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play", contentDescription = "Play",
tint = Color.White, tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) )
} }

View File

@@ -0,0 +1,188 @@
@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.viewmodel.LanguageViewModel
@Composable
fun CsvImportDialog(
showDialog: Boolean,
parsedTable: List<List<String>>,
languageViewModel: LanguageViewModel,
onDismiss: () -> Unit,
onImport: (List<VocabularyItem>) -> Unit,
statusMessageService: StatusMessageService
) {
if (!showDialog) return
var selectedColFirst by remember { mutableIntStateOf(0) }
var selectedColSecond by remember { mutableIntStateOf(1.coerceAtMost((parsedTable.maxOfOrNull { it.size } ?: 1) - 1)) }
var skipHeader by remember { mutableStateOf(true) }
var selectedLangFirst by remember { mutableStateOf<Language?>(null) }
var selectedLangSecond by remember { mutableStateOf<Language?>(null) }
val errorSelectTwoColumns = stringResource(R.string.error_select_two_columns)
val errorSelectLanguages = stringResource(R.string.error_select_languages)
val errorNoRowsToImport = stringResource(R.string.error_no_rows_to_import)
AppDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.label_import_table_csv_excel)) }
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
// First Column Selection
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
var menu1Expanded by remember { mutableStateOf(false) }
AppOutlinedButton(onClick = { menu1Expanded = true }) {
Text(stringResource(R.string.label_column_n, selectedColFirst + 1))
}
DropdownMenu(expanded = menu1Expanded, onDismissRequest = { menu1Expanded = false }) {
(0 until columnCount).forEach { idx ->
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
DropdownMenuItem(
text = { Text("#${idx + 1}$header") },
onClick = { selectedColFirst = idx; menu1Expanded = false }
)
}
}
}
// Second Column Selection
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_second_column), modifier = Modifier.weight(1f))
var menu2Expanded by remember { mutableStateOf(false) }
AppOutlinedButton(onClick = { menu2Expanded = true }) {
Text(stringResource(R.string.label_column_n, selectedColSecond + 1))
}
DropdownMenu(expanded = menu2Expanded, onDismissRequest = { menu2Expanded = false }) {
(0 until columnCount).forEach { idx ->
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
DropdownMenuItem(
text = { Text("#${idx + 1}$header") },
onClick = { selectedColSecond = idx; menu2Expanded = false }
)
}
}
}
// Language Selection
Text(stringResource(R.string.label_languages))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_first_language))
SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangFirst,
onLanguageSelected = { selectedLangFirst = it }
)
}
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_second_language))
SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangSecond,
onLanguageSelected = { selectedLangSecond = it }
)
}
}
// Skip Header Checkbox
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.label_header_row))
}
// Preview
val startIdx = if (skipHeader) 1 else 0
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
Text(stringResource(R.string.label_preview_first, previewA))
Text(stringResource(R.string.label_preview_second, previewB))
// Row Count
val totalRows = parsedTable.drop(startIdx).count { row ->
val a = row.getOrNull(selectedColFirst).orEmpty().isNotBlank()
val b = row.getOrNull(selectedColSecond).orEmpty().isNotBlank()
a || b
}
Text(stringResource(R.string.text_rows_to_import_1d, totalRows))
// Action Buttons
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.label_cancel))
}
Spacer(modifier = Modifier.width(8.dp))
PrimaryButton(
onClick = {
if (selectedColFirst == selectedColSecond) {
statusMessageService.showErrorMessage(errorSelectTwoColumns)
return@PrimaryButton
}
val langA = selectedLangFirst
val langB = selectedLangSecond
if (langA == null || langB == null) {
statusMessageService.showErrorMessage(errorSelectLanguages)
return@PrimaryButton
}
val items = parsedTable.drop(startIdx).mapNotNull { row ->
val a = row.getOrNull(selectedColFirst)?.trim().orEmpty()
val b = row.getOrNull(selectedColSecond)?.trim().orEmpty()
if (a.isBlank() && b.isBlank()) null else VocabularyItem(
id = 0,
languageFirstId = langA.nameResId,
languageSecondId = langB.nameResId,
wordFirst = a,
wordSecond = b
)
}
if (items.isEmpty()) {
statusMessageService.showErrorMessage(errorNoRowsToImport)
return@PrimaryButton
}
onImport(items)
},
text = stringResource(R.string.label_import)
)
}
}
}
}

View File

@@ -60,12 +60,16 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.semanticColors import eu.gaudian.translator.ui.theme.semanticColors
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppSwitch import eu.gaudian.translator.view.composable.AppSwitch
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown
import eu.gaudian.translator.view.composable.DropdownDefaults import eu.gaudian.translator.view.composable.DropdownDefaults
import eu.gaudian.translator.view.composable.LargeDropdownMenuItem import eu.gaudian.translator.view.composable.LargeDropdownMenuItem
@@ -73,12 +77,15 @@ import eu.gaudian.translator.viewmodel.CorrectionViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// 1. STATEFUL COMPONENT (Connects to ViewModels)
@Composable @Composable
fun CorrectionScreen( fun CorrectionScreen(
correctionViewModel: CorrectionViewModel, navController: NavController
languageViewModel: LanguageViewModel
) { ) {
val activity = LocalContext.current.findActivity()
val correctionViewModel: CorrectionViewModel = hiltViewModel(activity)
val languageViewModel : LanguageViewModel = hiltViewModel(activity)
val textFieldValue by correctionViewModel.textFieldValue.collectAsState() val textFieldValue by correctionViewModel.textFieldValue.collectAsState()
val explanation by correctionViewModel.explanation.collectAsState() val explanation by correctionViewModel.explanation.collectAsState()
val isLoading by correctionViewModel.isLoading.collectAsState() val isLoading by correctionViewModel.isLoading.collectAsState()
@@ -89,6 +96,15 @@ fun CorrectionScreen(
val successColor = MaterialTheme.semanticColors.success val successColor = MaterialTheme.semanticColors.success
Column(){
AppTopAppBar(
title = stringResource(R.string.label_correction),
onNavigateBack = {
navController.popBackStack()
},
)
CorrectionScreenContent( CorrectionScreenContent(
textFieldValue = textFieldValue, textFieldValue = textFieldValue,
explanation = explanation, explanation = explanation,
@@ -113,6 +129,7 @@ fun CorrectionScreen(
) )
} }
) )
}
} }
// 2. STATELESS COMPONENT (Handles UI Layout) // 2. STATELESS COMPONENT (Handles UI Layout)
@@ -304,7 +321,6 @@ fun CorrectionScreenContent(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,

View File

@@ -1,12 +1,16 @@
package eu.gaudian.translator.view.dictionary package eu.gaudian.translator.view.dictionary
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import eu.gaudian.translator.R
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.viewmodel.DictionaryViewModel import eu.gaudian.translator.viewmodel.DictionaryViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
@@ -20,6 +24,11 @@ fun DictionaryScreen(
val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity) val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
Column {
AppTopAppBar(
title = stringResource(R.string.label_dictionary),
onNavigateBack = { navController.popBackStack() }
)
// Use the new refactored component // Use the new refactored component
DictionaryScreenContent( DictionaryScreenContent(
@@ -29,6 +38,7 @@ fun DictionaryScreen(
languageViewModel = languageViewModel, languageViewModel = languageViewModel,
onNavigateToOptions = onNavigateToOptions onNavigateToOptions = onNavigateToOptions
) )
}
} }
@Preview @Preview

View File

@@ -1,106 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.dictionary
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.LocalConnectionConfigured
import eu.gaudian.translator.view.NoConnectionScreen
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppTabLayout
import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.composable.TabItem
import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.viewmodel.CorrectionViewModel
import eu.gaudian.translator.viewmodel.DictionaryViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel
@Composable
private fun getDictionaryTabs(): List<TabItem> {
return listOf(
DictionaryTab(stringResource(R.string.label_dictionary), AppIcons.Dictionary),
DictionaryTab(stringResource(R.string.title_corrector), AppIcons.Check)
)
}
private data class DictionaryTab(override val title: String, override val icon: ImageVector) :
TabItem
@Composable
fun MainDictionaryScreen(
navController: NavController
) {
val activity = LocalContext.current.findActivity()
val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val correctionViewModel: CorrectionViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val dictionaryTabs = getDictionaryTabs()
val connectionConfigured = LocalConnectionConfigured.current
if (!connectionConfigured) {
NoConnectionScreen(onSettingsClick = {navController.navigate(SettingsRoutes.API_KEY)})
return
}
var selectedTab by remember { mutableStateOf(dictionaryTabs[0]) }
Column {
AppTabLayout(
tabs = dictionaryTabs,
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
onNavigateBack = {
if (!navController.popBackStack()) {
navController.navigate(Screen.Home.route) {
launchSingleTop = true
restoreState = false
}
}
}
)
when (selectedTab) {
dictionaryTabs[0] -> DictionaryScreen(
navController = navController,
onEntryClick = { entry ->
// Set flag indicating navigation is from external source (not DictionaryResultScreen)
dictionaryViewModel.setNavigatingFromDictionaryResult(false)
navController.navigate("dictionary_result/${entry.id}")
},
onNavigateToOptions = {
navController.navigate("dictionary_options")
}
)
dictionaryTabs[1] -> CorrectionScreen(
correctionViewModel = correctionViewModel,
languageViewModel = languageViewModel
)
}
}
}
@ThemePreviews
@Composable
fun DictionaryHostScreenPreview() {
val navController = rememberNavController()
MainDictionaryScreen(
navController = navController,
)
}

View File

@@ -337,6 +337,7 @@ fun AllCardsView(
onItemLongClick: (VocabularyItem) -> Unit, onItemLongClick: (VocabularyItem) -> Unit,
onDeleteClick: (VocabularyItem) -> Unit, onDeleteClick: (VocabularyItem) -> Unit,
listState: LazyListState, listState: LazyListState,
onAddClick: (() -> Unit)? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (vocabularyItems.isEmpty()) { if (vocabularyItems.isEmpty()) {
@@ -359,6 +360,21 @@ fun AllCardsView(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (onAddClick != null) {
Spacer(modifier = Modifier.height(24.dp))
androidx.compose.material3.Button(
onClick = onAddClick,
modifier = Modifier.fillMaxWidth(0.6f)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(R.string.label_add_vocabulary))
}
}
} }
} else { } else {
LazyColumn( LazyColumn(

View File

@@ -266,6 +266,7 @@ fun LibraryScreen(
selection = selection, selection = selection,
stageMapping = stageMapping, stageMapping = stageMapping,
listState = lazyListState, listState = lazyListState,
onAddClick = { navController.navigate(NavigationRoutes.NEW_WORD) },
onItemClick = { item -> onItemClick = { item ->
if (isInSelectionMode) { if (isInSelectionMode) {
selection = if (selection.contains(item.id.toLong())) { selection = if (selection.contains(item.id.toLong())) {

View File

@@ -8,7 +8,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -23,8 +22,6 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
@@ -59,12 +56,11 @@ import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCard import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppScaffold import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.CsvImportDialog
import eu.gaudian.translator.view.composable.PrimaryButton import eu.gaudian.translator.view.composable.PrimaryButton
import eu.gaudian.translator.view.composable.SecondaryButton import eu.gaudian.translator.view.composable.SecondaryButton
import eu.gaudian.translator.view.composable.SingleLanguageDropDown
import eu.gaudian.translator.viewmodel.CategoryViewModel import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ExportImportViewModel import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.ExportState import eu.gaudian.translator.viewmodel.ExportState
@@ -659,117 +655,18 @@ fun VocabularyRepositoryOptionsScreen(
// CSV Import Dialog // CSV Import Dialog
if (showTableImportDialog.value) { if (showTableImportDialog.value) {
AlertDialog(
onDismissRequest = { showTableImportDialog.value = false },
title = { Text(stringResource(R.string.label_import_table_csv_excel)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
var menu1Expanded by remember { mutableStateOf(false) }
AppOutlinedButton(onClick = { menu1Expanded = true }) { Text(stringResource(R.string.label_column_n, selectedColFirst + 1)) }
DropdownMenu(expanded = menu1Expanded, onDismissRequest = { menu1Expanded = false }) {
(0 until columnCount).forEach { idx ->
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
DropdownMenuItem(
text = { Text("#${idx + 1}$header") },
onClick = { selectedColFirst = idx; menu1Expanded = false }
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_second_column), modifier = Modifier.weight(1f))
var menu2Expanded by remember { mutableStateOf(false) }
AppOutlinedButton(onClick = { menu2Expanded = true }) { Text(stringResource(R.string.label_column_n, selectedColSecond + 1)) }
DropdownMenu(expanded = menu2Expanded, onDismissRequest = { menu2Expanded = false }) {
(0 until columnCount).forEach { idx ->
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
DropdownMenuItem(
text = { Text("#${idx + 1}$header") },
onClick = { selectedColSecond = idx; menu2Expanded = false }
)
}
}
}
Text(stringResource(R.string.label_languages))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_first_language))
SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangFirst,
onLanguageSelected = { selectedLangFirst = it }
)
}
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_second_language))
SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangSecond,
onLanguageSelected = { selectedLangSecond = it }
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.label_header_row))
}
val startIdx = if (skipHeader) 1 else 0
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
Text(stringResource(R.string.label_preview_first, previewA))
Text(stringResource(R.string.label_preview_second, previewB))
val totalRows = parsedTable.drop(startIdx).count { row ->
val a = row.getOrNull(selectedColFirst).orEmpty().isNotBlank()
val b = row.getOrNull(selectedColSecond).orEmpty().isNotBlank()
a || b
}
Text(stringResource(R.string.text_rows_to_import_1d, totalRows))
}
},
confirmButton = {
val errorSelectTwoColumns = stringResource(R.string.error_select_two_columns)
val errorSelectLanguages = stringResource(R.string.error_select_languages)
val errorNoRowsToImport = stringResource(R.string.error_no_rows_to_import)
val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from) val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from)
TextButton(onClick = { CsvImportDialog(
if (selectedColFirst == selectedColSecond) { showDialog = showTableImportDialog.value,
statusMessageService.showErrorMessage(errorSelectTwoColumns) parsedTable = parsedTable,
return@TextButton languageViewModel = languageViewModel,
} onDismiss = { showTableImportDialog.value = false },
val langA = selectedLangFirst onImport = { items ->
val langB = selectedLangSecond
if (langA == null || langB == null) {
statusMessageService.showErrorMessage(errorSelectLanguages)
return@TextButton
}
val startIdx = if (skipHeader) 1 else 0
val items = parsedTable.drop(startIdx).mapNotNull { row ->
val a = row.getOrNull(selectedColFirst)?.trim().orEmpty()
val b = row.getOrNull(selectedColSecond)?.trim().orEmpty()
if (a.isBlank() && b.isBlank()) null else eu.gaudian.translator.model.VocabularyItem(
id = 0,
languageFirstId = langA.nameResId,
languageSecondId = langB.nameResId,
wordFirst = a,
wordSecond = b
)
}
if (items.isEmpty()) {
statusMessageService.showErrorMessage(errorNoRowsToImport)
return@TextButton
}
vocabularyViewModel.addVocabularyItems(items) vocabularyViewModel.addVocabularyItems(items)
statusMessageService.showSuccessMessage(infoImportedItemsFrom + " " +items.size) statusMessageService.showSuccessMessage("$infoImportedItemsFrom ${items.size}")
showTableImportDialog.value = false showTableImportDialog.value = false
}) { Text(stringResource(R.string.label_import)) }
}, },
dismissButton = { statusMessageService = statusMessageService
TextButton(onClick = { showTableImportDialog.value = false }) { Text(stringResource(R.string.label_cancel)) }
}
) )
} }
} }

View File

@@ -6,6 +6,7 @@ import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
@@ -58,6 +59,7 @@ import eu.gaudian.translator.view.LocalConnectionConfigured
import eu.gaudian.translator.view.NoConnectionScreen import eu.gaudian.translator.view.NoConnectionScreen
import eu.gaudian.translator.view.composable.AppCard import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppOutlinedCard import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.dialogs.AddVocabularyDialog import eu.gaudian.translator.view.dialogs.AddVocabularyDialog
import eu.gaudian.translator.view.hints.HintDefinition import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.view.settings.SettingsRoutes import eu.gaudian.translator.view.settings.SettingsRoutes
@@ -88,7 +90,10 @@ fun TranslationScreen(
if (isInitializationComplete && !connectionConfigured) { if (isInitializationComplete && !connectionConfigured) {
NoConnectionScreen(onSettingsClick = { navController.navigate(SettingsRoutes.API_KEY) }) NoConnectionScreen(
onSettingsClick = { navController.navigate(SettingsRoutes.API_KEY) },
navController = navController
)
return return
} }
@@ -108,7 +113,7 @@ fun TranslationScreen(
onSettingsClick = onSettingsClick, onSettingsClick = onSettingsClick,
onNavigateBack = { onNavigateBack = {
if (!navController.popBackStack()) { if (!navController.popBackStack()) {
navController.navigate(eu.gaudian.translator.view.composable.Screen.Home.route) { navController.navigate(Screen.Home.route) {
launchSingleTop = true launchSingleTop = true
restoreState = false restoreState = false
} }
@@ -182,7 +187,7 @@ private fun LoadedTranslationContent(
AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) { AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) {
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
if (isLandscape) { if (isLandscape) {
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {

View File

@@ -367,7 +367,7 @@ fun ExplorePacksScreen(
Log.d(TAG, "Import success for ${pending.info.id}: " + Log.d(TAG, "Import success for ${pending.info.id}: " +
"imported=${state.result.itemsImported}, skipped=${state.result.itemsSkipped}") "imported=${state.result.itemsImported}, skipped=${state.result.itemsSkipped}")
vocabPacksViewModel.markImportedAndCleanup(pending.info) vocabPacksViewModel.markImportedAndCleanup(pending.info)
StatusMessageService.showSuccessById(StatusMessageId.SUCCESS_ITEMS_IMPORTED) StatusMessageService.showSuccessById(StatusMessageId.SUCCESS_All_ITEMS_IMPORTED)
isImporting = false isImporting = false
pendingImportPackState = null pendingImportPackState = null
exportImportViewModel.resetImportState() exportImportViewModel.resetImportState()
@@ -563,12 +563,6 @@ fun ExplorePacksScreen(
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text(
manifestError ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { vocabPacksViewModel.loadManifest() }) { Button(onClick = { vocabPacksViewModel.loadManifest() }) {
Text(stringResource(R.string.label_retry)) Text(stringResource(R.string.label_retry))

View File

@@ -1,7 +1,5 @@
package eu.gaudian.translator.view.vocabulary package eu.gaudian.translator.view.vocabulary
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -22,10 +20,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.DriveFolderUpload import androidx.compose.material.icons.filled.DriveFolderUpload
import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material3.AlertDialog import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -35,7 +31,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -51,17 +46,14 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.VocabularyItem import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.LocalConnectionConfigured
import eu.gaudian.translator.view.NavigationRoutes import eu.gaudian.translator.view.NavigationRoutes
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCard import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIconContainer import eu.gaudian.translator.view.composable.AppIconContainer
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppOutlinedTextField import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.AppSlider import eu.gaudian.translator.view.composable.AppSlider
import eu.gaudian.translator.view.composable.AppTopAppBar import eu.gaudian.translator.view.composable.AppTopAppBar
@@ -71,6 +63,7 @@ import eu.gaudian.translator.view.composable.SourceLanguageDropdown
import eu.gaudian.translator.view.composable.TargetLanguageDropdown import eu.gaudian.translator.view.composable.TargetLanguageDropdown
import eu.gaudian.translator.view.hints.HintDefinition import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.view.library.VocabularyCard import eu.gaudian.translator.view.library.VocabularyCard
import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -101,109 +94,12 @@ fun NewWordScreen(
} }
} }
val statusMessageService = StatusMessageService
val context = LocalContext.current
val showTableImportDialog = remember { mutableStateOf(false) }
var parsedTable by remember { mutableStateOf<List<List<String>>>(emptyList()) }
var selectedColFirst by remember { mutableIntStateOf(0) }
var selectedColSecond by remember { mutableIntStateOf(1) }
var skipHeader by remember { mutableStateOf(true) }
var selectedLangFirst by remember { mutableStateOf<Language?>(null) }
var selectedLangSecond by remember { mutableStateOf<Language?>(null) }
val recentlyAdded = remember(recentItems) { val recentlyAdded = remember(recentItems) {
recentItems.sortedByDescending { it.id }.take(4) recentItems.sortedByDescending { it.id }.take(4)
} }
fun parseCsv(text: String): List<List<String>> {
if (text.isBlank()) return emptyList()
val candidates = listOf(',', ';', '\t')
val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList()
val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } }
val rows = mutableListOf<List<String>>()
var current = StringBuilder()
var inQuotes = false
val currentRow = mutableListOf<String>()
var i = 0
while (i < text.length) {
when (val ch = text[i]) {
'"' -> {
if (inQuotes && i + 1 < text.length && text[i + 1] == '"') {
current.append('"')
i++
} else {
inQuotes = !inQuotes
}
}
'\r' -> {
// ignore
}
'\n' -> {
val field = current.toString()
current = StringBuilder()
currentRow.add(field)
rows.add(currentRow.toList())
currentRow.clear()
inQuotes = false
}
else -> {
if (ch == delimiter && !inQuotes) {
val field = current.toString()
currentRow.add(field)
current = StringBuilder()
} else {
current.append(ch)
}
}
}
i++
}
if (current.isNotEmpty() || currentRow.isNotEmpty()) {
currentRow.add(current.toString())
rows.add(currentRow.toList())
}
return rows.map { row ->
row.map { it.trim().trim('"') }
}.filter { r -> r.any { it.isNotBlank() } }
}
val importTableLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri ->
uri?.let { u ->
try {
context.contentResolver.takePersistableUriPermission(u, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
} catch (_: Exception) {}
try {
val mime = context.contentResolver.getType(u)
val isExcel = mime == "application/vnd.ms-excel" ||
mime == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
if (isExcel) {
statusMessageService.showErrorById(StatusMessageId.ERROR_EXCEL_NOT_SUPPORTED)
return@let
}
context.contentResolver.openInputStream(u)?.use { inputStream ->
val text = inputStream.bufferedReader().use { it.readText() }
val rows = parseCsv(text)
if (rows.isNotEmpty() && rows.maxOf { it.size } >= 2) {
parsedTable = rows
selectedColFirst = 0
selectedColSecond = 1.coerceAtMost(rows.first().size - 1)
showTableImportDialog.value = true
} else {
statusMessageService.showErrorById(StatusMessageId.ERROR_PARSING_TABLE)
}
}
} catch (_: Exception) {
statusMessageService.showErrorById(StatusMessageId.ERROR_PARSING_TABLE_WITH_REASON)
}
}
}
)
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -247,6 +143,7 @@ fun NewWordScreen(
} }
} }
}, },
navController = navController,
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -262,6 +159,7 @@ fun NewWordScreen(
// Import CSV - Full width card at bottom // Import CSV - Full width card at bottom
ImportCsvCard( ImportCsvCard(
onClick = { onClick = {
@Suppress("HardCodedStringLiteral")
navController.navigate("settings_vocabulary_repository_options") navController.navigate("settings_vocabulary_repository_options")
} }
) )
@@ -307,123 +205,6 @@ fun NewWordScreen(
Spacer(modifier = Modifier.height(100.dp)) Spacer(modifier = Modifier.height(100.dp))
} }
} }
if (showTableImportDialog.value) {
AlertDialog(
onDismissRequest = { showTableImportDialog.value = false },
title = { Text(stringResource(R.string.label_import_table_csv_excel)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
var menu1Expanded by remember { mutableStateOf(false) }
AppOutlinedButton(onClick = { menu1Expanded = true }) {
Text(stringResource(R.string.label_column_n, selectedColFirst + 1))
}
DropdownMenu(expanded = menu1Expanded, onDismissRequest = { menu1Expanded = false }) {
(0 until columnCount).forEach { idx ->
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
DropdownMenuItem(
text = { Text("#${idx + 1}$header") },
onClick = { selectedColFirst = idx; menu1Expanded = false }
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_second_column), modifier = Modifier.weight(1f))
var menu2Expanded by remember { mutableStateOf(false) }
AppOutlinedButton(onClick = { menu2Expanded = true }) {
Text(stringResource(R.string.label_column_n, selectedColSecond + 1))
}
DropdownMenu(expanded = menu2Expanded, onDismissRequest = { menu2Expanded = false }) {
(0 until columnCount).forEach { idx ->
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
DropdownMenuItem(
text = { Text("#${idx + 1}$header") },
onClick = { selectedColSecond = idx; menu2Expanded = false }
)
}
}
}
Text(stringResource(R.string.label_languages))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_first_language))
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangFirst,
onLanguageSelected = { selectedLangFirst = it }
)
}
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_second_language))
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangSecond,
onLanguageSelected = { selectedLangSecond = it }
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.label_header_row))
}
val startIdx = if (skipHeader) 1 else 0
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
Text(stringResource(R.string.label_preview_first, previewA))
Text(stringResource(R.string.label_preview_second, previewB))
val totalRows = parsedTable.drop(startIdx).count { row ->
val a = row.getOrNull(selectedColFirst).orEmpty().isNotBlank()
val b = row.getOrNull(selectedColSecond).orEmpty().isNotBlank()
a || b
}
Text(stringResource(R.string.text_rows_to_import_1d, totalRows))
}
},
confirmButton = {
TextButton(onClick = {
if (selectedColFirst == selectedColSecond) {
statusMessageService.showErrorById(StatusMessageId.ERROR_SELECT_TWO_COLUMNS)
return@TextButton
}
val langA = selectedLangFirst
val langB = selectedLangSecond
if (langA == null || langB == null) {
statusMessageService.showErrorById(StatusMessageId.ERROR_SELECT_LANGUAGES)
return@TextButton
}
val startIdx = if (skipHeader) 1 else 0
val items = parsedTable.drop(startIdx).mapNotNull { row ->
val a = row.getOrNull(selectedColFirst)?.trim().orEmpty()
val b = row.getOrNull(selectedColSecond)?.trim().orEmpty()
if (a.isBlank() && b.isBlank()) null else VocabularyItem(
id = 0,
languageFirstId = langA.nameResId,
languageSecondId = langB.nameResId,
wordFirst = a,
wordSecond = b
)
}
if (items.isEmpty()) {
statusMessageService.showErrorById(StatusMessageId.ERROR_NO_ROWS_TO_IMPORT)
return@TextButton
}
vocabularyViewModel.addVocabularyItems(items)
statusMessageService.showSuccessById(StatusMessageId.SUCCESS_ITEMS_IMPORTED)
showTableImportDialog.value = false
}) { Text(stringResource(R.string.label_import)) }
},
dismissButton = {
TextButton(onClick = { showTableImportDialog.value = false }) {
Text(stringResource(R.string.label_cancel))
}
}
)
}
} }
// --- AI GENERATOR CARD (From previous implementation) --- // --- AI GENERATOR CARD (From previous implementation) ---
@@ -437,9 +218,14 @@ fun AIGeneratorCard(
languageViewModel: LanguageViewModel, languageViewModel: LanguageViewModel,
isGenerating: Boolean, isGenerating: Boolean,
onGenerate: () -> Unit, onGenerate: () -> Unit,
navController: NavHostController,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val connectionConfigured = LocalConnectionConfigured.current
if (connectionConfigured) {
// Show the normal AI generator card
val icon = Icons.Default.AutoAwesome val icon = Icons.Default.AutoAwesome
val hints = stringArrayResource(R.array.vocabulary_hints) val hints = stringArrayResource(R.array.vocabulary_hints)
AppCard( AppCard(
@@ -543,6 +329,49 @@ fun AIGeneratorCard(
} }
} }
} }
} else {
// Show the "configure connection" card when not configured
AppCard(
modifier = modifier.fillMaxWidth(),
title = stringResource(R.string.label_ai_generator),
icon = Icons.Default.AutoAwesome,
hintContent = HintDefinition.VOCABULARY_GENERATE_AI.hint(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.text_ai_generator_requires_configuration),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
AppButton(
onClick = { navController.navigate(SettingsRoutes.API_KEY) },
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(imageVector = Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.label_configure),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
} }
// --- NEW COMPONENTS START HERE --- // --- NEW COMPONENTS START HERE ---

View File

@@ -8,7 +8,8 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.gaudian.translator.model.VocabularyItem import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.model.communication.FileDownloadManager import eu.gaudian.translator.model.communication.files_download.DownloadConfig
import eu.gaudian.translator.model.communication.files_download.FileDownloadManager
import eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo import eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo
import eu.gaudian.translator.model.jsonParser import eu.gaudian.translator.model.jsonParser
import eu.gaudian.translator.utils.Log import eu.gaudian.translator.utils.Log
@@ -250,7 +251,7 @@ class VocabPacksViewModel @Inject constructor(
} }
private fun localFile(info: VocabCollectionInfo): File = private fun localFile(info: VocabCollectionInfo): File =
File(context.filesDir, "flashcard-collections/${info.filename}") File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
companion object { companion object {
private const val PREFS_NAME = "vocab_packs_imported" private const val PREFS_NAME = "vocab_packs_imported"

View File

@@ -58,7 +58,6 @@
<string name="error_select_two_columns">Bitte zwei Spalten auswählen.</string> <string name="error_select_two_columns">Bitte zwei Spalten auswählen.</string>
<string name="error_select_languages">Bitte zwei Sprachen auswählen.</string> <string name="error_select_languages">Bitte zwei Sprachen auswählen.</string>
<string name="error_no_rows_to_import">Keine Zeilen zum Importieren. Bitte Spalten und Kopfzeile prüfen.</string> <string name="error_no_rows_to_import">Keine Zeilen zum Importieren. Bitte Spalten und Kopfzeile prüfen.</string>
<string name="info_imported_items_from">%1$d Vokabeln importiert.</string>
<string name="label_import">Importieren</string> <string name="label_import">Importieren</string>
<string name="menu_create_youtube_exercise">YouTube-Übung erstellen</string> <string name="menu_create_youtube_exercise">YouTube-Übung erstellen</string>
<string name="text_youtube_link">YouTube-Link</string> <string name="text_youtube_link">YouTube-Link</string>
@@ -547,7 +546,6 @@
<string name="sorting_hint_title">Vokabeln sortieren</string> <string name="sorting_hint_title">Vokabeln sortieren</string>
<string name="text_optional">" (optional)"</string> <string name="text_optional">" (optional)"</string>
<string name="text_check_availability">Verfügbarkeit prüfen</string> <string name="text_check_availability">Verfügbarkeit prüfen</string>
<string name="text_no_valid_api_configuration_could_be_found">Keine gültige API-Konfiguration gefunden. Bitte konfiguriere zuerst einen API-Anbieter in den Einstellungen.</string>
<string name="text_try_wiktionary_first">Zuerst Wiktionary versuchen</string> <string name="text_try_wiktionary_first">Zuerst Wiktionary versuchen</string>
<string name="text_try_first_finding_the_word_on">Versuche zuerst, das Wort auf Wiktionary zu finden, bevor eine KI-Antwort generiert wird.</string> <string name="text_try_first_finding_the_word_on">Versuche zuerst, das Wort auf Wiktionary zu finden, bevor eine KI-Antwort generiert wird.</string>
<string name="text_question_of">Frage %1$d von %2$d</string> <string name="text_question_of">Frage %1$d von %2$d</string>

View File

@@ -8,7 +8,6 @@
<string name="label_translate">Traduzir</string> <string name="label_translate">Traduzir</string>
<string name="label_add">Adicionar</string> <string name="label_add">Adicionar</string>
<string name="label_close">Fechar</string> <string name="label_close">Fechar</string>
<string name="label_cancel">Cancelar</string>
<string name="label_confirm">Confirmar</string> <string name="label_confirm">Confirmar</string>
<string name="label_select">Selecionar</string> <string name="label_select">Selecionar</string>
<string name="label_done">Concluído</string> <string name="label_done">Concluído</string>
@@ -58,7 +57,6 @@
<string name="error_select_two_columns">Por favor, selecione duas colunas.</string> <string name="error_select_two_columns">Por favor, selecione duas colunas.</string>
<string name="error_select_languages">Por favor, selecione dois idiomas.</string> <string name="error_select_languages">Por favor, selecione dois idiomas.</string>
<string name="error_no_rows_to_import">Nenhuma linha para importar. Verifique as colunas e o cabeçalho.</string> <string name="error_no_rows_to_import">Nenhuma linha para importar. Verifique as colunas e o cabeçalho.</string>
<string name="info_imported_items_from">%1$d itens de vocabulário importados.</string>
<string name="label_import">Importar</string> <string name="label_import">Importar</string>
<string name="menu_create_youtube_exercise">Criar Exercício do YouTube</string> <string name="menu_create_youtube_exercise">Criar Exercício do YouTube</string>
<string name="text_youtube_link">Link do YouTube</string> <string name="text_youtube_link">Link do YouTube</string>
@@ -90,7 +88,6 @@
<string name="text_widget_title_weekly_activity">Atividade Semanal</string> <string name="text_widget_title_weekly_activity">Atividade Semanal</string>
<string name="text_loading_3d">Carregando…</string> <string name="text_loading_3d">Carregando…</string>
<string name="text_show_loading">Mostrar Carregamento</string> <string name="text_show_loading">Mostrar Carregamento</string>
<string name="text_cancel_loading">Cancelar Carregamento</string>
<string name="text_show_info_message">Mostrar Mensagem Informativa</string> <string name="text_show_info_message">Mostrar Mensagem Informativa</string>
<string name="text_show_error_message">Mostrar Mensagem de Erro</string> <string name="text_show_error_message">Mostrar Mensagem de Erro</string>
<string name="text_reset_intro">Resetar Introdução</string> <string name="text_reset_intro">Resetar Introdução</string>
@@ -543,7 +540,6 @@
<string name="sorting_hint_title">Organização de Vocabulário</string> <string name="sorting_hint_title">Organização de Vocabulário</string>
<string name="text_optional">" (opcional)"</string> <string name="text_optional">" (opcional)"</string>
<string name="text_check_availability">Verificar disponibilidade</string> <string name="text_check_availability">Verificar disponibilidade</string>
<string name="text_no_valid_api_configuration_could_be_found">Nenhuma configuração de API válida foi encontrada. Antes de usar o app, configure pelo menos um provedor de API.</string>
<string name="text_try_wiktionary_first">Tentar Wikcionário Primeiro</string> <string name="text_try_wiktionary_first">Tentar Wikcionário Primeiro</string>
<string name="text_try_first_finding_the_word_on">Tente primeiro encontrar a palavra no Wikcionário antes de gerar uma resposta de IA</string> <string name="text_try_first_finding_the_word_on">Tente primeiro encontrar a palavra no Wikcionário antes de gerar uma resposta de IA</string>
<string name="text_question_of">Pergunta %1$d de %2$d</string> <string name="text_question_of">Pergunta %1$d de %2$d</string>
@@ -620,11 +616,9 @@
<string name="text_do_you_want_to_minimize_the_app">Quer minimizar o app?</string> <string name="text_do_you_want_to_minimize_the_app">Quer minimizar o app?</string>
<string name="text_error_deleting_dictionaries">Erro ao excluir dicionários: %1$s</string> <string name="text_error_deleting_dictionaries">Erro ao excluir dicionários: %1$s</string>
<string name="text_error_deleting_dictionary">Erro ao excluir dicionário: %1$s</string> <string name="text_error_deleting_dictionary">Erro ao excluir dicionário: %1$s</string>
<string name="text_error_deleting_orphaned_file">Erro ao deletar arquivo órfão: %1$s</string>
<string name="text_error_downloading_dictionary">Erro ao baixar dicionário: %1$s</string> <string name="text_error_downloading_dictionary">Erro ao baixar dicionário: %1$s</string>
<string name="text_error_loading_stored_values">Erro ao carregar valores armazenados: %1$s</string> <string name="text_error_loading_stored_values">Erro ao carregar valores armazenados: %1$s</string>
<string name="text_error_saving_entry">Erro ao salvar entrada: %1$s</string> <string name="text_error_saving_entry">Erro ao salvar entrada: %1$s</string>
<string name="text_failed_to_delete_dictionary">Falha ao deletar dicionário: %1$s</string>
<string name="text_failed_to_delete_orphaned_file">Falhou ao excluir arquivo órfão: %1$s</string> <string name="text_failed_to_delete_orphaned_file">Falhou ao excluir arquivo órfão: %1$s</string>
<string name="text_failed_to_delete_some_dictionaries">Falhou ao excluir alguns dicionários</string> <string name="text_failed_to_delete_some_dictionaries">Falhou ao excluir alguns dicionários</string>
<string name="text_failed_to_download_dictionary">Falhou ao baixar dicionário: %1$s</string> <string name="text_failed_to_download_dictionary">Falhou ao baixar dicionário: %1$s</string>
@@ -771,8 +765,6 @@
<string name="text_use_downloaded_dictionary">Usar dicionário baixado</string> <string name="text_use_downloaded_dictionary">Usar dicionário baixado</string>
<string name="text_watch_video_again">Assistir ao Vídeo Novamente</string> <string name="text_watch_video_again">Assistir ao Vídeo Novamente</string>
<string name="text_dialog_delete_provider">Tem certeza que deseja excluir o provedor "%1$s"? Isso também removerá todos os modelos associados a este provedor. Esta ação não pode ser desfeita.</string> <string name="text_dialog_delete_provider">Tem certeza que deseja excluir o provedor "%1$s"? Isso também removerá todos os modelos associados a este provedor. Esta ação não pode ser desfeita.</string>
<string name="label_delete_model">Deletar Modelo</string>
<string name="text_dialog_delete_model">Tem certeza que deseja deletar o modelo "%1$s" de %2$s? Essa ação não pode ser desfeita.</string>
<string name="label_task_model_assignments">Atribuições do Modelo de Tarefa</string> <string name="label_task_model_assignments">Atribuições do Modelo de Tarefa</string>
<string name="text_configure_which_ai_model_to_use_for_each_task_type">Configurar qual modelo de IA usar para cada tipo de tarefa</string> <string name="text_configure_which_ai_model_to_use_for_each_task_type">Configurar qual modelo de IA usar para cada tipo de tarefa</string>
<string name="label_custom">Personalizado</string> <string name="label_custom">Personalizado</string>
@@ -785,7 +777,6 @@
<string name="text_translation_will_appear_here">A tradução vai aparecer aqui</string> <string name="text_translation_will_appear_here">A tradução vai aparecer aqui</string>
<string name="label_delete_key">Apagar Chave</string> <string name="label_delete_key">Apagar Chave</string>
<string name="text_dialog_delete_key">Tem certeza que quer apagar a Chave desse Fornecedor?</string> <string name="text_dialog_delete_key">Tem certeza que quer apagar a Chave desse Fornecedor?</string>
<string name="text_delete_all_providers_and_models">Deletar todos os provedores e modelos</string>
<string name="label_dictionary_content">Conteúdo do Dicionário</string> <string name="label_dictionary_content">Conteúdo do Dicionário</string>
<string name="tab_ai_definition">Definição de IA</string> <string name="tab_ai_definition">Definição de IA</string>
<string name="tab_downloaded">Baixado</string> <string name="tab_downloaded">Baixado</string>

View File

@@ -128,7 +128,7 @@
<string name="existing_item_id_d">Existing Item (ID: %1$d)</string> <string name="existing_item_id_d">Existing Item (ID: %1$d)</string>
<string name="experimental_features">Experimental Features</string> <string name="experimental_features">Experimental Features</string>
<string name="experimental_features_description">Enable experimental features that are not yet ready for production.</string> <string name="experimental_features_description">Enable experimental features that arent yet ready for production.</string>
<string name="export_vocabulary_data">Export Vocabulary Data</string> <string name="export_vocabulary_data">Export Vocabulary Data</string>
@@ -238,6 +238,8 @@
<string name="label_adverb">Adverb</string> <string name="label_adverb">Adverb</string>
<string name="label_ai_configuration">AI Configuration</string> <string name="label_ai_configuration">AI Configuration</string>
<string name="label_ai_generator">AI Generator</string> <string name="label_ai_generator">AI Generator</string>
<string name="text_ai_generator_requires_configuration">In order to generate vocabulary with AI, you have to configure a connection to an AI service.</string>
<string name="label_configure">Configure</string>
<string name="label_ai_model">AI Model</string> <string name="label_ai_model">AI Model</string>
<string name="label_ai_model_and_prompt"><![CDATA[AI Model & Prompt]]></string> <string name="label_ai_model_and_prompt"><![CDATA[AI Model & Prompt]]></string>
<string name="label_all_cards">All Cards</string> <string name="label_all_cards">All Cards</string>
@@ -511,7 +513,7 @@
<string name="message_error_api_key_missing">API Key is missing or invalid.</string> <string name="message_error_api_key_missing">API Key is missing or invalid.</string>
<string name="message_error_articles_remove_failed">Error removing articles: %1$s</string> <string name="message_error_articles_remove_failed">Error removing articles: %1$s</string>
<string name="message_error_category_update_failed">Error updating category: %1$s</string> <string name="message_error_category_update_failed">Error updating category: %1$s</string>
<string name="message_error_excel_not_supported">Excel is not supported. Use CSV instead.</string> <string name="message_error_excel_not_supported">Excel isnt supported. Use CSV instead.</string>
<string name="message_error_file_picker_not_initialized">Save File Launcher not initialized.</string> <string name="message_error_file_picker_not_initialized">Save File Launcher not initialized.</string>
<string name="message_error_file_save_cancelled">File save cancelled or failed.</string> <string name="message_error_file_save_cancelled">File save cancelled or failed.</string>
<string name="message_error_file_save_failed">Error saving file: %1$s</string> <string name="message_error_file_save_failed">Error saving file: %1$s</string>
@@ -869,7 +871,7 @@
<string name="text_assemble_the_word_here">Bring the letters into the right order</string> <string name="text_assemble_the_word_here">Bring the letters into the right order</string>
<string name="text_assign_a_different_language_items">Assign a different language to these items.</string> <string name="text_assign_a_different_language_items">Assign a different language to these items.</string>
<string name="text_assign_these_items_2d">Assign these items:</string> <string name="text_assign_these_items_2d">Assign these items:</string>
<string name="text_authentication_is_required_and_has_failed">Authentication is required and has failed or has not yet been provided.</string> <string name="text_authentication_is_required_and_has_failed">Authentication is required and has failed or hasnt yet been provided.</string>
<string name="text_automatically_discover_models_from">Automatically discover models from %1$s</string> <string name="text_automatically_discover_models_from">Automatically discover models from %1$s</string>
<string name="text_available_dictionaries">Available Dictionaries</string> <string name="text_available_dictionaries">Available Dictionaries</string>
<string name="text_available_models">Available Models:</string> <string name="text_available_models">Available Models:</string>
@@ -915,8 +917,8 @@
<string name="text_description_dictionary_prompt">Set a model for generating dictionary content and give optional instructions.</string> <string name="text_description_dictionary_prompt">Set a model for generating dictionary content and give optional instructions.</string>
<string name="text_developed_by_jonas_gaudian">Developed by Jonas Gaudian\n</string> <string name="text_developed_by_jonas_gaudian">Developed by Jonas Gaudian\n</string>
<string name="text_dialog_delete_key">Are you sure you want to delete the Key for this Provider?</string> <string name="text_dialog_delete_key">Are you sure you want to delete the Key for this Provider?</string>
<string name="text_dialog_delete_model">Are you sure you want to delete the model \"%1$s\" from %2$s? This action cannot be undone.</string> <string name="text_dialog_delete_model">Are you sure you want to delete the model \"%1$s\" from %2$s? This action cant be undone.</string>
<string name="text_dialog_delete_provider">Are you sure you want to delete the provider \"%1$s\"? This will also remove all models associated with this provider. This action cannot be undone.</string> <string name="text_dialog_delete_provider">Are you sure you want to delete the provider \"%1$s\"? This will also remove all models associated with this provider. This action cant be undone.</string>
<string name="text_dictionary_deleted_successfully">Dictionary deleted successfully</string> <string name="text_dictionary_deleted_successfully">Dictionary deleted successfully</string>
<string name="text_dictionary_downloaded_successfully">Dictionary downloaded successfully</string> <string name="text_dictionary_downloaded_successfully">Dictionary downloaded successfully</string>
<string name="text_dictionary_manager_description">You can download dictionaries for certain languages which can be used insteaf of AI generation for dictionary content.</string> <string name="text_dictionary_manager_description">You can download dictionaries for certain languages which can be used insteaf of AI generation for dictionary content.</string>
@@ -987,7 +989,7 @@
<string name="text_language_direction_disabled_with_pairs">Clear language pair selection to choose a direction.</string> <string name="text_language_direction_disabled_with_pairs">Clear language pair selection to choose a direction.</string>
<string name="text_language_direction_explanation">You can set an optional preference which language should come first or second.</string> <string name="text_language_direction_explanation">You can set an optional preference which language should come first or second.</string>
<string name="text_language_options">Language Options</string> <string name="text_language_options">Language Options</string>
<string name="text_language_settings_description">Set what languages you want to use in the app. Languages that are not activated will not appear in this app. You can also add your own language to the list, or change an existing language (region/locale)</string> <string name="text_language_settings_description">Set what languages you want to use in the app. Languages that arent activated wont appear in this app. You can also add your own language to the list, or change an existing language (region/locale)</string>
<string name="text_last_7_days">Last 7 Days</string> <string name="text_last_7_days">Last 7 Days</string>
<string name="text_light">Light</string> <string name="text_light">Light</string>
<string name="text_list">List</string> <string name="text_list">List</string>
@@ -1011,7 +1013,7 @@
<string name="text_no_key">No Key</string> <string name="text_no_key">No Key</string>
<string name="text_no_models_found">No models found</string> <string name="text_no_models_found">No models found</string>
<string name="text_no_packs_match_search">No packs match your search.</string> <string name="text_no_packs_match_search">No packs match your search.</string>
<string name="text_no_valid_api_configuration_could_be_found">No valid API configuration could be found in the settings. Before using this app, you have to configure at least one API provider.</string> <string name="text_no_valid_api_configuration_could_be_found">No valid LLM API configuration could be found in the settings. Before using this function, you have to configure at least one API provider.</string>
<string name="text_no_vocabulary_available">No vocabulary available.</string> <string name="text_no_vocabulary_available">No vocabulary available.</string>
<string name="text_no_vocabulary_due_today">No Vocabulary Due Today</string> <string name="text_no_vocabulary_due_today">No Vocabulary Due Today</string>
<string name="text_none">None</string> <string name="text_none">None</string>
@@ -1020,7 +1022,7 @@
<string name="text_optional">" (optional)"</string> <string name="text_optional">" (optional)"</string>
<string name="text_optional_describe_what_this_model_is_good_for">Optional: Describe what this model is good for</string> <string name="text_optional_describe_what_this_model_is_good_for">Optional: Describe what this model is good for</string>
<string name="text_orphaned_file_deleted_successfully">Orphaned file deleted successfully</string> <string name="text_orphaned_file_deleted_successfully">Orphaned file deleted successfully</string>
<string name="text_orphaned_file_description">This file exists locally but is not in the server manifest or missing assets. It may be from an older version or a failed download.</string> <string name="text_orphaned_file_description">This file exists locally but isnt in the server manifest or missing assets. It may be from an older version or a failed download.</string>
<string name="text_paste_or_open_a_">Paste or open a YouTube link to see its subtitles here.</string> <string name="text_paste_or_open_a_">Paste or open a YouTube link to see its subtitles here.</string>
<string name="text_please_select_a_dictionary_language_first">Please select a dictionary language first.</string> <string name="text_please_select_a_dictionary_language_first">Please select a dictionary language first.</string>
<string name="text_question">Question</string> <string name="text_question">Question</string>
@@ -1064,7 +1066,7 @@
<string name="text_shuffle_card_order">Shuffle card order</string> <string name="text_shuffle_card_order">Shuffle card order</string>
<string name="text_shuffle_card_order_description">Shuffle Card Order</string> <string name="text_shuffle_card_order_description">Shuffle Card Order</string>
<string name="text_shuffle_languages">Shuffle Languages</string> <string name="text_shuffle_languages">Shuffle Languages</string>
<string name="text_shuffle_languages_description">Shuffle what language comes first. Does not affect language direction preferences.</string> <string name="text_shuffle_languages_description">Shuffle what language comes first. Doesnt affect language direction preferences.</string>
<string name="text_shuffle_languages_disabled_by_direction">Disable language direction preference to enable shuffling.</string> <string name="text_shuffle_languages_disabled_by_direction">Disable language direction preference to enable shuffling.</string>
<string name="text_some_items_are_in_the_wrong_category">Some items are in the wrong category.</string> <string name="text_some_items_are_in_the_wrong_category">Some items are in the wrong category.</string>
<string name="text_stage_2d">Stage %1$s</string> <string name="text_stage_2d">Stage %1$s</string>
@@ -1076,9 +1078,9 @@
<string name="text_the_correct_sentence_was_2d">The correct sentence was: %1$s</string> <string name="text_the_correct_sentence_was_2d">The correct sentence was: %1$s</string>
<string name="text_the_correct_translation_is_2d">The correct translation is: %1$s</string> <string name="text_the_correct_translation_is_2d">The correct translation is: %1$s</string>
<string name="text_theme_preview">Theme Preview</string> <string name="text_theme_preview">Theme Preview</string>
<string name="text_these_files_exist_locally">These files exist locally but are not in the server manifest. They may be from older versions.</string> <string name="text_these_files_exist_locally">These files exist locally but arent in the server manifest. They may be from older versions.</string>
<string name="text_this_must_match_the_provider_s_model_name_exactly">This must match the provider\'s model name exactly</string> <string name="text_this_must_match_the_provider_s_model_name_exactly">This must match the provider\'s model name exactly</string>
<string name="text_this_will_remove_all">This will remove all configured API providers, models, and stored API keys. This action cannot be undone.</string> <string name="text_this_will_remove_all">This will remove all configured API providers, models, and stored API keys. This action cant be undone.</string>
<string name="text_too_many_requests">Too Many Requests: The user has sent too many requests in a given amount of time.</string> <string name="text_too_many_requests">Too Many Requests: The user has sent too many requests in a given amount of time.</string>
<string name="text_total_learned_words">Total Learned Words</string> <string name="text_total_learned_words">Total Learned Words</string>
<string name="text_training_mode">Training Mode</string> <string name="text_training_mode">Training Mode</string>
@@ -1104,13 +1106,13 @@
<string name="text_youtube_link">YouTube Link</string> <string name="text_youtube_link">YouTube Link</string>
<string name="the_request_was_successful">The request was successful.</string> <string name="the_request_was_successful">The request was successful.</string>
<string name="the_requested_resource_could_not_be_found">The requested resource could not be found.</string> <string name="the_requested_resource_could_not_be_found">The requested resource couldnt be found.</string>
<string name="the_server_could_not_understand_the_request">The server could not understand the request.</string> <string name="the_server_could_not_understand_the_request">The server couldnt understand the request.</string>
<string name="the_server_understood_the_request_but_is_refusing_to_authorize_it">The server understood the request, but is refusing to authorize it.</string> <string name="the_server_understood_the_request_but_is_refusing_to_authorize_it">The server understood the request, but is refusing to authorize it.</string>
<string name="this_is_a_sample_output_text">This is a sample output text.</string> <string name="this_is_a_sample_output_text">This is a sample output text.</string>
<string name="this_is_the_content_inside_the_card">This is the content inside the card.</string> <string name="this_is_the_content_inside_the_card">This is the content inside the card.</string>
<string name="this_mode_will_not_affect_your_progress_in_stages">This mode will not affect your progress in stages.</string> <string name="this_mode_will_not_affect_your_progress_in_stages">This mode wont affect your progress in stages.</string>
<string name="timeout">Timeout</string> <string name="timeout">Timeout</string>
@@ -1169,4 +1171,8 @@
<!-- Explore Packs Hint --> <!-- Explore Packs Hint -->
<string name="hint_explore_packs_title">About Vocabulary Packs</string> <string name="hint_explore_packs_title">About Vocabulary Packs</string>
<string name="label_import_csv_or_lists">Import Lists or CSV</string> <string name="label_import_csv_or_lists">Import Lists or CSV</string>
<string name="label_corrector">Corrector</string>
<string name="label_correction">Correction</string>
<string name="message_error_no_model_configured">No AI connection is configured. This functionality does not work without a valid configuration.</string>
<string name="message_success_all_items_imported">All items were imported successfully.</string>
</resources> </resources>