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>
<SelectionState runConfigName="app">
<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">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCW11ML1WV" />
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\jonas\.android\avd\Pixel_6.avd" />
</handle>
</Target>
</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.Log
import eu.gaudian.translator.utils.StatusAction
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.viewmodel.MessageAction
import eu.gaudian.translator.viewmodel.MessageDisplayType
@@ -403,11 +404,7 @@ class ApiManager(private val context: Context) {
if (languageModel == null) {
val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context))
StatusMessageService.trigger(StatusAction.ShowActionableMessage(
text = errorMsg,
type = MessageDisplayType.ACTIONABLE_ERROR,
action = MessageAction.NAVIGATE_TO_API_KEYS
))
StatusMessageService.showErrorById(StatusMessageId.ERROR_NO_MODEL_CONFIGURED)
callback.onFailure(errorMsg)
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 androidx.core.content.edit
import eu.gaudian.translator.R
import eu.gaudian.translator.model.communication.files_download.FlashcardCollectionInfo
import eu.gaudian.translator.model.communication.files_download.FlashcardManifestResponse
import eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo
import eu.gaudian.translator.model.communication.Asset
import eu.gaudian.translator.model.communication.FileInfo
import eu.gaudian.translator.model.communication.ManifestResponse
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -18,91 +20,122 @@ import java.io.File
import java.io.FileOutputStream
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.
* All URLs and paths are centralized in [DownloadConfig].
*/
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()
.baseUrl(baseUrl)
// ===== Retrofit Services =====
private val dictionaryRetrofit = Retrofit.Builder()
.baseUrl(DownloadConfig.DICTIONARIES_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().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)
// ===== Dictionary Manifest =====
/**
* Fetches the manifest from the server.
*/
suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== fetchManifest() called ===")
Log.d("FileDownloadManager", "Fetching manifest from: ${DownloadConfig.DICTIONARIES_MANIFEST_URL}")
try {
val response = manifestApiService.getManifest().execute()
Log.d("FileDownloadManager", "Manifest response received - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
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 {
@Suppress("HardCodedStringLiteral") val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
@Suppress("HardCodedStringLiteral")
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
}
} catch (e: Exception) {
@Suppress("HardCodedStringLiteral")
Log.e("FileDownloadManager", "Error fetching manifest", e)
throw e
}
}
// ===== Dictionary Downloads =====
/**
* Downloads all assets for a file and verifies their checksums.
*/
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
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
val assetContribution = assetProgress / totalAssets
val previousAssetsProgress = completedAssets.toFloat() / totalAssets
onProgress(previousAssetsProgress + assetContribution)
}
if (!success) {
Log.e("FileDownloadManager", "Failed to download asset: ${asset.filename}")
return@withContext false
}
}
// Save version after all assets are downloaded successfully
sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
@Suppress("HardCodedStringLiteral")
Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
Log.d("FileDownloadManager", "Saved version ${fileInfo.version} for id ${fileInfo.id}")
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) {
val fileUrl = "${baseUrl}${asset.filename}"
private suspend fun downloadDictionaryAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== downloadDictionaryAsset() called ===")
val fileUrl = DownloadConfig.getDictionaryAssetUrl(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 {
Log.d("FileDownloadManager", "Creating HTTP request...")
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
@@ -116,16 +149,19 @@ class FileDownloadManager(private val context: Context) {
val body = response.body
val contentLength = body.contentLength()
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
if (contentLength <= 0) {
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
throw Exception("Invalid file size: $contentLength")
}
Log.d("FileDownloadManager", "Starting file download to: ${localFile.absolutePath}")
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead: Long = 0
@Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256")
val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
@@ -137,16 +173,19 @@ class FileDownloadManager(private val context: Context) {
output.flush()
// Compute checksum
val computedChecksum = digest.digest().joinToString("") {
@Suppress("HardCodedStringLiteral")
"%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)) {
@Suppress("HardCodedStringLiteral")
Log.d("FileDownloadManager", "Download successful for ${asset.filename}")
Log.d("FileDownloadManager", "Checksum VERIFIED for ${asset.filename}")
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
true
} else {
Log.e("FileDownloadManager", "Checksum MISMATCH for ${asset.filename}")
Log.e("FileDownloadManager",
context.getString(
R.string.text_checksum_mismatch_for_expected_got,
@@ -154,34 +193,44 @@ class FileDownloadManager(private val context: Context) {
asset.checksumSha256,
computedChecksum
))
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete() // Delete corrupted file
throw Exception("Checksum verification failed for ${asset.filename}")
}
}
}
} catch (e: Exception) {
@Suppress("HardCodedStringLiteral")
Log.e("FileDownloadManager", "Error downloading asset", e)
Log.e("FileDownloadManager", "Error downloading asset from $fileUrl", e)
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
// Clean up partial download
if (localFile.exists()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete()
}
throw e
}
}
// ===== Version Management =====
/**
* Checks if a newer version is available for a file.
*/
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"
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).
*/
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 parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
@@ -189,9 +238,12 @@ class FileDownloadManager(private val context: Context) {
val part1 = parts1.getOrElse(i) { 0 }
val part2 = parts2.getOrElse(i) { 0 }
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
}
@@ -199,37 +251,12 @@ class FileDownloadManager(private val context: Context) {
* Gets the local version of a file.
*/
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 =====
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
}
}
// ===== Flashcard Collections =====
/**
* Downloads a flashcard collection file with checksum verification.
@@ -238,19 +265,34 @@ class FileDownloadManager(private val context: Context) {
flashcardInfo: FlashcardCollectionInfo,
onProgress: (Float) -> Unit = {}
): 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 source = DownloadSource.FLASHCARDS
val fileUrl = "${source.baseUrl}${source.subdirectory}/${asset.filename}"
val localFile = File(context.filesDir, "${source.subdirectory}/${asset.filename}")
val fileUrl = DownloadConfig.getFlashcardAssetUrl(asset.filename)
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${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
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 {
Log.d("FileDownloadManager", "Creating HTTP request for flashcard...")
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
@@ -263,10 +305,13 @@ class FileDownloadManager(private val context: Context) {
val body = response.body
val contentLength = body.contentLength()
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
if (contentLength <= 0) {
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
throw Exception("Invalid file size: $contentLength")
}
Log.d("FileDownloadManager", "Starting flashcard download to: ${localFile.absolutePath}")
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
@@ -285,26 +330,37 @@ class FileDownloadManager(private val context: Context) {
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)) {
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
sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) }
Log.d("FileDownloadManager", "Saved version ${flashcardInfo.version} for id ${flashcardInfo.id}")
true
} else {
Log.e("FileDownloadManager", "Checksum MISMATCH for flashcard ${asset.filename}")
Log.e("FileDownloadManager", context.getString(
R.string.text_checksum_mismatch_for_expected_got,
asset.filename,
asset.checksumSha256,
computedChecksum
))
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete()
throw Exception("Checksum verification failed for ${asset.filename}")
}
}
}
} 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()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete()
}
throw e
@@ -315,28 +371,44 @@ class FileDownloadManager(private val context: Context) {
* Checks if a newer version is available for a flashcard collection.
*/
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"
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.
*/
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).
* 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) {
Log.d("FileDownloadManager", "=== fetchVocabManifest() called ===")
Log.d("FileDownloadManager", "Fetching vocab manifest from: ${DownloadConfig.FLASHCARDS_MANIFEST_URL}")
try {
val response = flashcardApiService.getVocabManifest().execute()
Log.d("FileDownloadManager", "Vocab manifest response - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
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 {
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
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.
* 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.
*/
@@ -358,16 +430,34 @@ class FileDownloadManager(private val context: Context) {
info: VocabCollectionInfo,
onProgress: (Float) -> Unit = {}
): Boolean = withContext(Dispatchers.IO) {
val subdirectory = DownloadSource.FLASHCARDS.subdirectory
val fileUrl = "${DownloadSource.FLASHCARDS.baseUrl}$subdirectory/${info.filename}"
val localFile = File(context.filesDir, "$subdirectory/${info.filename}")
localFile.parentFile?.mkdirs()
Log.d("FileDownloadManager", "=== downloadVocabCollection() called ===")
Log.d("FileDownloadManager", "Vocab info - id: ${info.id}, name: ${info.name}, version: ${info.version}")
Log.d("FileDownloadManager", "Vocab filename: ${info.filename}, size: ${info.sizeBytes} bytes")
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 {
Log.d("FileDownloadManager", "Creating HTTP request for vocab pack...")
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
@@ -380,13 +470,15 @@ class FileDownloadManager(private val context: Context) {
val body = response.body
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 ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
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) {
output.write(buffer, 0, bytesRead)
@@ -396,17 +488,23 @@ class FileDownloadManager(private val context: Context) {
}
output.flush()
val computedChecksum = digest.digest().joinToString("") {
@Suppress("HardCodedStringLiteral") "%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: ${info.checksumSha256}")
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) {
putString("vocab_${info.id}", info.version.toString())
}
Log.d("FileDownloadManager", "Saved version ${info.version} for vocab_${info.id}")
true
} else {
Log.e("FileDownloadManager", "Checksum MISMATCH for vocab pack ${info.filename}")
Log.e("FileDownloadManager",
context.getString(
R.string.text_checksum_mismatch_for_expected_got,
@@ -415,29 +513,48 @@ class FileDownloadManager(private val context: Context) {
computedChecksum
)
)
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete()
throw Exception("Checksum verification failed for ${info.filename}")
}
}
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error downloading vocab pack", e)
if (localFile.exists()) localFile.delete()
Log.e("FileDownloadManager", "Error downloading vocab pack from $fileUrl", e)
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
if (localFile.exists()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete()
}
throw e
}
}
/** Returns true if the local file for this collection exists. */
fun isVocabCollectionDownloaded(info: VocabCollectionInfo): Boolean =
File(context.filesDir, "${DownloadSource.FLASHCARDS.subdirectory}/${info.filename}").exists()
fun isVocabCollectionDownloaded(info: VocabCollectionInfo): Boolean {
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. */
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"
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"). */
fun getVocabLocalVersion(packId: String): String =
sharedPreferences.getString("vocab_$packId", "0") ?: "0"
fun getVocabLocalVersion(packId: String): String {
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.http.GET
import retrofit2.http.Url
/**
* API service for flashcard / vocabulary-pack downloads.
* Base URL should be set to DownloadConfig.POLLY_BASE_URL
*/
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.
* Returns a JSON object { manifest_version, updated_at, lists: [...] }.
* URL: http://23.88.48.47/flashcard-collections/vocab_manifest.json
* Fetches the vocab manifest using the full URL from DownloadConfig.
*/
@GET("flashcard-collections/vocab_manifest.json")
fun getVocabManifest(): Call<VocabManifestResponse>
@GET
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.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 {
/**
* Fetches the manifest from the server.
* Fetches the manifest from the server using the full URL.
*/
@GET("manifest.json")
fun getManifest(): Call<ManifestResponse>
@GET
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 eu.gaudian.translator.R
import eu.gaudian.translator.model.communication.Asset
import eu.gaudian.translator.model.communication.FileDownloadManager
import eu.gaudian.translator.model.communication.FileInfo
import eu.gaudian.translator.model.communication.ManifestResponse
import eu.gaudian.translator.model.communication.files_download.FileDownloadManager
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.flow.Flow
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),
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_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),
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),
@@ -66,6 +67,7 @@ enum class StatusMessageId(
// 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_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
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -23,8 +22,6 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.CsvImportDialog
import eu.gaudian.translator.view.composable.PrimaryButton
import eu.gaudian.translator.view.composable.SecondaryButton
import eu.gaudian.translator.view.composable.SingleLanguageDropDown
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.ExportState
@@ -659,117 +655,18 @@ fun VocabularyRepositoryOptionsScreen(
// CSV Import Dialog
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))
}
val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from)
CsvImportDialog(
showDialog = showTableImportDialog.value,
parsedTable = parsedTable,
languageViewModel = languageViewModel,
onDismiss = { showTableImportDialog.value = false },
onImport = { items ->
vocabularyViewModel.addVocabularyItems(items)
statusMessageService.showSuccessMessage("$infoImportedItemsFrom ${items.size}")
showTableImportDialog.value = false
},
confirmButton = {
val errorSelectTwoColumns = stringResource(R.string.error_select_two_columns)
val errorSelectLanguages = stringResource(R.string.error_select_languages)
val errorNoRowsToImport = stringResource(R.string.error_no_rows_to_import)
val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from)
TextButton(onClick = {
if (selectedColFirst == selectedColSecond) {
statusMessageService.showErrorMessage(errorSelectTwoColumns)
return@TextButton
}
val langA = selectedLangFirst
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)
statusMessageService.showSuccessMessage(infoImportedItemsFrom + " " +items.size)
showTableImportDialog.value = false
}) { Text(stringResource(R.string.label_import)) }
},
dismissButton = {
TextButton(onClick = { showTableImportDialog.value = false }) { Text(stringResource(R.string.label_cancel)) }
}
statusMessageService = statusMessageService
)
}
}

