implement CEFR level filtering and language-based sorting in ExplorePacksScreen

This commit is contained in:
jonasgaudian
2026-02-18 23:35:57 +01:00
parent 0a202191eb
commit 0f8d605df7
5 changed files with 130 additions and 105 deletions

View File

@@ -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<VocabCollectionInfo>? = withContext(Dispatchers.IO) {
try {

View File

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

View File

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

View File

@@ -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<PackUiState?>(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<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
// 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<Color, String>? = 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)
}
}

View File

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