Refactor the CSV import logic into a reusable CsvImportDialog component and centralize download configurations.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user