View File

@@ -367,7 +367,7 @@ fun ExplorePacksScreen(
Log.d(TAG, "Import success for ${pending.info.id}: " +
"imported=${state.result.itemsImported}, skipped=${state.result.itemsSkipped}")
vocabPacksViewModel.markImportedAndCleanup(pending.info)
StatusMessageService.showSuccessById(StatusMessageId.SUCCESS_ITEMS_IMPORTED)
StatusMessageService.showSuccessById(StatusMessageId.SUCCESS_All_ITEMS_IMPORTED)
isImporting = false
pendingImportPackState = null
exportImportViewModel.resetImportState()

View File

@@ -1,7 +1,5 @@
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.layout.Arrangement
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.EditNote
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -36,7 +31,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -52,10 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language
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.view.LocalConnectionConfigured
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.AppIconContainer
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.AppSlider
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) {
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(
modifier = modifier
.fillMaxSize()
@@ -266,6 +159,7 @@ fun NewWordScreen(
// Import CSV - Full width card at bottom
ImportCsvCard(
onClick = {
@Suppress("HardCodedStringLiteral")
navController.navigate("settings_vocabulary_repository_options")
}
)
@@ -311,123 +205,6 @@ fun NewWordScreen(
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) ---

View File

@@ -8,7 +8,8 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
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.jsonParser
import eu.gaudian.translator.utils.Log
@@ -250,7 +251,7 @@ class VocabPacksViewModel @Inject constructor(
}
private fun localFile(info: VocabCollectionInfo): File =
File(context.filesDir, "flashcard-collections/${info.filename}")
File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
companion object {
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_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="info_imported_items_from">%1$d Vokabeln importiert.</string>
<string name="label_import">Importieren</string>
<string name="menu_create_youtube_exercise">YouTube-Übung erstellen</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_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="info_imported_items_from">%1$d itens de vocabulário importados.</string>
<string name="label_import">Importar</string>
<string name="menu_create_youtube_exercise">Criar Exercício 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_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>