From 0a202191eb13dba9eb2739b0101b55d658c2603c Mon Sep 17 00:00:00 2001 From: jonasgaudian <43753916+jonasgaudian@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:11:32 +0100 Subject: [PATCH] implement vocabulary packs exploration and download functionality --- .../files_download/FileDownloadManager.kt | 115 +++ .../files_download/FlashcardApiService.kt | 14 +- .../files_download/FlashcardManifestModels.kt | 73 +- .../eu/gaudian/translator/view/Navigation.kt | 6 + .../translator/view/library/LibraryScreen.kt | 4 +- .../view/vocabulary/ExplorePacksScreen.kt | 757 ++++++++++++++++++ .../view/vocabulary/NewWordScreen.kt | 23 +- .../viewmodel/VocabPacksViewModel.kt | 165 ++++ .../viewmodel/VocabularyViewModel.kt | 21 +- app/src/main/res/values-de-rDE/strings.xml | 6 + app/src/main/res/values-pt-rBR/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 12 files changed, 1150 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt create mode 100644 app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FileDownloadManager.kt b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FileDownloadManager.kt index 5f16bc8..67cbe96 100644 --- a/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FileDownloadManager.kt +++ b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FileDownloadManager.kt @@ -322,4 +322,119 @@ class FileDownloadManager(private val context: Context) { fun getFlashcardLocalVersion(collectionId: String): String { 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? = 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" } diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardApiService.kt b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardApiService.kt index f8b18d0..a4c79d1 100644 --- a/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardApiService.kt +++ b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardApiService.kt @@ -4,14 +4,20 @@ import retrofit2.Call import retrofit2.http.GET /** - * API service for fetching flashcard collection manifests and downloading files. + * API service for flashcard / vocabulary-pack downloads. */ interface FlashcardApiService { - /** - * Fetches the flashcard collection manifest from the server. - */ + // ── Legacy endpoint (old manifest schema) ──────────────────────────────── @GET("flashcard-collections/manifest.json") fun getFlashcardManifest(): Call + // ── 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 } diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardManifestModels.kt b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardManifestModels.kt index a71d1c5..de35232 100644 --- a/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardManifestModels.kt +++ b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardManifestModels.kt @@ -2,38 +2,65 @@ package eu.gaudian.translator.model.communication 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 = 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, + @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( @SerializedName("collections") val collections: List ) -/** - * Data class representing information about a downloadable flashcard collection. - */ data class FlashcardCollectionInfo( - @SerializedName("id") - val id: String, - @SerializedName("name") - val name: String, - @SerializedName("description") - val description: String, - @SerializedName("version") - val version: String, - @SerializedName("asset") - val asset: FlashcardAsset + @SerializedName("id") val id: String, + @SerializedName("name") val name: String, + @SerializedName("description") 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( - @SerializedName("filename") - val filename: String, - @SerializedName("size_bytes") - val sizeBytes: Long, - @SerializedName("checksum_sha256") - val checksumSha256: String + @SerializedName("filename") val filename: String, + @SerializedName("size_bytes") val sizeBytes: Long, + @SerializedName("checksum_sha256") val checksumSha256: String ) diff --git a/app/src/main/java/eu/gaudian/translator/view/Navigation.kt b/app/src/main/java/eu/gaudian/translator/view/Navigation.kt index 700d0ef..570a27a 100644 --- a/app/src/main/java/eu/gaudian/translator/view/Navigation.kt +++ b/app/src/main/java/eu/gaudian/translator/view/Navigation.kt @@ -40,6 +40,7 @@ import eu.gaudian.translator.view.settings.settingsGraph import eu.gaudian.translator.view.stats.StatsScreen import eu.gaudian.translator.view.translation.TranslationScreen 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.NewWordReviewScreen import eu.gaudian.translator.view.vocabulary.NewWordScreen @@ -68,6 +69,7 @@ object NavigationRoutes { const val STATS_VOCABULARY_SORTING = "stats/vocabulary_sorting" const val STATS_NO_GRAMMAR_ITEMS = "stats/no_grammar_items" const val STATS_VOCABULARY_LIST = "stats/vocabulary_list" + const val EXPLORE_PACKS = "explore_packs" } @OptIn(androidx.compose.animation.ExperimentalAnimationApi::class) @@ -169,6 +171,10 @@ fun AppNavHost( NewWordReviewScreen(navController = navController) } + composable(NavigationRoutes.EXPLORE_PACKS) { + ExplorePacksScreen(navController = navController) + } + composable( route = "${NavigationRoutes.START_EXERCISE}?categoryId={categoryId}", arguments = listOf( diff --git a/app/src/main/java/eu/gaudian/translator/view/library/LibraryScreen.kt b/app/src/main/java/eu/gaudian/translator/view/library/LibraryScreen.kt index 724c186..ccb1ef4 100644 --- a/app/src/main/java/eu/gaudian/translator/view/library/LibraryScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/library/LibraryScreen.kt @@ -78,6 +78,7 @@ import eu.gaudian.translator.viewmodel.LanguageConfigViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel.SortOrder +import eu.gaudian.translator.viewmodel.toStringResource import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -485,8 +486,7 @@ fun FilterBottomSheetContent( selected = sortOrder == order, onClick = { sortOrder = order }, label = { - Text(order.name.replace('_', ' ').lowercase() - .replaceFirstChar { it.titlecase() }) + Text(stringResource(order.toStringResource())) } ) } diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt new file mode 100644 index 0000000..bdd11c9 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt @@ -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 = + 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(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? = 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) + } + } + } + } + } + } +} diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/NewWordScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/NewWordScreen.kt index a4af4a3..9985538 100644 --- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/NewWordScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/NewWordScreen.kt @@ -45,7 +45,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -253,7 +252,10 @@ fun NewWordScreen( Spacer(modifier = Modifier.height(24.dp)) - BottomActionCardsRow( + BottomActionCardsRow( + onExplorePsClick = { + navController.navigate(NavigationRoutes.EXPLORE_PACKS) + }, onImportCsvClick = { navController.navigate("settings_vocabulary_repository_options") } @@ -683,22 +685,22 @@ fun AddManuallyCard( @Composable fun BottomActionCardsRow( modifier: Modifier = Modifier, + onExplorePsClick: () -> Unit, onImportCsvClick: () -> Unit ) { Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - //TODO Explore Packs Card + // Explore Packs Card AppCard( modifier = Modifier .weight(1f) .height(120.dp), + onClick = onExplorePsClick ) { Column( - modifier = Modifier - .fillMaxSize() - .alpha(0.6f), + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { @@ -721,14 +723,7 @@ fun BottomActionCardsRow( text = "Explore Packs", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(6.dp)) - @Suppress("HardCodedStringLiteral") - Text( - text = "Coming soon", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + color = MaterialTheme.colorScheme.onSurface ) } } diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt new file mode 100644 index 0000000..c68f169 --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt @@ -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>(emptyList()) + val packs: StateFlow> = _packs.asStateFlow() + + private val _isLoadingManifest = MutableStateFlow(false) + val isLoadingManifest: StateFlow = _isLoadingManifest.asStateFlow() + + private val _manifestError = MutableStateFlow(null) + val manifestError: StateFlow = _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 } + } +} diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyViewModel.kt index d4780b0..483994b 100644 --- a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyViewModel.kt +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabularyViewModel.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import eu.gaudian.translator.R import eu.gaudian.translator.model.CardSet import eu.gaudian.translator.model.Language import eu.gaudian.translator.model.VocabularyCategory @@ -702,9 +703,11 @@ class VocabularyViewModel @Inject constructor( } filteredList = when (sortOrder) { - SortOrder.NEWEST_FIRST -> filteredList.sortedByDescending { it.id } - SortOrder.OLDEST_FIRST -> filteredList.sortedBy { it.id } - SortOrder.ALPHABETICAL -> filteredList.sortedBy { it.wordFirst } + SortOrder.NEWEST_FIRST -> filteredList.sortedByDescending { it.createdAt } + SortOrder.OLDEST_FIRST -> filteredList.sortedBy { it.createdAt } + SortOrder.ALPHABETICAL -> filteredList.sortedWith( + compareBy(String.CASE_INSENSITIVE_ORDER) { it.wordFirst.trim() } + ) 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 SortOrder { NEWEST_FIRST, OLDEST_FIRST, ALPHABETICAL, LANGUAGE } + data class VocabularyItemDetails @OptIn(ExperimentalTime::class) constructor( val stage: VocabularyStage, 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 +} diff --git a/app/src/main/res/values-de-rDE/strings.xml b/app/src/main/res/values-de-rDE/strings.xml index ac91791..9523bb9 100644 --- a/app/src/main/res/values-de-rDE/strings.xml +++ b/app/src/main/res/values-de-rDE/strings.xml @@ -916,5 +916,11 @@ Hinzufügen Suche gelernt + + + Neueste zuerst + Älteste zuerst + Alphabetisch + Sprache diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7e92e29..98f4e40 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -912,5 +912,11 @@ Adicionar Buscar aprendido + + + Mais Recentes Primeiro + Mais Antigos Primeiro + Alfabético + Idioma diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36a0ca0..2c83a1a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1119,4 +1119,10 @@ learned All Categories Show More + + + Newest First + Oldest First + Alphabetical + Language