implement vocabulary packs exploration and download functionality

This commit is contained in:
jonasgaudian
2026-02-18 23:11:32 +01:00
parent d12a21909c
commit 0a202191eb
12 changed files with 1150 additions and 46 deletions

View File

@@ -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"
} }

View File

@@ -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>
} }

View File

@@ -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
) )

View File

@@ -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(

View File

@@ -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() })
} }
) )
} }

View File

@@ -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)
}
}
}
}
}
}
}

View File

@@ -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
@@ -254,6 +253,9 @@ 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)
) )
} }
} }

View File

@@ -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 }
}
}

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>