Refactor the CSV import logic into a reusable CsvImportDialog component and centralize download configurations.
This commit is contained in:
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -4,10 +4,10 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-02-16T10:13:39.492968600Z">
|
<DropdownSelection timestamp="2026-02-20T17:14:10.736481200Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCW11ML1WV" />
|
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\jonas\.android\avd\Pixel_6.avd" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import eu.gaudian.translator.model.repository.SettingsRepository
|
|||||||
import eu.gaudian.translator.utils.ApiCallback
|
import eu.gaudian.translator.utils.ApiCallback
|
||||||
import eu.gaudian.translator.utils.Log
|
import eu.gaudian.translator.utils.Log
|
||||||
import eu.gaudian.translator.utils.StatusAction
|
import eu.gaudian.translator.utils.StatusAction
|
||||||
|
import eu.gaudian.translator.utils.StatusMessageId
|
||||||
import eu.gaudian.translator.utils.StatusMessageService
|
import eu.gaudian.translator.utils.StatusMessageService
|
||||||
import eu.gaudian.translator.viewmodel.MessageAction
|
import eu.gaudian.translator.viewmodel.MessageAction
|
||||||
import eu.gaudian.translator.viewmodel.MessageDisplayType
|
import eu.gaudian.translator.viewmodel.MessageDisplayType
|
||||||
@@ -403,11 +404,7 @@ class ApiManager(private val context: Context) {
|
|||||||
|
|
||||||
if (languageModel == null) {
|
if (languageModel == null) {
|
||||||
val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context))
|
val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context))
|
||||||
StatusMessageService.trigger(StatusAction.ShowActionableMessage(
|
StatusMessageService.showErrorById(StatusMessageId.ERROR_NO_MODEL_CONFIGURED)
|
||||||
text = errorMsg,
|
|
||||||
type = MessageDisplayType.ACTIONABLE_ERROR,
|
|
||||||
action = MessageAction.NAVIGATE_TO_API_KEYS
|
|
||||||
))
|
|
||||||
callback.onFailure(errorMsg)
|
callback.onFailure(errorMsg)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.model.communication.files_download.FlashcardCollectionInfo
|
import eu.gaudian.translator.model.communication.Asset
|
||||||
import eu.gaudian.translator.model.communication.files_download.FlashcardManifestResponse
|
import eu.gaudian.translator.model.communication.FileInfo
|
||||||
import eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo
|
import eu.gaudian.translator.model.communication.ManifestResponse
|
||||||
import eu.gaudian.translator.utils.Log
|
import eu.gaudian.translator.utils.Log
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -18,91 +20,122 @@ import java.io.File
|
|||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
/**
|
|
||||||
* Enum representing different download sources.
|
|
||||||
*/
|
|
||||||
enum class DownloadSource(val baseUrl: String, val subdirectory: String) {
|
|
||||||
DICTIONARIES("http://23.88.48.47/", "dictionaries"),
|
|
||||||
FLASHCARDS("http://23.88.48.47/", "flashcard-collections")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages downloading files from the server, verifying checksums, and checking versions.
|
* Manages downloading files from the server, verifying checksums, and checking versions.
|
||||||
|
* All URLs and paths are centralized in [DownloadConfig].
|
||||||
*/
|
*/
|
||||||
class FileDownloadManager(private val context: Context) {
|
class FileDownloadManager(private val context: Context) {
|
||||||
|
|
||||||
private val baseUrl = "http://23.88.48.47/"
|
init {
|
||||||
|
Log.d("FileDownloadManager", "=== FileDownloadManager initialized ===")
|
||||||
|
Log.d("FileDownloadManager", "Context filesDir: ${context.filesDir.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Polly base URL: ${DownloadConfig.POLLY_BASE_URL}")
|
||||||
|
Log.d("FileDownloadManager", "Dictionaries URL: ${DownloadConfig.DICTIONARIES_BASE_URL}")
|
||||||
|
Log.d("FileDownloadManager", "Flashcards URL: ${DownloadConfig.FLASHCARDS_BASE_URL}")
|
||||||
|
}
|
||||||
|
|
||||||
private val retrofit = Retrofit.Builder()
|
// ===== Retrofit Services =====
|
||||||
.baseUrl(baseUrl)
|
|
||||||
|
private val dictionaryRetrofit = Retrofit.Builder()
|
||||||
|
.baseUrl(DownloadConfig.DICTIONARIES_BASE_URL)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.client(OkHttpClient.Builder().build())
|
.client(OkHttpClient.Builder().build())
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val manifestApiService = retrofit.create<ManifestApiService>()
|
private val manifestApiService = dictionaryRetrofit.create<ManifestApiService>()
|
||||||
|
|
||||||
|
private val flashcardRetrofit = Retrofit.Builder()
|
||||||
|
.baseUrl(DownloadConfig.POLLY_BASE_URL)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.client(OkHttpClient.Builder().build())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val flashcardApiService = flashcardRetrofit.create<FlashcardApiService>()
|
||||||
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
|
private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// ===== Dictionary Manifest =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the manifest from the server.
|
* Fetches the manifest from the server.
|
||||||
*/
|
*/
|
||||||
suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
|
suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("FileDownloadManager", "=== fetchManifest() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Fetching manifest from: ${DownloadConfig.DICTIONARIES_MANIFEST_URL}")
|
||||||
try {
|
try {
|
||||||
val response = manifestApiService.getManifest().execute()
|
val response = manifestApiService.getManifest().execute()
|
||||||
|
Log.d("FileDownloadManager", "Manifest response received - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
response.body()
|
val manifest = response.body()
|
||||||
|
Log.d("FileDownloadManager", "Manifest parsed successfully, files count: ${manifest?.files?.size ?: 0}")
|
||||||
|
manifest?.files?.forEach { file ->
|
||||||
|
Log.d("FileDownloadManager", " - File: ${file.id}, name: ${file.name}, version: ${file.version}, assets: ${file.assets.size}")
|
||||||
|
}
|
||||||
|
manifest
|
||||||
} else {
|
} else {
|
||||||
@Suppress("HardCodedStringLiteral") val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
||||||
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
|
Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
|
||||||
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
|
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
Log.e("FileDownloadManager", "Error fetching manifest", e)
|
Log.e("FileDownloadManager", "Error fetching manifest", e)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Dictionary Downloads =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads all assets for a file and verifies their checksums.
|
* Downloads all assets for a file and verifies their checksums.
|
||||||
*/
|
*/
|
||||||
suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
|
suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("FileDownloadManager", "=== downloadFile() called ===")
|
||||||
|
Log.d("FileDownloadManager", "File info - id: ${fileInfo.id}, name: ${fileInfo.name}, version: ${fileInfo.version}")
|
||||||
|
Log.d("FileDownloadManager", "Total assets to download: ${fileInfo.assets.size}")
|
||||||
val totalAssets = fileInfo.assets.size
|
val totalAssets = fileInfo.assets.size
|
||||||
|
|
||||||
for ((completedAssets, asset) in fileInfo.assets.withIndex()) {
|
for ((completedAssets, asset) in fileInfo.assets.withIndex()) {
|
||||||
val success = downloadAsset(asset) { assetProgress ->
|
Log.d("FileDownloadManager", "Processing asset ${completedAssets + 1}/$totalAssets: ${asset.filename}")
|
||||||
|
val success = downloadDictionaryAsset(asset) { assetProgress ->
|
||||||
// Calculate overall progress
|
// Calculate overall progress
|
||||||
val assetContribution = assetProgress / totalAssets
|
val assetContribution = assetProgress / totalAssets
|
||||||
val previousAssetsProgress = completedAssets.toFloat() / totalAssets
|
val previousAssetsProgress = completedAssets.toFloat() / totalAssets
|
||||||
onProgress(previousAssetsProgress + assetContribution)
|
onProgress(previousAssetsProgress + assetContribution)
|
||||||
}
|
}
|
||||||
if (!success) {
|
if (!success) {
|
||||||
|
Log.e("FileDownloadManager", "Failed to download asset: ${asset.filename}")
|
||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save version after all assets are downloaded successfully
|
// Save version after all assets are downloaded successfully
|
||||||
sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
|
sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
|
Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
|
||||||
|
Log.d("FileDownloadManager", "Saved version ${fileInfo.version} for id ${fileInfo.id}")
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a specific asset and verifies its checksum.
|
* Downloads a specific dictionary asset and verifies its checksum.
|
||||||
*/
|
*/
|
||||||
private suspend fun downloadAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
|
private suspend fun downloadDictionaryAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
|
||||||
val fileUrl = "${baseUrl}${asset.filename}"
|
Log.d("FileDownloadManager", "=== downloadDictionaryAsset() called ===")
|
||||||
|
val fileUrl = DownloadConfig.getDictionaryAssetUrl(asset.filename)
|
||||||
val localFile = File(context.filesDir, asset.filename)
|
val localFile = File(context.filesDir, asset.filename)
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
|
||||||
|
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Log.d("FileDownloadManager", "Creating HTTP request...")
|
||||||
val client = OkHttpClient()
|
val client = OkHttpClient()
|
||||||
val request = Request.Builder().url(fileUrl).build()
|
val request = Request.Builder().url(fileUrl).build()
|
||||||
val response = client.newCall(request).execute()
|
val response = client.newCall(request).execute()
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
|
||||||
|
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
val errorMessage = context.getString(
|
val errorMessage = context.getString(
|
||||||
R.string.text_download_failed_http,
|
R.string.text_download_failed_http,
|
||||||
@@ -116,16 +149,19 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
val body = response.body
|
val body = response.body
|
||||||
|
|
||||||
val contentLength = body.contentLength()
|
val contentLength = body.contentLength()
|
||||||
|
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
|
||||||
if (contentLength <= 0) {
|
if (contentLength <= 0) {
|
||||||
|
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
|
||||||
throw Exception("Invalid file size: $contentLength")
|
throw Exception("Invalid file size: $contentLength")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Starting file download to: ${localFile.absolutePath}")
|
||||||
FileOutputStream(localFile).use { output ->
|
FileOutputStream(localFile).use { output ->
|
||||||
body.byteStream().use { input ->
|
body.byteStream().use { input ->
|
||||||
val buffer = ByteArray(8192)
|
val buffer = ByteArray(8192)
|
||||||
var bytesRead: Int
|
var bytesRead: Int
|
||||||
var totalBytesRead: Long = 0
|
var totalBytesRead: Long = 0
|
||||||
@Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256")
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||||
output.write(buffer, 0, bytesRead)
|
output.write(buffer, 0, bytesRead)
|
||||||
@@ -137,16 +173,19 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
output.flush()
|
output.flush()
|
||||||
|
|
||||||
// Compute checksum
|
// Compute checksum
|
||||||
val computedChecksum = digest.digest().joinToString("") {
|
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
"%02X".format(it)
|
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
|
||||||
}
|
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
|
||||||
|
|
||||||
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
|
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
|
||||||
@Suppress("HardCodedStringLiteral")
|
Log.d("FileDownloadManager", "Checksum VERIFIED for ${asset.filename}")
|
||||||
Log.d("FileDownloadManager", "Download successful for ${asset.filename}")
|
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
|
Log.e("FileDownloadManager", "Checksum MISMATCH for ${asset.filename}")
|
||||||
Log.e("FileDownloadManager",
|
Log.e("FileDownloadManager",
|
||||||
context.getString(
|
context.getString(
|
||||||
R.string.text_checksum_mismatch_for_expected_got,
|
R.string.text_checksum_mismatch_for_expected_got,
|
||||||
@@ -154,34 +193,44 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
asset.checksumSha256,
|
asset.checksumSha256,
|
||||||
computedChecksum
|
computedChecksum
|
||||||
))
|
))
|
||||||
|
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
|
||||||
localFile.delete() // Delete corrupted file
|
localFile.delete() // Delete corrupted file
|
||||||
throw Exception("Checksum verification failed for ${asset.filename}")
|
throw Exception("Checksum verification failed for ${asset.filename}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@Suppress("HardCodedStringLiteral")
|
Log.e("FileDownloadManager", "Error downloading asset from $fileUrl", e)
|
||||||
Log.e("FileDownloadManager", "Error downloading asset", e)
|
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
|
||||||
// Clean up partial download
|
// Clean up partial download
|
||||||
if (localFile.exists()) {
|
if (localFile.exists()) {
|
||||||
|
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
|
||||||
localFile.delete()
|
localFile.delete()
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Version Management =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a newer version is available for a file.
|
* Checks if a newer version is available for a file.
|
||||||
*/
|
*/
|
||||||
fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
|
fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
|
||||||
|
Log.d("FileDownloadManager", "=== isNewerVersionAvailable() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Checking file: ${fileInfo.id} (${fileInfo.name})")
|
||||||
val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0"
|
val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0"
|
||||||
return compareVersions(fileInfo.version, localVersion) > 0
|
Log.d("FileDownloadManager", "Local version: $localVersion, Server version: ${fileInfo.version}")
|
||||||
|
val result = compareVersions(fileInfo.version, localVersion) > 0
|
||||||
|
Log.d("FileDownloadManager", "Newer version available: $result")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compares two version strings (assuming semantic versioning).
|
* Compares two version strings (assuming semantic versioning).
|
||||||
*/
|
*/
|
||||||
private fun compareVersions(version1: String, version2: String): Int {
|
private fun compareVersions(version1: String, version2: String): Int {
|
||||||
|
Log.d("FileDownloadManager", "Comparing versions: '$version1' vs '$version2'")
|
||||||
val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
|
val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
|
val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
|
||||||
@@ -189,9 +238,12 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
val part1 = parts1.getOrElse(i) { 0 }
|
val part1 = parts1.getOrElse(i) { 0 }
|
||||||
val part2 = parts2.getOrElse(i) { 0 }
|
val part2 = parts2.getOrElse(i) { 0 }
|
||||||
if (part1 != part2) {
|
if (part1 != part2) {
|
||||||
return part1.compareTo(part2)
|
val result = part1.compareTo(part2)
|
||||||
|
Log.d("FileDownloadManager", "Version comparison result: $result (at part $i: $part1 vs $part2)")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Log.d("FileDownloadManager", "Versions are equal, returning 0")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,37 +251,12 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
* Gets the local version of a file.
|
* Gets the local version of a file.
|
||||||
*/
|
*/
|
||||||
fun getLocalVersion(fileId: String): String {
|
fun getLocalVersion(fileId: String): String {
|
||||||
return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
|
val version = sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
|
||||||
|
Log.d("FileDownloadManager", "getLocalVersion($fileId) = $version")
|
||||||
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Flashcard Collections Support =====
|
// ===== Flashcard Collections =====
|
||||||
|
|
||||||
private val flashcardRetrofit = Retrofit.Builder()
|
|
||||||
.baseUrl(DownloadSource.FLASHCARDS.baseUrl)
|
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
|
||||||
.client(OkHttpClient.Builder().build())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private val flashcardApiService = flashcardRetrofit.create<FlashcardApiService>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the flashcard collection manifest from the server.
|
|
||||||
*/
|
|
||||||
suspend fun fetchFlashcardManifest(): FlashcardManifestResponse? = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val response = flashcardApiService.getFlashcardManifest().execute()
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
response.body()
|
|
||||||
} else {
|
|
||||||
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
|
||||||
Log.e("FileDownloadManager", "Failed to fetch flashcard manifest: $errorMessage")
|
|
||||||
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("FileDownloadManager", "Error fetching flashcard manifest", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a flashcard collection file with checksum verification.
|
* Downloads a flashcard collection file with checksum verification.
|
||||||
@@ -238,19 +265,34 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
flashcardInfo: FlashcardCollectionInfo,
|
flashcardInfo: FlashcardCollectionInfo,
|
||||||
onProgress: (Float) -> Unit = {}
|
onProgress: (Float) -> Unit = {}
|
||||||
): Boolean = withContext(Dispatchers.IO) {
|
): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("FileDownloadManager", "=== downloadFlashcardCollection() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Flashcard info - id: ${flashcardInfo.id}, version: ${flashcardInfo.version}")
|
||||||
|
|
||||||
val asset = flashcardInfo.asset
|
val asset = flashcardInfo.asset
|
||||||
val source = DownloadSource.FLASHCARDS
|
val fileUrl = DownloadConfig.getFlashcardAssetUrl(asset.filename)
|
||||||
val fileUrl = "${source.baseUrl}${source.subdirectory}/${asset.filename}"
|
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${asset.filename}")
|
||||||
val localFile = File(context.filesDir, "${source.subdirectory}/${asset.filename}")
|
|
||||||
|
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
|
||||||
|
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
|
||||||
|
|
||||||
// Create subdirectory if it doesn't exist
|
// Create subdirectory if it doesn't exist
|
||||||
localFile.parentFile?.mkdirs()
|
val parentDir = localFile.parentFile
|
||||||
|
if (parentDir != null && !parentDir.exists()) {
|
||||||
|
Log.d("FileDownloadManager", "Creating subdirectory: ${parentDir.absolutePath}")
|
||||||
|
val created = parentDir.mkdirs()
|
||||||
|
Log.d("FileDownloadManager", "Subdirectory created: $created")
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Log.d("FileDownloadManager", "Creating HTTP request for flashcard...")
|
||||||
val client = OkHttpClient()
|
val client = OkHttpClient()
|
||||||
val request = Request.Builder().url(fileUrl).build()
|
val request = Request.Builder().url(fileUrl).build()
|
||||||
val response = client.newCall(request).execute()
|
val response = client.newCall(request).execute()
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
|
||||||
|
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
val errorMessage = context.getString(
|
val errorMessage = context.getString(
|
||||||
R.string.text_download_failed_http,
|
R.string.text_download_failed_http,
|
||||||
@@ -263,10 +305,13 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
|
|
||||||
val body = response.body
|
val body = response.body
|
||||||
val contentLength = body.contentLength()
|
val contentLength = body.contentLength()
|
||||||
|
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
|
||||||
if (contentLength <= 0) {
|
if (contentLength <= 0) {
|
||||||
|
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
|
||||||
throw Exception("Invalid file size: $contentLength")
|
throw Exception("Invalid file size: $contentLength")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Starting flashcard download to: ${localFile.absolutePath}")
|
||||||
FileOutputStream(localFile).use { output ->
|
FileOutputStream(localFile).use { output ->
|
||||||
body.byteStream().use { input ->
|
body.byteStream().use { input ->
|
||||||
val buffer = ByteArray(8192)
|
val buffer = ByteArray(8192)
|
||||||
@@ -285,26 +330,37 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
|
|
||||||
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
|
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
|
||||||
|
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
|
||||||
|
|
||||||
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
|
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
|
||||||
Log.d("FileDownloadManager", "Flashcard download successful for ${asset.filename}")
|
Log.d("FileDownloadManager", "Checksum VERIFIED for flashcard ${asset.filename}")
|
||||||
|
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
|
||||||
// Save version
|
// Save version
|
||||||
sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) }
|
sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) }
|
||||||
|
Log.d("FileDownloadManager", "Saved version ${flashcardInfo.version} for id ${flashcardInfo.id}")
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
|
Log.e("FileDownloadManager", "Checksum MISMATCH for flashcard ${asset.filename}")
|
||||||
Log.e("FileDownloadManager", context.getString(
|
Log.e("FileDownloadManager", context.getString(
|
||||||
R.string.text_checksum_mismatch_for_expected_got,
|
R.string.text_checksum_mismatch_for_expected_got,
|
||||||
asset.filename,
|
asset.filename,
|
||||||
asset.checksumSha256,
|
asset.checksumSha256,
|
||||||
computedChecksum
|
computedChecksum
|
||||||
))
|
))
|
||||||
|
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
|
||||||
localFile.delete()
|
localFile.delete()
|
||||||
throw Exception("Checksum verification failed for ${asset.filename}")
|
throw Exception("Checksum verification failed for ${asset.filename}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("FileDownloadManager", "Error downloading flashcard collection", e)
|
Log.e("FileDownloadManager", "Error downloading flashcard collection from $fileUrl", e)
|
||||||
|
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
|
||||||
if (localFile.exists()) {
|
if (localFile.exists()) {
|
||||||
|
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
|
||||||
localFile.delete()
|
localFile.delete()
|
||||||
}
|
}
|
||||||
throw e
|
throw e
|
||||||
@@ -315,28 +371,44 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
* Checks if a newer version is available for a flashcard collection.
|
* Checks if a newer version is available for a flashcard collection.
|
||||||
*/
|
*/
|
||||||
fun isNewerFlashcardVersionAvailable(flashcardInfo: FlashcardCollectionInfo): Boolean {
|
fun isNewerFlashcardVersionAvailable(flashcardInfo: FlashcardCollectionInfo): Boolean {
|
||||||
|
Log.d("FileDownloadManager", "=== isNewerFlashcardVersionAvailable() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Checking flashcard: ${flashcardInfo.id}")
|
||||||
val localVersion = sharedPreferences.getString(flashcardInfo.id, "0.0.0") ?: "0.0.0"
|
val localVersion = sharedPreferences.getString(flashcardInfo.id, "0.0.0") ?: "0.0.0"
|
||||||
return compareVersions(flashcardInfo.version, localVersion) > 0
|
Log.d("FileDownloadManager", "Local version: $localVersion, Server version: ${flashcardInfo.version}")
|
||||||
|
val result = compareVersions(flashcardInfo.version, localVersion) > 0
|
||||||
|
Log.d("FileDownloadManager", "Newer version available: $result")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the local version of a flashcard collection.
|
* Gets the local version of a flashcard collection.
|
||||||
*/
|
*/
|
||||||
fun getFlashcardLocalVersion(collectionId: String): String {
|
fun getFlashcardLocalVersion(collectionId: String): String {
|
||||||
return sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
|
val version = sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
|
||||||
|
Log.d("FileDownloadManager", "getFlashcardLocalVersion($collectionId) = $version")
|
||||||
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Vocab Packs (vocab_manifest.json) =====
|
// ===== Vocab Packs =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the vocabulary-pack manifest (vocab_manifest.json).
|
* Fetches the vocabulary-pack manifest (vocab_manifest.json).
|
||||||
* Unwraps the top-level [eu.gaudian.translator.model.communication.files_download.VocabManifestResponse] and returns the `lists` array.
|
* Unwraps the top-level [VocabManifestResponse] and returns the `lists` array.
|
||||||
*/
|
*/
|
||||||
suspend fun fetchVocabManifest(): List<VocabCollectionInfo>? = withContext(Dispatchers.IO) {
|
suspend fun fetchVocabManifest(): List<VocabCollectionInfo>? = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("FileDownloadManager", "=== fetchVocabManifest() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Fetching vocab manifest from: ${DownloadConfig.FLASHCARDS_MANIFEST_URL}")
|
||||||
try {
|
try {
|
||||||
val response = flashcardApiService.getVocabManifest().execute()
|
val response = flashcardApiService.getVocabManifest().execute()
|
||||||
|
Log.d("FileDownloadManager", "Vocab manifest response - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
|
||||||
if (response.isSuccessful) {
|
if (response.isSuccessful) {
|
||||||
response.body()?.lists
|
val manifest = response.body()
|
||||||
|
val lists = manifest?.lists
|
||||||
|
Log.d("FileDownloadManager", "Vocab manifest parsed successfully, lists count: ${lists?.size ?: 0}")
|
||||||
|
lists?.forEach { list ->
|
||||||
|
Log.d("FileDownloadManager", " - Vocab list: ${list.id}, name: ${list.name}, version: ${list.version}, filename: ${list.filename}")
|
||||||
|
}
|
||||||
|
lists
|
||||||
} else {
|
} else {
|
||||||
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
||||||
Log.e("FileDownloadManager", "Failed to fetch vocab manifest: $errorMessage")
|
Log.e("FileDownloadManager", "Failed to fetch vocab manifest: $errorMessage")
|
||||||
@@ -350,7 +422,7 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads a single vocabulary pack file and verifies its SHA-256 checksum.
|
* Downloads a single vocabulary pack file and verifies its SHA-256 checksum.
|
||||||
* The file is stored at [filesDir]/flashcard-collections/[filename].
|
* The file is stored at [filesDir]/[DownloadConfig.LOCAL_FLASHCARDS_PATH]/[filename].
|
||||||
*
|
*
|
||||||
* @return true on success, false (or throws) on failure.
|
* @return true on success, false (or throws) on failure.
|
||||||
*/
|
*/
|
||||||
@@ -358,16 +430,34 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
info: VocabCollectionInfo,
|
info: VocabCollectionInfo,
|
||||||
onProgress: (Float) -> Unit = {}
|
onProgress: (Float) -> Unit = {}
|
||||||
): Boolean = withContext(Dispatchers.IO) {
|
): Boolean = withContext(Dispatchers.IO) {
|
||||||
val subdirectory = DownloadSource.FLASHCARDS.subdirectory
|
Log.d("FileDownloadManager", "=== downloadVocabCollection() called ===")
|
||||||
val fileUrl = "${DownloadSource.FLASHCARDS.baseUrl}$subdirectory/${info.filename}"
|
Log.d("FileDownloadManager", "Vocab info - id: ${info.id}, name: ${info.name}, version: ${info.version}")
|
||||||
val localFile = File(context.filesDir, "$subdirectory/${info.filename}")
|
Log.d("FileDownloadManager", "Vocab filename: ${info.filename}, size: ${info.sizeBytes} bytes")
|
||||||
localFile.parentFile?.mkdirs()
|
|
||||||
|
val fileUrl = DownloadConfig.getFlashcardAssetUrl(info.filename)
|
||||||
|
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
|
||||||
|
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${info.checksumSha256}")
|
||||||
|
|
||||||
|
// Create subdirectory if it doesn't exist
|
||||||
|
val parentDir = localFile.parentFile
|
||||||
|
if (parentDir != null && !parentDir.exists()) {
|
||||||
|
Log.d("FileDownloadManager", "Creating subdirectory: ${parentDir.absolutePath}")
|
||||||
|
val created = parentDir.mkdirs()
|
||||||
|
Log.d("FileDownloadManager", "Subdirectory created: $created")
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Log.d("FileDownloadManager", "Creating HTTP request for vocab pack...")
|
||||||
val client = OkHttpClient()
|
val client = OkHttpClient()
|
||||||
val request = Request.Builder().url(fileUrl).build()
|
val request = Request.Builder().url(fileUrl).build()
|
||||||
val response = client.newCall(request).execute()
|
val response = client.newCall(request).execute()
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
|
||||||
|
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
val errorMessage = context.getString(
|
val errorMessage = context.getString(
|
||||||
R.string.text_download_failed_http,
|
R.string.text_download_failed_http,
|
||||||
@@ -380,13 +470,15 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
|
|
||||||
val body = response.body
|
val body = response.body
|
||||||
val contentLength = body.contentLength().takeIf { it > 0 } ?: info.sizeBytes
|
val contentLength = body.contentLength().takeIf { it > 0 } ?: info.sizeBytes
|
||||||
|
Log.d("FileDownloadManager", "Content length from header: ${body.contentLength()}, using: $contentLength bytes")
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Starting vocab pack download to: ${localFile.absolutePath}")
|
||||||
FileOutputStream(localFile).use { output ->
|
FileOutputStream(localFile).use { output ->
|
||||||
body.byteStream().use { input ->
|
body.byteStream().use { input ->
|
||||||
val buffer = ByteArray(8192)
|
val buffer = ByteArray(8192)
|
||||||
var bytesRead: Int
|
var bytesRead: Int
|
||||||
var totalBytesRead: Long = 0
|
var totalBytesRead: Long = 0
|
||||||
@Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256")
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||||
output.write(buffer, 0, bytesRead)
|
output.write(buffer, 0, bytesRead)
|
||||||
@@ -396,17 +488,23 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
output.flush()
|
output.flush()
|
||||||
|
|
||||||
val computedChecksum = digest.digest().joinToString("") {
|
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
|
||||||
@Suppress("HardCodedStringLiteral") "%02X".format(it)
|
|
||||||
}
|
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
|
||||||
|
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${info.checksumSha256}")
|
||||||
|
|
||||||
if (computedChecksum.equals(info.checksumSha256, ignoreCase = true)) {
|
if (computedChecksum.equals(info.checksumSha256, ignoreCase = true)) {
|
||||||
Log.d("FileDownloadManager", "Vocab pack downloaded: ${info.filename}")
|
Log.d("FileDownloadManager", "Checksum VERIFIED for vocab pack ${info.filename}")
|
||||||
|
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
|
||||||
sharedPreferences.edit(commit = true) {
|
sharedPreferences.edit(commit = true) {
|
||||||
putString("vocab_${info.id}", info.version.toString())
|
putString("vocab_${info.id}", info.version.toString())
|
||||||
}
|
}
|
||||||
|
Log.d("FileDownloadManager", "Saved version ${info.version} for vocab_${info.id}")
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
|
Log.e("FileDownloadManager", "Checksum MISMATCH for vocab pack ${info.filename}")
|
||||||
Log.e("FileDownloadManager",
|
Log.e("FileDownloadManager",
|
||||||
context.getString(
|
context.getString(
|
||||||
R.string.text_checksum_mismatch_for_expected_got,
|
R.string.text_checksum_mismatch_for_expected_got,
|
||||||
@@ -415,29 +513,48 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
computedChecksum
|
computedChecksum
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
|
||||||
localFile.delete()
|
localFile.delete()
|
||||||
throw Exception("Checksum verification failed for ${info.filename}")
|
throw Exception("Checksum verification failed for ${info.filename}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("FileDownloadManager", "Error downloading vocab pack", e)
|
Log.e("FileDownloadManager", "Error downloading vocab pack from $fileUrl", e)
|
||||||
if (localFile.exists()) localFile.delete()
|
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
|
||||||
|
if (localFile.exists()) {
|
||||||
|
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
|
||||||
|
localFile.delete()
|
||||||
|
}
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if the local file for this collection exists. */
|
/** Returns true if the local file for this collection exists. */
|
||||||
fun isVocabCollectionDownloaded(info: VocabCollectionInfo): Boolean =
|
fun isVocabCollectionDownloaded(info: VocabCollectionInfo): Boolean {
|
||||||
File(context.filesDir, "${DownloadSource.FLASHCARDS.subdirectory}/${info.filename}").exists()
|
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
|
||||||
|
val exists = localFile.exists()
|
||||||
|
Log.d("FileDownloadManager", "isVocabCollectionDownloaded(${info.id}): $exists (path: ${localFile.absolutePath})")
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns true if the server version is newer than the locally saved version. */
|
/** Returns true if the server version is newer than the locally saved version. */
|
||||||
fun isNewerVocabVersionAvailable(info: VocabCollectionInfo): Boolean {
|
fun isNewerVocabVersionAvailable(info: VocabCollectionInfo): Boolean {
|
||||||
|
Log.d("FileDownloadManager", "=== isNewerVocabVersionAvailable() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Checking vocab: ${info.id} (${info.name})")
|
||||||
val localVersion = sharedPreferences.getString("vocab_${info.id}", "0") ?: "0"
|
val localVersion = sharedPreferences.getString("vocab_${info.id}", "0") ?: "0"
|
||||||
return (info.version.toString().toIntOrNull() ?: 0) > (localVersion.toIntOrNull() ?: 0)
|
val serverVersion = info.version.toString().toIntOrNull() ?: 0
|
||||||
|
val localVersionInt = localVersion.toIntOrNull() ?: 0
|
||||||
|
Log.d("FileDownloadManager", "Local version: $localVersionInt, Server version: $serverVersion")
|
||||||
|
val result = serverVersion > localVersionInt
|
||||||
|
Log.d("FileDownloadManager", "Newer version available: $result")
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the locally saved version number string for a vocab pack (default "0"). */
|
/** Returns the locally saved version number string for a vocab pack (default "0"). */
|
||||||
fun getVocabLocalVersion(packId: String): String =
|
fun getVocabLocalVersion(packId: String): String {
|
||||||
sharedPreferences.getString("vocab_$packId", "0") ?: "0"
|
val version = sharedPreferences.getString("vocab_$packId", "0") ?: "0"
|
||||||
|
Log.d("FileDownloadManager", "getVocabLocalVersion($packId) = $version")
|
||||||
|
return version
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,18 @@
|
|||||||
package eu.gaudian.translator.model.communication
|
package eu.gaudian.translator.model.communication.files_download
|
||||||
|
|
||||||
import eu.gaudian.translator.model.communication.files_download.FlashcardManifestResponse
|
|
||||||
import eu.gaudian.translator.model.communication.files_download.VocabManifestResponse
|
|
||||||
import retrofit2.Call
|
import retrofit2.Call
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Url
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API service for flashcard / vocabulary-pack downloads.
|
* API service for flashcard / vocabulary-pack downloads.
|
||||||
|
* Base URL should be set to DownloadConfig.POLLY_BASE_URL
|
||||||
*/
|
*/
|
||||||
interface FlashcardApiService {
|
interface FlashcardApiService {
|
||||||
|
|
||||||
// ── Legacy endpoint (old manifest schema) ────────────────────────────────
|
|
||||||
@GET("flashcard-collections/manifest.json")
|
|
||||||
fun getFlashcardManifest(): Call<FlashcardManifestResponse>
|
|
||||||
|
|
||||||
// ── New vocab packs endpoint ──────────────────────────────────────────────
|
|
||||||
/**
|
/**
|
||||||
* Fetches the vocabulary-pack manifest.
|
* Fetches the vocab manifest using the full URL from DownloadConfig.
|
||||||
* Returns a JSON object { manifest_version, updated_at, lists: [...] }.
|
|
||||||
* URL: http://23.88.48.47/flashcard-collections/vocab_manifest.json
|
|
||||||
*/
|
*/
|
||||||
@GET("flashcard-collections/vocab_manifest.json")
|
@GET
|
||||||
fun getVocabManifest(): Call<VocabManifestResponse>
|
fun getVocabManifest(@Url url: String = DownloadConfig.FLASHCARDS_MANIFEST_URL): Call<VocabManifestResponse>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
package eu.gaudian.translator.model.communication
|
package eu.gaudian.translator.model.communication.files_download
|
||||||
|
|
||||||
|
import eu.gaudian.translator.model.communication.ManifestResponse
|
||||||
import retrofit2.Call
|
import retrofit2.Call
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Url
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API service for fetching the manifest and downloading files.
|
* API service for fetching the dictionary manifest.
|
||||||
|
* Base URL should be set to DownloadConfig.DICTIONARIES_BASE_URL
|
||||||
*/
|
*/
|
||||||
interface ManifestApiService {
|
interface ManifestApiService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the manifest from the server.
|
* Fetches the manifest from the server using the full URL.
|
||||||
*/
|
*/
|
||||||
@GET("manifest.json")
|
@GET
|
||||||
fun getManifest(): Call<ManifestResponse>
|
fun getManifest(@Url url: String = DownloadConfig.DICTIONARIES_MANIFEST_URL): Call<ManifestResponse>
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -6,9 +6,9 @@ import android.content.Context
|
|||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.model.communication.Asset
|
import eu.gaudian.translator.model.communication.Asset
|
||||||
import eu.gaudian.translator.model.communication.FileDownloadManager
|
|
||||||
import eu.gaudian.translator.model.communication.FileInfo
|
import eu.gaudian.translator.model.communication.FileInfo
|
||||||
import eu.gaudian.translator.model.communication.ManifestResponse
|
import eu.gaudian.translator.model.communication.ManifestResponse
|
||||||
|
import eu.gaudian.translator.model.communication.files_download.FileDownloadManager
|
||||||
import eu.gaudian.translator.utils.Log
|
import eu.gaudian.translator.utils.Log
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ enum class StatusMessageId(
|
|||||||
ERROR_VOCABULARY_IMPORT_FAILED(R.string.message_error_vocabulary_import_failed, MessageDisplayType.ERROR, 5),
|
ERROR_VOCABULARY_IMPORT_FAILED(R.string.message_error_vocabulary_import_failed, MessageDisplayType.ERROR, 5),
|
||||||
SUCCESS_ITEMS_MERGED(R.string.message_success_items_merged, MessageDisplayType.SUCCESS, 3),
|
SUCCESS_ITEMS_MERGED(R.string.message_success_items_merged, MessageDisplayType.SUCCESS, 3),
|
||||||
SUCCESS_ITEMS_ADDED(R.string.message_success_items_added, MessageDisplayType.SUCCESS, 3),
|
SUCCESS_ITEMS_ADDED(R.string.message_success_items_added, MessageDisplayType.SUCCESS, 3),
|
||||||
|
SUCCESS_All_ITEMS_IMPORTED(R.string.message_success_all_items_imported, MessageDisplayType.SUCCESS, 3),
|
||||||
ERROR_ITEMS_ADD_FAILED(R.string.message_error_items_add_failed, MessageDisplayType.ERROR, 5),
|
ERROR_ITEMS_ADD_FAILED(R.string.message_error_items_add_failed, MessageDisplayType.ERROR, 5),
|
||||||
SUCCESS_ITEMS_DELETED(R.string.message_success_items_deleted, MessageDisplayType.SUCCESS, 3),
|
SUCCESS_ITEMS_DELETED(R.string.message_success_items_deleted, MessageDisplayType.SUCCESS, 3),
|
||||||
ERROR_ITEMS_DELETE_FAILED(R.string.message_error_items_delete_failed, MessageDisplayType.ERROR, 5),
|
ERROR_ITEMS_DELETE_FAILED(R.string.message_error_items_delete_failed, MessageDisplayType.ERROR, 5),
|
||||||
@@ -66,6 +67,7 @@ enum class StatusMessageId(
|
|||||||
// API Key related
|
// API Key related
|
||||||
ERROR_API_KEY_MISSING(R.string.message_error_api_key_missing, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
|
ERROR_API_KEY_MISSING(R.string.message_error_api_key_missing, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
|
||||||
ERROR_API_KEY_INVALID(R.string.message_error_api_key_invalid, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
|
ERROR_API_KEY_INVALID(R.string.message_error_api_key_invalid, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
|
||||||
|
ERROR_NO_MODEL_CONFIGURED(R.string.message_error_no_model_configured, MessageDisplayType.ERROR, 5),
|
||||||
|
|
||||||
// Translation related
|
// Translation related
|
||||||
LOADING_TRANSLATING(R.string.message_loading_translating, MessageDisplayType.LOADING, 0),
|
LOADING_TRANSLATING(R.string.message_loading_translating, MessageDisplayType.LOADING, 0),
|
||||||
|
|||||||
@@ -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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -23,8 +22,6 @@ import androidx.compose.material3.CardDefaults
|
|||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
@@ -59,12 +56,11 @@ import eu.gaudian.translator.utils.findActivity
|
|||||||
import eu.gaudian.translator.view.composable.AppButton
|
import eu.gaudian.translator.view.composable.AppButton
|
||||||
import eu.gaudian.translator.view.composable.AppCard
|
import eu.gaudian.translator.view.composable.AppCard
|
||||||
import eu.gaudian.translator.view.composable.AppIcons
|
import eu.gaudian.translator.view.composable.AppIcons
|
||||||
import eu.gaudian.translator.view.composable.AppOutlinedButton
|
|
||||||
import eu.gaudian.translator.view.composable.AppScaffold
|
import eu.gaudian.translator.view.composable.AppScaffold
|
||||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
|
import eu.gaudian.translator.view.composable.CsvImportDialog
|
||||||
import eu.gaudian.translator.view.composable.PrimaryButton
|
import eu.gaudian.translator.view.composable.PrimaryButton
|
||||||
import eu.gaudian.translator.view.composable.SecondaryButton
|
import eu.gaudian.translator.view.composable.SecondaryButton
|
||||||
import eu.gaudian.translator.view.composable.SingleLanguageDropDown
|
|
||||||
import eu.gaudian.translator.viewmodel.CategoryViewModel
|
import eu.gaudian.translator.viewmodel.CategoryViewModel
|
||||||
import eu.gaudian.translator.viewmodel.ExportImportViewModel
|
import eu.gaudian.translator.viewmodel.ExportImportViewModel
|
||||||
import eu.gaudian.translator.viewmodel.ExportState
|
import eu.gaudian.translator.viewmodel.ExportState
|
||||||
@@ -659,117 +655,18 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
|
|
||||||
// CSV Import Dialog
|
// CSV Import Dialog
|
||||||
if (showTableImportDialog.value) {
|
if (showTableImportDialog.value) {
|
||||||
AlertDialog(
|
val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from)
|
||||||
onDismissRequest = { showTableImportDialog.value = false },
|
CsvImportDialog(
|
||||||
title = { Text(stringResource(R.string.label_import_table_csv_excel)) },
|
showDialog = showTableImportDialog.value,
|
||||||
text = {
|
parsedTable = parsedTable,
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
languageViewModel = languageViewModel,
|
||||||
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
|
onDismiss = { showTableImportDialog.value = false },
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
onImport = { items ->
|
||||||
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
|
vocabularyViewModel.addVocabularyItems(items)
|
||||||
var menu1Expanded by remember { mutableStateOf(false) }
|
statusMessageService.showSuccessMessage("$infoImportedItemsFrom ${items.size}")
|
||||||
AppOutlinedButton(onClick = { menu1Expanded = true }) { Text(stringResource(R.string.label_column_n, selectedColFirst + 1)) }
|
showTableImportDialog.value = false
|
||||||
DropdownMenu(expanded = menu1Expanded, onDismissRequest = { menu1Expanded = false }) {
|
|
||||||
(0 until columnCount).forEach { idx ->
|
|
||||||
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("#${idx + 1} • $header") },
|
|
||||||
onClick = { selectedColFirst = idx; menu1Expanded = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(stringResource(R.string.label_second_column), modifier = Modifier.weight(1f))
|
|
||||||
var menu2Expanded by remember { mutableStateOf(false) }
|
|
||||||
AppOutlinedButton(onClick = { menu2Expanded = true }) { Text(stringResource(R.string.label_column_n, selectedColSecond + 1)) }
|
|
||||||
DropdownMenu(expanded = menu2Expanded, onDismissRequest = { menu2Expanded = false }) {
|
|
||||||
(0 until columnCount).forEach { idx ->
|
|
||||||
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("#${idx + 1} • $header") },
|
|
||||||
onClick = { selectedColSecond = idx; menu2Expanded = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(stringResource(R.string.label_languages))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(stringResource(R.string.label_first_language))
|
|
||||||
SingleLanguageDropDown(
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
selectedLanguage = selectedLangFirst,
|
|
||||||
onLanguageSelected = { selectedLangFirst = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(stringResource(R.string.label_second_language))
|
|
||||||
SingleLanguageDropDown(
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
selectedLanguage = selectedLangSecond,
|
|
||||||
onLanguageSelected = { selectedLangSecond = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(stringResource(R.string.label_header_row))
|
|
||||||
}
|
|
||||||
val startIdx = if (skipHeader) 1 else 0
|
|
||||||
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
|
|
||||||
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
|
|
||||||
Text(stringResource(R.string.label_preview_first, previewA))
|
|
||||||
Text(stringResource(R.string.label_preview_second, previewB))
|
|
||||||
val totalRows = parsedTable.drop(startIdx).count { row ->
|
|
||||||
val a = row.getOrNull(selectedColFirst).orEmpty().isNotBlank()
|
|
||||||
val b = row.getOrNull(selectedColSecond).orEmpty().isNotBlank()
|
|
||||||
a || b
|
|
||||||
}
|
|
||||||
Text(stringResource(R.string.text_rows_to_import_1d, totalRows))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
confirmButton = {
|
statusMessageService = statusMessageService
|
||||||
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)) }
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ fun ExplorePacksScreen(
|
|||||||
Log.d(TAG, "Import success for ${pending.info.id}: " +
|
Log.d(TAG, "Import success for ${pending.info.id}: " +
|
||||||
"imported=${state.result.itemsImported}, skipped=${state.result.itemsSkipped}")
|
"imported=${state.result.itemsImported}, skipped=${state.result.itemsSkipped}")
|
||||||
vocabPacksViewModel.markImportedAndCleanup(pending.info)
|
vocabPacksViewModel.markImportedAndCleanup(pending.info)
|
||||||
StatusMessageService.showSuccessById(StatusMessageId.SUCCESS_ITEMS_IMPORTED)
|
StatusMessageService.showSuccessById(StatusMessageId.SUCCESS_All_ITEMS_IMPORTED)
|
||||||
isImporting = false
|
isImporting = false
|
||||||
pendingImportPackState = null
|
pendingImportPackState = null
|
||||||
exportImportViewModel.resetImportState()
|
exportImportViewModel.resetImportState()
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary
|
package eu.gaudian.translator.view.vocabulary
|
||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -23,10 +21,7 @@ import androidx.compose.material.icons.filled.AutoAwesome
|
|||||||
import androidx.compose.material.icons.filled.DriveFolderUpload
|
import androidx.compose.material.icons.filled.DriveFolderUpload
|
||||||
import androidx.compose.material.icons.filled.EditNote
|
import androidx.compose.material.icons.filled.EditNote
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -36,7 +31,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -52,10 +46,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.model.Language
|
|
||||||
import eu.gaudian.translator.model.VocabularyItem
|
import eu.gaudian.translator.model.VocabularyItem
|
||||||
import eu.gaudian.translator.utils.StatusMessageId
|
|
||||||
import eu.gaudian.translator.utils.StatusMessageService
|
|
||||||
import eu.gaudian.translator.utils.findActivity
|
import eu.gaudian.translator.utils.findActivity
|
||||||
import eu.gaudian.translator.view.LocalConnectionConfigured
|
import eu.gaudian.translator.view.LocalConnectionConfigured
|
||||||
import eu.gaudian.translator.view.NavigationRoutes
|
import eu.gaudian.translator.view.NavigationRoutes
|
||||||
@@ -63,7 +54,6 @@ import eu.gaudian.translator.view.composable.AppButton
|
|||||||
import eu.gaudian.translator.view.composable.AppCard
|
import eu.gaudian.translator.view.composable.AppCard
|
||||||
import eu.gaudian.translator.view.composable.AppIconContainer
|
import eu.gaudian.translator.view.composable.AppIconContainer
|
||||||
import eu.gaudian.translator.view.composable.AppIcons
|
import eu.gaudian.translator.view.composable.AppIcons
|
||||||
import eu.gaudian.translator.view.composable.AppOutlinedButton
|
|
||||||
import eu.gaudian.translator.view.composable.AppOutlinedTextField
|
import eu.gaudian.translator.view.composable.AppOutlinedTextField
|
||||||
import eu.gaudian.translator.view.composable.AppSlider
|
import eu.gaudian.translator.view.composable.AppSlider
|
||||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
@@ -104,109 +94,12 @@ fun NewWordScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val statusMessageService = StatusMessageService
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val showTableImportDialog = remember { mutableStateOf(false) }
|
|
||||||
var parsedTable by remember { mutableStateOf<List<List<String>>>(emptyList()) }
|
|
||||||
var selectedColFirst by remember { mutableIntStateOf(0) }
|
|
||||||
var selectedColSecond by remember { mutableIntStateOf(1) }
|
|
||||||
var skipHeader by remember { mutableStateOf(true) }
|
|
||||||
var selectedLangFirst by remember { mutableStateOf<Language?>(null) }
|
|
||||||
var selectedLangSecond by remember { mutableStateOf<Language?>(null) }
|
|
||||||
|
|
||||||
val recentlyAdded = remember(recentItems) {
|
val recentlyAdded = remember(recentItems) {
|
||||||
recentItems.sortedByDescending { it.id }.take(4)
|
recentItems.sortedByDescending { it.id }.take(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseCsv(text: String): List<List<String>> {
|
|
||||||
if (text.isBlank()) return emptyList()
|
|
||||||
val candidates = listOf(',', ';', '\t')
|
|
||||||
val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList()
|
|
||||||
val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } }
|
|
||||||
|
|
||||||
val rows = mutableListOf<List<String>>()
|
|
||||||
var current = StringBuilder()
|
|
||||||
var inQuotes = false
|
|
||||||
val currentRow = mutableListOf<String>()
|
|
||||||
|
|
||||||
var i = 0
|
|
||||||
while (i < text.length) {
|
|
||||||
when (val ch = text[i]) {
|
|
||||||
'"' -> {
|
|
||||||
if (inQuotes && i + 1 < text.length && text[i + 1] == '"') {
|
|
||||||
current.append('"')
|
|
||||||
i++
|
|
||||||
} else {
|
|
||||||
inQuotes = !inQuotes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'\r' -> {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
'\n' -> {
|
|
||||||
val field = current.toString()
|
|
||||||
current = StringBuilder()
|
|
||||||
currentRow.add(field)
|
|
||||||
rows.add(currentRow.toList())
|
|
||||||
currentRow.clear()
|
|
||||||
inQuotes = false
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
if (ch == delimiter && !inQuotes) {
|
|
||||||
val field = current.toString()
|
|
||||||
currentRow.add(field)
|
|
||||||
current = StringBuilder()
|
|
||||||
} else {
|
|
||||||
current.append(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
if (current.isNotEmpty() || currentRow.isNotEmpty()) {
|
|
||||||
currentRow.add(current.toString())
|
|
||||||
rows.add(currentRow.toList())
|
|
||||||
}
|
|
||||||
return rows.map { row ->
|
|
||||||
row.map { it.trim().trim('"') }
|
|
||||||
}.filter { r -> r.any { it.isNotBlank() } }
|
|
||||||
}
|
|
||||||
|
|
||||||
val importTableLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
|
||||||
onResult = { uri ->
|
|
||||||
uri?.let { u ->
|
|
||||||
try {
|
|
||||||
context.contentResolver.takePersistableUriPermission(u, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
try {
|
|
||||||
val mime = context.contentResolver.getType(u)
|
|
||||||
val isExcel = mime == "application/vnd.ms-excel" ||
|
|
||||||
mime == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
||||||
if (isExcel) {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_EXCEL_NOT_SUPPORTED)
|
|
||||||
return@let
|
|
||||||
}
|
|
||||||
context.contentResolver.openInputStream(u)?.use { inputStream ->
|
|
||||||
val text = inputStream.bufferedReader().use { it.readText() }
|
|
||||||
val rows = parseCsv(text)
|
|
||||||
if (rows.isNotEmpty() && rows.maxOf { it.size } >= 2) {
|
|
||||||
parsedTable = rows
|
|
||||||
selectedColFirst = 0
|
|
||||||
selectedColSecond = 1.coerceAtMost(rows.first().size - 1)
|
|
||||||
showTableImportDialog.value = true
|
|
||||||
} else {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_PARSING_TABLE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_PARSING_TABLE_WITH_REASON)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -266,6 +159,7 @@ fun NewWordScreen(
|
|||||||
// Import CSV - Full width card at bottom
|
// Import CSV - Full width card at bottom
|
||||||
ImportCsvCard(
|
ImportCsvCard(
|
||||||
onClick = {
|
onClick = {
|
||||||
|
@Suppress("HardCodedStringLiteral")
|
||||||
navController.navigate("settings_vocabulary_repository_options")
|
navController.navigate("settings_vocabulary_repository_options")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -311,123 +205,6 @@ fun NewWordScreen(
|
|||||||
Spacer(modifier = Modifier.height(100.dp))
|
Spacer(modifier = Modifier.height(100.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTableImportDialog.value) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showTableImportDialog.value = false },
|
|
||||||
title = { Text(stringResource(R.string.label_import_table_csv_excel)) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
|
|
||||||
var menu1Expanded by remember { mutableStateOf(false) }
|
|
||||||
AppOutlinedButton(onClick = { menu1Expanded = true }) {
|
|
||||||
Text(stringResource(R.string.label_column_n, selectedColFirst + 1))
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = menu1Expanded, onDismissRequest = { menu1Expanded = false }) {
|
|
||||||
(0 until columnCount).forEach { idx ->
|
|
||||||
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("#${idx + 1} • $header") },
|
|
||||||
onClick = { selectedColFirst = idx; menu1Expanded = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(stringResource(R.string.label_second_column), modifier = Modifier.weight(1f))
|
|
||||||
var menu2Expanded by remember { mutableStateOf(false) }
|
|
||||||
AppOutlinedButton(onClick = { menu2Expanded = true }) {
|
|
||||||
Text(stringResource(R.string.label_column_n, selectedColSecond + 1))
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = menu2Expanded, onDismissRequest = { menu2Expanded = false }) {
|
|
||||||
(0 until columnCount).forEach { idx ->
|
|
||||||
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("#${idx + 1} • $header") },
|
|
||||||
onClick = { selectedColSecond = idx; menu2Expanded = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(stringResource(R.string.label_languages))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(stringResource(R.string.label_first_language))
|
|
||||||
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
selectedLanguage = selectedLangFirst,
|
|
||||||
onLanguageSelected = { selectedLangFirst = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(stringResource(R.string.label_second_language))
|
|
||||||
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
selectedLanguage = selectedLangSecond,
|
|
||||||
onLanguageSelected = { selectedLangSecond = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
androidx.compose.material3.Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(stringResource(R.string.label_header_row))
|
|
||||||
}
|
|
||||||
val startIdx = if (skipHeader) 1 else 0
|
|
||||||
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
|
|
||||||
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
|
|
||||||
Text(stringResource(R.string.label_preview_first, previewA))
|
|
||||||
Text(stringResource(R.string.label_preview_second, previewB))
|
|
||||||
val totalRows = parsedTable.drop(startIdx).count { row ->
|
|
||||||
val a = row.getOrNull(selectedColFirst).orEmpty().isNotBlank()
|
|
||||||
val b = row.getOrNull(selectedColSecond).orEmpty().isNotBlank()
|
|
||||||
a || b
|
|
||||||
}
|
|
||||||
Text(stringResource(R.string.text_rows_to_import_1d, totalRows))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
if (selectedColFirst == selectedColSecond) {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_SELECT_TWO_COLUMNS)
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
val langA = selectedLangFirst
|
|
||||||
val langB = selectedLangSecond
|
|
||||||
if (langA == null || langB == null) {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_SELECT_LANGUAGES)
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
val startIdx = if (skipHeader) 1 else 0
|
|
||||||
val items = parsedTable.drop(startIdx).mapNotNull { row ->
|
|
||||||
val a = row.getOrNull(selectedColFirst)?.trim().orEmpty()
|
|
||||||
val b = row.getOrNull(selectedColSecond)?.trim().orEmpty()
|
|
||||||
if (a.isBlank() && b.isBlank()) null else VocabularyItem(
|
|
||||||
id = 0,
|
|
||||||
languageFirstId = langA.nameResId,
|
|
||||||
languageSecondId = langB.nameResId,
|
|
||||||
wordFirst = a,
|
|
||||||
wordSecond = b
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (items.isEmpty()) {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_NO_ROWS_TO_IMPORT)
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
vocabularyViewModel.addVocabularyItems(items)
|
|
||||||
statusMessageService.showSuccessById(StatusMessageId.SUCCESS_ITEMS_IMPORTED)
|
|
||||||
showTableImportDialog.value = false
|
|
||||||
}) { Text(stringResource(R.string.label_import)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showTableImportDialog.value = false }) {
|
|
||||||
Text(stringResource(R.string.label_cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- AI GENERATOR CARD (From previous implementation) ---
|
// --- AI GENERATOR CARD (From previous implementation) ---
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.gaudian.translator.model.VocabularyItem
|
import eu.gaudian.translator.model.VocabularyItem
|
||||||
import eu.gaudian.translator.model.communication.FileDownloadManager
|
import eu.gaudian.translator.model.communication.files_download.DownloadConfig
|
||||||
|
import eu.gaudian.translator.model.communication.files_download.FileDownloadManager
|
||||||
import eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo
|
import eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo
|
||||||
import eu.gaudian.translator.model.jsonParser
|
import eu.gaudian.translator.model.jsonParser
|
||||||
import eu.gaudian.translator.utils.Log
|
import eu.gaudian.translator.utils.Log
|
||||||
@@ -250,7 +251,7 @@ class VocabPacksViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun localFile(info: VocabCollectionInfo): File =
|
private fun localFile(info: VocabCollectionInfo): File =
|
||||||
File(context.filesDir, "flashcard-collections/${info.filename}")
|
File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val PREFS_NAME = "vocab_packs_imported"
|
private const val PREFS_NAME = "vocab_packs_imported"
|
||||||
|
|||||||
@@ -58,7 +58,6 @@
|
|||||||
<string name="error_select_two_columns">Bitte zwei Spalten auswählen.</string>
|
<string name="error_select_two_columns">Bitte zwei Spalten auswählen.</string>
|
||||||
<string name="error_select_languages">Bitte zwei Sprachen auswählen.</string>
|
<string name="error_select_languages">Bitte zwei Sprachen auswählen.</string>
|
||||||
<string name="error_no_rows_to_import">Keine Zeilen zum Importieren. Bitte Spalten und Kopfzeile prüfen.</string>
|
<string name="error_no_rows_to_import">Keine Zeilen zum Importieren. Bitte Spalten und Kopfzeile prüfen.</string>
|
||||||
<string name="info_imported_items_from">%1$d Vokabeln importiert.</string>
|
|
||||||
<string name="label_import">Importieren</string>
|
<string name="label_import">Importieren</string>
|
||||||
<string name="menu_create_youtube_exercise">YouTube-Übung erstellen</string>
|
<string name="menu_create_youtube_exercise">YouTube-Übung erstellen</string>
|
||||||
<string name="text_youtube_link">YouTube-Link</string>
|
<string name="text_youtube_link">YouTube-Link</string>
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
<string name="error_select_two_columns">Por favor, selecione duas colunas.</string>
|
<string name="error_select_two_columns">Por favor, selecione duas colunas.</string>
|
||||||
<string name="error_select_languages">Por favor, selecione dois idiomas.</string>
|
<string name="error_select_languages">Por favor, selecione dois idiomas.</string>
|
||||||
<string name="error_no_rows_to_import">Nenhuma linha para importar. Verifique as colunas e o cabeçalho.</string>
|
<string name="error_no_rows_to_import">Nenhuma linha para importar. Verifique as colunas e o cabeçalho.</string>
|
||||||
<string name="info_imported_items_from">%1$d itens de vocabulário importados.</string>
|
|
||||||
<string name="label_import">Importar</string>
|
<string name="label_import">Importar</string>
|
||||||
<string name="menu_create_youtube_exercise">Criar Exercício do YouTube</string>
|
<string name="menu_create_youtube_exercise">Criar Exercício do YouTube</string>
|
||||||
<string name="text_youtube_link">Link do YouTube</string>
|
<string name="text_youtube_link">Link do YouTube</string>
|
||||||
|
|||||||
@@ -1173,4 +1173,6 @@
|
|||||||
<string name="label_import_csv_or_lists">Import Lists or CSV</string>
|
<string name="label_import_csv_or_lists">Import Lists or CSV</string>
|
||||||
<string name="label_corrector">Corrector</string>
|
<string name="label_corrector">Corrector</string>
|
||||||
<string name="label_correction">Correction</string>
|
<string name="label_correction">Correction</string>
|
||||||
|
<string name="message_error_no_model_configured">No AI connection is configured. This functionality does not work without a valid configuration.</string>
|
||||||
|
<string name="message_success_all_items_imported">All items were imported successfully.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user