implement vocabulary packs exploration and download functionality
This commit is contained in:
@@ -322,4 +322,119 @@ class FileDownloadManager(private val context: Context) {
|
|||||||
fun getFlashcardLocalVersion(collectionId: String): String {
|
fun getFlashcardLocalVersion(collectionId: String): String {
|
||||||
return sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
|
return sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Vocab Packs (vocab_manifest.json) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the vocabulary-pack manifest (vocab_manifest.json).
|
||||||
|
* Unwraps the top-level [VocabManifestResponse] and returns the `lists` array.
|
||||||
|
*/
|
||||||
|
suspend fun fetchVocabManifest(): List<VocabCollectionInfo>? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val response = flashcardApiService.getVocabManifest().execute()
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
response.body()?.lists
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
||||||
|
Log.e("FileDownloadManager", "Failed to fetch vocab manifest: $errorMessage")
|
||||||
|
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FileDownloadManager", "Error fetching vocab manifest", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a single vocabulary pack file and verifies its SHA-256 checksum.
|
||||||
|
* The file is stored at [filesDir]/flashcard-collections/[filename].
|
||||||
|
*
|
||||||
|
* @return true on success, false (or throws) on failure.
|
||||||
|
*/
|
||||||
|
suspend fun downloadVocabCollection(
|
||||||
|
info: VocabCollectionInfo,
|
||||||
|
onProgress: (Float) -> Unit = {}
|
||||||
|
): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val subdirectory = DownloadSource.FLASHCARDS.subdirectory
|
||||||
|
val fileUrl = "${DownloadSource.FLASHCARDS.baseUrl}$subdirectory/${info.filename}"
|
||||||
|
val localFile = File(context.filesDir, "$subdirectory/${info.filename}")
|
||||||
|
localFile.parentFile?.mkdirs()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val client = OkHttpClient()
|
||||||
|
val request = Request.Builder().url(fileUrl).build()
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorMessage = context.getString(
|
||||||
|
R.string.text_download_failed_http,
|
||||||
|
response.code,
|
||||||
|
response.message
|
||||||
|
)
|
||||||
|
Log.e("FileDownloadManager", errorMessage)
|
||||||
|
throw Exception(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body
|
||||||
|
val contentLength = body.contentLength().takeIf { it > 0 } ?: info.sizeBytes
|
||||||
|
|
||||||
|
FileOutputStream(localFile).use { output ->
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
var bytesRead: Int
|
||||||
|
var totalBytesRead: Long = 0
|
||||||
|
@Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256")
|
||||||
|
|
||||||
|
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||||
|
output.write(buffer, 0, bytesRead)
|
||||||
|
digest.update(buffer, 0, bytesRead)
|
||||||
|
totalBytesRead += bytesRead
|
||||||
|
if (contentLength > 0) onProgress(totalBytesRead.toFloat() / contentLength)
|
||||||
|
}
|
||||||
|
output.flush()
|
||||||
|
|
||||||
|
val computedChecksum = digest.digest().joinToString("") {
|
||||||
|
@Suppress("HardCodedStringLiteral") "%02X".format(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (computedChecksum.equals(info.checksumSha256, ignoreCase = true)) {
|
||||||
|
Log.d("FileDownloadManager", "Vocab pack downloaded: ${info.filename}")
|
||||||
|
sharedPreferences.edit(commit = true) {
|
||||||
|
putString("vocab_${info.id}", info.version.toString())
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
Log.e("FileDownloadManager",
|
||||||
|
context.getString(
|
||||||
|
R.string.text_checksum_mismatch_for_expected_got,
|
||||||
|
info.filename,
|
||||||
|
info.checksumSha256,
|
||||||
|
computedChecksum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
localFile.delete()
|
||||||
|
throw Exception("Checksum verification failed for ${info.filename}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FileDownloadManager", "Error downloading vocab pack", e)
|
||||||
|
if (localFile.exists()) localFile.delete()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the local file for this collection exists. */
|
||||||
|
fun isVocabCollectionDownloaded(info: VocabCollectionInfo): Boolean =
|
||||||
|
File(context.filesDir, "${DownloadSource.FLASHCARDS.subdirectory}/${info.filename}").exists()
|
||||||
|
|
||||||
|
/** Returns true if the server version is newer than the locally saved version. */
|
||||||
|
fun isNewerVocabVersionAvailable(info: VocabCollectionInfo): Boolean {
|
||||||
|
val localVersion = sharedPreferences.getString("vocab_${info.id}", "0") ?: "0"
|
||||||
|
return (info.version.toString().toIntOrNull() ?: 0) > (localVersion.toIntOrNull() ?: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the locally saved version number string for a vocab pack (default "0"). */
|
||||||
|
fun getVocabLocalVersion(packId: String): String =
|
||||||
|
sharedPreferences.getString("vocab_$packId", "0") ?: "0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ import retrofit2.Call
|
|||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API service for fetching flashcard collection manifests and downloading files.
|
* API service for flashcard / vocabulary-pack downloads.
|
||||||
*/
|
*/
|
||||||
interface FlashcardApiService {
|
interface FlashcardApiService {
|
||||||
|
|
||||||
/**
|
// ── Legacy endpoint (old manifest schema) ────────────────────────────────
|
||||||
* Fetches the flashcard collection manifest from the server.
|
|
||||||
*/
|
|
||||||
@GET("flashcard-collections/manifest.json")
|
@GET("flashcard-collections/manifest.json")
|
||||||
fun getFlashcardManifest(): Call<FlashcardManifestResponse>
|
fun getFlashcardManifest(): Call<FlashcardManifestResponse>
|
||||||
|
|
||||||
|
// ── New vocab packs endpoint ──────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Fetches the vocabulary-pack manifest.
|
||||||
|
* Returns a JSON object { manifest_version, updated_at, lists: [...] }.
|
||||||
|
* URL: http://23.88.48.47/flashcard-collections/vocab_manifest.json
|
||||||
|
*/
|
||||||
|
@GET("flashcard-collections/vocab_manifest.json")
|
||||||
|
fun getVocabManifest(): Call<VocabManifestResponse>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,38 +2,65 @@ package eu.gaudian.translator.model.communication
|
|||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// New: vocab_manifest.json schema
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data class representing the flashcard collection manifest response from the server.
|
* Top-level wrapper returned by vocab_manifest.json.
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* "manifest_version": "1.0",
|
||||||
|
* "updated_at": "…",
|
||||||
|
* "lists": [ … ]
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
|
data class VocabManifestResponse(
|
||||||
|
@SerializedName("manifest_version") val manifestVersion: String = "",
|
||||||
|
@SerializedName("updated_at") val updatedAt: String = "",
|
||||||
|
@SerializedName("lists") val lists: List<VocabCollectionInfo> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry inside the `lists` array of vocab_manifest.json.
|
||||||
|
*/
|
||||||
|
data class VocabCollectionInfo(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("name") val name: String,
|
||||||
|
@SerializedName("description") val description: String,
|
||||||
|
@SerializedName("filename") val filename: String,
|
||||||
|
/** [lang_first_id, lang_second_id] matching Language IDs in the app */
|
||||||
|
@SerializedName("language_ids") val languageIds: List<Int>,
|
||||||
|
@SerializedName("category") val category: String,
|
||||||
|
@SerializedName("item_count") val itemCount: Int,
|
||||||
|
@SerializedName("emoji") val emoji: String,
|
||||||
|
@SerializedName("version") val version: Int,
|
||||||
|
@SerializedName("size_bytes") val sizeBytes: Long,
|
||||||
|
@SerializedName("checksum_sha256") val checksumSha256: String,
|
||||||
|
@SerializedName("created_at") val createdAt: String,
|
||||||
|
@SerializedName("updated_at") val updatedAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy models (kept for backward compatibility with the old manifest.json
|
||||||
|
// dictionary download path)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
data class FlashcardManifestResponse(
|
data class FlashcardManifestResponse(
|
||||||
@SerializedName("collections")
|
@SerializedName("collections")
|
||||||
val collections: List<FlashcardCollectionInfo>
|
val collections: List<FlashcardCollectionInfo>
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Data class representing information about a downloadable flashcard collection.
|
|
||||||
*/
|
|
||||||
data class FlashcardCollectionInfo(
|
data class FlashcardCollectionInfo(
|
||||||
@SerializedName("id")
|
@SerializedName("id") val id: String,
|
||||||
val id: String,
|
@SerializedName("name") val name: String,
|
||||||
@SerializedName("name")
|
@SerializedName("description") val description: String,
|
||||||
val name: String,
|
@SerializedName("version") val version: String,
|
||||||
@SerializedName("description")
|
@SerializedName("asset") val asset: FlashcardAsset
|
||||||
val description: String,
|
|
||||||
@SerializedName("version")
|
|
||||||
val version: String,
|
|
||||||
@SerializedName("asset")
|
|
||||||
val asset: FlashcardAsset
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Data class representing an asset file within a flashcard collection.
|
|
||||||
*/
|
|
||||||
data class FlashcardAsset(
|
data class FlashcardAsset(
|
||||||
@SerializedName("filename")
|
@SerializedName("filename") val filename: String,
|
||||||
val filename: String,
|
@SerializedName("size_bytes") val sizeBytes: Long,
|
||||||
@SerializedName("size_bytes")
|
@SerializedName("checksum_sha256") val checksumSha256: String
|
||||||
val sizeBytes: Long,
|
|
||||||
@SerializedName("checksum_sha256")
|
|
||||||
val checksumSha256: String
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import eu.gaudian.translator.view.settings.settingsGraph
|
|||||||
import eu.gaudian.translator.view.stats.StatsScreen
|
import eu.gaudian.translator.view.stats.StatsScreen
|
||||||
import eu.gaudian.translator.view.translation.TranslationScreen
|
import eu.gaudian.translator.view.translation.TranslationScreen
|
||||||
import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
|
import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
|
||||||
|
import eu.gaudian.translator.view.vocabulary.ExplorePacksScreen
|
||||||
import eu.gaudian.translator.view.vocabulary.LanguageJourneyScreen
|
import eu.gaudian.translator.view.vocabulary.LanguageJourneyScreen
|
||||||
import eu.gaudian.translator.view.vocabulary.NewWordReviewScreen
|
import eu.gaudian.translator.view.vocabulary.NewWordReviewScreen
|
||||||
import eu.gaudian.translator.view.vocabulary.NewWordScreen
|
import eu.gaudian.translator.view.vocabulary.NewWordScreen
|
||||||
@@ -68,6 +69,7 @@ object NavigationRoutes {
|
|||||||
const val STATS_VOCABULARY_SORTING = "stats/vocabulary_sorting"
|
const val STATS_VOCABULARY_SORTING = "stats/vocabulary_sorting"
|
||||||
const val STATS_NO_GRAMMAR_ITEMS = "stats/no_grammar_items"
|
const val STATS_NO_GRAMMAR_ITEMS = "stats/no_grammar_items"
|
||||||
const val STATS_VOCABULARY_LIST = "stats/vocabulary_list"
|
const val STATS_VOCABULARY_LIST = "stats/vocabulary_list"
|
||||||
|
const val EXPLORE_PACKS = "explore_packs"
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||||
@@ -169,6 +171,10 @@ fun AppNavHost(
|
|||||||
NewWordReviewScreen(navController = navController)
|
NewWordReviewScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(NavigationRoutes.EXPLORE_PACKS) {
|
||||||
|
ExplorePacksScreen(navController = navController)
|
||||||
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
route = "${NavigationRoutes.START_EXERCISE}?categoryId={categoryId}",
|
route = "${NavigationRoutes.START_EXERCISE}?categoryId={categoryId}",
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
|
|||||||
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 eu.gaudian.translator.viewmodel.VocabularyViewModel.SortOrder
|
import eu.gaudian.translator.viewmodel.VocabularyViewModel.SortOrder
|
||||||
|
import eu.gaudian.translator.viewmodel.toStringResource
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@@ -485,8 +486,7 @@ fun FilterBottomSheetContent(
|
|||||||
selected = sortOrder == order,
|
selected = sortOrder == order,
|
||||||
onClick = { sortOrder = order },
|
onClick = { sortOrder = order },
|
||||||
label = {
|
label = {
|
||||||
Text(order.name.replace('_', ' ').lowercase()
|
Text(stringResource(order.toStringResource()))
|
||||||
.replaceFirstChar { it.titlecase() })
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,757 @@
|
|||||||
|
@file:Suppress("HardCodedStringLiteral")
|
||||||
|
|
||||||
|
package eu.gaudian.translator.view.vocabulary
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.SwapHoriz
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
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.sp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import eu.gaudian.translator.model.ConflictStrategy
|
||||||
|
import eu.gaudian.translator.utils.Log
|
||||||
|
import eu.gaudian.translator.utils.StatusMessageId
|
||||||
|
import eu.gaudian.translator.utils.StatusMessageService
|
||||||
|
import eu.gaudian.translator.utils.findActivity
|
||||||
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
|
import eu.gaudian.translator.viewmodel.ExportImportViewModel
|
||||||
|
import eu.gaudian.translator.viewmodel.ImportState
|
||||||
|
import eu.gaudian.translator.viewmodel.PackDownloadState
|
||||||
|
import eu.gaudian.translator.viewmodel.PackUiState
|
||||||
|
import eu.gaudian.translator.viewmodel.VocabPacksViewModel
|
||||||
|
|
||||||
|
private const val TAG = "ExplorePacksScreen"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filter enum
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum class PackFilter { All, MostPopular, Newest, Beginner }
|
||||||
|
|
||||||
|
private val PackFilter.label: String
|
||||||
|
get() = when (this) {
|
||||||
|
PackFilter.All -> "All"
|
||||||
|
PackFilter.MostPopular -> "Most Popular"
|
||||||
|
PackFilter.Newest -> "Newest"
|
||||||
|
PackFilter.Beginner -> "Beginner"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gradient palette – deterministic per pack ID hash
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private val gradientPalette = listOf(
|
||||||
|
listOf(Color(0xFF1565C0), Color(0xFF42A5F5)),
|
||||||
|
listOf(Color(0xFF00695C), Color(0xFF26A69A)),
|
||||||
|
listOf(Color(0xFF6A1B9A), Color(0xFFAB47BC)),
|
||||||
|
listOf(Color(0xFFE65100), Color(0xFFFFA726)),
|
||||||
|
listOf(Color(0xFF212121), Color(0xFF546E7A)),
|
||||||
|
listOf(Color(0xFFC62828), Color(0xFFEF9A9A)),
|
||||||
|
listOf(Color(0xFF1B5E20), Color(0xFF66BB6A)),
|
||||||
|
listOf(Color(0xFF0D47A1), Color(0xFF90CAF9)),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun gradientForId(id: String): List<Color> =
|
||||||
|
gradientPalette[Math.abs(id.hashCode()) % gradientPalette.size]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Screen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ExplorePacksScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val activity = LocalContext.current.findActivity()
|
||||||
|
|
||||||
|
// VocabPacksViewModel is screen-scoped (not activity-scoped) – no persistent pack state needed
|
||||||
|
val vocabPacksViewModel: VocabPacksViewModel = hiltViewModel()
|
||||||
|
// ExportImportViewModel handles full Polly-format import with real conflict strategy
|
||||||
|
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
|
||||||
|
val packs by vocabPacksViewModel.packs.collectAsState()
|
||||||
|
val isLoadingManifest by vocabPacksViewModel.isLoadingManifest.collectAsState()
|
||||||
|
val manifestError by vocabPacksViewModel.manifestError.collectAsState()
|
||||||
|
val importState by exportImportViewModel.importState.collectAsState()
|
||||||
|
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
var selectedFilter by remember { mutableStateOf(PackFilter.All) }
|
||||||
|
|
||||||
|
// Which pack is being imported right now (captured when dialog opens)
|
||||||
|
var pendingImportPackState by remember { mutableStateOf<PackUiState?>(null) }
|
||||||
|
var selectedConflictStrategy by remember { mutableStateOf(ConflictStrategy.MERGE) }
|
||||||
|
var showStrategyDialog by remember { mutableStateOf(false) }
|
||||||
|
var isImporting by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Observe importState and react when the async import finishes
|
||||||
|
LaunchedEffect(importState) {
|
||||||
|
val pending = pendingImportPackState ?: return@LaunchedEffect
|
||||||
|
when (val state = importState) {
|
||||||
|
is ImportState.Success -> {
|
||||||
|
Log.d(TAG, "Import success for ${pending.info.id}: " +
|
||||||
|
"imported=${state.result.itemsImported}, skipped=${state.result.itemsSkipped}")
|
||||||
|
vocabPacksViewModel.markImportedAndCleanup(pending.info)
|
||||||
|
StatusMessageService.showSuccessById(StatusMessageId.SUCCESS_ITEMS_IMPORTED)
|
||||||
|
isImporting = false
|
||||||
|
pendingImportPackState = null
|
||||||
|
exportImportViewModel.resetImportState()
|
||||||
|
}
|
||||||
|
is ImportState.Error -> {
|
||||||
|
Log.e(TAG, "Import failed for ${pending.info.id}: ${state.message}")
|
||||||
|
StatusMessageService.showErrorById(StatusMessageId.ERROR_PARSING_TABLE)
|
||||||
|
isImporting = false
|
||||||
|
exportImportViewModel.resetImportState()
|
||||||
|
}
|
||||||
|
else -> { /* Idle or Loading – nothing to do */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val filteredPacks = remember(packs, selectedFilter, searchQuery) {
|
||||||
|
packs.filter { ps ->
|
||||||
|
val info = ps.info
|
||||||
|
val matchSearch = searchQuery.isBlank() ||
|
||||||
|
info.name.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
info.category.contains(searchQuery, ignoreCase = true)
|
||||||
|
val matchFilter = when (selectedFilter) {
|
||||||
|
PackFilter.All -> true
|
||||||
|
PackFilter.MostPopular -> info.itemCount >= 80
|
||||||
|
PackFilter.Newest -> info.version >= 1
|
||||||
|
PackFilter.Beginner -> info.itemCount <= 60
|
||||||
|
}
|
||||||
|
matchSearch && matchFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
contentAlignment = Alignment.TopCenter
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 700.dp)
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
// ── Top bar ───────────────────────────────────────────────────────
|
||||||
|
AppTopAppBar(
|
||||||
|
title = "Explore Packs",
|
||||||
|
onNavigateBack = { navController.popBackStack() },
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { vocabPacksViewModel.loadManifest() }) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Reload")
|
||||||
|
}
|
||||||
|
IconButton(onClick = { /* TODO: advanced filter sheet */ }) {
|
||||||
|
Icon(Icons.Default.FilterList, contentDescription = "Filter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// ── Search ────────────────────────────────────────────────────────
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = { searchQuery = it },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"Search topics, phrases…",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||||
|
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f),
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// ── Language selector row ─────────────────────────────────────────
|
||||||
|
PackLanguageSelectorRow()
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// ── Filter chips ──────────────────────────────────────────────────
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
PackFilter.entries.forEach { filter ->
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedFilter == filter,
|
||||||
|
onClick = { selectedFilter = filter },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = filter.label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = if (selectedFilter == filter) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
selectedLabelColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// ── Section header ────────────────────────────────────────────────
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Featured Collections",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
if (!isLoadingManifest && packs.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
"${filteredPacks.size} packs",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// ── Content area ──────────────────────────────────────────────────
|
||||||
|
when {
|
||||||
|
isLoadingManifest -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text("Loading packs…", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestError != null -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Could not load packs",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
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))
|
||||||
|
Button(onClick = { vocabPacksViewModel.loadManifest() }) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredPacks.isEmpty() -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
"No packs match your search.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(bottom = 100.dp),
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(filteredPacks, key = { it.info.id }) { packState ->
|
||||||
|
PackCard(
|
||||||
|
packState = packState,
|
||||||
|
onGetClick = { vocabPacksViewModel.downloadPack(packState.info) },
|
||||||
|
onAddToLibraryClick = {
|
||||||
|
pendingImportPackState = packState
|
||||||
|
showStrategyDialog = true
|
||||||
|
},
|
||||||
|
onDeleteClick = { vocabPacksViewModel.deletePack(packState.info) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conflict strategy dialog ──────────────────────────────────────────────
|
||||||
|
if (showStrategyDialog && pendingImportPackState != null) {
|
||||||
|
val packState = pendingImportPackState!!
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
if (!isImporting) {
|
||||||
|
showStrategyDialog = false
|
||||||
|
pendingImportPackState = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = { Icon(Icons.Default.Warning, contentDescription = null) },
|
||||||
|
title = { Text("Import ${packState.info.name} ") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (isImporting) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
"Importing ${packState.info.itemCount} words…",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"${packState.info.itemCount} words will be added to your library.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"How should duplicates be handled?",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
PackConflictStrategyOption(
|
||||||
|
selected = selectedConflictStrategy == ConflictStrategy.MERGE,
|
||||||
|
onSelected = { selectedConflictStrategy = ConflictStrategy.MERGE },
|
||||||
|
title = "Merge (Recommended)",
|
||||||
|
description = "Keep existing progress; merge categories intelligently."
|
||||||
|
)
|
||||||
|
PackConflictStrategyOption(
|
||||||
|
selected = selectedConflictStrategy == ConflictStrategy.SKIP,
|
||||||
|
onSelected = { selectedConflictStrategy = ConflictStrategy.SKIP },
|
||||||
|
title = "Skip Duplicates",
|
||||||
|
description = "Only add words that don't already exist."
|
||||||
|
)
|
||||||
|
PackConflictStrategyOption(
|
||||||
|
selected = selectedConflictStrategy == ConflictStrategy.REPLACE,
|
||||||
|
onSelected = { selectedConflictStrategy = ConflictStrategy.REPLACE },
|
||||||
|
title = "Replace Existing",
|
||||||
|
description = "Overwrite matching words with the pack version."
|
||||||
|
)
|
||||||
|
PackConflictStrategyOption(
|
||||||
|
selected = selectedConflictStrategy == ConflictStrategy.RENAME,
|
||||||
|
onSelected = { selectedConflictStrategy = ConflictStrategy.RENAME },
|
||||||
|
title = "Keep Both",
|
||||||
|
description = "Add all words from the pack as new entries."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !isImporting,
|
||||||
|
onClick = {
|
||||||
|
val json = vocabPacksViewModel.readPackRawJson(packState.info)
|
||||||
|
if (json == null) {
|
||||||
|
Log.e(TAG, "readPackRawJson returned null for ${packState.info.id}")
|
||||||
|
StatusMessageService.showErrorById(StatusMessageId.ERROR_PARSING_TABLE)
|
||||||
|
showStrategyDialog = false
|
||||||
|
pendingImportPackState = null
|
||||||
|
return@TextButton
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Starting import for ${packState.info.id} with strategy=$selectedConflictStrategy, json=${json.length} chars")
|
||||||
|
isImporting = true
|
||||||
|
showStrategyDialog = false // close dialog; LaunchedEffect handles completion
|
||||||
|
exportImportViewModel.importFromJson(json, selectedConflictStrategy)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Add to Library")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
enabled = !isImporting,
|
||||||
|
onClick = {
|
||||||
|
showStrategyDialog = false
|
||||||
|
pendingImportPackState = null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Language selector (placeholder — will use SourceLanguageDropdown in future)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PackLanguageSelectorRow(modifier: Modifier = Modifier) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
tonalElevation = 1.dp
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("🇬🇧", fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("English", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { /* TODO: swap */ }, modifier = Modifier.size(32.dp)) {
|
||||||
|
Icon(Icons.Default.SwapHoriz, contentDescription = "Swap",
|
||||||
|
tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Spanish", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("🇪🇸", fontSize = 18.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conflict strategy option (mirrors VocabularyRepositoryOptionsScreen)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PackConflictStrategyOption(
|
||||||
|
selected: Boolean,
|
||||||
|
onSelected: () -> Unit,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelected() },
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(selected = selected, onClick = onSelected)
|
||||||
|
Column(modifier = Modifier.padding(start = 8.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text(description, style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Single pack card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PackCard(
|
||||||
|
packState: PackUiState,
|
||||||
|
onGetClick: () -> Unit,
|
||||||
|
onAddToLibraryClick: () -> Unit,
|
||||||
|
onDeleteClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val info = packState.info
|
||||||
|
val gradient = gradientForId(info.id)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
tonalElevation = 2.dp
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
// ── Gradient image area ───────────────────────────────────────────
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1.1f)
|
||||||
|
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
|
||||||
|
.background(Brush.verticalGradient(gradient)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(text = info.emoji, fontSize = 42.sp)
|
||||||
|
|
||||||
|
// Downloading overlay
|
||||||
|
if (packState.downloadState == PackDownloadState.DOWNLOADING) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.45f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { packState.progress },
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
"${(packState.progress * 100).toInt()}%",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status badge (top-right corner)
|
||||||
|
val badgeData: Pair<Color, String>? = when (packState.downloadState) {
|
||||||
|
PackDownloadState.DOWNLOADED -> Color(0xFF1565C0) to "Ready"
|
||||||
|
PackDownloadState.IMPORTED -> Color(0xFF388E3C) to "In Library"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (badgeData != null) {
|
||||||
|
Box(modifier = Modifier.align(Alignment.TopEnd).padding(8.dp)) {
|
||||||
|
Surface(shape = RoundedCornerShape(6.dp), color = badgeData.first) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(12.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(3.dp))
|
||||||
|
Text(
|
||||||
|
badgeData.second,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download progress bar
|
||||||
|
if (packState.downloadState == PackDownloadState.DOWNLOADING) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { packState.progress },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pack info ─────────────────────────────────────────────────────
|
||||||
|
Column(modifier = Modifier.padding(10.dp)) {
|
||||||
|
Text(
|
||||||
|
text = info.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = info.category,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "${info.itemCount} cards",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// ── Action button(s) ──────────────────────────────────────────
|
||||||
|
when (packState.downloadState) {
|
||||||
|
PackDownloadState.IDLE -> {
|
||||||
|
Button(
|
||||||
|
onClick = onGetClick,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(34.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Text("Get",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PackDownloadState.DOWNLOADING -> {
|
||||||
|
Button(
|
||||||
|
onClick = {},
|
||||||
|
enabled = false,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(34.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Text("Downloading…", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PackDownloadState.DOWNLOADED -> {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = onAddToLibraryClick,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(34.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Add ${info.itemCount} words",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDeleteClick,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(30.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Remove", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PackDownloadState.IMPORTED -> {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onGetClick,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(34.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.CheckCircle, contentDescription = null,
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = Color(0xFF388E3C))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("In Library", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PackDownloadState.ERROR -> {
|
||||||
|
Button(
|
||||||
|
onClick = onGetClick,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(34.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Retry",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
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.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -253,7 +252,10 @@ fun NewWordScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
BottomActionCardsRow(
|
BottomActionCardsRow(
|
||||||
|
onExplorePsClick = {
|
||||||
|
navController.navigate(NavigationRoutes.EXPLORE_PACKS)
|
||||||
|
},
|
||||||
onImportCsvClick = {
|
onImportCsvClick = {
|
||||||
navController.navigate("settings_vocabulary_repository_options")
|
navController.navigate("settings_vocabulary_repository_options")
|
||||||
}
|
}
|
||||||
@@ -683,22 +685,22 @@ fun AddManuallyCard(
|
|||||||
@Composable
|
@Composable
|
||||||
fun BottomActionCardsRow(
|
fun BottomActionCardsRow(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onExplorePsClick: () -> Unit,
|
||||||
onImportCsvClick: () -> Unit
|
onImportCsvClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
//TODO Explore Packs Card
|
// Explore Packs Card
|
||||||
AppCard(
|
AppCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(120.dp),
|
.height(120.dp),
|
||||||
|
onClick = onExplorePsClick
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.fillMaxSize()
|
|
||||||
.alpha(0.6f),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
@@ -721,14 +723,7 @@ fun BottomActionCardsRow(
|
|||||||
text = "Explore Packs",
|
text = "Explore Packs",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
Text(
|
|
||||||
text = "Coming soon",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
@file:Suppress("HardCodedStringLiteral")
|
||||||
|
|
||||||
|
package eu.gaudian.translator.viewmodel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import eu.gaudian.translator.model.communication.FileDownloadManager
|
||||||
|
import eu.gaudian.translator.model.communication.VocabCollectionInfo
|
||||||
|
import eu.gaudian.translator.utils.Log
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-pack download/import state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum class PackDownloadState { IDLE, DOWNLOADING, DOWNLOADED, IMPORTED, ERROR }
|
||||||
|
|
||||||
|
data class PackUiState(
|
||||||
|
val info: VocabCollectionInfo,
|
||||||
|
val downloadState: PackDownloadState = PackDownloadState.IDLE,
|
||||||
|
val progress: Float = 0f,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ViewModel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private const val TAG = "VocabPacksViewModel"
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class VocabPacksViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val downloadManager = FileDownloadManager(context)
|
||||||
|
|
||||||
|
private val _packs = MutableStateFlow<List<PackUiState>>(emptyList())
|
||||||
|
val packs: StateFlow<List<PackUiState>> = _packs.asStateFlow()
|
||||||
|
|
||||||
|
private val _isLoadingManifest = MutableStateFlow(false)
|
||||||
|
val isLoadingManifest: StateFlow<Boolean> = _isLoadingManifest.asStateFlow()
|
||||||
|
|
||||||
|
private val _manifestError = MutableStateFlow<String?>(null)
|
||||||
|
val manifestError: StateFlow<String?> = _manifestError.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadManifest()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manifest ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun loadManifest() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isLoadingManifest.value = true
|
||||||
|
_manifestError.value = null
|
||||||
|
try {
|
||||||
|
val manifest = downloadManager.fetchVocabManifest()
|
||||||
|
Log.d(TAG, "Fetched ${manifest?.size ?: 0} packs from manifest")
|
||||||
|
_packs.value = manifest?.map { info ->
|
||||||
|
val downloaded = downloadManager.isVocabCollectionDownloaded(info)
|
||||||
|
PackUiState(
|
||||||
|
info = info,
|
||||||
|
downloadState = if (downloaded) PackDownloadState.DOWNLOADED else PackDownloadState.IDLE,
|
||||||
|
)
|
||||||
|
} ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to fetch manifest", e)
|
||||||
|
_manifestError.value = e.message
|
||||||
|
} finally {
|
||||||
|
_isLoadingManifest.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Download ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun downloadPack(info: VocabCollectionInfo) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
Log.d(TAG, "Starting download of ${info.id}")
|
||||||
|
updatePack(info.id) { it.copy(downloadState = PackDownloadState.DOWNLOADING, progress = 0f) }
|
||||||
|
try {
|
||||||
|
val success = downloadManager.downloadVocabCollection(info) { progress ->
|
||||||
|
updatePack(info.id) { it.copy(progress = progress) }
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
Log.d(TAG, "Download complete for ${info.id} (${info.filename})")
|
||||||
|
updatePack(info.id) { it.copy(downloadState = PackDownloadState.DOWNLOADED, progress = 1f) }
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Download returned false for ${info.id}")
|
||||||
|
updatePack(info.id) { it.copy(downloadState = PackDownloadState.ERROR) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Download exception for ${info.id}", e)
|
||||||
|
updatePack(info.id) { it.copy(downloadState = PackDownloadState.ERROR) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read file for import ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the downloaded pack file and returns its raw JSON string, or null if the
|
||||||
|
* file doesn't exist. The caller (screen) passes this to [ExportImportViewModel.importFromJson].
|
||||||
|
*
|
||||||
|
* The file is in the full Polly export format:
|
||||||
|
* { "type": "Category", "items": [...], "category": {...}, ... }
|
||||||
|
*/
|
||||||
|
fun readPackRawJson(info: VocabCollectionInfo): String? {
|
||||||
|
val file = localFile(info)
|
||||||
|
if (!file.exists()) {
|
||||||
|
Log.e(TAG, "Pack file not found: ${file.absolutePath}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val json = file.readText()
|
||||||
|
Log.d(TAG, "Read pack JSON for ${info.id}: ${json.length} chars")
|
||||||
|
json
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error reading pack file for ${info.id}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Post-import cleanup ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called after the import has been confirmed successful.
|
||||||
|
* Deletes the local JSON file (items are now in the DB) and marks the
|
||||||
|
* card as [PackDownloadState.IMPORTED].
|
||||||
|
*/
|
||||||
|
fun markImportedAndCleanup(info: VocabCollectionInfo) {
|
||||||
|
val file = localFile(info)
|
||||||
|
if (file.exists()) {
|
||||||
|
file.delete()
|
||||||
|
Log.d(TAG, "Deleted local pack file: ${file.absolutePath}")
|
||||||
|
}
|
||||||
|
updatePack(info.id) { it.copy(downloadState = PackDownloadState.IMPORTED, progress = 1f) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun deletePack(info: VocabCollectionInfo) {
|
||||||
|
val file = localFile(info)
|
||||||
|
if (file.exists()) file.delete()
|
||||||
|
Log.d(TAG, "Deleted pack (user-requested): ${info.id}")
|
||||||
|
updatePack(info.id) { it.copy(downloadState = PackDownloadState.IDLE, progress = 0f) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun localFile(info: VocabCollectionInfo): File =
|
||||||
|
File(context.filesDir, "flashcard-collections/${info.filename}")
|
||||||
|
|
||||||
|
private fun updatePack(packId: String, transform: (PackUiState) -> PackUiState) {
|
||||||
|
_packs.value = _packs.value.map { if (it.info.id == packId) transform(it) else it }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.model.CardSet
|
import eu.gaudian.translator.model.CardSet
|
||||||
import eu.gaudian.translator.model.Language
|
import eu.gaudian.translator.model.Language
|
||||||
import eu.gaudian.translator.model.VocabularyCategory
|
import eu.gaudian.translator.model.VocabularyCategory
|
||||||
@@ -702,9 +703,11 @@ class VocabularyViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
filteredList = when (sortOrder) {
|
filteredList = when (sortOrder) {
|
||||||
SortOrder.NEWEST_FIRST -> filteredList.sortedByDescending { it.id }
|
SortOrder.NEWEST_FIRST -> filteredList.sortedByDescending { it.createdAt }
|
||||||
SortOrder.OLDEST_FIRST -> filteredList.sortedBy { it.id }
|
SortOrder.OLDEST_FIRST -> filteredList.sortedBy { it.createdAt }
|
||||||
SortOrder.ALPHABETICAL -> filteredList.sortedBy { it.wordFirst }
|
SortOrder.ALPHABETICAL -> filteredList.sortedWith(
|
||||||
|
compareBy(String.CASE_INSENSITIVE_ORDER) { it.wordFirst.trim() }
|
||||||
|
)
|
||||||
SortOrder.LANGUAGE -> filteredList.sortedWith(compareBy({ it.languageFirstId }, { it.languageSecondId }))
|
SortOrder.LANGUAGE -> filteredList.sortedWith(compareBy({ it.languageFirstId }, { it.languageSecondId }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1407,6 +1410,7 @@ class VocabularyViewModel @Inject constructor(
|
|||||||
enum class DeleteType { VOCABULARY_ITEM_BY_ID, VOCABULARY_ITEM, VOCABULARY_ITEMS, REMOVE_FROM_CATEGORY }
|
enum class DeleteType { VOCABULARY_ITEM_BY_ID, VOCABULARY_ITEM, VOCABULARY_ITEMS, REMOVE_FROM_CATEGORY }
|
||||||
enum class SortOrder { NEWEST_FIRST, OLDEST_FIRST, ALPHABETICAL, LANGUAGE }
|
enum class SortOrder { NEWEST_FIRST, OLDEST_FIRST, ALPHABETICAL, LANGUAGE }
|
||||||
|
|
||||||
|
|
||||||
data class VocabularyItemDetails @OptIn(ExperimentalTime::class) constructor(
|
data class VocabularyItemDetails @OptIn(ExperimentalTime::class) constructor(
|
||||||
val stage: VocabularyStage,
|
val stage: VocabularyStage,
|
||||||
val lastCorrectAnswer: kotlin.time.Instant?,
|
val lastCorrectAnswer: kotlin.time.Instant?,
|
||||||
@@ -1449,3 +1453,14 @@ class VocabularyViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the string resource ID for a given SortOrder.
|
||||||
|
* This allows internationalization of sort order labels.
|
||||||
|
*/
|
||||||
|
fun VocabularyViewModel.SortOrder.toStringResource(): Int = when (this) {
|
||||||
|
VocabularyViewModel.SortOrder.NEWEST_FIRST -> R.string.sort_order_newest_first
|
||||||
|
VocabularyViewModel.SortOrder.OLDEST_FIRST -> R.string.sort_order_oldest_first
|
||||||
|
VocabularyViewModel.SortOrder.ALPHABETICAL -> R.string.sort_order_alphabetical
|
||||||
|
VocabularyViewModel.SortOrder.LANGUAGE -> R.string.sort_order_language
|
||||||
|
}
|
||||||
|
|||||||
@@ -916,5 +916,11 @@
|
|||||||
<string name="cd_add">Hinzufügen</string>
|
<string name="cd_add">Hinzufügen</string>
|
||||||
<string name="cd_searchh">Suche</string>
|
<string name="cd_searchh">Suche</string>
|
||||||
<string name="label_learnedd">gelernt</string>
|
<string name="label_learnedd">gelernt</string>
|
||||||
|
|
||||||
|
<!-- Sort Order Options -->
|
||||||
|
<string name="sort_order_newest_first">Neueste zuerst</string>
|
||||||
|
<string name="sort_order_oldest_first">Älteste zuerst</string>
|
||||||
|
<string name="sort_order_alphabetical">Alphabetisch</string>
|
||||||
|
<string name="sort_order_language">Sprache</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@@ -912,5 +912,11 @@
|
|||||||
<string name="cd_add">Adicionar</string>
|
<string name="cd_add">Adicionar</string>
|
||||||
<string name="cd_searchh">Buscar</string>
|
<string name="cd_searchh">Buscar</string>
|
||||||
<string name="label_learnedd">aprendido</string>
|
<string name="label_learnedd">aprendido</string>
|
||||||
|
|
||||||
|
<!-- Sort Order Options -->
|
||||||
|
<string name="sort_order_newest_first">Mais Recentes Primeiro</string>
|
||||||
|
<string name="sort_order_oldest_first">Mais Antigos Primeiro</string>
|
||||||
|
<string name="sort_order_alphabetical">Alfabético</string>
|
||||||
|
<string name="sort_order_language">Idioma</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
||||||
|
|||||||
@@ -1119,4 +1119,10 @@
|
|||||||
<string name="label_learnedd">learned</string>
|
<string name="label_learnedd">learned</string>
|
||||||
<string name="label_all_categoriess">All Categories</string>
|
<string name="label_all_categoriess">All Categories</string>
|
||||||
<string name="label_show_more">Show More</string>
|
<string name="label_show_more">Show More</string>
|
||||||
|
|
||||||
|
<!-- Sort Order Options -->
|
||||||
|
<string name="sort_order_newest_first">Newest First</string>
|
||||||
|
<string name="sort_order_oldest_first">Oldest First</string>
|
||||||
|
<string name="sort_order_alphabetical">Alphabetical</string>
|
||||||
|
<string name="sort_order_language">Language</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user