Compare commits
8 Commits
b75f5f32a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
199f5ae33f | ||
|
|
cfd71162a0 | ||
|
|
c94b29073f | ||
|
|
95dfd3c7eb | ||
|
|
d6a9ccf4e3 | ||
|
|
863920143d | ||
|
|
15d03ef57f | ||
|
|
f737657cdb |
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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
All vocabulary lists in this section were generated using AI. While I strive for accuracy and quality, please keep in mind that these are machine-generated collections.
|
All vocabulary lists in this section were generated automatically. While I strive for accuracy and quality, please keep in mind that these are machine-generated collections.
|
||||||
|
|
||||||
I'm a single developer building and maintaining this app in my spare time. I'm passionate about creating tools that help people learn languages, but I have limited resources and time.
|
I'm a single developer building and maintaining this app in my spare time. I'm passionate about creating tools that help people learn languages, but I have limited resources and time.
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ data class VocabularyItem(
|
|||||||
features = switchedFeaturesJson
|
features = switchedFeaturesJson
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hasFeatures(): Boolean {
|
||||||
|
return !features.isNullOrBlank() && features != "{}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ class TranslationService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) {
|
// Public method to directly use LibreTranslate (bypasses AI)
|
||||||
|
suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("libreTranslate: $text, $source, $target")
|
||||||
try {
|
try {
|
||||||
val json = org.json.JSONObject().apply {
|
val json = org.json.JSONObject().apply {
|
||||||
put("q", text)
|
put("q", text)
|
||||||
|
|||||||
@@ -82,19 +82,14 @@ val LocalConnectionConfigured = compositionLocalOf { true }
|
|||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
|
||||||
private val settingsViewModel: SettingsViewModel by viewModels()
|
private val settingsViewModel: SettingsViewModel by viewModels()
|
||||||
private var isReady = false
|
private var isReady = false
|
||||||
private var isUiLoaded = false
|
private var isUiLoaded = false
|
||||||
private var isInitializing = true
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
installSplashScreen().apply {
|
installSplashScreen().apply {
|
||||||
// The splash screen will now correctly wait until isReady is true
|
|
||||||
setKeepOnScreenCondition { !isReady }
|
setKeepOnScreenCondition { !isReady }
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
@@ -104,28 +99,22 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
|
||||||
// Show UI immediately and load data in background
|
|
||||||
setContent {
|
setContent {
|
||||||
AppTheme(settingsViewModel = settingsViewModel) {
|
AppTheme(settingsViewModel = settingsViewModel) {
|
||||||
TranslatorApp(settingsViewModel = settingsViewModel)
|
TranslatorApp(settingsViewModel = settingsViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark UI as loaded immediately after setContent
|
|
||||||
isUiLoaded = true
|
isUiLoaded = true
|
||||||
|
|
||||||
// Start initialization in background without blocking UI
|
|
||||||
initializeData()
|
initializeData()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initializeData() {
|
private fun initializeData() {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
// Get repositories from the Application instance (lazy initialization)
|
|
||||||
val myApp = application as MyApplication
|
val myApp = application as MyApplication
|
||||||
val languageRepository = myApp.languageRepository
|
val languageRepository = myApp.languageRepository
|
||||||
val apiRepository = myApp.apiRepository
|
val apiRepository = myApp.apiRepository
|
||||||
|
|
||||||
// Perform initialization in parallel where possible
|
|
||||||
val languageJob = launch {
|
val languageJob = launch {
|
||||||
languageRepository.initializeDefaultLanguages()
|
languageRepository.initializeDefaultLanguages()
|
||||||
languageRepository.initializeAllLanguages()
|
languageRepository.initializeAllLanguages()
|
||||||
@@ -135,13 +124,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
apiRepository.initialInit()
|
apiRepository.initialInit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for both to complete
|
|
||||||
languageJob.join()
|
languageJob.join()
|
||||||
apiJob.join()
|
apiJob.join()
|
||||||
|
|
||||||
// Signal readiness after all work is done.
|
|
||||||
isReady = true
|
isReady = true
|
||||||
isInitializing = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,10 +135,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Suppress("AssignedValueIsNeverRead")
|
@Suppress("AssignedValueIsNeverRead")
|
||||||
@SuppressLint("LocalContextResourcesRead")
|
@SuppressLint("LocalContextResourcesRead")
|
||||||
@Composable
|
@Composable
|
||||||
fun TranslatorApp(
|
fun TranslatorApp(settingsViewModel: SettingsViewModel) {
|
||||||
settingsViewModel: SettingsViewModel
|
|
||||||
) {
|
|
||||||
|
|
||||||
val activity = LocalContext.current.findActivity()
|
val activity = LocalContext.current.findActivity()
|
||||||
val statusViewModel: StatusViewModel = hiltViewModel(activity)
|
val statusViewModel: StatusViewModel = hiltViewModel(activity)
|
||||||
val statusMessageService = StatusMessageService
|
val statusMessageService = StatusMessageService
|
||||||
@@ -179,7 +162,6 @@ fun TranslatorApp(
|
|||||||
showExitDialog = true
|
showExitDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (showExitDialog) {
|
if (showExitDialog) {
|
||||||
AppAlertDialog(
|
AppAlertDialog(
|
||||||
onDismissRequest = { showExitDialog = false },
|
onDismissRequest = { showExitDialog = false },
|
||||||
@@ -188,7 +170,6 @@ fun TranslatorApp(
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
showExitDialog = false
|
showExitDialog = false
|
||||||
// Minimize the app similar to default back at root behavior
|
|
||||||
activity.moveTaskToBack(true)
|
activity.moveTaskToBack(true)
|
||||||
}) {
|
}) {
|
||||||
Text(stringResource(R.string.quit))
|
Text(stringResource(R.string.quit))
|
||||||
@@ -202,7 +183,6 @@ fun TranslatorApp(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for app updates and show "What's New" dialog if needed
|
|
||||||
var showWhatsNewDialog by remember { mutableStateOf(false) }
|
var showWhatsNewDialog by remember { mutableStateOf(false) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val changelogEntries = context.resources.getStringArray(R.array.changelog_entries)
|
val changelogEntries = context.resources.getStringArray(R.array.changelog_entries)
|
||||||
@@ -210,7 +190,6 @@ fun TranslatorApp(
|
|||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
try {
|
try {
|
||||||
// Only check for updates if the intro is completed
|
|
||||||
if (introCompleted) {
|
if (introCompleted) {
|
||||||
val currentVersion = BuildConfig.VERSION_NAME
|
val currentVersion = BuildConfig.VERSION_NAME
|
||||||
val hasSeenCurrentVersion = settingsViewModel.hasSeenCurrentVersion(currentVersion)
|
val hasSeenCurrentVersion = settingsViewModel.hasSeenCurrentVersion(currentVersion)
|
||||||
@@ -260,14 +239,16 @@ fun TranslatorApp(
|
|||||||
Screen.Translation.route,
|
Screen.Translation.route,
|
||||||
Screen.Dictionary.route,
|
Screen.Dictionary.route,
|
||||||
Screen.Exercises.route,
|
Screen.Exercises.route,
|
||||||
Screen.Settings.route
|
Screen.Settings.route,
|
||||||
|
Screen.Corrector.route
|
||||||
)
|
)
|
||||||
} == true
|
} == true
|
||||||
val isBottomBarHidden = isHiddenByHierarchy || currentRoute in setOf(
|
val isBottomBarHidden = isHiddenByHierarchy || currentRoute in setOf(
|
||||||
"new_word",
|
"new_word",
|
||||||
"new_word_review",
|
"new_word_review",
|
||||||
"vocabulary_detail/{itemId}",
|
"vocabulary_detail/{itemId}",
|
||||||
"daily_review"
|
"daily_review",
|
||||||
|
"explore_packs"
|
||||||
) || currentRoute?.startsWith("start_exercise") == true
|
) || currentRoute?.startsWith("start_exercise") == true
|
||||||
|| currentRoute?.startsWith("vocabulary_exercise") == true
|
|| currentRoute?.startsWith("vocabulary_exercise") == true
|
||||||
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)
|
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)
|
||||||
@@ -282,12 +263,11 @@ fun TranslatorApp(
|
|||||||
Screen.Translation,
|
Screen.Translation,
|
||||||
Screen.Dictionary,
|
Screen.Dictionary,
|
||||||
Screen.Settings,
|
Screen.Settings,
|
||||||
Screen.Exercises
|
Screen.Exercises,
|
||||||
|
Screen.Corrector
|
||||||
)
|
)
|
||||||
|
|
||||||
// Always reset the selected section to its root and clear back stack between sections
|
|
||||||
if (inSameSection) {
|
if (inSameSection) {
|
||||||
// If already within the same section, ensure we are at its graph root
|
|
||||||
navController.navigate(screen.route) {
|
navController.navigate(screen.route) {
|
||||||
popUpTo(screen.route) {
|
popUpTo(screen.route) {
|
||||||
inclusive = false
|
inclusive = false
|
||||||
@@ -302,9 +282,8 @@ fun TranslatorApp(
|
|||||||
restoreState = false
|
restoreState = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Switching sections: clear entire back stack to start to avoid back navigation results
|
|
||||||
navController.navigate(screen.route) {
|
navController.navigate(screen.route) {
|
||||||
popUpTo(0) { // Pop everything
|
popUpTo(0) {
|
||||||
inclusive = true
|
inclusive = true
|
||||||
saveState = false
|
saveState = false
|
||||||
}
|
}
|
||||||
@@ -339,8 +318,7 @@ fun TranslatorApp(
|
|||||||
statusState = statusState,
|
statusState = statusState,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
onDismiss = { statusMessageService.trigger(StatusAction.HideMessageBar) },
|
onDismiss = { statusMessageService.trigger(StatusAction.HideMessageBar) },
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
AppNavHost(
|
AppNavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -392,10 +370,8 @@ private fun AppTheme(
|
|||||||
val window = (view.context as Activity).window
|
val window = (view.context as Activity).window
|
||||||
val windowInsetsController = WindowInsetsControllerCompat(window, view)
|
val windowInsetsController = WindowInsetsControllerCompat(window, view)
|
||||||
|
|
||||||
// We must keep this for older Android version!!!
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
window.statusBarColor = colorScheme.surface.toArgb()
|
window.statusBarColor = colorScheme.surface.toArgb()
|
||||||
//Elevation must be the same as BottomNavigationBar
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
window.navigationBarColor = colorScheme.surfaceColorAtElevation(8.dp).toArgb()
|
window.navigationBarColor = colorScheme.surfaceColorAtElevation(8.dp).toArgb()
|
||||||
|
|
||||||
@@ -442,6 +418,4 @@ private fun AppTheme(
|
|||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ import eu.gaudian.translator.model.VocabularyStage
|
|||||||
import eu.gaudian.translator.view.categories.CategoryDetailScreen
|
import eu.gaudian.translator.view.categories.CategoryDetailScreen
|
||||||
import eu.gaudian.translator.view.categories.CategoryListScreen
|
import eu.gaudian.translator.view.categories.CategoryListScreen
|
||||||
import eu.gaudian.translator.view.composable.Screen
|
import eu.gaudian.translator.view.composable.Screen
|
||||||
|
import eu.gaudian.translator.view.dictionary.CorrectionScreen
|
||||||
import eu.gaudian.translator.view.dictionary.DictionaryResultScreen
|
import eu.gaudian.translator.view.dictionary.DictionaryResultScreen
|
||||||
|
import eu.gaudian.translator.view.dictionary.DictionaryScreen
|
||||||
import eu.gaudian.translator.view.dictionary.EtymologyResultScreen
|
import eu.gaudian.translator.view.dictionary.EtymologyResultScreen
|
||||||
import eu.gaudian.translator.view.dictionary.MainDictionaryScreen
|
|
||||||
import eu.gaudian.translator.view.home.DailyReviewScreen
|
import eu.gaudian.translator.view.home.DailyReviewScreen
|
||||||
import eu.gaudian.translator.view.home.HomeScreen
|
import eu.gaudian.translator.view.home.HomeScreen
|
||||||
import eu.gaudian.translator.view.library.LibraryScreen
|
import eu.gaudian.translator.view.library.LibraryScreen
|
||||||
@@ -78,22 +79,14 @@ fun AppNavHost(
|
|||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 1. Define your Main Tab "Leaf" Routes (the actual start destinations of your graphs)
|
|
||||||
val mainTabRoutes = setOf(
|
val mainTabRoutes = setOf(
|
||||||
Screen.Home.route,
|
Screen.Home.route,
|
||||||
Screen.Library.route,
|
Screen.Library.route,
|
||||||
Screen.Stats.route,
|
Screen.Stats.route,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helper to check if a route is a top-level tab
|
|
||||||
// Note: Routes can be "main_home", "main_library" etc. but mainTabRoutes contains parent routes
|
|
||||||
fun isTabTransition(initial: String?, target: String?): Boolean {
|
fun isTabTransition(initial: String?, target: String?): Boolean {
|
||||||
if (initial == null || target == null) return false
|
if (initial == null || target == null) return false
|
||||||
// Check if either the direct route OR a "main_*" variant is in mainTabRoutes
|
|
||||||
val initialIsTab = mainTabRoutes.contains(initial) ||
|
val initialIsTab = mainTabRoutes.contains(initial) ||
|
||||||
mainTabRoutes.any { route ->
|
mainTabRoutes.any { route ->
|
||||||
initial == "main_${route}" || initial.startsWith("${route}_")
|
initial == "main_${route}" || initial.startsWith("${route}_")
|
||||||
@@ -109,45 +102,33 @@ fun AppNavHost(
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.Home.route,
|
startDestination = Screen.Home.route,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
||||||
// ENTER TRANSITION
|
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
|
if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
|
||||||
// Tab Switch: Just Fade In (Subtle Scale for modern feel)
|
|
||||||
fadeIn(animationSpec = tween(TRANSITION_DURATION)) +
|
fadeIn(animationSpec = tween(TRANSITION_DURATION)) +
|
||||||
scaleIn(initialScale = 0.96f, animationSpec = tween(TRANSITION_DURATION))
|
scaleIn(initialScale = 0.96f, animationSpec = tween(TRANSITION_DURATION))
|
||||||
} else {
|
} else {
|
||||||
// Detail Screen: Slide in from Right
|
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
|
initialOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
|
||||||
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
||||||
) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
|
) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// EXIT TRANSITION
|
|
||||||
exitTransition = {
|
exitTransition = {
|
||||||
if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
|
if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
|
||||||
// Tab Switch: Just Fade Out
|
|
||||||
fadeOut(animationSpec = tween(TRANSITION_DURATION))
|
fadeOut(animationSpec = tween(TRANSITION_DURATION))
|
||||||
} else {
|
} else {
|
||||||
// Detail Screen: Slide out to Left
|
|
||||||
slideOutHorizontally(
|
slideOutHorizontally(
|
||||||
targetOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
|
targetOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
|
||||||
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
||||||
) + fadeOut(animationSpec = tween(TRANSITION_DURATION))
|
) + fadeOut(animationSpec = tween(TRANSITION_DURATION))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// POP ENTER (Pressing Back) -> Always Slide back from left
|
|
||||||
popEnterTransition = {
|
popEnterTransition = {
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
|
initialOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
|
||||||
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
||||||
) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
|
) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
|
||||||
},
|
},
|
||||||
|
|
||||||
// POP EXIT (Pressing Back) -> Always Slide away to right
|
|
||||||
popExitTransition = {
|
popExitTransition = {
|
||||||
slideOutHorizontally(
|
slideOutHorizontally(
|
||||||
targetOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
|
targetOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
|
||||||
@@ -158,23 +139,18 @@ fun AppNavHost(
|
|||||||
composable(Screen.Home.route) {
|
composable(Screen.Home.route) {
|
||||||
HomeScreen(navController = navController)
|
HomeScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(NavigationRoutes.DAILY_REVIEW) {
|
composable(NavigationRoutes.DAILY_REVIEW) {
|
||||||
DailyReviewScreen(navController = navController)
|
DailyReviewScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(NavigationRoutes.NEW_WORD) {
|
composable(NavigationRoutes.NEW_WORD) {
|
||||||
NewWordScreen(navController = navController)
|
NewWordScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(NavigationRoutes.NEW_WORD_REVIEW) {
|
composable(NavigationRoutes.NEW_WORD_REVIEW) {
|
||||||
NewWordReviewScreen(navController = navController)
|
NewWordReviewScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(NavigationRoutes.EXPLORE_PACKS) {
|
composable(NavigationRoutes.EXPLORE_PACKS) {
|
||||||
ExplorePacksScreen(navController = navController)
|
ExplorePacksScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = "${NavigationRoutes.START_EXERCISE}?categoryId={categoryId}",
|
route = "${NavigationRoutes.START_EXERCISE}?categoryId={categoryId}",
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
@@ -193,7 +169,6 @@ fun AppNavHost(
|
|||||||
dueTodayOnly = false
|
dueTodayOnly = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(NavigationRoutes.START_EXERCISE_DAILY) {
|
composable(NavigationRoutes.START_EXERCISE_DAILY) {
|
||||||
StartExerciseScreen(
|
StartExerciseScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -201,13 +176,12 @@ fun AppNavHost(
|
|||||||
dueTodayOnly = true
|
dueTodayOnly = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define all other navigation graphs at the same top level.
|
|
||||||
homeGraph(navController)
|
homeGraph(navController)
|
||||||
libraryGraph(navController)
|
libraryGraph(navController)
|
||||||
statsGraph(navController)
|
statsGraph(navController)
|
||||||
translationGraph(navController)
|
translationGraph(navController)
|
||||||
dictionaryGraph(navController)
|
dictionaryGraph(navController)
|
||||||
|
correctorGraph(navController)
|
||||||
exerciseGraph(navController)
|
exerciseGraph(navController)
|
||||||
settingsGraph(navController)
|
settingsGraph(navController)
|
||||||
}
|
}
|
||||||
@@ -233,9 +207,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
LibraryScreen(navController = navController)
|
LibraryScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("vocabulary_sorting") {
|
composable("vocabulary_sorting") {
|
||||||
VocabularySortingScreen(
|
VocabularySortingScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("vocabulary_detail/{itemId}") { backStackEntry ->
|
composable("vocabulary_detail/{itemId}") { backStackEntry ->
|
||||||
val itemId = backStackEntry.arguments?.getString("itemId")?.toIntOrNull()
|
val itemId = backStackEntry.arguments?.getString("itemId")?.toIntOrNull()
|
||||||
@@ -252,10 +224,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
composable("dictionary_result/{entryId}") { backStackEntry ->
|
composable("dictionary_result/{entryId}") { backStackEntry ->
|
||||||
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
|
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
|
||||||
if (entryId != null) {
|
if (entryId != null) {
|
||||||
DictionaryResultScreen(
|
DictionaryResultScreen(entryId = entryId, navController = navController)
|
||||||
entryId = entryId,
|
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Text("Error: Invalid Entry ID")
|
Text("Error: Invalid Entry ID")
|
||||||
}
|
}
|
||||||
@@ -267,23 +236,16 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
showDueTodayOnly = showDueTodayOnly,
|
showDueTodayOnly = showDueTodayOnly,
|
||||||
categoryId = categoryId,
|
categoryId = categoryId,
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
enableNavigationButtons = true
|
enableNavigationButtons = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("language_progress") {
|
composable("language_progress") {
|
||||||
LanguageJourneyScreen(
|
LanguageJourneyScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
|
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
|
||||||
VocabularyHeatmapScreen(
|
VocabularyHeatmapScreen(navController = navController)
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
|
composable("vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
|
||||||
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
||||||
@@ -291,14 +253,11 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
val stage = stageString?.let {
|
val stage = stageString?.let {
|
||||||
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
|
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
AllCardsListScreen(
|
AllCardsListScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showDueTodayOnly = showDueTodayOnly,
|
showDueTodayOnly = showDueTodayOnly,
|
||||||
stage = stage,
|
stage = stage,
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
categoryId = 0,
|
categoryId = 0,
|
||||||
enableNavigationButtons = true
|
enableNavigationButtons = true
|
||||||
@@ -311,22 +270,15 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
navArgument("categories") { type = NavType.StringType; nullable = true },
|
navArgument("categories") { type = NavType.StringType; nullable = true },
|
||||||
navArgument("stages") { type = NavType.StringType; nullable = true },
|
navArgument("stages") { type = NavType.StringType; nullable = true },
|
||||||
navArgument("languages") { type = NavType.StringType; nullable = true },
|
navArgument("languages") { type = NavType.StringType; nullable = true },
|
||||||
navArgument("dailyOnly") {
|
navArgument("dailyOnly") { type = NavType.BoolType; defaultValue = false }
|
||||||
type = NavType.BoolType
|
|
||||||
defaultValue = false
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val arguments = backStackEntry.arguments
|
val arguments = backStackEntry.arguments
|
||||||
|
|
||||||
val dailyOnly = arguments?.getBoolean("dailyOnly") ?: false
|
val dailyOnly = arguments?.getBoolean("dailyOnly") ?: false
|
||||||
|
|
||||||
val categoryIds = arguments?.getString("categories")
|
val categoryIds = arguments?.getString("categories")
|
||||||
val stageNames = arguments?.getString("stages")
|
val stageNames = arguments?.getString("stages")
|
||||||
val languageIds = arguments?.getString("languages")
|
val languageIds = arguments?.getString("languages")
|
||||||
|
|
||||||
val dailyOnlyJson = "{\"dailyOnly\": $dailyOnly}"
|
val dailyOnlyJson = "{\"dailyOnly\": $dailyOnly}"
|
||||||
|
|
||||||
VocabularyExerciseHostScreen(
|
VocabularyExerciseHostScreen(
|
||||||
categoryIdsAsJson = categoryIds,
|
categoryIdsAsJson = categoryIds,
|
||||||
stageNamesAsJson = stageNames,
|
stageNamesAsJson = stageNames,
|
||||||
@@ -336,13 +288,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
navController = navController
|
navController = navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable("vocabulary_exercise/{dailyOnly}?", arguments = listOf(navArgument("dailyOnly") { type = NavType.BoolType })) { _ ->
|
||||||
route = "vocabulary_exercise/{dailyOnly}?",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("dailyOnly") { type = NavType.BoolType },
|
|
||||||
)
|
|
||||||
) { _ ->
|
|
||||||
|
|
||||||
VocabularyExerciseHostScreen(
|
VocabularyExerciseHostScreen(
|
||||||
categoryIdsAsJson = null,
|
categoryIdsAsJson = null,
|
||||||
stageNamesAsJson = null,
|
stageNamesAsJson = null,
|
||||||
@@ -352,34 +298,18 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
dailyOnlyAsJson = "{\"dailyOnly\": true}"
|
dailyOnlyAsJson = "{\"dailyOnly\": true}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable("stage_detail/{stage}", arguments = listOf(navArgument("stage") { type = NavType.EnumType(VocabularyStage::class.java) })) { backStackEntry ->
|
||||||
"stage_detail/{stage}",
|
@Suppress("DEPRECATION")
|
||||||
arguments = listOf(
|
val stage = backStackEntry.arguments?.getSerializable("stage") as VocabularyStage
|
||||||
navArgument("stage") {
|
StageDetailScreen(navController = navController, stage = stage)
|
||||||
type = NavType.EnumType(VocabularyStage::class.java)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
{ backStackEntry ->
|
|
||||||
@Suppress("DEPRECATION") val stage = backStackEntry.arguments?.getSerializable("stage") as VocabularyStage
|
|
||||||
//NOTE: can ignore warning for now, once moved away from min SDK 28, use:
|
|
||||||
// val stage = backStackEntry.arguments?.getSerializable("stage", VocabularyStage::class.java)
|
|
||||||
StageDetailScreen(
|
|
||||||
navController = navController,
|
|
||||||
stage = stage
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("category_detail/{categoryId}") { backStackEntry ->
|
composable("category_detail/{categoryId}") { backStackEntry ->
|
||||||
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
|
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
|
||||||
|
|
||||||
if (categoryId != null) {
|
if (categoryId != null) {
|
||||||
CategoryDetailScreen(
|
CategoryDetailScreen(
|
||||||
categoryId = categoryId,
|
categoryId = categoryId,
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
navController = navController
|
navController = navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -387,37 +317,22 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
composable("category_list_screen") {
|
composable("category_list_screen") {
|
||||||
CategoryListScreen(
|
CategoryListScreen(
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onCategoryClicked = { categoryId ->
|
onCategoryClicked = { categoryId -> navController.navigate("category_detail/$categoryId") }
|
||||||
navController.navigate("category_detail/$categoryId")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable("vocabulary_sorting?mode={mode}", arguments = listOf(navArgument("mode") { type = NavType.StringType; nullable = true })) { backStackEntry ->
|
||||||
route = "vocabulary_sorting?mode={mode}", // Route now accepts an optional 'mode'
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("mode") { // Define the argument
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { backStackEntry ->
|
|
||||||
VocabularySortingScreen(
|
VocabularySortingScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
// Pass the argument to the screen
|
|
||||||
initialFilterMode = backStackEntry.arguments?.getString("mode")
|
initialFilterMode = backStackEntry.arguments?.getString("mode")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("no_grammar_items") {
|
composable("no_grammar_items") {
|
||||||
NoGrammarItemsScreen(
|
NoGrammarItemsScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun NavGraphBuilder.statsGraph(
|
fun NavGraphBuilder.statsGraph(navController: NavHostController) {
|
||||||
navController: NavHostController,
|
|
||||||
) {
|
|
||||||
navigation(
|
navigation(
|
||||||
startDestination = "main_stats",
|
startDestination = "main_stats",
|
||||||
route = Screen.Stats.route
|
route = Screen.Stats.route
|
||||||
@@ -426,9 +341,7 @@ fun NavGraphBuilder.statsGraph(
|
|||||||
StatsScreen(navController = navController)
|
StatsScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("stats/vocabulary_sorting") {
|
composable("stats/vocabulary_sorting") {
|
||||||
VocabularySortingScreen(
|
VocabularySortingScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("stats/vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry ->
|
composable("stats/vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry ->
|
||||||
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
||||||
@@ -437,22 +350,16 @@ fun NavGraphBuilder.statsGraph(
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
showDueTodayOnly = showDueTodayOnly,
|
showDueTodayOnly = showDueTodayOnly,
|
||||||
categoryId = categoryId,
|
categoryId = categoryId,
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
enableNavigationButtons = true
|
enableNavigationButtons = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(NavigationRoutes.STATS_LANGUAGE_PROGRESS) {
|
composable(NavigationRoutes.STATS_LANGUAGE_PROGRESS) {
|
||||||
LanguageJourneyScreen(
|
LanguageJourneyScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
|
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
|
||||||
VocabularyHeatmapScreen(
|
VocabularyHeatmapScreen(navController = navController)
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("stats/vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
|
composable("stats/vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
|
||||||
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
||||||
@@ -460,14 +367,11 @@ fun NavGraphBuilder.statsGraph(
|
|||||||
val stage = stageString?.let {
|
val stage = stageString?.let {
|
||||||
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
|
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
AllCardsListScreen(
|
AllCardsListScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showDueTodayOnly = showDueTodayOnly,
|
showDueTodayOnly = showDueTodayOnly,
|
||||||
stage = stage,
|
stage = stage,
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
categoryId = 0,
|
categoryId = 0,
|
||||||
enableNavigationButtons = true
|
enableNavigationButtons = true
|
||||||
@@ -475,14 +379,11 @@ fun NavGraphBuilder.statsGraph(
|
|||||||
}
|
}
|
||||||
composable("stats/category_detail/{categoryId}") { backStackEntry ->
|
composable("stats/category_detail/{categoryId}") { backStackEntry ->
|
||||||
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
|
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
|
||||||
|
|
||||||
if (categoryId != null) {
|
if (categoryId != null) {
|
||||||
CategoryDetailScreen(
|
CategoryDetailScreen(
|
||||||
categoryId = categoryId,
|
categoryId = categoryId,
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
navController = navController
|
navController = navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -490,29 +391,14 @@ fun NavGraphBuilder.statsGraph(
|
|||||||
composable("stats/category_list_screen") {
|
composable("stats/category_list_screen") {
|
||||||
CategoryListScreen(
|
CategoryListScreen(
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onCategoryClicked = { categoryId ->
|
onCategoryClicked = { categoryId -> navController.navigate("stats/category_detail/$categoryId") }
|
||||||
navController.navigate("stats/category_detail/$categoryId")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable("stats/vocabulary_sorting?mode={mode}", arguments = listOf(navArgument("mode") { type = NavType.StringType; nullable = true })) { backStackEntry ->
|
||||||
route = "stats/vocabulary_sorting?mode={mode}",
|
VocabularySortingScreen(navController = navController, initialFilterMode = backStackEntry.arguments?.getString("mode"))
|
||||||
arguments = listOf(
|
|
||||||
navArgument("mode") {
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { backStackEntry ->
|
|
||||||
VocabularySortingScreen(
|
|
||||||
navController = navController,
|
|
||||||
initialFilterMode = backStackEntry.arguments?.getString("mode")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("stats/no_grammar_items") {
|
composable("stats/no_grammar_items") {
|
||||||
NoGrammarItemsScreen(
|
NoGrammarItemsScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,15 +425,16 @@ fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
|
|||||||
route = Screen.Dictionary.route
|
route = Screen.Dictionary.route
|
||||||
) {
|
) {
|
||||||
composable("main_dictionary") {
|
composable("main_dictionary") {
|
||||||
MainDictionaryScreen(navController = navController)
|
DictionaryScreen(
|
||||||
|
navController = navController,
|
||||||
|
onEntryClick = { entry -> navController.navigate("dictionary_result/${entry.id}") },
|
||||||
|
onNavigateToOptions = { navController.navigate("dictionary_options") }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable("dictionary_result/{entryId}") { backStackEntry ->
|
composable("dictionary_result/{entryId}") { backStackEntry ->
|
||||||
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
|
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
|
||||||
if (entryId != null) {
|
if (entryId != null) {
|
||||||
DictionaryResultScreen(
|
DictionaryResultScreen(entryId = entryId, navController = navController)
|
||||||
entryId = entryId,
|
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Text("Error: Invalid Entry ID")
|
Text("Error: Invalid Entry ID")
|
||||||
}
|
}
|
||||||
@@ -558,43 +445,39 @@ fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
|
|||||||
composable("etymology_result/{word}/{languageCode}") { backStackEntry ->
|
composable("etymology_result/{word}/{languageCode}") { backStackEntry ->
|
||||||
val word = backStackEntry.arguments?.getString("word") ?: ""
|
val word = backStackEntry.arguments?.getString("word") ?: ""
|
||||||
val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1
|
val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1
|
||||||
EtymologyResultScreen(
|
EtymologyResultScreen(navController = navController, word = word, languageCode = languageCode)
|
||||||
navController = navController,
|
|
||||||
word = word,
|
|
||||||
languageCode = languageCode
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NavGraphBuilder.correctorGraph(navController: NavHostController) {
|
||||||
|
navigation(
|
||||||
|
startDestination = "main_corrector",
|
||||||
|
route = Screen.Corrector.route
|
||||||
|
) {
|
||||||
|
composable("main_corrector") {
|
||||||
|
CorrectionScreen(navController = navController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||||
fun NavGraphBuilder.exerciseGraph(
|
fun NavGraphBuilder.exerciseGraph(navController: NavHostController) {
|
||||||
navController: NavHostController,
|
|
||||||
) {
|
|
||||||
navigation(
|
navigation(
|
||||||
startDestination = "main_exercise",
|
startDestination = "main_exercise",
|
||||||
route = Screen.Exercises.route
|
route = Screen.Exercises.route
|
||||||
) {
|
) {
|
||||||
composable("main_exercise") {
|
composable("main_exercise") {
|
||||||
MainExerciseScreen(
|
MainExerciseScreen(navController = navController)
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("exercise_session") {
|
composable("exercise_session") {
|
||||||
ExerciseSessionScreen(
|
ExerciseSessionScreen(navController = navController)
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("youtube_exercise") {
|
composable("youtube_exercise") {
|
||||||
YouTubeExerciseScreen(
|
YouTubeExerciseScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("youtube_browse") {
|
composable("youtube_browse") {
|
||||||
YouTubeBrowserScreen(
|
YouTubeBrowserScreen(navController = navController)
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,13 +16,18 @@ import androidx.compose.ui.res.painterResource
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.view.composable.AppButton
|
import eu.gaudian.translator.view.composable.AppButton
|
||||||
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NoConnectionScreen(onSettingsClick: () -> Unit) {
|
fun NoConnectionScreen(onSettingsClick: () -> Unit, navController: NavController) {
|
||||||
|
AppTopAppBar(
|
||||||
|
title = "No Connection",
|
||||||
|
onNavigateBack = {navController.popBackStack()},
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
@@ -41,7 +46,7 @@ fun NoConnectionScreen(onSettingsClick: () -> Unit) {
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
AppButton(onClick = onSettingsClick, modifier = Modifier.padding(top = 16.dp)) {
|
AppButton(onClick = onSettingsClick, modifier = Modifier.padding(top = 16.dp)) {
|
||||||
Text(text = stringResource(id = R.string.settings_title_connection))
|
Text(text = "Configure Connection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A compact action card with an icon and label, designed for use in rows or grids.
|
||||||
|
* Used for quick action buttons like "Explore Packs", "Import CSV", etc.
|
||||||
|
*
|
||||||
|
* @param label The text label below the icon
|
||||||
|
* @param icon The icon to display
|
||||||
|
* @param onClick Callback when the card is clicked
|
||||||
|
* @param modifier Modifier for the card
|
||||||
|
* @param height The height of the card (default 120.dp)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppActionCard(
|
||||||
|
label: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
height: Dp = 120.dp,
|
||||||
|
iconContainerSize: Dp = 48.dp,
|
||||||
|
iconSize: Dp = 24.dp
|
||||||
|
) {
|
||||||
|
AppCard(
|
||||||
|
modifier = modifier.height(height),
|
||||||
|
onClick = onClick
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
CircularIconContainer(
|
||||||
|
imageVector = icon,
|
||||||
|
size = iconContainerSize,
|
||||||
|
iconSize = iconSize
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A section header label with consistent styling.
|
||||||
|
* Used for section titles like "Recently Added", etc.
|
||||||
|
*
|
||||||
|
* @param text The section title text
|
||||||
|
* @param modifier Modifier for the text
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SectionLabel(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A labeled section with an optional action button.
|
||||||
|
* Provides consistent header styling for sections with a title and optional action.
|
||||||
|
*
|
||||||
|
* @param title The section title
|
||||||
|
* @param modifier Modifier for the section header
|
||||||
|
* @param actionLabel Optional label for the action button
|
||||||
|
* @param onActionClick Optional callback for the action button
|
||||||
|
* @param content The content below the header
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LabeledSection(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
actionLabel: String? = null,
|
||||||
|
onActionClick: (() -> Unit)? = null,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
// Header row with title and optional action
|
||||||
|
if (actionLabel != null && onActionClick != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
SectionLabel(text = title)
|
||||||
|
androidx.compose.material3.TextButton(onClick = onActionClick) {
|
||||||
|
Text(actionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SectionLabel(text = title)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package eu.gaudian.translator.view.composable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable icon container that displays an icon inside a shaped background.
|
||||||
|
* Used throughout the app for consistent icon presentation in cards, buttons, and action items.
|
||||||
|
*
|
||||||
|
* @param imageVector The icon to display
|
||||||
|
* @param modifier Modifier to be applied to the container
|
||||||
|
* @param size The size of the container (default 40.dp)
|
||||||
|
* @param iconSize The size of the icon itself (default 24.dp)
|
||||||
|
* @param shape The shape of the container (default RoundedCornerShape(12.dp))
|
||||||
|
* @param backgroundColor Background color of the container
|
||||||
|
* @param iconTint Tint color for the icon
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppIconContainer(
|
||||||
|
imageVector: ImageVector,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 40.dp,
|
||||||
|
iconSize: Dp = 24.dp,
|
||||||
|
shape: androidx.compose.ui.graphics.Shape = RoundedCornerShape(12.dp),
|
||||||
|
backgroundColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary,
|
||||||
|
contentDescription: String? = null
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(shape)
|
||||||
|
.background(backgroundColor),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = imageVector,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier.size(iconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A circular variant of AppIconContainer.
|
||||||
|
* Convenience wrapper for circular icon containers.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CircularIconContainer(
|
||||||
|
imageVector: ImageVector,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 48.dp,
|
||||||
|
iconSize: Dp = 24.dp,
|
||||||
|
backgroundColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary,
|
||||||
|
contentDescription: String? = null
|
||||||
|
) {
|
||||||
|
AppIconContainer(
|
||||||
|
imageVector = imageVector,
|
||||||
|
modifier = modifier,
|
||||||
|
size = size,
|
||||||
|
iconSize = iconSize,
|
||||||
|
shape = CircleShape,
|
||||||
|
backgroundColor = backgroundColor,
|
||||||
|
iconTint = iconTint,
|
||||||
|
contentDescription = contentDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -115,7 +115,7 @@ fun AppOutlinedTextField(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier,
|
||||||
label = label,
|
label = label,
|
||||||
trailingIcon = finalTrailingIcon,
|
trailingIcon = finalTrailingIcon,
|
||||||
shape = ComponentDefaults.DefaultShape,
|
shape = ComponentDefaults.DefaultShape,
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package eu.gaudian.translator.view.composable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A styled filled text input field.
|
||||||
|
* Different from AppOutlinedTextField - this uses a filled background style.
|
||||||
|
*
|
||||||
|
* @param value The input text to be shown in the text field.
|
||||||
|
* @param onValueChange The callback that is triggered when the input service updates the text.
|
||||||
|
* @param modifier The modifier to be applied to the text field.
|
||||||
|
* @param placeholder The placeholder text to display when the field is empty.
|
||||||
|
* @param enabled Whether the text field is enabled.
|
||||||
|
* @param readOnly Whether the text field is read-only.
|
||||||
|
* @param singleLine Whether the text field is single line.
|
||||||
|
* @param minLines Minimum number of lines.
|
||||||
|
* @param maxLines Maximum number of lines.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppTextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeholder: String? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
readOnly: Boolean = false,
|
||||||
|
singleLine: Boolean = true,
|
||||||
|
minLines: Int = 1,
|
||||||
|
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||||
|
leadingIcon: @Composable (() -> Unit)? = null,
|
||||||
|
trailingIcon: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val cornerRadius = 12.dp
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
placeholder = placeholder?.let {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(cornerRadius),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = MaterialTheme.colorScheme.primary,
|
||||||
|
focusedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
),
|
||||||
|
singleLine = singleLine,
|
||||||
|
minLines = minLines,
|
||||||
|
maxLines = maxLines,
|
||||||
|
enabled = enabled,
|
||||||
|
readOnly = readOnly,
|
||||||
|
leadingIcon = leadingIcon,
|
||||||
|
trailingIcon = trailingIcon
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import androidx.compose.animation.core.spring
|
|||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -77,6 +78,7 @@ sealed class Screen(
|
|||||||
object Settings : Screen("settings", R.string.title_settings, AppIcons.SettingsFilled, AppIcons.SettingsOutlined)
|
object Settings : Screen("settings", R.string.title_settings, AppIcons.SettingsFilled, AppIcons.SettingsOutlined)
|
||||||
object More : Screen("more", R.string.label_more, AppIcons.MoreVert, AppIcons.MoreHorizontal)
|
object More : Screen("more", R.string.label_more, AppIcons.MoreVert, AppIcons.MoreHorizontal)
|
||||||
object Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
|
object Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
|
||||||
|
object Corrector : Screen("corrector", R.string.title_corrector, AppIcons.SpellCheck, AppIcons.SpellCheck)
|
||||||
object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
|
object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -88,6 +90,7 @@ sealed class Screen(
|
|||||||
val items = mutableListOf<Screen>()
|
val items = mutableListOf<Screen>()
|
||||||
items.add(Translation)
|
items.add(Translation)
|
||||||
items.add(Dictionary)
|
items.add(Dictionary)
|
||||||
|
items.add(Corrector)
|
||||||
items.add(Settings)
|
items.add(Settings)
|
||||||
if (showExperimental) {
|
if (showExperimental) {
|
||||||
items.add(Exercises)
|
items.add(Exercises)
|
||||||
@@ -258,7 +261,7 @@ fun BottomNavigationBar(
|
|||||||
.background(
|
.background(
|
||||||
brush = Brush.radialGradient(
|
brush = Brush.radialGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -271,6 +274,12 @@ fun BottomNavigationBar(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(playButtonSize)
|
.size(playButtonSize)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
|
// CHANGED: Added a border to give the button definition
|
||||||
|
.border(
|
||||||
|
width = 4.dp, // Adjust this thickness to your liking
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant, // Creates a nice "cutout" separation
|
||||||
|
shape = CircleShape
|
||||||
|
)
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
.clickable {
|
.clickable {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
@@ -281,7 +290,7 @@ fun BottomNavigationBar(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.PlayArrow,
|
imageVector = Icons.Filled.PlayArrow,
|
||||||
contentDescription = "Play",
|
contentDescription = "Play",
|
||||||
tint = Color.White,
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -24,6 +23,7 @@ import androidx.core.net.toUri
|
|||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.ui.theme.ThemePreviews
|
import eu.gaudian.translator.ui.theme.ThemePreviews
|
||||||
import eu.gaudian.translator.view.composable.AppDialog
|
import eu.gaudian.translator.view.composable.AppDialog
|
||||||
|
import eu.gaudian.translator.view.composable.AppOutlinedTextField
|
||||||
import eu.gaudian.translator.view.composable.AppSlider
|
import eu.gaudian.translator.view.composable.AppSlider
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ fun RequestMorePackDialog(
|
|||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
AppOutlinedTextField(
|
||||||
value = topic,
|
value = topic,
|
||||||
onValueChange = { topic = it },
|
onValueChange = { topic = it },
|
||||||
placeholder = { Text("e.g. Travel, Business, Cooking…") },
|
placeholder = { Text("e.g. Travel, Business, Cooking…") },
|
||||||
@@ -78,20 +78,21 @@ fun RequestMorePackDialog(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(
|
||||||
OutlinedTextField(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
AppOutlinedTextField(
|
||||||
value = langFrom,
|
value = langFrom,
|
||||||
onValueChange = { langFrom = it },
|
onValueChange = { langFrom = it },
|
||||||
placeholder = { Text(stringResource(R.string.label_from)) },
|
placeholder = { Text(stringResource(R.string.label_from)) },
|
||||||
label = { Text(stringResource(R.string.label_from)) },
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
AppOutlinedTextField(
|
||||||
value = langTo,
|
value = langTo,
|
||||||
onValueChange = { langTo = it },
|
onValueChange = { langTo = it },
|
||||||
placeholder = { Text(stringResource(R.string.label_to)) },
|
placeholder = { Text(stringResource(R.string.label_to)) },
|
||||||
label = { Text(stringResource(R.string.label_to)) },
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -60,12 +60,16 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.ui.theme.semanticColors
|
import eu.gaudian.translator.ui.theme.semanticColors
|
||||||
|
import eu.gaudian.translator.utils.findActivity
|
||||||
import eu.gaudian.translator.view.composable.AppButton
|
import eu.gaudian.translator.view.composable.AppButton
|
||||||
import eu.gaudian.translator.view.composable.AppIcons
|
import eu.gaudian.translator.view.composable.AppIcons
|
||||||
import eu.gaudian.translator.view.composable.AppOutlinedButton
|
import eu.gaudian.translator.view.composable.AppOutlinedButton
|
||||||
import eu.gaudian.translator.view.composable.AppSwitch
|
import eu.gaudian.translator.view.composable.AppSwitch
|
||||||
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown
|
import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown
|
||||||
import eu.gaudian.translator.view.composable.DropdownDefaults
|
import eu.gaudian.translator.view.composable.DropdownDefaults
|
||||||
import eu.gaudian.translator.view.composable.LargeDropdownMenuItem
|
import eu.gaudian.translator.view.composable.LargeDropdownMenuItem
|
||||||
@@ -73,12 +77,15 @@ import eu.gaudian.translator.viewmodel.CorrectionViewModel
|
|||||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// 1. STATEFUL COMPONENT (Connects to ViewModels)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CorrectionScreen(
|
fun CorrectionScreen(
|
||||||
correctionViewModel: CorrectionViewModel,
|
navController: NavController
|
||||||
languageViewModel: LanguageViewModel
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
val activity = LocalContext.current.findActivity()
|
||||||
|
val correctionViewModel: CorrectionViewModel = hiltViewModel(activity)
|
||||||
|
val languageViewModel : LanguageViewModel = hiltViewModel(activity)
|
||||||
val textFieldValue by correctionViewModel.textFieldValue.collectAsState()
|
val textFieldValue by correctionViewModel.textFieldValue.collectAsState()
|
||||||
val explanation by correctionViewModel.explanation.collectAsState()
|
val explanation by correctionViewModel.explanation.collectAsState()
|
||||||
val isLoading by correctionViewModel.isLoading.collectAsState()
|
val isLoading by correctionViewModel.isLoading.collectAsState()
|
||||||
@@ -89,6 +96,15 @@ fun CorrectionScreen(
|
|||||||
|
|
||||||
val successColor = MaterialTheme.semanticColors.success
|
val successColor = MaterialTheme.semanticColors.success
|
||||||
|
|
||||||
|
Column(){
|
||||||
|
|
||||||
|
AppTopAppBar(
|
||||||
|
title = stringResource(R.string.label_correction),
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
CorrectionScreenContent(
|
CorrectionScreenContent(
|
||||||
textFieldValue = textFieldValue,
|
textFieldValue = textFieldValue,
|
||||||
explanation = explanation,
|
explanation = explanation,
|
||||||
@@ -113,6 +129,7 @@ fun CorrectionScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. STATELESS COMPONENT (Handles UI Layout)
|
// 2. STATELESS COMPONENT (Handles UI Layout)
|
||||||
@@ -304,7 +321,6 @@ fun CorrectionScreenContent(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package eu.gaudian.translator.view.dictionary
|
package eu.gaudian.translator.view.dictionary
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.utils.findActivity
|
import eu.gaudian.translator.utils.findActivity
|
||||||
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
import eu.gaudian.translator.viewmodel.DictionaryViewModel
|
import eu.gaudian.translator.viewmodel.DictionaryViewModel
|
||||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||||
|
|
||||||
@@ -20,15 +24,21 @@ fun DictionaryScreen(
|
|||||||
val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
AppTopAppBar(
|
||||||
|
title = stringResource(R.string.label_dictionary),
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
|
||||||
// Use the new refactored component
|
// Use the new refactored component
|
||||||
DictionaryScreenContent(
|
DictionaryScreenContent(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
onEntryClick = onEntryClick,
|
onEntryClick = onEntryClick,
|
||||||
dictionaryViewModel = dictionaryViewModel,
|
dictionaryViewModel = dictionaryViewModel,
|
||||||
languageViewModel = languageViewModel,
|
languageViewModel = languageViewModel,
|
||||||
onNavigateToOptions = onNavigateToOptions
|
onNavigateToOptions = onNavigateToOptions
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
@file:Suppress("HardCodedStringLiteral")
|
|
||||||
|
|
||||||
package eu.gaudian.translator.view.dictionary
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import eu.gaudian.translator.R
|
|
||||||
import eu.gaudian.translator.ui.theme.ThemePreviews
|
|
||||||
import eu.gaudian.translator.utils.findActivity
|
|
||||||
import eu.gaudian.translator.view.LocalConnectionConfigured
|
|
||||||
import eu.gaudian.translator.view.NoConnectionScreen
|
|
||||||
import eu.gaudian.translator.view.composable.AppIcons
|
|
||||||
import eu.gaudian.translator.view.composable.AppTabLayout
|
|
||||||
import eu.gaudian.translator.view.composable.Screen
|
|
||||||
import eu.gaudian.translator.view.composable.TabItem
|
|
||||||
import eu.gaudian.translator.view.settings.SettingsRoutes
|
|
||||||
import eu.gaudian.translator.viewmodel.CorrectionViewModel
|
|
||||||
import eu.gaudian.translator.viewmodel.DictionaryViewModel
|
|
||||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun getDictionaryTabs(): List<TabItem> {
|
|
||||||
return listOf(
|
|
||||||
DictionaryTab(stringResource(R.string.label_dictionary), AppIcons.Dictionary),
|
|
||||||
DictionaryTab(stringResource(R.string.title_corrector), AppIcons.Check)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
private data class DictionaryTab(override val title: String, override val icon: ImageVector) :
|
|
||||||
TabItem
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MainDictionaryScreen(
|
|
||||||
navController: NavController
|
|
||||||
) {
|
|
||||||
|
|
||||||
|
|
||||||
val activity = LocalContext.current.findActivity()
|
|
||||||
|
|
||||||
val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
|
||||||
val correctionViewModel: CorrectionViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
|
||||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
|
||||||
val dictionaryTabs = getDictionaryTabs()
|
|
||||||
|
|
||||||
val connectionConfigured = LocalConnectionConfigured.current
|
|
||||||
|
|
||||||
if (!connectionConfigured) {
|
|
||||||
NoConnectionScreen(onSettingsClick = {navController.navigate(SettingsRoutes.API_KEY)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectedTab by remember { mutableStateOf(dictionaryTabs[0]) }
|
|
||||||
Column {
|
|
||||||
AppTabLayout(
|
|
||||||
tabs = dictionaryTabs,
|
|
||||||
selectedTab = selectedTab,
|
|
||||||
onTabSelected = { selectedTab = it },
|
|
||||||
onNavigateBack = {
|
|
||||||
if (!navController.popBackStack()) {
|
|
||||||
navController.navigate(Screen.Home.route) {
|
|
||||||
launchSingleTop = true
|
|
||||||
restoreState = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
when (selectedTab) {
|
|
||||||
dictionaryTabs[0] -> DictionaryScreen(
|
|
||||||
navController = navController,
|
|
||||||
onEntryClick = { entry ->
|
|
||||||
// Set flag indicating navigation is from external source (not DictionaryResultScreen)
|
|
||||||
dictionaryViewModel.setNavigatingFromDictionaryResult(false)
|
|
||||||
navController.navigate("dictionary_result/${entry.id}")
|
|
||||||
},
|
|
||||||
onNavigateToOptions = {
|
|
||||||
navController.navigate("dictionary_options")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
dictionaryTabs[1] -> CorrectionScreen(
|
|
||||||
correctionViewModel = correctionViewModel,
|
|
||||||
languageViewModel = languageViewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ThemePreviews
|
|
||||||
@Composable
|
|
||||||
fun DictionaryHostScreenPreview() {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
|
|
||||||
MainDictionaryScreen(
|
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -101,7 +101,7 @@ fun DailyReviewScreen(
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
contentPadding = PaddingValues(16.dp)
|
contentPadding = PaddingValues(16.dp)
|
||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ 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
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
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
|
||||||
@@ -27,7 +29,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -47,6 +48,7 @@ import eu.gaudian.translator.R
|
|||||||
import eu.gaudian.translator.utils.findActivity
|
import eu.gaudian.translator.utils.findActivity
|
||||||
import eu.gaudian.translator.view.NavigationRoutes
|
import eu.gaudian.translator.view.NavigationRoutes
|
||||||
import eu.gaudian.translator.view.composable.AppCard
|
import eu.gaudian.translator.view.composable.AppCard
|
||||||
|
import eu.gaudian.translator.view.composable.LabeledSection
|
||||||
import eu.gaudian.translator.view.composable.Screen
|
import eu.gaudian.translator.view.composable.Screen
|
||||||
import eu.gaudian.translator.view.settings.SettingsRoutes
|
import eu.gaudian.translator.view.settings.SettingsRoutes
|
||||||
import eu.gaudian.translator.view.stats.widgets.WeeklyActivityChartWidget
|
import eu.gaudian.translator.view.stats.widgets.WeeklyActivityChartWidget
|
||||||
@@ -356,20 +358,12 @@ fun WeeklyProgressSection(
|
|||||||
val viewModel: ProgressViewModel = hiltViewModel(activity)
|
val viewModel: ProgressViewModel = hiltViewModel(activity)
|
||||||
val weeklyActivityStats by viewModel.weeklyActivityStats.collectAsState()
|
val weeklyActivityStats by viewModel.weeklyActivityStats.collectAsState()
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
LabeledSection(
|
||||||
Row(
|
title = stringResource(R.string.label_weekly_progress),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
actionLabel = stringResource(R.string.label_see_history),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
onActionClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
|
||||||
) {
|
) {
|
||||||
Text(text = stringResource(R.string.label_weekly_progress), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
TextButton(onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }) {
|
|
||||||
Text(stringResource(R.string.label_see_history), softWrap = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
AppCard(
|
AppCard(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
|
onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
|
||||||
@@ -405,12 +399,16 @@ fun BottomStatsSection(
|
|||||||
val learnedWords by viewModel.totalWordsCompleted.collectAsState()
|
val learnedWords by viewModel.totalWordsCompleted.collectAsState()
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(IntrinsicSize.Min),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Total Words
|
// Total Words
|
||||||
AppCard(
|
AppCard(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
onClick = { navController.navigate(Screen.Library.route) }
|
onClick = { navController.navigate(Screen.Library.route) }
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
Column(modifier = Modifier.padding(20.dp)) {
|
||||||
@@ -423,7 +421,9 @@ fun BottomStatsSection(
|
|||||||
|
|
||||||
// Learned
|
// Learned
|
||||||
AppCard(
|
AppCard(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
onClick = { navController.navigate(NavigationRoutes.STATS_LANGUAGE_PROGRESS) }
|
onClick = { navController.navigate(NavigationRoutes.STATS_LANGUAGE_PROGRESS) }
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
Column(modifier = Modifier.padding(20.dp)) {
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.AddCircleOutline
|
import androidx.compose.material.icons.filled.AddCircleOutline
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.LocalMall
|
import androidx.compose.material.icons.filled.LocalMall
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material.icons.rounded.Check
|
||||||
|
import androidx.compose.material.icons.rounded.Star
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -58,6 +60,7 @@ import androidx.compose.ui.draw.drawBehind
|
|||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.PathEffect
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -70,6 +73,8 @@ import eu.gaudian.translator.R
|
|||||||
import eu.gaudian.translator.model.Language
|
import eu.gaudian.translator.model.Language
|
||||||
import eu.gaudian.translator.model.VocabularyCategory
|
import eu.gaudian.translator.model.VocabularyCategory
|
||||||
import eu.gaudian.translator.model.VocabularyItem
|
import eu.gaudian.translator.model.VocabularyItem
|
||||||
|
import eu.gaudian.translator.model.VocabularyStage
|
||||||
|
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.insertBreakOpportunities
|
import eu.gaudian.translator.view.composable.insertBreakOpportunities
|
||||||
|
|
||||||
@@ -127,22 +132,29 @@ fun SelectionTopBar(
|
|||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
// 1. Close Button
|
||||||
IconButton(onClick = onCloseClick) {
|
IconButton(onClick = onCloseClick) {
|
||||||
Icon(imageVector = AppIcons.Close, contentDescription = stringResource(R.string.label_close_selection_mode))
|
Icon(
|
||||||
}
|
imageVector = AppIcons.Close,
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
contentDescription = stringResource(R.string.label_close_selection_mode)
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.d_selected, selectionCount),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
|
||||||
|
// 2. Title Text (Gets weight to prevent pushing icons off-screen)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.d_selected, selectionCount),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Action Icons Group
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
IconButton(onClick = onSelectAllClick) {
|
IconButton(onClick = onSelectAllClick) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = AppIcons.SelectAll,
|
imageVector = AppIcons.SelectAll,
|
||||||
@@ -320,10 +332,12 @@ fun AllCardsView(
|
|||||||
vocabularyItems: List<VocabularyItem>,
|
vocabularyItems: List<VocabularyItem>,
|
||||||
allLanguages: List<Language>,
|
allLanguages: List<Language>,
|
||||||
selection: Set<Long>,
|
selection: Set<Long>,
|
||||||
|
stageMapping: Map<Int, VocabularyStage> = emptyMap(),
|
||||||
onItemClick: (VocabularyItem) -> Unit,
|
onItemClick: (VocabularyItem) -> Unit,
|
||||||
onItemLongClick: (VocabularyItem) -> Unit,
|
onItemLongClick: (VocabularyItem) -> Unit,
|
||||||
onDeleteClick: (VocabularyItem) -> Unit,
|
onDeleteClick: (VocabularyItem) -> Unit,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
|
onAddClick: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (vocabularyItems.isEmpty()) {
|
if (vocabularyItems.isEmpty()) {
|
||||||
@@ -346,11 +360,26 @@ fun AllCardsView(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
if (onAddClick != null) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
androidx.compose.material3.Button(
|
||||||
|
onClick = onAddClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.6f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(text = stringResource(R.string.label_add_vocabulary))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 100.dp)
|
contentPadding = PaddingValues(bottom = 100.dp)
|
||||||
) {
|
) {
|
||||||
@@ -359,10 +388,12 @@ fun AllCardsView(
|
|||||||
key = { it.id }
|
key = { it.id }
|
||||||
) { item ->
|
) { item ->
|
||||||
val isSelected = selection.contains(item.id.toLong())
|
val isSelected = selection.contains(item.id.toLong())
|
||||||
|
val stage = stageMapping[item.id] ?: VocabularyStage.NEW
|
||||||
VocabularyCard(
|
VocabularyCard(
|
||||||
item = item,
|
item = item,
|
||||||
allLanguages = allLanguages,
|
allLanguages = allLanguages,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
|
stage = stage,
|
||||||
onItemClick = { onItemClick(item) },
|
onItemClick = { onItemClick(item) },
|
||||||
onItemLongClick = { onItemLongClick(item) },
|
onItemLongClick = { onItemLongClick(item) },
|
||||||
onDeleteClick = { onDeleteClick(item) }
|
onDeleteClick = { onDeleteClick(item) }
|
||||||
@@ -372,14 +403,12 @@ fun AllCardsView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Individual vocabulary card component
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VocabularyCard(
|
fun VocabularyCard(
|
||||||
item: VocabularyItem,
|
item: VocabularyItem,
|
||||||
allLanguages: List<Language>,
|
allLanguages: List<Language>,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
|
stage: VocabularyStage = VocabularyStage.NEW,
|
||||||
onItemClick: () -> Unit,
|
onItemClick: () -> Unit,
|
||||||
onItemLongClick: () -> Unit,
|
onItemLongClick: () -> Unit,
|
||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
@@ -392,14 +421,15 @@ fun VocabularyCard(
|
|||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(12.dp)) // Slightly rounder for a modern look
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onItemClick,
|
onClick = onItemClick,
|
||||||
onLongClick = onItemLongClick
|
onLongClick = onItemLongClick
|
||||||
),
|
),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.surfaceContainer
|
// Fixed the contentColor bug here:
|
||||||
|
contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
|
||||||
),
|
),
|
||||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
|
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
|
||||||
) {
|
) {
|
||||||
@@ -410,50 +440,46 @@ fun VocabularyCard(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 12.dp) // Ensures text doesn't bleed into the trailing icon
|
||||||
|
) {
|
||||||
// Top row: First word + Language Pill
|
// Top row: First word + Language Pill
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = insertBreakOpportunities(item.wordFirst),
|
text = insertBreakOpportunities(item.wordFirst),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
// This modifier allows the text to wrap without squishing the pill
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Surface(
|
LanguagePill(
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
|
text = langFirst,
|
||||||
shape = RoundedCornerShape(4.dp)
|
backgroundColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
|
||||||
) {
|
textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
Text(
|
)
|
||||||
text = langFirst,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(6.dp)) // Slightly more breathing room
|
||||||
|
|
||||||
// Bottom row: Second word + Language Pill
|
// Bottom row: Second word + Language Pill
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = insertBreakOpportunities(item.wordSecond),
|
text = insertBreakOpportunities(item.wordSecond),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
// Applied to the second text as well for consistency
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Surface(
|
LanguagePill(
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
|
text = langSecond,
|
||||||
shape = RoundedCornerShape(8.dp)
|
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
|
||||||
) {
|
textColor = MaterialTheme.colorScheme.primary
|
||||||
Text(
|
)
|
||||||
text = langSecond,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,18 +490,124 @@ fun VocabularyCard(
|
|||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
IconButton(onClick = { /* Options menu could go here */ }) {
|
// Stage indicator showing the vocabulary item's learning stage
|
||||||
Icon(
|
StageIndicator(stage = stage)
|
||||||
imageVector = Icons.Default.MoreVert,
|
|
||||||
contentDescription = stringResource(R.string.cd_options),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StageIndicator(
|
||||||
|
stage: VocabularyStage,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
// Convert VocabularyStage to a step number (0-6)
|
||||||
|
val step = when (stage) {
|
||||||
|
VocabularyStage.NEW -> 0
|
||||||
|
VocabularyStage.STAGE_1 -> 1
|
||||||
|
VocabularyStage.STAGE_2 -> 2
|
||||||
|
VocabularyStage.STAGE_3 -> 3
|
||||||
|
VocabularyStage.STAGE_4 -> 4
|
||||||
|
VocabularyStage.STAGE_5 -> 5
|
||||||
|
VocabularyStage.LEARNED -> 6
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Calculate how full the ring should be (0.0 to 1.0)
|
||||||
|
val maxSteps = 6f
|
||||||
|
val progress = step / maxSteps
|
||||||
|
|
||||||
|
// 2. Determine the ring color based on the stage
|
||||||
|
val indicatorColor = when (stage) {
|
||||||
|
VocabularyStage.NEW -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
|
||||||
|
VocabularyStage.STAGE_1, VocabularyStage.STAGE_2 -> Color(0xFFE57373) // Soft Red
|
||||||
|
VocabularyStage.STAGE_3 -> Color(0xFFFFB74D) // Soft Orange
|
||||||
|
VocabularyStage.STAGE_4 -> Color(0xFFFFD54F) // Soft Yellow
|
||||||
|
VocabularyStage.STAGE_5 -> Color(0xFFAED581) // Light Green
|
||||||
|
VocabularyStage.LEARNED -> Color(0xFF81C784) // Solid Green
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = modifier.size(36.dp) // Keeps it neatly sized within the row
|
||||||
|
) {
|
||||||
|
// The background track (empty ring)
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { 1f },
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
// The colored progress ring
|
||||||
|
if (stage != VocabularyStage.NEW) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
color = indicatorColor,
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
strokeCap = StrokeCap.Round, // Gives the progress bar nice rounded ends
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The center content (Number or Icon)
|
||||||
|
when (stage) {
|
||||||
|
VocabularyStage.NEW -> {
|
||||||
|
// An empty dot or small icon to denote it's untouched
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Star, // Or any generic 'new' icon
|
||||||
|
contentDescription = "New Word",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
VocabularyStage.LEARNED -> {
|
||||||
|
// A checkmark for mastery
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Check,
|
||||||
|
contentDescription = "Learned",
|
||||||
|
tint = indicatorColor,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Display the actual level number (1 through 5)
|
||||||
|
Text(
|
||||||
|
text = step.toString(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Extracted for consistency and cleaner code
|
||||||
|
@Composable
|
||||||
|
private fun LanguagePill(
|
||||||
|
text: String,
|
||||||
|
backgroundColor: Color,
|
||||||
|
textColor: Color
|
||||||
|
) {
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
Surface(
|
||||||
|
color = backgroundColor,
|
||||||
|
shape = RoundedCornerShape(6.dp) // Consistent corner rounding for all pills
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = textColor,
|
||||||
|
// Guaranteed to never wrap awkwardly
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grid view of categories
|
* Grid view of categories
|
||||||
*/
|
*/
|
||||||
@@ -515,13 +647,11 @@ fun CategoryCard(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Card(
|
AppCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(140.dp)
|
.height(140.dp)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -709,6 +839,7 @@ fun VocabularyCardPreview() {
|
|||||||
),
|
),
|
||||||
allLanguages = emptyList(),
|
allLanguages = emptyList(),
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
|
stage = VocabularyStage.NEW,
|
||||||
onItemClick = {},
|
onItemClick = {},
|
||||||
onItemLongClick = {},
|
onItemLongClick = {},
|
||||||
onDeleteClick = {}
|
onDeleteClick = {}
|
||||||
@@ -716,6 +847,154 @@ fun VocabularyCardPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("HardCodedStringLiteral")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun VocabularyCardStage1Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
VocabularyCard(
|
||||||
|
item = VocabularyItem(
|
||||||
|
id = 2,
|
||||||
|
wordFirst = "Goodbye",
|
||||||
|
wordSecond = "Adiós",
|
||||||
|
languageFirstId = 1,
|
||||||
|
languageSecondId = 2,
|
||||||
|
createdAt = null,
|
||||||
|
features = null,
|
||||||
|
zipfFrequencyFirst = null,
|
||||||
|
zipfFrequencySecond = null
|
||||||
|
),
|
||||||
|
allLanguages = emptyList(),
|
||||||
|
isSelected = false,
|
||||||
|
stage = VocabularyStage.STAGE_1,
|
||||||
|
onItemClick = {},
|
||||||
|
onItemLongClick = {},
|
||||||
|
onDeleteClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("HardCodedStringLiteral")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun VocabularyCardStage3Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
VocabularyCard(
|
||||||
|
item = VocabularyItem(
|
||||||
|
id = 3,
|
||||||
|
wordFirst = "Thank you",
|
||||||
|
wordSecond = "Gracias",
|
||||||
|
languageFirstId = 1,
|
||||||
|
languageSecondId = 2,
|
||||||
|
createdAt = null,
|
||||||
|
features = null,
|
||||||
|
zipfFrequencyFirst = null,
|
||||||
|
zipfFrequencySecond = null
|
||||||
|
),
|
||||||
|
allLanguages = emptyList(),
|
||||||
|
isSelected = false,
|
||||||
|
stage = VocabularyStage.STAGE_3,
|
||||||
|
onItemClick = {},
|
||||||
|
onItemLongClick = {},
|
||||||
|
onDeleteClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("HardCodedStringLiteral")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun VocabularyCardStage5Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
VocabularyCard(
|
||||||
|
item = VocabularyItem(
|
||||||
|
id = 4,
|
||||||
|
wordFirst = "Please",
|
||||||
|
wordSecond = "Por favor",
|
||||||
|
languageFirstId = 1,
|
||||||
|
languageSecondId = 2,
|
||||||
|
createdAt = null,
|
||||||
|
features = null,
|
||||||
|
zipfFrequencyFirst = null,
|
||||||
|
zipfFrequencySecond = null
|
||||||
|
),
|
||||||
|
allLanguages = emptyList(),
|
||||||
|
isSelected = false,
|
||||||
|
stage = VocabularyStage.STAGE_5,
|
||||||
|
onItemClick = {},
|
||||||
|
onItemLongClick = {},
|
||||||
|
onDeleteClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("HardCodedStringLiteral")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun VocabularyCardLearnedPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
VocabularyCard(
|
||||||
|
item = VocabularyItem(
|
||||||
|
id = 5,
|
||||||
|
wordFirst = "Yes",
|
||||||
|
wordSecond = "Sí",
|
||||||
|
languageFirstId = 1,
|
||||||
|
languageSecondId = 2,
|
||||||
|
createdAt = null,
|
||||||
|
features = null,
|
||||||
|
zipfFrequencyFirst = null,
|
||||||
|
zipfFrequencySecond = null
|
||||||
|
),
|
||||||
|
allLanguages = emptyList(),
|
||||||
|
isSelected = false,
|
||||||
|
stage = VocabularyStage.LEARNED,
|
||||||
|
onItemClick = {},
|
||||||
|
onItemLongClick = {},
|
||||||
|
onDeleteClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun StageIndicatorNewPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
StageIndicator(stage = VocabularyStage.NEW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun StageIndicatorStage1Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
StageIndicator(stage = VocabularyStage.STAGE_1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun StageIndicatorStage3Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
StageIndicator(stage = VocabularyStage.STAGE_3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun StageIndicatorStage5Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
StageIndicator(stage = VocabularyStage.STAGE_5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun StageIndicatorLearnedPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
StageIndicator(stage = VocabularyStage.LEARNED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
@Suppress("HardCodedStringLiteral")
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val vocabularyItems by vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
|
val vocabularyItems by vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
val stageMapping by vocabularyViewModel.stageMapping.collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||||
|
|
||||||
// Handle export state
|
// Handle export state
|
||||||
LaunchedEffect(exportState) {
|
LaunchedEffect(exportState) {
|
||||||
@@ -263,7 +264,9 @@ fun LibraryScreen(
|
|||||||
vocabularyItems = vocabularyItems,
|
vocabularyItems = vocabularyItems,
|
||||||
allLanguages = allLanguages,
|
allLanguages = allLanguages,
|
||||||
selection = selection,
|
selection = selection,
|
||||||
|
stageMapping = stageMapping,
|
||||||
listState = lazyListState,
|
listState = lazyListState,
|
||||||
|
onAddClick = { navController.navigate(NavigationRoutes.NEW_WORD) },
|
||||||
onItemClick = { item ->
|
onItemClick = { item ->
|
||||||
if (isInSelectionMode) {
|
if (isInSelectionMode) {
|
||||||
selection = if (selection.contains(item.id.toLong())) {
|
selection = if (selection.contains(item.id.toLong())) {
|
||||||
|
|||||||
@@ -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)) }
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ fun StatusWidget(
|
|||||||
if (itemsWithoutGrammarCount > 0) {
|
if (itemsWithoutGrammarCount > 0) {
|
||||||
StatusItem(
|
StatusItem(
|
||||||
icon = AppIcons.Error,
|
icon = AppIcons.Error,
|
||||||
text = stringResource(R.string.items_without_grammar_infos),
|
text = stringResource(R.string.label_items_without_grammar),
|
||||||
count = itemsWithoutGrammarCount,
|
count = itemsWithoutGrammarCount,
|
||||||
onClick = onNavigateToNoGrammar,
|
onClick = onNavigateToNoGrammar,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ package eu.gaudian.translator.view.stats.widgets
|
|||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
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.RowScope
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
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
|
||||||
@@ -31,19 +32,31 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.ui.theme.ThemePreviews
|
import eu.gaudian.translator.ui.theme.ThemePreviews
|
||||||
import eu.gaudian.translator.ui.theme.semanticColors
|
|
||||||
import eu.gaudian.translator.viewmodel.WeeklyActivityStat
|
import eu.gaudian.translator.viewmodel.WeeklyActivityStat
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A widget that displays weekly activity statistics in a visually appealing bar chart.
|
* A widget that displays weekly activity statistics in a visually appealing smooth line chart.
|
||||||
* It's designed to be consistent with the app's modern, floating UI style.
|
* It's designed to be consistent with the app's modern UI style using the theme's colors.
|
||||||
*
|
*
|
||||||
* @param weeklyStats A list of [WeeklyActivityStat] for the last 7 days.
|
* @param weeklyStats A list of [WeeklyActivityStat] for the last 7 days.
|
||||||
*/
|
*/
|
||||||
@@ -51,20 +64,15 @@ import kotlinx.coroutines.delay
|
|||||||
fun WeeklyActivityChartWidget(
|
fun WeeklyActivityChartWidget(
|
||||||
weeklyStats: List<WeeklyActivityStat>
|
weeklyStats: List<WeeklyActivityStat>
|
||||||
) {
|
) {
|
||||||
val maxValue = remember(weeklyStats) {
|
|
||||||
(weeklyStats.flatMap { listOf(it.newlyAdded, it.completed, it.answeredRight) }.maxOrNull() ?: 0).let {
|
|
||||||
if (it < 10) 10 else ((it / 5) + 1) * 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasNoData = remember(weeklyStats) {
|
val hasNoData = remember(weeklyStats) {
|
||||||
weeklyStats.all { it.newlyAdded == 0 && it.completed == 0 && it.answeredRight == 0 }
|
weeklyStats.all { it.completed == 0 && it.answeredRight == 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasNoData) {
|
if (hasNoData) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -77,60 +85,293 @@ fun WeeklyActivityChartWidget(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(8.dp)
|
// Reduced horizontal padding to give the chart more space
|
||||||
|
.padding(vertical = 24.dp, horizontal = 12.dp)
|
||||||
) {
|
) {
|
||||||
WeeklyChartLegend()
|
WeeklyChartLegend()
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Row(
|
InteractiveLineChart(weeklyStats = weeklyStats)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
ChartFooter(weeklyStats = weeklyStats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InteractiveLineChart(weeklyStats: List<WeeklyActivityStat>) {
|
||||||
|
var selectedIndex by remember { mutableStateOf<Int?>(3) } // Default selection
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
|
||||||
|
val colorCompleted = MaterialTheme.colorScheme.primary
|
||||||
|
val colorCorrect = MaterialTheme.colorScheme.tertiary
|
||||||
|
|
||||||
|
val gridColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
|
||||||
|
val tooltipLineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
|
||||||
|
val dotCenterColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
val tooltipBgColor = MaterialTheme.colorScheme.inverseSurface
|
||||||
|
val tooltipTextColor = MaterialTheme.colorScheme.inverseOnSurface
|
||||||
|
|
||||||
|
var startAnimation by remember { mutableStateOf(false) }
|
||||||
|
val animationProgress by animateFloatAsState(
|
||||||
|
targetValue = if (startAnimation) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = 1000),
|
||||||
|
label = "chartAnimation"
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(100)
|
||||||
|
startAnimation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val yAxisMax = remember(weeklyStats) {
|
||||||
|
val max = weeklyStats.flatMap { listOf(it.completed, it.answeredRight) }.maxOrNull() ?: 0
|
||||||
|
if (max < 10) 10 else ((max / 10) + 1) * 10
|
||||||
|
}
|
||||||
|
val yMax = yAxisMax.toFloat()
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// Left Side: Y-Axis Amounts
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(180.dp)
|
||||||
|
// Reduced end padding to save space
|
||||||
|
.padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
horizontalAlignment = Alignment.End
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = yAxisMax.toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = (yAxisMax / 2).toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "0",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right Side: Chart Area
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(220.dp),
|
.height(180.dp)
|
||||||
verticalAlignment = Alignment.Bottom
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
val itemWidth = size.width / (weeklyStats.size - 1).coerceAtLeast(1)
|
||||||
|
selectedIndex = (offset.x / itemWidth).roundToInt().coerceIn(0, weeklyStats.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectHorizontalDragGestures { change, _ ->
|
||||||
|
val itemWidth = size.width / (weeklyStats.size - 1).coerceAtLeast(1)
|
||||||
|
selectedIndex = (change.position.x / itemWidth).roundToInt().coerceIn(0, weeklyStats.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
// Y-Axis Labels
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
Column(
|
val width = size.width
|
||||||
modifier = Modifier
|
val height = size.height
|
||||||
.fillMaxHeight()
|
val xSpacing = width / (weeklyStats.size - 1).coerceAtLeast(1)
|
||||||
.padding(end = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
horizontalAlignment = Alignment.End
|
|
||||||
) {
|
|
||||||
Text(maxValue.toString(), style = MaterialTheme.typography.labelSmall)
|
|
||||||
Text((maxValue / 2).toString(), style = MaterialTheme.typography.labelSmall)
|
|
||||||
Text("0", style = MaterialTheme.typography.labelSmall)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chart Bars
|
drawLine(gridColor, Offset(0f, 0f), Offset(width, 0f), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
|
||||||
Row(
|
drawLine(gridColor, Offset(0f, height / 2f), Offset(width, height / 2f), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
|
||||||
modifier = Modifier
|
drawLine(gridColor, Offset(0f, height), Offset(width, height), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight(),
|
if (animationProgress == 0f) return@Canvas
|
||||||
horizontalArrangement = Arrangement.SpaceAround,
|
|
||||||
verticalAlignment = Alignment.Bottom
|
val pointsCompleted = weeklyStats.mapIndexed { i, stat ->
|
||||||
) {
|
Offset(i * xSpacing, height - ((stat.completed * animationProgress) / yMax) * height)
|
||||||
weeklyStats.forEach { stat ->
|
}
|
||||||
Column(
|
val pointsCorrect = weeklyStats.mapIndexed { i, stat ->
|
||||||
modifier = Modifier.weight(1f),
|
Offset(i * xSpacing, height - ((stat.answeredRight * animationProgress) / yMax) * height)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
}
|
||||||
verticalArrangement = Arrangement.Bottom
|
|
||||||
) {
|
// Define Paths
|
||||||
Row(
|
val pathCorrect = Path().apply { smoothCurve(pointsCorrect) }
|
||||||
verticalAlignment = Alignment.Bottom,
|
val fillPathCorrect = Path().apply {
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
smoothCurve(pointsCorrect)
|
||||||
modifier = Modifier
|
lineTo(width, height)
|
||||||
.weight(1f)
|
lineTo(0f, height)
|
||||||
.fillMaxWidth(0.8f)
|
close()
|
||||||
) {
|
}
|
||||||
Bar(value = stat.newlyAdded, maxValue = maxValue, color = MaterialTheme.semanticColors.stageGradient1)
|
|
||||||
Bar(value = stat.completed, maxValue = maxValue, color = MaterialTheme.semanticColors.stageGradient3)
|
val pathCompleted = Path().apply { smoothCurve(pointsCompleted) }
|
||||||
Bar(value = stat.answeredRight, maxValue = maxValue, color = MaterialTheme.semanticColors.stageGradient5)
|
val fillPathCompleted = Path().apply {
|
||||||
|
smoothCurve(pointsCompleted)
|
||||||
|
lineTo(width, height)
|
||||||
|
lineTo(0f, height)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw semi-transparent fills first
|
||||||
|
drawPath(
|
||||||
|
path = fillPathCompleted,
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(colorCompleted.copy(alpha = 0.25f), Color.Transparent),
|
||||||
|
startY = 0f,
|
||||||
|
endY = height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
drawPath(
|
||||||
|
path = fillPathCorrect,
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(colorCorrect.copy(alpha = 0.25f), Color.Transparent),
|
||||||
|
startY = 0f,
|
||||||
|
endY = height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw solid strokes on top of the fills
|
||||||
|
drawPath(
|
||||||
|
path = pathCorrect,
|
||||||
|
color = colorCorrect,
|
||||||
|
style = Stroke(width = 6f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 12f), 0f))
|
||||||
|
)
|
||||||
|
|
||||||
|
drawPath(
|
||||||
|
path = pathCompleted,
|
||||||
|
color = colorCompleted,
|
||||||
|
style = Stroke(width = 8f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Interactive Highlights & Dual Separated Tooltips
|
||||||
|
selectedIndex?.let { index ->
|
||||||
|
val stat = weeklyStats[index]
|
||||||
|
val x = index * xSpacing
|
||||||
|
val yCompleted = height - ((stat.completed * animationProgress) / yMax) * height
|
||||||
|
val yCorrect = height - ((stat.answeredRight * animationProgress) / yMax) * height
|
||||||
|
|
||||||
|
// Vertical line marker
|
||||||
|
drawLine(
|
||||||
|
color = tooltipLineColor,
|
||||||
|
start = Offset(x, 0f),
|
||||||
|
end = Offset(x, height),
|
||||||
|
strokeWidth = 3f
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dots on lines
|
||||||
|
drawCircle(color = dotCenterColor, radius = 12f, center = Offset(x, yCompleted))
|
||||||
|
drawCircle(color = colorCompleted, radius = 7f, center = Offset(x, yCompleted))
|
||||||
|
|
||||||
|
drawCircle(color = dotCenterColor, radius = 12f, center = Offset(x, yCorrect))
|
||||||
|
drawCircle(color = colorCorrect, radius = 7f, center = Offset(x, yCorrect))
|
||||||
|
|
||||||
|
// Measure text
|
||||||
|
val textStyle = TextStyle(color = tooltipTextColor, fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||||
|
val textResCompleted = textMeasurer.measure(stat.completed.toString(), textStyle)
|
||||||
|
val textResCorrect = textMeasurer.measure(stat.answeredRight.toString(), textStyle)
|
||||||
|
|
||||||
|
val dotRadius = 5f
|
||||||
|
val gap = 6f
|
||||||
|
val padX = 12f
|
||||||
|
val padY = 8f
|
||||||
|
|
||||||
|
val w1 = padX * 2 + dotRadius * 2 + gap + textResCompleted.size.width
|
||||||
|
val h1 = padY * 2 + textResCompleted.size.height
|
||||||
|
|
||||||
|
val w2 = padX * 2 + dotRadius * 2 + gap + textResCorrect.size.width
|
||||||
|
val h2 = padY * 2 + textResCorrect.size.height
|
||||||
|
|
||||||
|
// Tooltip Overlap Prevention Logic
|
||||||
|
val completedIsHigher = yCompleted <= yCorrect
|
||||||
|
var yPosCompleted = if (completedIsHigher) yCompleted - h1 - 12f else yCompleted + 12f
|
||||||
|
var yPosCorrect = if (completedIsHigher) yCorrect + 12f else yCorrect - h2 - 12f
|
||||||
|
|
||||||
|
// Prevent clipping out of canvas bounds natively first
|
||||||
|
if (yPosCompleted < 0f && completedIsHigher) yPosCompleted = 0f
|
||||||
|
if (yPosCorrect < 0f && !completedIsHigher) yPosCorrect = 0f
|
||||||
|
if (yPosCompleted + h1 > height && !completedIsHigher) yPosCompleted = height - h1
|
||||||
|
if (yPosCorrect + h2 > height && completedIsHigher) yPosCorrect = height - h2
|
||||||
|
|
||||||
|
// Overlap resolution
|
||||||
|
val topRectY = minOf(yPosCompleted, yPosCorrect)
|
||||||
|
val topRectH = if (topRectY == yPosCompleted) h1 else h2
|
||||||
|
val bottomRectY = maxOf(yPosCompleted, yPosCorrect)
|
||||||
|
|
||||||
|
val gapBetweenTooltips = 8f
|
||||||
|
if (topRectY + topRectH + gapBetweenTooltips > bottomRectY) {
|
||||||
|
val midPointY = (yCompleted + yCorrect) / 2f
|
||||||
|
val adjustedTopY = midPointY - (topRectH + gapBetweenTooltips / 2f)
|
||||||
|
val adjustedBottomY = midPointY + (gapBetweenTooltips / 2f)
|
||||||
|
|
||||||
|
if (topRectY == yPosCompleted) {
|
||||||
|
yPosCompleted = adjustedTopY
|
||||||
|
yPosCorrect = adjustedBottomY
|
||||||
|
} else {
|
||||||
|
yPosCorrect = adjustedTopY
|
||||||
|
yPosCompleted = adjustedBottomY
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
}
|
||||||
Text(
|
|
||||||
text = stat.day,
|
// Final Canvas Bounds Check post-resolution
|
||||||
style = MaterialTheme.typography.bodySmall
|
val finalMinY = minOf(yPosCompleted, yPosCorrect)
|
||||||
|
if (finalMinY < 0f) {
|
||||||
|
yPosCompleted -= finalMinY
|
||||||
|
yPosCorrect -= finalMinY
|
||||||
|
}
|
||||||
|
val finalMaxY = maxOf(yPosCompleted + h1, yPosCorrect + h2)
|
||||||
|
if (finalMaxY > height) {
|
||||||
|
val shift = finalMaxY - height
|
||||||
|
yPosCompleted -= shift
|
||||||
|
yPosCorrect -= shift
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Completed Tooltip
|
||||||
|
val t1X = (x - w1 / 2f).coerceIn(0f, width - w1)
|
||||||
|
drawRoundRect(color = tooltipBgColor, topLeft = Offset(t1X, yPosCompleted), size = Size(w1, h1), cornerRadius = CornerRadius(16f, 16f))
|
||||||
|
drawCircle(color = colorCompleted, radius = dotRadius, center = Offset(t1X + padX + dotRadius, yPosCompleted + h1 / 2f))
|
||||||
|
drawText(textLayoutResult = textResCompleted, topLeft = Offset(t1X + padX + dotRadius * 2 + gap, yPosCompleted + padY))
|
||||||
|
|
||||||
|
// Draw Correct Tooltip
|
||||||
|
val t2X = (x - w2 / 2f).coerceIn(0f, width - w2)
|
||||||
|
drawRoundRect(color = tooltipBgColor, topLeft = Offset(t2X, yPosCorrect), size = Size(w2, h2), cornerRadius = CornerRadius(16f, 16f))
|
||||||
|
drawCircle(color = colorCorrect, radius = dotRadius, center = Offset(t2X + padX + dotRadius, yPosCorrect + h2 / 2f))
|
||||||
|
drawText(textLayoutResult = textResCorrect, topLeft = Offset(t2X + padX + dotRadius * 2 + gap, yPosCorrect + padY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// X-Axis Labels (Freed from fixed widths, prevented from wrapping)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
weeklyStats.forEachIndexed { index, stat ->
|
||||||
|
val isSelected = index == selectedIndex
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stat.day.uppercase().take(3) + ".",
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 11.sp, // Slightly smaller to ensure fit across all devices
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false // Prevents the text from splitting into multiple lines
|
||||||
|
)
|
||||||
|
if (isSelected) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(2.dp)
|
||||||
|
.width(20.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
// Invisible spacer to prevent layout jumping when line appears
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,41 +380,29 @@ fun WeeklyActivityChartWidget(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
private fun Path.smoothCurve(points: List<Offset>) {
|
||||||
private fun RowScope.Bar(value: Int, maxValue: Int, color: Color) {
|
if (points.isEmpty()) return
|
||||||
var startAnimation by remember { mutableStateOf(false) }
|
moveTo(points.first().x, points.first().y)
|
||||||
val barHeight by animateFloatAsState(
|
for (i in 1 until points.size) {
|
||||||
targetValue = if (startAnimation) value.toFloat() / maxValue.toFloat() else 0f,
|
val prev = points[i - 1]
|
||||||
animationSpec = tween(durationMillis = 1000),
|
val curr = points[i]
|
||||||
label = "barHeightAnimation"
|
val controlX = (prev.x + curr.x) / 2f
|
||||||
)
|
cubicTo(
|
||||||
|
controlX, prev.y,
|
||||||
LaunchedEffect(Unit) {
|
controlX, curr.y,
|
||||||
delay(200) // Small delay to ensure the UI is ready before animating
|
curr.x, curr.y
|
||||||
@Suppress("AssignedValueIsNeverRead")
|
)
|
||||||
startAnimation = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight(barHeight)
|
|
||||||
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
|
|
||||||
.background(color)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeeklyChartLegend() {
|
private fun WeeklyChartLegend() {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
.padding(horizontal = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
) {
|
||||||
LegendItem(color = MaterialTheme.semanticColors.stageGradient1, label = stringResource(R.string.label_added))
|
LegendItem(color = MaterialTheme.colorScheme.primary, label = stringResource(R.string.label_completed).uppercase())
|
||||||
LegendItem(color = MaterialTheme.semanticColors.stageGradient3, label = stringResource(R.string.label_completed))
|
LegendItem(color = MaterialTheme.colorScheme.tertiary, label = stringResource(R.string.label_correct).uppercase())
|
||||||
LegendItem(color = MaterialTheme.semanticColors.stageGradient5, label = stringResource(R.string.label_correct))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +414,51 @@ private fun LegendItem(color: Color, label: String) {
|
|||||||
.size(10.dp)
|
.size(10.dp)
|
||||||
.background(color, shape = CircleShape)
|
.background(color, shape = CircleShape)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(text = label, style = MaterialTheme.typography.labelMedium, fontSize = 12.sp)
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChartFooter(weeklyStats: List<WeeklyActivityStat>) {
|
||||||
|
val bestDay = remember(weeklyStats) {
|
||||||
|
weeklyStats.maxByOrNull { it.completed + it.answeredRight }?.day?.uppercase() ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Melhor Dia:",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = bestDay,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,13 +466,13 @@ private fun LegendItem(color: Color, label: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun WeeklyActivityChartWidgetPreview() {
|
fun WeeklyActivityChartWidgetPreview() {
|
||||||
val sampleStats = listOf(
|
val sampleStats = listOf(
|
||||||
WeeklyActivityStat("Mon", 10, 5, 20),
|
WeeklyActivityStat("Seg", 30, 15, 10),
|
||||||
WeeklyActivityStat("Tue", 12, 3, 15),
|
WeeklyActivityStat("Ter", 45, 20, 12),
|
||||||
WeeklyActivityStat("Wed", 8, 8, 25),
|
WeeklyActivityStat("Qua", 80, 25, 15),
|
||||||
WeeklyActivityStat("Thu", 15, 2, 18),
|
WeeklyActivityStat("Qui", 84, 35, 18),
|
||||||
WeeklyActivityStat("Fri", 5, 10, 30),
|
WeeklyActivityStat("Sex", 50, 40, 22),
|
||||||
WeeklyActivityStat("Sat", 7, 6, 22),
|
WeeklyActivityStat("Sáb", 70, 30, 20),
|
||||||
WeeklyActivityStat("Sun", 9, 4, 17)
|
WeeklyActivityStat("Dom", 60, 25, 18)
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.content.ClipData
|
|||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
@@ -58,6 +59,7 @@ import eu.gaudian.translator.view.LocalConnectionConfigured
|
|||||||
import eu.gaudian.translator.view.NoConnectionScreen
|
import eu.gaudian.translator.view.NoConnectionScreen
|
||||||
import eu.gaudian.translator.view.composable.AppCard
|
import eu.gaudian.translator.view.composable.AppCard
|
||||||
import eu.gaudian.translator.view.composable.AppOutlinedCard
|
import eu.gaudian.translator.view.composable.AppOutlinedCard
|
||||||
|
import eu.gaudian.translator.view.composable.Screen
|
||||||
import eu.gaudian.translator.view.dialogs.AddVocabularyDialog
|
import eu.gaudian.translator.view.dialogs.AddVocabularyDialog
|
||||||
import eu.gaudian.translator.view.hints.HintDefinition
|
import eu.gaudian.translator.view.hints.HintDefinition
|
||||||
import eu.gaudian.translator.view.settings.SettingsRoutes
|
import eu.gaudian.translator.view.settings.SettingsRoutes
|
||||||
@@ -88,7 +90,10 @@ fun TranslationScreen(
|
|||||||
|
|
||||||
|
|
||||||
if (isInitializationComplete && !connectionConfigured) {
|
if (isInitializationComplete && !connectionConfigured) {
|
||||||
NoConnectionScreen(onSettingsClick = { navController.navigate(SettingsRoutes.API_KEY) })
|
NoConnectionScreen(
|
||||||
|
onSettingsClick = { navController.navigate(SettingsRoutes.API_KEY) },
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +113,7 @@ fun TranslationScreen(
|
|||||||
onSettingsClick = onSettingsClick,
|
onSettingsClick = onSettingsClick,
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
if (!navController.popBackStack()) {
|
if (!navController.popBackStack()) {
|
||||||
navController.navigate(eu.gaudian.translator.view.composable.Screen.Home.route) {
|
navController.navigate(Screen.Home.route) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
restoreState = false
|
restoreState = false
|
||||||
}
|
}
|
||||||
@@ -182,7 +187,7 @@ private fun LoadedTranslationContent(
|
|||||||
|
|
||||||
AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) {
|
AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) {
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
if (isLandscape) {
|
if (isLandscape) {
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import eu.gaudian.translator.utils.findActivity
|
|||||||
import eu.gaudian.translator.view.composable.AppAlertDialog
|
import eu.gaudian.translator.view.composable.AppAlertDialog
|
||||||
import eu.gaudian.translator.view.composable.AppDialog
|
import eu.gaudian.translator.view.composable.AppDialog
|
||||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
|
import eu.gaudian.translator.view.composable.SectionLabel
|
||||||
import eu.gaudian.translator.view.dialogs.RequestMorePackDialog
|
import eu.gaudian.translator.view.dialogs.RequestMorePackDialog
|
||||||
import eu.gaudian.translator.view.hints.HintDefinition
|
import eu.gaudian.translator.view.hints.HintDefinition
|
||||||
import eu.gaudian.translator.view.translation.LanguageSelectorBar
|
import eu.gaudian.translator.view.translation.LanguageSelectorBar
|
||||||
@@ -88,6 +89,7 @@ import eu.gaudian.translator.viewmodel.ImportState
|
|||||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||||
import eu.gaudian.translator.viewmodel.PackDownloadState
|
import eu.gaudian.translator.viewmodel.PackDownloadState
|
||||||
import eu.gaudian.translator.viewmodel.PackUiState
|
import eu.gaudian.translator.viewmodel.PackUiState
|
||||||
|
import eu.gaudian.translator.viewmodel.TranslationViewModel
|
||||||
import eu.gaudian.translator.viewmodel.VocabPacksViewModel
|
import eu.gaudian.translator.viewmodel.VocabPacksViewModel
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -124,19 +126,168 @@ enum class PackFilter {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
private val gradientPalette = listOf(
|
private val gradientPalette = listOf(
|
||||||
listOf(Color(0xFF1565C0), Color(0xFF42A5F5)),
|
// Original Gradients
|
||||||
listOf(Color(0xFF00695C), Color(0xFF26A69A)),
|
listOf(Color(0xFF1565C0), Color(0xFF42A5F5)), // Blue
|
||||||
listOf(Color(0xFF6A1B9A), Color(0xFFAB47BC)),
|
listOf(Color(0xFF00695C), Color(0xFF26A69A)), // Teal
|
||||||
listOf(Color(0xFFE65100), Color(0xFFFFA726)),
|
listOf(Color(0xFF6A1B9A), Color(0xFFAB47BC)), // Purple
|
||||||
listOf(Color(0xFF212121), Color(0xFF546E7A)),
|
listOf(Color(0xFFE65100), Color(0xFFFFA726)), // Orange
|
||||||
listOf(Color(0xFFC62828), Color(0xFFEF9A9A)),
|
listOf(Color(0xFF212121), Color(0xFF546E7A)), // Dark Grey to Blue Grey
|
||||||
listOf(Color(0xFF1B5E20), Color(0xFF66BB6A)),
|
listOf(Color(0xFFC62828), Color(0xFFEF9A9A)), // Red
|
||||||
listOf(Color(0xFF0D47A1), Color(0xFF90CAF9)),
|
listOf(Color(0xFF1B5E20), Color(0xFF66BB6A)), // Green
|
||||||
|
listOf(Color(0xFF0D47A1), Color(0xFF90CAF9)), // Deep Blue
|
||||||
|
|
||||||
|
// New Monochromatic / Material Shades
|
||||||
|
listOf(Color(0xFFAD1457), Color(0xFFF06292)), // Pink
|
||||||
|
listOf(Color(0xFF283593), Color(0xFF7986CB)), // Indigo
|
||||||
|
listOf(Color(0xFF00838F), Color(0xFF4DD0E1)), // Cyan
|
||||||
|
listOf(Color(0xFFFF8F00), Color(0xFFFFD54F)), // Amber
|
||||||
|
listOf(Color(0xFFD84315), Color(0xFFFF8A65)), // Deep Orange
|
||||||
|
listOf(Color(0xFF4E342E), Color(0xFFA1887F)), // Brown
|
||||||
|
listOf(Color(0xFF4527A0), Color(0xFF9575CD)), // Deep Purple
|
||||||
|
listOf(Color(0xFF9E9D24), Color(0xFFDCE775)), // Lime
|
||||||
|
listOf(Color(0xFF37474F), Color(0xFF90A4AE)), // Cool Grey
|
||||||
|
listOf(Color(0xFFF57F17), Color(0xFFFFF176)), // Yellow
|
||||||
|
|
||||||
|
// New Multi-Hue / Vibrant Gradients
|
||||||
|
listOf(Color(0xFF1A237E), Color(0xFF880E4F)), // Deep Blue to Deep Pink (Midnight)
|
||||||
|
listOf(Color(0xFFE65100), Color(0xFFE91E63)), // Dark Orange to Pink (Sunset)
|
||||||
|
listOf(Color(0xFF0277BD), Color(0xFF00897B)), // Light Blue to Teal (Ocean)
|
||||||
|
listOf(Color(0xFF303F9F), Color(0xFF7B1FA2)), // Indigo to Purple (Galaxy)
|
||||||
|
listOf(Color(0xFFBF360C), Color(0xFFFFCA28)), // Deep Red to Amber (Fire)
|
||||||
|
listOf(Color(0xFF004D40), Color(0xFF64FFDA)), // Dark Teal to Mint (Aqua)
|
||||||
|
listOf(Color(0xFF4A148C), Color(0xFFF50057)), // Dark Purple to Neon Pink (Cyberpunk)
|
||||||
|
listOf(Color(0xFF1B5E20), Color(0xFFC0CA33)), // Dark Green to Lime (Forest)
|
||||||
|
listOf(Color(0xFF827717), Color(0xFFFF9800)), // Olive to Orange (Autumn)
|
||||||
|
listOf(Color(0xFF01579B), Color(0xFF00E5FF)), // Navy to Neon Cyan (Electric Blue)
|
||||||
|
|
||||||
|
// Pastel / Soft Gradients
|
||||||
|
listOf(Color(0xFF80DEEA), Color(0xFFE0F7FA)), // Soft Cyan
|
||||||
|
listOf(Color(0xFFF48FB1), Color(0xFFFCE4EC)), // Soft Pink
|
||||||
|
listOf(Color(0xFFCE93D8), Color(0xFFF3E5F5)), // Soft Purple
|
||||||
|
listOf(Color(0xFFA5D6A7), Color(0xFFE8F5E9)), // Soft Green
|
||||||
|
|
||||||
|
listOf(Color(0xFF81D4FA), Color(0xFFE1F5FE)), // Light Sky Blue
|
||||||
|
listOf(Color(0xFFB39DDB), Color(0xFFEDE7F6)), // Soft Lavender
|
||||||
|
listOf(Color(0xFFFFCC80), Color(0xFFFFF3E0)), // Peach / Warm Sand
|
||||||
|
listOf(Color(0xFFA5D6A7), Color(0xFFF1F8E9)), // Pale Mint
|
||||||
|
listOf(Color(0xFFFFF59D), Color(0xFFFFFDE7)), // Soft Lemon
|
||||||
|
listOf(Color(0xFFFFAB91), Color(0xFFFBE9E7)), // Pale Coral
|
||||||
|
listOf(Color(0xFFCE93D8), Color(0xFFF3E5F5)), // Light Orchid
|
||||||
|
listOf(Color(0xFFBCAAA4), Color(0xFFEFEBE9)), // Light Taupe / Oat
|
||||||
|
listOf(Color(0xFF90CAF9), Color(0xFFE3F2FD)), // Baby Blue
|
||||||
|
listOf(Color(0xFFF48FB1), Color(0xFFFCE4EC)), // Rosewater
|
||||||
|
|
||||||
|
// Soft Multi-Hue (Two-tone Pastels)
|
||||||
|
listOf(Color(0xFFE1BEE7), Color(0xFFBBDEFB)), // Light Purple to Light Blue (Cotton Candy)
|
||||||
|
listOf(Color(0xFFFFF9C4), Color(0xFFFFCCBC)), // Pale Yellow to Pale Peach (Morning Light)
|
||||||
|
listOf(Color(0xFFB2EBF2), Color(0xFFC8E6C9)), // Pale Cyan to Pale Green (Seafoam)
|
||||||
|
listOf(Color(0xFFFFD54F), Color(0xFFFF8A65)), // Warm Sun to Soft Coral (Soft Sunset)
|
||||||
|
listOf(Color(0xFFD1C4E9), Color(0xFFF8BBD0)), // Periwinkle to Blush Pink (Twilight)
|
||||||
|
listOf(Color(0xFFC5E1A5), Color(0xFFFFF59D)), // Spring Green to Pale Yellow (Meadow)
|
||||||
|
listOf(Color(0xFF80CBC4), Color(0xFF81D4FA)), // Soft Teal to Light Blue (Glacier)
|
||||||
|
listOf(Color(0xFFF8BBD0), Color(0xFFFFE0B2)), // Soft Pink to Cream (Sorbet)
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun gradientForId(id: String): List<Color> =
|
private fun gradientForId(id: String): List<Color> =
|
||||||
gradientPalette[abs(id.hashCode()) % gradientPalette.size]
|
gradientPalette[abs(id.hashCode()) % gradientPalette.size]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Translation cache - shared between PackCard and PackPreviewDialog, cleared on screen exit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private class TranslationCache {
|
||||||
|
// Cache key: pack ID, value: Pair(translatedName, translatedDescription)
|
||||||
|
private val cache = mutableMapOf<String, Pair<String, String>>()
|
||||||
|
|
||||||
|
fun get(packId: String): Pair<String, String>? = cache[packId]
|
||||||
|
|
||||||
|
fun put(packId: String, translated: Pair<String, String>) {
|
||||||
|
cache[packId] = translated
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberTranslationCache(): TranslationCache {
|
||||||
|
val cache = remember { TranslationCache() }
|
||||||
|
|
||||||
|
// Clear cache when leaving the screen
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Translation helper - translates pack name and description from English to device's locale using LibreTranslate
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberTranslatedPackInfo(
|
||||||
|
info: eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo,
|
||||||
|
translationViewModel: TranslationViewModel,
|
||||||
|
cache: TranslationCache
|
||||||
|
): Pair<String, String> {
|
||||||
|
// Check cache first
|
||||||
|
val cached = cache.get(info.id)
|
||||||
|
if (cached != null) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
var translatedName by remember(info.id) { mutableStateOf(info.name) }
|
||||||
|
var translatedDescription by remember(info.id) { mutableStateOf(info.description) }
|
||||||
|
|
||||||
|
// Get device locale using Locale.getDefault() which is more reliable
|
||||||
|
val deviceLocale = remember { java.util.Locale.getDefault().language }
|
||||||
|
|
||||||
|
// Launch translation when language or content changes
|
||||||
|
LaunchedEffect(info.name, info.description, deviceLocale) {
|
||||||
|
try {
|
||||||
|
// Always translate from English to device locale
|
||||||
|
val targetCode = deviceLocale
|
||||||
|
|
||||||
|
var finalName = info.name
|
||||||
|
var finalDescription = info.description
|
||||||
|
|
||||||
|
// Translate name if not empty using LibreTranslate directly
|
||||||
|
if (info.name.isNotBlank()) {
|
||||||
|
val nameResult = translationViewModel.translateWithLibreTranslate(info.name, targetCode, "en")
|
||||||
|
if (nameResult.isSuccess) {
|
||||||
|
finalName = nameResult.getOrNull() ?: info.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate description if not empty using LibreTranslate directly
|
||||||
|
if (info.description.isNotBlank()) {
|
||||||
|
val descResult = translationViewModel.translateWithLibreTranslate(info.description, targetCode, "en")
|
||||||
|
if (descResult.isSuccess) {
|
||||||
|
finalDescription = descResult.getOrNull() ?: info.description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
translatedName = finalName
|
||||||
|
translatedDescription = finalDescription
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
cache.put(info.id, Pair(finalName, finalDescription))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Translation failed for pack ${info.id}: ${e.message}")
|
||||||
|
// Keep original text on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Pair(translatedName, translatedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Screen
|
// Screen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -153,6 +304,7 @@ fun ExplorePacksScreen(
|
|||||||
val vocabPacksViewModel: VocabPacksViewModel = hiltViewModel()
|
val vocabPacksViewModel: VocabPacksViewModel = hiltViewModel()
|
||||||
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
val translationViewModel: TranslationViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
|
||||||
val packs by vocabPacksViewModel.packs.collectAsState()
|
val packs by vocabPacksViewModel.packs.collectAsState()
|
||||||
val isLoadingManifest by vocabPacksViewModel.isLoadingManifest.collectAsState()
|
val isLoadingManifest by vocabPacksViewModel.isLoadingManifest.collectAsState()
|
||||||
@@ -183,6 +335,9 @@ fun ExplorePacksScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Translation cache - shared between PackCard and PackPreviewDialog
|
||||||
|
val translationCache = rememberTranslationCache()
|
||||||
|
|
||||||
// Auto-open conflict dialog once a queued download finishes
|
// Auto-open conflict dialog once a queued download finishes
|
||||||
LaunchedEffect(packs, pendingImportPackId) {
|
LaunchedEffect(packs, pendingImportPackId) {
|
||||||
val id = pendingImportPackId ?: return@LaunchedEffect
|
val id = pendingImportPackId ?: return@LaunchedEffect
|
||||||
@@ -212,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()
|
||||||
@@ -227,10 +382,10 @@ fun ExplorePacksScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtered + sorted pack list
|
// Filtered + sorted pack list - also search through translated names
|
||||||
val filteredPacks = remember(
|
val filteredPacks = remember(
|
||||||
packs, selectedFilter, searchQuery,
|
packs, selectedFilter, searchQuery,
|
||||||
selectedSourceLanguage, selectedTargetLanguage
|
selectedSourceLanguage, selectedTargetLanguage, translationCache
|
||||||
) {
|
) {
|
||||||
val srcId = selectedSourceLanguage?.nameResId
|
val srcId = selectedSourceLanguage?.nameResId
|
||||||
val tgtId = selectedTargetLanguage?.nameResId
|
val tgtId = selectedTargetLanguage?.nameResId
|
||||||
@@ -247,9 +402,16 @@ fun ExplorePacksScreen(
|
|||||||
(tgtId == null || ids.contains(tgtId))
|
(tgtId == null || ids.contains(tgtId))
|
||||||
}
|
}
|
||||||
|
|
||||||
val matchSearch = searchQuery.isBlank() ||
|
// Search in both original English and translated names
|
||||||
|
val translated = translationCache.get(info.id)
|
||||||
|
val matchSearch = if (searchQuery.isBlank()) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
info.name.contains(searchQuery, ignoreCase = true) ||
|
info.name.contains(searchQuery, ignoreCase = true) ||
|
||||||
info.category.contains(searchQuery, ignoreCase = true)
|
info.category.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
(translated?.first?.contains(searchQuery, ignoreCase = true) == true) ||
|
||||||
|
(translated?.second?.contains(searchQuery, ignoreCase = true) == true)
|
||||||
|
}
|
||||||
|
|
||||||
val matchFilter = when (val code = selectedFilter.cefrCode) {
|
val matchFilter = when (val code = selectedFilter.cefrCode) {
|
||||||
null -> true // All or Newest – handled by sort below
|
null -> true // All or Newest – handled by sort below
|
||||||
@@ -365,11 +527,7 @@ fun ExplorePacksScreen(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
SectionLabel(text = stringResource(R.string.label_available_collections))
|
||||||
stringResource(R.string.label_available_collections),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
if (!isLoadingManifest && packs.isNotEmpty()) {
|
if (!isLoadingManifest && packs.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.label_d_packs, filteredPacks.size),
|
stringResource(R.string.label_d_packs, filteredPacks.size),
|
||||||
@@ -405,12 +563,6 @@ fun ExplorePacksScreen(
|
|||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
|
||||||
manifestError ?: "",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Button(onClick = { vocabPacksViewModel.loadManifest() }) {
|
Button(onClick = { vocabPacksViewModel.loadManifest() }) {
|
||||||
Text(stringResource(R.string.label_retry))
|
Text(stringResource(R.string.label_retry))
|
||||||
@@ -453,6 +605,9 @@ fun ExplorePacksScreen(
|
|||||||
items(filteredPacks, key = { it.info.id }) { packState ->
|
items(filteredPacks, key = { it.info.id }) { packState ->
|
||||||
PackCard(
|
PackCard(
|
||||||
packState = packState,
|
packState = packState,
|
||||||
|
languageViewModel = languageViewModel,
|
||||||
|
translationViewModel = translationViewModel,
|
||||||
|
translationCache = translationCache,
|
||||||
onCardClick = {
|
onCardClick = {
|
||||||
previewPack = packState
|
previewPack = packState
|
||||||
when (packState.downloadState) {
|
when (packState.downloadState) {
|
||||||
@@ -512,6 +667,9 @@ fun ExplorePacksScreen(
|
|||||||
if (preview != null) {
|
if (preview != null) {
|
||||||
PackPreviewDialog(
|
PackPreviewDialog(
|
||||||
packState = preview,
|
packState = preview,
|
||||||
|
languageViewModel = languageViewModel,
|
||||||
|
translationViewModel = translationViewModel,
|
||||||
|
translationCache = translationCache,
|
||||||
onDismiss = { previewPack = null },
|
onDismiss = { previewPack = null },
|
||||||
onGetClick = {
|
onGetClick = {
|
||||||
pendingImportPackId = preview.info.id
|
pendingImportPackId = preview.info.id
|
||||||
@@ -668,14 +826,30 @@ private fun PackConflictStrategyOption(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PackCard(
|
private fun PackCard(
|
||||||
packState: PackUiState,
|
packState: PackUiState,
|
||||||
|
languageViewModel: LanguageViewModel,
|
||||||
|
translationViewModel: TranslationViewModel,
|
||||||
|
translationCache: TranslationCache,
|
||||||
onCardClick: () -> Unit,
|
onCardClick: () -> Unit,
|
||||||
onGetClick: () -> Unit,
|
onGetClick: () -> Unit,
|
||||||
onAddToLibraryClick: () -> Unit,
|
onAddToLibraryClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val info = packState.info
|
val info = packState.info
|
||||||
val gradient = gradientForId(info.id)
|
val gradient = gradientForId(info.id)
|
||||||
|
|
||||||
|
// Get language names from language IDs
|
||||||
|
val languageIds = info.languageIds
|
||||||
|
val firstLanguageName by languageViewModel.getLanguageByIdFlow(languageIds.getOrNull(0)).collectAsState(initial = null)
|
||||||
|
val secondLanguageName by languageViewModel.getLanguageByIdFlow(languageIds.getOrNull(1)).collectAsState(initial = null)
|
||||||
|
|
||||||
|
val languageDisplayText = listOfNotNull(firstLanguageName?.name, secondLanguageName?.name)
|
||||||
|
.joinToString(" ⇆ ")
|
||||||
|
.ifEmpty { info.category }
|
||||||
|
|
||||||
|
// Get translated name and description
|
||||||
|
val (translatedName, translatedDescription) = rememberTranslatedPackInfo(info, translationViewModel, translationCache)
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -781,14 +955,14 @@ private fun PackCard(
|
|||||||
// ── Pack info ─────────────────────────────────────────────────────
|
// ── Pack info ─────────────────────────────────────────────────────
|
||||||
Column(modifier = Modifier.padding(10.dp)) {
|
Column(modifier = Modifier.padding(10.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = info.name,
|
text = translatedName,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 2
|
maxLines = 2
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Text(
|
Text(
|
||||||
text = info.category,
|
text = languageDisplayText,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
@@ -886,6 +1060,9 @@ private fun PackCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PackPreviewDialog(
|
private fun PackPreviewDialog(
|
||||||
packState: PackUiState,
|
packState: PackUiState,
|
||||||
|
languageViewModel: LanguageViewModel,
|
||||||
|
translationViewModel: TranslationViewModel,
|
||||||
|
translationCache: TranslationCache,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onGetClick: () -> Unit,
|
onGetClick: () -> Unit,
|
||||||
onAddToLibraryClick: () -> Unit,
|
onAddToLibraryClick: () -> Unit,
|
||||||
@@ -895,9 +1072,12 @@ private fun PackPreviewDialog(
|
|||||||
val gradient = gradientForId(info.id)
|
val gradient = gradientForId(info.id)
|
||||||
var selectedItem by remember { mutableStateOf<VocabularyItem?>(null) }
|
var selectedItem by remember { mutableStateOf<VocabularyItem?>(null) }
|
||||||
|
|
||||||
|
// Get translated name and description
|
||||||
|
val (translatedName, translatedDescription) = rememberTranslatedPackInfo(info, translationViewModel, translationCache)
|
||||||
|
|
||||||
AppDialog(
|
AppDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(info.name, fontWeight = FontWeight.Bold) },
|
title = { Text(translatedName, fontWeight = FontWeight.Bold) },
|
||||||
) {
|
) {
|
||||||
// ── Gradient banner ───────────────────────────────────────────
|
// ── Gradient banner ───────────────────────────────────────────
|
||||||
Box(
|
Box(
|
||||||
@@ -925,18 +1105,18 @@ private fun PackPreviewDialog(
|
|||||||
Column(modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) {
|
Column(modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) {
|
||||||
Text(info.emoji, fontSize = 32.sp)
|
Text(info.emoji, fontSize = 32.sp)
|
||||||
Text(
|
Text(
|
||||||
"${info.category} · ${stringResource(R.string.text_d_cards, info.itemCount)}",
|
"$translatedName · ${stringResource(R.string.text_d_cards, info.itemCount)}",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = Color.White.copy(alpha = 0.85f)
|
color = Color.White.copy(alpha = 0.85f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Description ───────────────────────────────────────────────
|
// ── Description ───────────────────────────────────────────────
|
||||||
if (info.description.isNotBlank()) {
|
if (translatedDescription.isNotBlank()) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = info.description,
|
text = translatedDescription,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -16,29 +14,23 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AutoAwesome
|
import androidx.compose.material.icons.filled.AutoAwesome
|
||||||
import androidx.compose.material.icons.filled.DriveFolderUpload
|
import androidx.compose.material.icons.filled.DriveFolderUpload
|
||||||
import androidx.compose.material.icons.filled.EditNote
|
import androidx.compose.material.icons.filled.EditNote
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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
|
||||||
@@ -46,7 +38,6 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringArrayResource
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -55,16 +46,15 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.model.Language
|
|
||||||
import eu.gaudian.translator.model.VocabularyItem
|
import eu.gaudian.translator.model.VocabularyItem
|
||||||
import eu.gaudian.translator.utils.StatusMessageId
|
|
||||||
import eu.gaudian.translator.utils.StatusMessageService
|
|
||||||
import eu.gaudian.translator.utils.findActivity
|
import eu.gaudian.translator.utils.findActivity
|
||||||
|
import eu.gaudian.translator.view.LocalConnectionConfigured
|
||||||
import eu.gaudian.translator.view.NavigationRoutes
|
import eu.gaudian.translator.view.NavigationRoutes
|
||||||
import eu.gaudian.translator.view.composable.AppButton
|
import eu.gaudian.translator.view.composable.AppButton
|
||||||
import eu.gaudian.translator.view.composable.AppCard
|
import eu.gaudian.translator.view.composable.AppCard
|
||||||
|
import eu.gaudian.translator.view.composable.AppIconContainer
|
||||||
import eu.gaudian.translator.view.composable.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.AppSlider
|
import eu.gaudian.translator.view.composable.AppSlider
|
||||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
import eu.gaudian.translator.view.composable.InspiringSearchField
|
import eu.gaudian.translator.view.composable.InspiringSearchField
|
||||||
@@ -73,6 +63,7 @@ import eu.gaudian.translator.view.composable.SourceLanguageDropdown
|
|||||||
import eu.gaudian.translator.view.composable.TargetLanguageDropdown
|
import eu.gaudian.translator.view.composable.TargetLanguageDropdown
|
||||||
import eu.gaudian.translator.view.hints.HintDefinition
|
import eu.gaudian.translator.view.hints.HintDefinition
|
||||||
import eu.gaudian.translator.view.library.VocabularyCard
|
import eu.gaudian.translator.view.library.VocabularyCard
|
||||||
|
import eu.gaudian.translator.view.settings.SettingsRoutes
|
||||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -103,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()
|
||||||
@@ -226,6 +120,14 @@ fun NewWordScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Explore Packs - Prominent full-width card at top
|
||||||
|
ExplorePacksProminentCard(
|
||||||
|
onClick = { navController.navigate(NavigationRoutes.EXPLORE_PACKS) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// AI Generator Card
|
||||||
AIGeneratorCard(
|
AIGeneratorCard(
|
||||||
category = category,
|
category = category,
|
||||||
onCategoryChange = { category = it },
|
onCategoryChange = { category = it },
|
||||||
@@ -241,10 +143,12 @@ fun NewWordScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
navController = navController,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Add Manually Card
|
||||||
AddManuallyCard(
|
AddManuallyCard(
|
||||||
languageViewModel = languageViewModel,
|
languageViewModel = languageViewModel,
|
||||||
vocabularyViewModel = vocabularyViewModel,
|
vocabularyViewModel = vocabularyViewModel,
|
||||||
@@ -252,11 +156,10 @@ fun NewWordScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
BottomActionCardsRow(
|
// Import CSV - Full width card at bottom
|
||||||
onExplorePsClick = {
|
ImportCsvCard(
|
||||||
navController.navigate(NavigationRoutes.EXPLORE_PACKS)
|
onClick = {
|
||||||
},
|
@Suppress("HardCodedStringLiteral")
|
||||||
onImportCsvClick = {
|
|
||||||
navController.navigate("settings_vocabulary_repository_options")
|
navController.navigate("settings_vocabulary_repository_options")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -302,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) ---
|
||||||
@@ -432,104 +218,156 @@ fun AIGeneratorCard(
|
|||||||
languageViewModel: LanguageViewModel,
|
languageViewModel: LanguageViewModel,
|
||||||
isGenerating: Boolean,
|
isGenerating: Boolean,
|
||||||
onGenerate: () -> Unit,
|
onGenerate: () -> Unit,
|
||||||
|
navController: NavHostController,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val icon = Icons.Default.AutoAwesome
|
val connectionConfigured = LocalConnectionConfigured.current
|
||||||
val hints = stringArrayResource(R.array.vocabulary_hints)
|
|
||||||
AppCard(
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
title = stringResource(R.string.label_ai_generator),
|
|
||||||
icon = icon,
|
|
||||||
hintContent = HintDefinition.VOCABULARY_GENERATE_AI.hint(),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(8.dp)) {
|
|
||||||
|
|
||||||
Text(
|
if (connectionConfigured) {
|
||||||
text = stringResource(R.string.text_search_term),
|
// Show the normal AI generator card
|
||||||
style = MaterialTheme.typography.labelLarge,
|
val icon = Icons.Default.AutoAwesome
|
||||||
fontWeight = FontWeight.SemiBold
|
val hints = stringArrayResource(R.array.vocabulary_hints)
|
||||||
)
|
AppCard(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
modifier = modifier.fillMaxWidth(),
|
||||||
InspiringSearchField(
|
title = stringResource(R.string.label_ai_generator),
|
||||||
value = category,
|
icon = icon,
|
||||||
hints = hints,
|
hintContent = HintDefinition.VOCABULARY_GENERATE_AI.hint(),
|
||||||
onValueChange = onCategoryChange
|
) {
|
||||||
)
|
Column(modifier = Modifier.padding(8.dp)) {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.text_select_languages),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
SourceLanguageDropdown(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
iconEnabled = false,
|
|
||||||
noBorder = true
|
|
||||||
)
|
|
||||||
TargetLanguageDropdown(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
iconEnabled = false,
|
|
||||||
noBorder = true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.text_select_amount),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
AppSlider(
|
|
||||||
value = amount,
|
|
||||||
onValueChange = onAmountChange,
|
|
||||||
valueRange = 1f..25f,
|
|
||||||
steps = 24,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.text_amount_2d, amount.toInt()),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.padding(top = 8.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
|
|
||||||
if (isGenerating) {
|
Text(
|
||||||
|
text = stringResource(R.string.text_search_term),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
InspiringSearchField(
|
||||||
|
value = category,
|
||||||
|
hints = hints,
|
||||||
|
onValueChange = onCategoryChange
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.text_select_languages),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
horizontalArrangement = Arrangement.Center
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator()
|
SourceLanguageDropdown(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
languageViewModel = languageViewModel,
|
||||||
|
iconEnabled = false,
|
||||||
|
noBorder = true
|
||||||
|
)
|
||||||
|
TargetLanguageDropdown(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
languageViewModel = languageViewModel,
|
||||||
|
iconEnabled = false,
|
||||||
|
noBorder = true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
}
|
Text(
|
||||||
|
text = stringResource(R.string.text_select_amount),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
AppSlider(
|
||||||
|
value = amount,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
|
valueRange = 1f..25f,
|
||||||
|
steps = 24,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.text_amount_2d, amount.toInt()),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
AppButton(
|
if (isGenerating) {
|
||||||
onClick = onGenerate,
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
AppButton(
|
||||||
|
onClick = onGenerate,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
enabled = category.isNotBlank() && !isGenerating
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Default.AutoAwesome, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.text_generate),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show the "configure connection" card when not configured
|
||||||
|
AppCard(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
title = stringResource(R.string.label_ai_generator),
|
||||||
|
icon = Icons.Default.AutoAwesome,
|
||||||
|
hintContent = HintDefinition.VOCABULARY_GENERATE_AI.hint(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(56.dp),
|
.padding(24.dp),
|
||||||
enabled = category.isNotBlank() && !isGenerating
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Row(
|
Text(
|
||||||
horizontalArrangement = Arrangement.Center,
|
text = stringResource(R.string.text_ai_generator_requires_configuration),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.fillMaxWidth()
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
AppButton(
|
||||||
|
onClick = { navController.navigate(SettingsRoutes.API_KEY) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
) {
|
) {
|
||||||
Icon(imageVector = Icons.Default.AutoAwesome, contentDescription = null, modifier = Modifier.size(20.dp))
|
Row(
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
horizontalArrangement = Arrangement.Center,
|
||||||
Text(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
text = stringResource(R.string.text_generate),
|
modifier = Modifier.fillMaxWidth()
|
||||||
style = MaterialTheme.typography.titleMedium,
|
) {
|
||||||
fontWeight = FontWeight.Bold
|
Icon(imageVector = Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
)
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_configure),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -556,26 +394,16 @@ fun AddManuallyCard(
|
|||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(24.dp)) {
|
Column(modifier = Modifier.padding(24.dp)) {
|
||||||
// Header Row
|
// Header Row - Using reusable AppIconContainer
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(
|
AppIconContainer(
|
||||||
modifier = Modifier
|
imageVector = Icons.Default.EditNote
|
||||||
.size(40.dp)
|
)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.EditNote,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.label_add_vocabulary),
|
text = stringResource(R.string.label_add_vocabulary),
|
||||||
@@ -588,37 +416,19 @@ fun AddManuallyCard(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Input Fields
|
// Input Fields - Using AppOutlinedTextField
|
||||||
TextField(
|
AppOutlinedTextField(
|
||||||
value = wordText,
|
value = wordText,
|
||||||
onValueChange = { wordText = it },
|
onValueChange = { wordText = it },
|
||||||
placeholder = { Text(stringResource(R.string.text_label_word), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) },
|
placeholder = { Text(stringResource(R.string.text_label_word)) }
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = MaterialTheme.colorScheme.surface, // Very dark background
|
|
||||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent
|
|
||||||
),
|
|
||||||
singleLine = true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
TextField(
|
AppOutlinedTextField(
|
||||||
value = translationText,
|
value = translationText,
|
||||||
onValueChange = { translationText = it },
|
onValueChange = { translationText = it },
|
||||||
placeholder = { Text(stringResource(R.string.text_translation), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) },
|
placeholder = { Text(stringResource(R.string.text_translation)) }
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent
|
|
||||||
),
|
|
||||||
singleLine = true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -653,7 +463,7 @@ fun AddManuallyCard(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Add to List Button (Darker variant)
|
// Add to List Button
|
||||||
AppButton(
|
AppButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val newItem = VocabularyItem(
|
val newItem = VocabularyItem(
|
||||||
@@ -682,83 +492,79 @@ fun AddManuallyCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Explore Packs Prominent Card (Full width at top) ---
|
||||||
@Composable
|
@Composable
|
||||||
fun BottomActionCardsRow(
|
fun ExplorePacksProminentCard(
|
||||||
modifier: Modifier = Modifier,
|
onClick: () -> Unit,
|
||||||
onExplorePsClick: () -> Unit,
|
modifier: Modifier = Modifier
|
||||||
onImportCsvClick: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
Row(
|
AppCard(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
onClick = onClick
|
||||||
) {
|
) {
|
||||||
// Explore Packs Card
|
Row(
|
||||||
AppCard(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.height(120.dp),
|
.padding(20.dp),
|
||||||
onClick = onExplorePsClick
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(
|
AppIconContainer(
|
||||||
modifier = Modifier.fillMaxSize(),
|
imageVector = AppIcons.Vocabulary,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
size = 56.dp,
|
||||||
verticalArrangement = Arrangement.Center
|
iconSize = 28.dp
|
||||||
) {
|
)
|
||||||
Box(
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
modifier = Modifier
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
.size(48.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = AppIcons.Vocabulary,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
Text(
|
Text(
|
||||||
text = "Explore Packs",
|
text = stringResource(R.string.title_explore_packs),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import CSV Card
|
|
||||||
AppCard(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(120.dp),
|
|
||||||
onClick = onImportCsvClick
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(48.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.DriveFolderUpload,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
text = "Import Lists or CSV",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.desc_explore_packs),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Import CSV Card (Full width at bottom) ---
|
||||||
|
@Composable
|
||||||
|
fun ImportCsvCard(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
AppCard(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
onClick = onClick
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AppIconContainer(
|
||||||
|
imageVector = Icons.Default.DriveFolderUpload,
|
||||||
|
size = 56.dp,
|
||||||
|
iconSize = 28.dp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_import_csv_or_lists),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.desc_import_csv),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ fun AllCardsListScreen(
|
|||||||
val vocabularyItems: List<VocabularyItem> = itemsToShow.ifEmpty {
|
val vocabularyItems: List<VocabularyItem> = itemsToShow.ifEmpty {
|
||||||
vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList()).value
|
vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList()).value
|
||||||
}
|
}
|
||||||
|
val stageMapping by vocabularyViewModel.stageMapping.collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||||
|
|
||||||
// Handle export state
|
// Handle export state
|
||||||
LaunchedEffect(exportState) {
|
LaunchedEffect(exportState) {
|
||||||
@@ -298,6 +299,7 @@ fun AllCardsListScreen(
|
|||||||
vocabularyItems = vocabularyItems,
|
vocabularyItems = vocabularyItems,
|
||||||
allLanguages = allLanguages,
|
allLanguages = allLanguages,
|
||||||
selection = selection,
|
selection = selection,
|
||||||
|
stageMapping = stageMapping,
|
||||||
listState = lazyListState,
|
listState = lazyListState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ private fun VocabularyCardContent(
|
|||||||
onMoveToStageClick = onMoveToStageClick,
|
onMoveToStageClick = onMoveToStageClick,
|
||||||
onDeleteClick = onDeleteClick,
|
onDeleteClick = onDeleteClick,
|
||||||
|
|
||||||
showAnalyzeGrammarButton = item.features.isNullOrBlank(),
|
showAnalyzeGrammarButton = !item.hasFeatures(),
|
||||||
onAnalyzeGrammarClick = {
|
onAnalyzeGrammarClick = {
|
||||||
vocabularyViewModel.fetchAndApplyGrammaticalDetailsForList(listOf(item))
|
vocabularyViewModel.fetchAndApplyGrammaticalDetailsForList(listOf(item))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ class ProgressViewModel @Inject constructor(
|
|||||||
|
|
||||||
// Calculate localized day name
|
// Calculate localized day name
|
||||||
val calendarDay = ((date.dayOfWeek.ordinal + 1) % 7) + 1
|
val calendarDay = ((date.dayOfWeek.ordinal + 1) % 7) + 1
|
||||||
val localizedDay = DateFormatSymbols.getInstance(Locale.getDefault()).shortWeekdays[calendarDay].uppercase()
|
val localizedDay = DateFormatSymbols.getInstance(Locale.getDefault()).weekdays[calendarDay]
|
||||||
|
|
||||||
WeeklyActivityStat(
|
WeeklyActivityStat(
|
||||||
// 3. Get the actual day name from the date and take the first 3 letters.
|
// 3. Get the actual day name from the date and take the first 3 letters.
|
||||||
|
|||||||
@@ -177,6 +177,17 @@ class TranslationViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direct LibreTranslate without AI - for translating pack names/descriptions
|
||||||
|
suspend fun translateWithLibreTranslate(text: String, targetLanguageCode: String, sourceLanguageCode: String?): Result<String> {
|
||||||
|
// If source and target are the same, return the original text without calling the API
|
||||||
|
val sourceCode = sourceLanguageCode?.lowercase() ?: "en"
|
||||||
|
val targetCode = targetLanguageCode.lowercase()
|
||||||
|
if (sourceCode == targetCode) {
|
||||||
|
return Result.success(text)
|
||||||
|
}
|
||||||
|
return translationService.libreTranslate(text, sourceLanguageCode, targetLanguageCode, 1)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getMultipleTranslations(sentence: String, contextPhrase: String? = null): Result<List<String>> {
|
suspend fun getMultipleTranslations(sentence: String, contextPhrase: String? = null): Result<List<String>> {
|
||||||
return translationService.getMultipleSynonyms(sentence, contextPhrase)
|
return translationService.getMultipleSynonyms(sentence, contextPhrase)
|
||||||
.also { result ->
|
.also { result ->
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1327,7 +1327,7 @@ class VocabularyViewModel @Inject constructor(
|
|||||||
|
|
||||||
val itemsWithoutGrammarCount: StateFlow<Int> = vocabularyItems
|
val itemsWithoutGrammarCount: StateFlow<Int> = vocabularyItems
|
||||||
.map { items ->
|
.map { items ->
|
||||||
items.count { it.features.isNullOrEmpty() }
|
items.count { it.hasFeatures() }
|
||||||
}
|
}
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -419,7 +418,6 @@
|
|||||||
<string name="label_all_types">Alle Typen</string>
|
<string name="label_all_types">Alle Typen</string>
|
||||||
<string name="filter_and_sort">Filtern und Sortieren</string>
|
<string name="filter_and_sort">Filtern und Sortieren</string>
|
||||||
<string name="language_with_id_d_not_found">Sprache mit ID %1$d nicht gefunden</string>
|
<string name="language_with_id_d_not_found">Sprache mit ID %1$d nicht gefunden</string>
|
||||||
<string name="items_without_grammar_infos">Einträge ohne Grammatikinfos</string>
|
|
||||||
<string name="resolve_missing_language_id">Fehlende Sprach-ID auflösen: %1$d</string>
|
<string name="resolve_missing_language_id">Fehlende Sprach-ID auflösen: %1$d</string>
|
||||||
<string name="found_d_items_using_this_missing_language_id">%1$d Einträge mit dieser fehlenden Sprach-ID gefunden.</string>
|
<string name="found_d_items_using_this_missing_language_id">%1$d Einträge mit dieser fehlenden Sprach-ID gefunden.</string>
|
||||||
<string name="hide_affected_items">Betroffene Einträge ausblenden</string>
|
<string name="hide_affected_items">Betroffene Einträge ausblenden</string>
|
||||||
@@ -548,7 +546,6 @@
|
|||||||
<string name="sorting_hint_title">Vokabeln sortieren</string>
|
<string name="sorting_hint_title">Vokabeln sortieren</string>
|
||||||
<string name="text_optional">" (optional)"</string>
|
<string name="text_optional">" (optional)"</string>
|
||||||
<string name="text_check_availability">Verfügbarkeit prüfen</string>
|
<string name="text_check_availability">Verfügbarkeit prüfen</string>
|
||||||
<string name="text_no_valid_api_configuration_could_be_found">Keine gültige API-Konfiguration gefunden. Bitte konfiguriere zuerst einen API-Anbieter in den Einstellungen.</string>
|
|
||||||
<string name="text_try_wiktionary_first">Zuerst Wiktionary versuchen</string>
|
<string name="text_try_wiktionary_first">Zuerst Wiktionary versuchen</string>
|
||||||
<string name="text_try_first_finding_the_word_on">Versuche zuerst, das Wort auf Wiktionary zu finden, bevor eine KI-Antwort generiert wird.</string>
|
<string name="text_try_first_finding_the_word_on">Versuche zuerst, das Wort auf Wiktionary zu finden, bevor eine KI-Antwort generiert wird.</string>
|
||||||
<string name="text_question_of">Frage %1$d von %2$d</string>
|
<string name="text_question_of">Frage %1$d von %2$d</string>
|
||||||
@@ -903,7 +900,6 @@
|
|||||||
<string name="text_add_new_word_to_list">Extrahiere ein neues Wort in deine Liste</string>
|
<string name="text_add_new_word_to_list">Extrahiere ein neues Wort in deine Liste</string>
|
||||||
<string name="cd_scroll_to_top">Nach oben scrollen</string>
|
<string name="cd_scroll_to_top">Nach oben scrollen</string>
|
||||||
<string name="cd_settings">Einstellungen</string>
|
<string name="cd_settings">Einstellungen</string>
|
||||||
<string name="label_import_csv">CSV importieren</string>
|
|
||||||
<string name="label_ai_generator">KI-Generator</string>
|
<string name="label_ai_generator">KI-Generator</string>
|
||||||
<string name="label_new_wordss">Neue Wörter</string>
|
<string name="label_new_wordss">Neue Wörter</string>
|
||||||
<string name="label_recently_added">Kürzlich hinzugefügt</string>
|
<string name="label_recently_added">Kürzlich hinzugefügt</string>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
<string name="label_translate">Traduzir</string>
|
<string name="label_translate">Traduzir</string>
|
||||||
<string name="label_add">Adicionar</string>
|
<string name="label_add">Adicionar</string>
|
||||||
<string name="label_close">Fechar</string>
|
<string name="label_close">Fechar</string>
|
||||||
<string name="label_cancel">Cancelar</string>
|
|
||||||
<string name="label_confirm">Confirmar</string>
|
<string name="label_confirm">Confirmar</string>
|
||||||
<string name="label_select">Selecionar</string>
|
<string name="label_select">Selecionar</string>
|
||||||
<string name="label_done">Concluído</string>
|
<string name="label_done">Concluído</string>
|
||||||
@@ -58,7 +57,6 @@
|
|||||||
<string name="error_select_two_columns">Por favor, selecione duas colunas.</string>
|
<string name="error_select_two_columns">Por favor, selecione duas colunas.</string>
|
||||||
<string name="error_select_languages">Por favor, selecione dois idiomas.</string>
|
<string name="error_select_languages">Por favor, selecione dois idiomas.</string>
|
||||||
<string name="error_no_rows_to_import">Nenhuma linha para importar. Verifique as colunas e o cabeçalho.</string>
|
<string name="error_no_rows_to_import">Nenhuma linha para importar. Verifique as colunas e o cabeçalho.</string>
|
||||||
<string name="info_imported_items_from">%1$d itens de vocabulário importados.</string>
|
|
||||||
<string name="label_import">Importar</string>
|
<string name="label_import">Importar</string>
|
||||||
<string name="menu_create_youtube_exercise">Criar Exercício do YouTube</string>
|
<string name="menu_create_youtube_exercise">Criar Exercício do YouTube</string>
|
||||||
<string name="text_youtube_link">Link do YouTube</string>
|
<string name="text_youtube_link">Link do YouTube</string>
|
||||||
@@ -90,7 +88,6 @@
|
|||||||
<string name="text_widget_title_weekly_activity">Atividade Semanal</string>
|
<string name="text_widget_title_weekly_activity">Atividade Semanal</string>
|
||||||
<string name="text_loading_3d">Carregando…</string>
|
<string name="text_loading_3d">Carregando…</string>
|
||||||
<string name="text_show_loading">Mostrar Carregamento</string>
|
<string name="text_show_loading">Mostrar Carregamento</string>
|
||||||
<string name="text_cancel_loading">Cancelar Carregamento</string>
|
|
||||||
<string name="text_show_info_message">Mostrar Mensagem Informativa</string>
|
<string name="text_show_info_message">Mostrar Mensagem Informativa</string>
|
||||||
<string name="text_show_error_message">Mostrar Mensagem de Erro</string>
|
<string name="text_show_error_message">Mostrar Mensagem de Erro</string>
|
||||||
<string name="text_reset_intro">Resetar Introdução</string>
|
<string name="text_reset_intro">Resetar Introdução</string>
|
||||||
@@ -415,7 +412,6 @@
|
|||||||
<string name="label_all_types">Todos os Tipos</string>
|
<string name="label_all_types">Todos os Tipos</string>
|
||||||
<string name="filter_and_sort">Filtrar e Ordenar</string>
|
<string name="filter_and_sort">Filtrar e Ordenar</string>
|
||||||
<string name="language_with_id_d_not_found">Idioma com id %1$d não encontrado</string>
|
<string name="language_with_id_d_not_found">Idioma com id %1$d não encontrado</string>
|
||||||
<string name="items_without_grammar_infos">Itens sem infos de gramática</string>
|
|
||||||
<string name="resolve_missing_language_id">Resolver ID de Idioma Ausente: %1$d</string>
|
<string name="resolve_missing_language_id">Resolver ID de Idioma Ausente: %1$d</string>
|
||||||
<string name="found_d_items_using_this_missing_language_id">Encontrados %1$d itens usando este ID de idioma ausente.</string>
|
<string name="found_d_items_using_this_missing_language_id">Encontrados %1$d itens usando este ID de idioma ausente.</string>
|
||||||
<string name="hide_affected_items">Ocultar Itens Afetados</string>
|
<string name="hide_affected_items">Ocultar Itens Afetados</string>
|
||||||
@@ -544,7 +540,6 @@
|
|||||||
<string name="sorting_hint_title">Organização de Vocabulário</string>
|
<string name="sorting_hint_title">Organização de Vocabulário</string>
|
||||||
<string name="text_optional">" (opcional)"</string>
|
<string name="text_optional">" (opcional)"</string>
|
||||||
<string name="text_check_availability">Verificar disponibilidade</string>
|
<string name="text_check_availability">Verificar disponibilidade</string>
|
||||||
<string name="text_no_valid_api_configuration_could_be_found">Nenhuma configuração de API válida foi encontrada. Antes de usar o app, configure pelo menos um provedor de API.</string>
|
|
||||||
<string name="text_try_wiktionary_first">Tentar Wikcionário Primeiro</string>
|
<string name="text_try_wiktionary_first">Tentar Wikcionário Primeiro</string>
|
||||||
<string name="text_try_first_finding_the_word_on">Tente primeiro encontrar a palavra no Wikcionário antes de gerar uma resposta de IA</string>
|
<string name="text_try_first_finding_the_word_on">Tente primeiro encontrar a palavra no Wikcionário antes de gerar uma resposta de IA</string>
|
||||||
<string name="text_question_of">Pergunta %1$d de %2$d</string>
|
<string name="text_question_of">Pergunta %1$d de %2$d</string>
|
||||||
@@ -621,11 +616,9 @@
|
|||||||
<string name="text_do_you_want_to_minimize_the_app">Quer minimizar o app?</string>
|
<string name="text_do_you_want_to_minimize_the_app">Quer minimizar o app?</string>
|
||||||
<string name="text_error_deleting_dictionaries">Erro ao excluir dicionários: %1$s</string>
|
<string name="text_error_deleting_dictionaries">Erro ao excluir dicionários: %1$s</string>
|
||||||
<string name="text_error_deleting_dictionary">Erro ao excluir dicionário: %1$s</string>
|
<string name="text_error_deleting_dictionary">Erro ao excluir dicionário: %1$s</string>
|
||||||
<string name="text_error_deleting_orphaned_file">Erro ao deletar arquivo órfão: %1$s</string>
|
|
||||||
<string name="text_error_downloading_dictionary">Erro ao baixar dicionário: %1$s</string>
|
<string name="text_error_downloading_dictionary">Erro ao baixar dicionário: %1$s</string>
|
||||||
<string name="text_error_loading_stored_values">Erro ao carregar valores armazenados: %1$s</string>
|
<string name="text_error_loading_stored_values">Erro ao carregar valores armazenados: %1$s</string>
|
||||||
<string name="text_error_saving_entry">Erro ao salvar entrada: %1$s</string>
|
<string name="text_error_saving_entry">Erro ao salvar entrada: %1$s</string>
|
||||||
<string name="text_failed_to_delete_dictionary">Falha ao deletar dicionário: %1$s</string>
|
|
||||||
<string name="text_failed_to_delete_orphaned_file">Falhou ao excluir arquivo órfão: %1$s</string>
|
<string name="text_failed_to_delete_orphaned_file">Falhou ao excluir arquivo órfão: %1$s</string>
|
||||||
<string name="text_failed_to_delete_some_dictionaries">Falhou ao excluir alguns dicionários</string>
|
<string name="text_failed_to_delete_some_dictionaries">Falhou ao excluir alguns dicionários</string>
|
||||||
<string name="text_failed_to_download_dictionary">Falhou ao baixar dicionário: %1$s</string>
|
<string name="text_failed_to_download_dictionary">Falhou ao baixar dicionário: %1$s</string>
|
||||||
@@ -772,8 +765,6 @@
|
|||||||
<string name="text_use_downloaded_dictionary">Usar dicionário baixado</string>
|
<string name="text_use_downloaded_dictionary">Usar dicionário baixado</string>
|
||||||
<string name="text_watch_video_again">Assistir ao Vídeo Novamente</string>
|
<string name="text_watch_video_again">Assistir ao Vídeo Novamente</string>
|
||||||
<string name="text_dialog_delete_provider">Tem certeza que deseja excluir o provedor "%1$s"? Isso também removerá todos os modelos associados a este provedor. Esta ação não pode ser desfeita.</string>
|
<string name="text_dialog_delete_provider">Tem certeza que deseja excluir o provedor "%1$s"? Isso também removerá todos os modelos associados a este provedor. Esta ação não pode ser desfeita.</string>
|
||||||
<string name="label_delete_model">Deletar Modelo</string>
|
|
||||||
<string name="text_dialog_delete_model">Tem certeza que deseja deletar o modelo "%1$s" de %2$s? Essa ação não pode ser desfeita.</string>
|
|
||||||
<string name="label_task_model_assignments">Atribuições do Modelo de Tarefa</string>
|
<string name="label_task_model_assignments">Atribuições do Modelo de Tarefa</string>
|
||||||
<string name="text_configure_which_ai_model_to_use_for_each_task_type">Configurar qual modelo de IA usar para cada tipo de tarefa</string>
|
<string name="text_configure_which_ai_model_to_use_for_each_task_type">Configurar qual modelo de IA usar para cada tipo de tarefa</string>
|
||||||
<string name="label_custom">Personalizado</string>
|
<string name="label_custom">Personalizado</string>
|
||||||
@@ -786,7 +777,6 @@
|
|||||||
<string name="text_translation_will_appear_here">A tradução vai aparecer aqui</string>
|
<string name="text_translation_will_appear_here">A tradução vai aparecer aqui</string>
|
||||||
<string name="label_delete_key">Apagar Chave</string>
|
<string name="label_delete_key">Apagar Chave</string>
|
||||||
<string name="text_dialog_delete_key">Tem certeza que quer apagar a Chave desse Fornecedor?</string>
|
<string name="text_dialog_delete_key">Tem certeza que quer apagar a Chave desse Fornecedor?</string>
|
||||||
<string name="text_delete_all_providers_and_models">Deletar todos os provedores e modelos</string>
|
|
||||||
<string name="label_dictionary_content">Conteúdo do Dicionário</string>
|
<string name="label_dictionary_content">Conteúdo do Dicionário</string>
|
||||||
<string name="tab_ai_definition">Definição de IA</string>
|
<string name="tab_ai_definition">Definição de IA</string>
|
||||||
<string name="tab_downloaded">Baixado</string>
|
<string name="tab_downloaded">Baixado</string>
|
||||||
@@ -899,7 +889,6 @@
|
|||||||
<string name="text_add_new_word_to_list">Extrair uma nova palavra para a sua lista</string>
|
<string name="text_add_new_word_to_list">Extrair uma nova palavra para a sua lista</string>
|
||||||
<string name="cd_scroll_to_top">Rolar para o topo</string>
|
<string name="cd_scroll_to_top">Rolar para o topo</string>
|
||||||
<string name="cd_settings">Configurações</string>
|
<string name="cd_settings">Configurações</string>
|
||||||
<string name="label_import_csv">Importar CSV</string>
|
|
||||||
<string name="label_ai_generator">Gerador de IA</string>
|
<string name="label_ai_generator">Gerador de IA</string>
|
||||||
<string name="label_new_wordss">Novas Palavras</string>
|
<string name="label_new_wordss">Novas Palavras</string>
|
||||||
<string name="label_recently_added">Adicionados recentemente</string>
|
<string name="label_recently_added">Adicionados recentemente</string>
|
||||||
|
|||||||
@@ -77,6 +77,8 @@
|
|||||||
|
|
||||||
<string name="desc_daily_review_due">%1$d words need attention</string>
|
<string name="desc_daily_review_due">%1$d words need attention</string>
|
||||||
<string name="desc_expand_your_vocabulary">Expand your vocabulary</string>
|
<string name="desc_expand_your_vocabulary">Expand your vocabulary</string>
|
||||||
|
<string name="desc_explore_packs">Discover lists to download</string>
|
||||||
|
<string name="desc_import_csv">Import words from CSV or lists</string>
|
||||||
|
|
||||||
<string name="description">Description</string>
|
<string name="description">Description</string>
|
||||||
|
|
||||||
@@ -126,7 +128,7 @@
|
|||||||
<string name="existing_item_id_d">Existing Item (ID: %1$d)</string>
|
<string name="existing_item_id_d">Existing Item (ID: %1$d)</string>
|
||||||
|
|
||||||
<string name="experimental_features">Experimental Features</string>
|
<string name="experimental_features">Experimental Features</string>
|
||||||
<string name="experimental_features_description">Enable experimental features that are not yet ready for production.</string>
|
<string name="experimental_features_description">Enable experimental features that aren’t yet ready for production.</string>
|
||||||
|
|
||||||
<string name="export_vocabulary_data">Export Vocabulary Data</string>
|
<string name="export_vocabulary_data">Export Vocabulary Data</string>
|
||||||
|
|
||||||
@@ -208,7 +210,7 @@
|
|||||||
<string name="item_id">Item ID: %1$d</string>
|
<string name="item_id">Item ID: %1$d</string>
|
||||||
|
|
||||||
<string name="items">%1$d items</string>
|
<string name="items">%1$d items</string>
|
||||||
<string name="items_without_grammar_infos">Items without grammar infos</string>
|
<string name="label_items_without_grammar">Items without grammar infos</string>
|
||||||
|
|
||||||
<string name="keep_both">Keep Both</string>
|
<string name="keep_both">Keep Both</string>
|
||||||
|
|
||||||
@@ -236,6 +238,8 @@
|
|||||||
<string name="label_adverb">Adverb</string>
|
<string name="label_adverb">Adverb</string>
|
||||||
<string name="label_ai_configuration">AI Configuration</string>
|
<string name="label_ai_configuration">AI Configuration</string>
|
||||||
<string name="label_ai_generator">AI Generator</string>
|
<string name="label_ai_generator">AI Generator</string>
|
||||||
|
<string name="text_ai_generator_requires_configuration">In order to generate vocabulary with AI, you have to configure a connection to an AI service.</string>
|
||||||
|
<string name="label_configure">Configure</string>
|
||||||
<string name="label_ai_model">AI Model</string>
|
<string name="label_ai_model">AI Model</string>
|
||||||
<string name="label_ai_model_and_prompt"><![CDATA[AI Model & Prompt]]></string>
|
<string name="label_ai_model_and_prompt"><![CDATA[AI Model & Prompt]]></string>
|
||||||
<string name="label_all_cards">All Cards</string>
|
<string name="label_all_cards">All Cards</string>
|
||||||
@@ -509,7 +513,7 @@
|
|||||||
<string name="message_error_api_key_missing">API Key is missing or invalid.</string>
|
<string name="message_error_api_key_missing">API Key is missing or invalid.</string>
|
||||||
<string name="message_error_articles_remove_failed">Error removing articles: %1$s</string>
|
<string name="message_error_articles_remove_failed">Error removing articles: %1$s</string>
|
||||||
<string name="message_error_category_update_failed">Error updating category: %1$s</string>
|
<string name="message_error_category_update_failed">Error updating category: %1$s</string>
|
||||||
<string name="message_error_excel_not_supported">Excel is not supported. Use CSV instead.</string>
|
<string name="message_error_excel_not_supported">Excel isn’t supported. Use CSV instead.</string>
|
||||||
<string name="message_error_file_picker_not_initialized">Save File Launcher not initialized.</string>
|
<string name="message_error_file_picker_not_initialized">Save File Launcher not initialized.</string>
|
||||||
<string name="message_error_file_save_cancelled">File save cancelled or failed.</string>
|
<string name="message_error_file_save_cancelled">File save cancelled or failed.</string>
|
||||||
<string name="message_error_file_save_failed">Error saving file: %1$s</string>
|
<string name="message_error_file_save_failed">Error saving file: %1$s</string>
|
||||||
@@ -867,7 +871,7 @@
|
|||||||
<string name="text_assemble_the_word_here">Bring the letters into the right order</string>
|
<string name="text_assemble_the_word_here">Bring the letters into the right order</string>
|
||||||
<string name="text_assign_a_different_language_items">Assign a different language to these items.</string>
|
<string name="text_assign_a_different_language_items">Assign a different language to these items.</string>
|
||||||
<string name="text_assign_these_items_2d">Assign these items:</string>
|
<string name="text_assign_these_items_2d">Assign these items:</string>
|
||||||
<string name="text_authentication_is_required_and_has_failed">Authentication is required and has failed or has not yet been provided.</string>
|
<string name="text_authentication_is_required_and_has_failed">Authentication is required and has failed or hasn’t yet been provided.</string>
|
||||||
<string name="text_automatically_discover_models_from">Automatically discover models from %1$s</string>
|
<string name="text_automatically_discover_models_from">Automatically discover models from %1$s</string>
|
||||||
<string name="text_available_dictionaries">Available Dictionaries</string>
|
<string name="text_available_dictionaries">Available Dictionaries</string>
|
||||||
<string name="text_available_models">Available Models:</string>
|
<string name="text_available_models">Available Models:</string>
|
||||||
@@ -913,8 +917,8 @@
|
|||||||
<string name="text_description_dictionary_prompt">Set a model for generating dictionary content and give optional instructions.</string>
|
<string name="text_description_dictionary_prompt">Set a model for generating dictionary content and give optional instructions.</string>
|
||||||
<string name="text_developed_by_jonas_gaudian">Developed by Jonas Gaudian\n</string>
|
<string name="text_developed_by_jonas_gaudian">Developed by Jonas Gaudian\n</string>
|
||||||
<string name="text_dialog_delete_key">Are you sure you want to delete the Key for this Provider?</string>
|
<string name="text_dialog_delete_key">Are you sure you want to delete the Key for this Provider?</string>
|
||||||
<string name="text_dialog_delete_model">Are you sure you want to delete the model \"%1$s\" from %2$s? This action cannot be undone.</string>
|
<string name="text_dialog_delete_model">Are you sure you want to delete the model \"%1$s\" from %2$s? This action can’t be undone.</string>
|
||||||
<string name="text_dialog_delete_provider">Are you sure you want to delete the provider \"%1$s\"? This will also remove all models associated with this provider. This action cannot be undone.</string>
|
<string name="text_dialog_delete_provider">Are you sure you want to delete the provider \"%1$s\"? This will also remove all models associated with this provider. This action can’t be undone.</string>
|
||||||
<string name="text_dictionary_deleted_successfully">Dictionary deleted successfully</string>
|
<string name="text_dictionary_deleted_successfully">Dictionary deleted successfully</string>
|
||||||
<string name="text_dictionary_downloaded_successfully">Dictionary downloaded successfully</string>
|
<string name="text_dictionary_downloaded_successfully">Dictionary downloaded successfully</string>
|
||||||
<string name="text_dictionary_manager_description">You can download dictionaries for certain languages which can be used insteaf of AI generation for dictionary content.</string>
|
<string name="text_dictionary_manager_description">You can download dictionaries for certain languages which can be used insteaf of AI generation for dictionary content.</string>
|
||||||
@@ -985,7 +989,7 @@
|
|||||||
<string name="text_language_direction_disabled_with_pairs">Clear language pair selection to choose a direction.</string>
|
<string name="text_language_direction_disabled_with_pairs">Clear language pair selection to choose a direction.</string>
|
||||||
<string name="text_language_direction_explanation">You can set an optional preference which language should come first or second.</string>
|
<string name="text_language_direction_explanation">You can set an optional preference which language should come first or second.</string>
|
||||||
<string name="text_language_options">Language Options</string>
|
<string name="text_language_options">Language Options</string>
|
||||||
<string name="text_language_settings_description">Set what languages you want to use in the app. Languages that are not activated will not appear in this app. You can also add your own language to the list, or change an existing language (region/locale)</string>
|
<string name="text_language_settings_description">Set what languages you want to use in the app. Languages that aren’t activated won’t appear in this app. You can also add your own language to the list, or change an existing language (region/locale)</string>
|
||||||
<string name="text_last_7_days">Last 7 Days</string>
|
<string name="text_last_7_days">Last 7 Days</string>
|
||||||
<string name="text_light">Light</string>
|
<string name="text_light">Light</string>
|
||||||
<string name="text_list">List</string>
|
<string name="text_list">List</string>
|
||||||
@@ -1009,7 +1013,7 @@
|
|||||||
<string name="text_no_key">No Key</string>
|
<string name="text_no_key">No Key</string>
|
||||||
<string name="text_no_models_found">No models found</string>
|
<string name="text_no_models_found">No models found</string>
|
||||||
<string name="text_no_packs_match_search">No packs match your search.</string>
|
<string name="text_no_packs_match_search">No packs match your search.</string>
|
||||||
<string name="text_no_valid_api_configuration_could_be_found">No valid API configuration could be found in the settings. Before using this app, you have to configure at least one API provider.</string>
|
<string name="text_no_valid_api_configuration_could_be_found">No valid LLM API configuration could be found in the settings. Before using this function, you have to configure at least one API provider.</string>
|
||||||
<string name="text_no_vocabulary_available">No vocabulary available.</string>
|
<string name="text_no_vocabulary_available">No vocabulary available.</string>
|
||||||
<string name="text_no_vocabulary_due_today">No Vocabulary Due Today</string>
|
<string name="text_no_vocabulary_due_today">No Vocabulary Due Today</string>
|
||||||
<string name="text_none">None</string>
|
<string name="text_none">None</string>
|
||||||
@@ -1018,7 +1022,7 @@
|
|||||||
<string name="text_optional">" (optional)"</string>
|
<string name="text_optional">" (optional)"</string>
|
||||||
<string name="text_optional_describe_what_this_model_is_good_for">Optional: Describe what this model is good for</string>
|
<string name="text_optional_describe_what_this_model_is_good_for">Optional: Describe what this model is good for</string>
|
||||||
<string name="text_orphaned_file_deleted_successfully">Orphaned file deleted successfully</string>
|
<string name="text_orphaned_file_deleted_successfully">Orphaned file deleted successfully</string>
|
||||||
<string name="text_orphaned_file_description">This file exists locally but is not in the server manifest or missing assets. It may be from an older version or a failed download.</string>
|
<string name="text_orphaned_file_description">This file exists locally but isn’t in the server manifest or missing assets. It may be from an older version or a failed download.</string>
|
||||||
<string name="text_paste_or_open_a_">Paste or open a YouTube link to see its subtitles here.</string>
|
<string name="text_paste_or_open_a_">Paste or open a YouTube link to see its subtitles here.</string>
|
||||||
<string name="text_please_select_a_dictionary_language_first">Please select a dictionary language first.</string>
|
<string name="text_please_select_a_dictionary_language_first">Please select a dictionary language first.</string>
|
||||||
<string name="text_question">Question</string>
|
<string name="text_question">Question</string>
|
||||||
@@ -1062,7 +1066,7 @@
|
|||||||
<string name="text_shuffle_card_order">Shuffle card order</string>
|
<string name="text_shuffle_card_order">Shuffle card order</string>
|
||||||
<string name="text_shuffle_card_order_description">Shuffle Card Order</string>
|
<string name="text_shuffle_card_order_description">Shuffle Card Order</string>
|
||||||
<string name="text_shuffle_languages">Shuffle Languages</string>
|
<string name="text_shuffle_languages">Shuffle Languages</string>
|
||||||
<string name="text_shuffle_languages_description">Shuffle what language comes first. Does not affect language direction preferences.</string>
|
<string name="text_shuffle_languages_description">Shuffle what language comes first. Doesn’t affect language direction preferences.</string>
|
||||||
<string name="text_shuffle_languages_disabled_by_direction">Disable language direction preference to enable shuffling.</string>
|
<string name="text_shuffle_languages_disabled_by_direction">Disable language direction preference to enable shuffling.</string>
|
||||||
<string name="text_some_items_are_in_the_wrong_category">Some items are in the wrong category.</string>
|
<string name="text_some_items_are_in_the_wrong_category">Some items are in the wrong category.</string>
|
||||||
<string name="text_stage_2d">Stage %1$s</string>
|
<string name="text_stage_2d">Stage %1$s</string>
|
||||||
@@ -1074,9 +1078,9 @@
|
|||||||
<string name="text_the_correct_sentence_was_2d">The correct sentence was: %1$s</string>
|
<string name="text_the_correct_sentence_was_2d">The correct sentence was: %1$s</string>
|
||||||
<string name="text_the_correct_translation_is_2d">The correct translation is: %1$s</string>
|
<string name="text_the_correct_translation_is_2d">The correct translation is: %1$s</string>
|
||||||
<string name="text_theme_preview">Theme Preview</string>
|
<string name="text_theme_preview">Theme Preview</string>
|
||||||
<string name="text_these_files_exist_locally">These files exist locally but are not in the server manifest. They may be from older versions.</string>
|
<string name="text_these_files_exist_locally">These files exist locally but aren’t in the server manifest. They may be from older versions.</string>
|
||||||
<string name="text_this_must_match_the_provider_s_model_name_exactly">This must match the provider\'s model name exactly</string>
|
<string name="text_this_must_match_the_provider_s_model_name_exactly">This must match the provider\'s model name exactly</string>
|
||||||
<string name="text_this_will_remove_all">This will remove all configured API providers, models, and stored API keys. This action cannot be undone.</string>
|
<string name="text_this_will_remove_all">This will remove all configured API providers, models, and stored API keys. This action can’t be undone.</string>
|
||||||
<string name="text_too_many_requests">Too Many Requests: The user has sent too many requests in a given amount of time.</string>
|
<string name="text_too_many_requests">Too Many Requests: The user has sent too many requests in a given amount of time.</string>
|
||||||
<string name="text_total_learned_words">Total Learned Words</string>
|
<string name="text_total_learned_words">Total Learned Words</string>
|
||||||
<string name="text_training_mode">Training Mode</string>
|
<string name="text_training_mode">Training Mode</string>
|
||||||
@@ -1102,13 +1106,13 @@
|
|||||||
<string name="text_youtube_link">YouTube Link</string>
|
<string name="text_youtube_link">YouTube Link</string>
|
||||||
|
|
||||||
<string name="the_request_was_successful">The request was successful.</string>
|
<string name="the_request_was_successful">The request was successful.</string>
|
||||||
<string name="the_requested_resource_could_not_be_found">The requested resource could not be found.</string>
|
<string name="the_requested_resource_could_not_be_found">The requested resource couldn’t be found.</string>
|
||||||
<string name="the_server_could_not_understand_the_request">The server could not understand the request.</string>
|
<string name="the_server_could_not_understand_the_request">The server couldn’t understand the request.</string>
|
||||||
<string name="the_server_understood_the_request_but_is_refusing_to_authorize_it">The server understood the request, but is refusing to authorize it.</string>
|
<string name="the_server_understood_the_request_but_is_refusing_to_authorize_it">The server understood the request, but is refusing to authorize it.</string>
|
||||||
|
|
||||||
<string name="this_is_a_sample_output_text">This is a sample output text.</string>
|
<string name="this_is_a_sample_output_text">This is a sample output text.</string>
|
||||||
<string name="this_is_the_content_inside_the_card">This is the content inside the card.</string>
|
<string name="this_is_the_content_inside_the_card">This is the content inside the card.</string>
|
||||||
<string name="this_mode_will_not_affect_your_progress_in_stages">This mode will not affect your progress in stages.</string>
|
<string name="this_mode_will_not_affect_your_progress_in_stages">This mode won’t affect your progress in stages.</string>
|
||||||
|
|
||||||
<string name="timeout">Timeout</string>
|
<string name="timeout">Timeout</string>
|
||||||
|
|
||||||
@@ -1166,4 +1170,9 @@
|
|||||||
|
|
||||||
<!-- Explore Packs Hint -->
|
<!-- Explore Packs Hint -->
|
||||||
<string name="hint_explore_packs_title">About Vocabulary Packs</string>
|
<string name="hint_explore_packs_title">About Vocabulary Packs</string>
|
||||||
|
<string name="label_import_csv_or_lists">Import Lists or CSV</string>
|
||||||
|
<string name="label_corrector">Corrector</string>
|
||||||
|
<string name="label_correction">Correction</string>
|
||||||
|
<string name="message_error_no_model_configured">No AI connection is configured. This functionality does not work without a valid configuration.</string>
|
||||||
|
<string name="message_success_all_items_imported">All items were imported successfully.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user