Refactor the CSV import logic into a reusable CsvImportDialog component and centralize download configurations.

This commit is contained in:
jonasgaudian
2026-02-21 11:44:45 +01:00
parent cfd71162a0
commit 199f5ae33f
16 changed files with 505 additions and 468 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

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

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

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

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
@@ -23,10 +21,7 @@ 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.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
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
@@ -36,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
@@ -52,10 +46,7 @@ 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.LocalConnectionConfigured
import eu.gaudian.translator.view.NavigationRoutes import eu.gaudian.translator.view.NavigationRoutes
@@ -63,7 +54,6 @@ 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
@@ -104,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()
@@ -266,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")
} }
) )
@@ -311,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) ---

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>

View File

@@ -57,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>

View File

@@ -1173,4 +1173,6 @@
<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_corrector">Corrector</string>
<string name="label_correction">Correction</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>