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 67cbe96..5291610 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 @@ -3,6 +3,9 @@ package eu.gaudian.translator.model.communication import android.content.Context import androidx.core.content.edit import eu.gaudian.translator.R +import eu.gaudian.translator.model.communication.files_download.FlashcardCollectionInfo +import eu.gaudian.translator.model.communication.files_download.FlashcardManifestResponse +import eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo import eu.gaudian.translator.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -327,7 +330,7 @@ class FileDownloadManager(private val context: Context) { /** * Fetches the vocabulary-pack manifest (vocab_manifest.json). - * Unwraps the top-level [VocabManifestResponse] and returns the `lists` array. + * Unwraps the top-level [eu.gaudian.translator.model.communication.files_download.VocabManifestResponse] and returns the `lists` array. */ suspend fun fetchVocabManifest(): List? = withContext(Dispatchers.IO) { try { 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 a4c79d1..f47fe2a 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 @@ -1,5 +1,7 @@ package eu.gaudian.translator.model.communication +import eu.gaudian.translator.model.communication.files_download.FlashcardManifestResponse +import eu.gaudian.translator.model.communication.files_download.VocabManifestResponse import retrofit2.Call import retrofit2.http.GET 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 de35232..920fc90 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 @@ -1,4 +1,4 @@ -package eu.gaudian.translator.model.communication +package eu.gaudian.translator.model.communication.files_download import com.google.gson.annotations.SerializedName @@ -35,6 +35,8 @@ data class VocabCollectionInfo( @SerializedName("item_count") val itemCount: Int, @SerializedName("emoji") val emoji: String, @SerializedName("version") val version: Int, + /** CEFR difficulty level: A1, A2, B1, B2, C1, C2 (empty string if not set) */ + @SerializedName("level") val level: String = "", @SerializedName("size_bytes") val sizeBytes: Long, @SerializedName("checksum_sha256") val checksumSha256: String, @SerializedName("created_at") val createdAt: String, 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 index bdd11c9..19f3153 100644 --- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt @@ -18,9 +18,11 @@ 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.LazyRow 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.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle @@ -28,7 +30,6 @@ 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 @@ -75,8 +76,10 @@ 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.view.translation.LanguageSelectorBar import eu.gaudian.translator.viewmodel.ExportImportViewModel import eu.gaudian.translator.viewmodel.ImportState +import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.PackDownloadState import eu.gaudian.translator.viewmodel.PackUiState import eu.gaudian.translator.viewmodel.VocabPacksViewModel @@ -84,18 +87,31 @@ import eu.gaudian.translator.viewmodel.VocabPacksViewModel private const val TAG = "ExplorePacksScreen" // --------------------------------------------------------------------------- -// Filter enum +// Filter enum – CEFR levels + utility filters // --------------------------------------------------------------------------- -enum class PackFilter { All, MostPopular, Newest, Beginner } +enum class PackFilter { + All, Newest, + A1, A2, B1, B2, C1, C2; -private val PackFilter.label: String - get() = when (this) { - PackFilter.All -> "All" - PackFilter.MostPopular -> "Most Popular" - PackFilter.Newest -> "Newest" - PackFilter.Beginner -> "Beginner" - } + val label: String + get() = when (this) { + All -> "All" + Newest -> "Newest" + A1 -> "Beginner · A1" + A2 -> "Elementary · A2" + B1 -> "Intermediate · B1" + B2 -> "Upper Int. · B2" + C1 -> "Advanced · C1" + C2 -> "Proficient · C2" + } + + val cefrCode: String? + get() = when (this) { + A1 -> "A1"; A2 -> "A2"; B1 -> "B1"; B2 -> "B2"; C1 -> "C1"; C2 -> "C2" + else -> null + } +} // --------------------------------------------------------------------------- // Gradient palette – deterministic per pack ID hash @@ -127,26 +143,26 @@ fun ExplorePacksScreen( ) { 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 vocabPacksViewModel: VocabPacksViewModel = hiltViewModel() + val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity) + val languageViewModel: LanguageViewModel = 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() + val packs by vocabPacksViewModel.packs.collectAsState() + val isLoadingManifest by vocabPacksViewModel.isLoadingManifest.collectAsState() + val manifestError by vocabPacksViewModel.manifestError.collectAsState() + val importState by exportImportViewModel.importState.collectAsState() + val selectedSourceLanguage by languageViewModel.selectedSourceLanguage.collectAsState() + val selectedTargetLanguage by languageViewModel.selectedTargetLanguage.collectAsState() - var searchQuery by remember { mutableStateOf("") } - var selectedFilter by remember { mutableStateOf(PackFilter.All) } + 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) } + 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 + // Observe async import result LaunchedEffect(importState) { val pending = pendingImportPackState ?: return@LaunchedEffect when (val state = importState) { @@ -165,23 +181,43 @@ fun ExplorePacksScreen( isImporting = false exportImportViewModel.resetImportState() } - else -> { /* Idle or Loading – nothing to do */ } + else -> {} } } - val filteredPacks = remember(packs, selectedFilter, searchQuery) { + // Filtered + sorted pack list + val filteredPacks = remember( + packs, selectedFilter, searchQuery, + selectedSourceLanguage, selectedTargetLanguage + ) { + val srcId = selectedSourceLanguage?.nameResId + val tgtId = selectedTargetLanguage?.nameResId + packs.filter { ps -> val info = ps.info + + // Language filter – only when a language pair is selected + val matchLanguage = if (srcId == null && tgtId == null) { + true + } else { + val ids = info.languageIds.toSet() + (srcId == null || ids.contains(srcId)) && + (tgtId == null || ids.contains(tgtId)) + } + 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 + + val matchFilter = when (val code = selectedFilter.cefrCode) { + null -> true // All or Newest – handled by sort below + else -> info.level.equals(code, ignoreCase = true) } - matchSearch && matchFilter + + matchLanguage && matchSearch && matchFilter + }.let { list -> + if (selectedFilter == PackFilter.Newest) list.sortedByDescending { it.info.version } + else list } } @@ -239,17 +275,27 @@ fun ExplorePacksScreen( Spacer(modifier = Modifier.height(12.dp)) - // ── Language selector row ───────────────────────────────────────── - PackLanguageSelectorRow() + // ── Language selector – reuses LanguageSelectorBar ──────────────── + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 1.dp + ) { + LanguageSelectorBar( + languageViewModel = languageViewModel, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } Spacer(modifier = Modifier.height(12.dp)) - // ── Filter chips ────────────────────────────────────────────────── - Row( + // ── Filter chips – horizontally scrollable ──────────────────────── + LazyRow( horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() + contentPadding = PaddingValues(horizontal = 2.dp) ) { - PackFilter.entries.forEach { filter -> + items(PackFilter.entries) { filter -> FilterChip( selected = selectedFilter == filter, onClick = { selectedFilter = filter }, @@ -375,9 +421,9 @@ fun ExplorePacksScreen( pendingImportPackState = null } }, - icon = { Icon(Icons.Default.Warning, contentDescription = null) }, - title = { Text("Import ${packState.info.name} ") }, - text = { + icon = { Icon(Icons.Default.Warning, contentDescription = null) }, + title = { Text("Import \"${packState.info.name}\"") }, + text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (isImporting) { Box( @@ -444,14 +490,13 @@ fun ExplorePacksScreen( pendingImportPackState = null return@TextButton } - Log.d(TAG, "Starting import for ${packState.info.id} with strategy=$selectedConflictStrategy, json=${json.length} chars") + Log.d(TAG, "Starting import for ${packState.info.id} " + + "strategy=$selectedConflictStrategy json=${json.length} chars") isImporting = true - showStrategyDialog = false // close dialog; LaunchedEffect handles completion + showStrategyDialog = false exportImportViewModel.importFromJson(json, selectedConflictStrategy) } - ) { - Text("Add to Library") - } + ) { Text("Add to Library") } }, dismissButton = { TextButton( @@ -460,53 +505,14 @@ fun ExplorePacksScreen( showStrategyDialog = false pendingImportPackState = null } - ) { - Text("Cancel") - } + ) { 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) +// Conflict strategy option // --------------------------------------------------------------------------- @Composable @@ -527,9 +533,7 @@ private fun PackConflictStrategyOption( ) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(10.dp), + modifier = Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically ) { RadioButton(selected = selected, onClick = onSelected) @@ -599,7 +603,25 @@ private fun PackCard( } } - // Status badge (top-right corner) + // Level badge (top-left) – only when a CEFR level is set + if (info.level.isNotBlank()) { + Box(modifier = Modifier.align(Alignment.TopStart).padding(8.dp)) { + Surface( + shape = RoundedCornerShape(6.dp), + color = Color.Black.copy(alpha = 0.45f) + ) { + Text( + text = info.level, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp) + ) + } + } + } + + // Status badge (top-right) val badgeData: Pair? = when (packState.downloadState) { PackDownloadState.DOWNLOADED -> Color(0xFF1565C0) to "Ready" PackDownloadState.IMPORTED -> Color(0xFF388E3C) to "In Library" @@ -671,8 +693,7 @@ private fun PackCard( contentPadding = PaddingValues(0.dp), shape = RoundedCornerShape(8.dp) ) { - Text("Get", - style = MaterialTheme.typography.labelMedium, + Text("Get", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) } } @@ -700,11 +721,9 @@ private fun PackCard( containerColor = MaterialTheme.colorScheme.secondary ) ) { - Text( - "Add ${info.itemCount} words", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold - ) + Text("Add ${info.itemCount} words", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold) } OutlinedButton( onClick = onDeleteClick, @@ -745,8 +764,7 @@ private fun PackCard( containerColor = MaterialTheme.colorScheme.error ) ) { - Text("Retry", - style = MaterialTheme.typography.labelMedium, + Text("Retry", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) } } diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt index c68f169..49669c9 100644 --- a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt @@ -8,7 +8,7 @@ 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.model.communication.files_download.VocabCollectionInfo import eu.gaudian.translator.utils.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow