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 android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import eu.gaudian.translator.R 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 eu.gaudian.translator.utils.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -327,7 +330,7 @@ class FileDownloadManager(private val context: Context) {
/** /**
* Fetches the vocabulary-pack manifest (vocab_manifest.json). * 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) { suspend fun fetchVocabManifest(): List<VocabCollectionInfo>? = withContext(Dispatchers.IO) {
try { try {

View File

@@ -1,5 +1,7 @@
package eu.gaudian.translator.model.communication 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.Call
import retrofit2.http.GET 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 import com.google.gson.annotations.SerializedName
@@ -35,6 +35,8 @@ data class VocabCollectionInfo(
@SerializedName("item_count") val itemCount: Int, @SerializedName("item_count") val itemCount: Int,
@SerializedName("emoji") val emoji: String, @SerializedName("emoji") val emoji: String,
@SerializedName("version") val version: Int, @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("size_bytes") val sizeBytes: Long,
@SerializedName("checksum_sha256") val checksumSha256: String, @SerializedName("checksum_sha256") val checksumSha256: String,
@SerializedName("created_at") val createdAt: 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle 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.FilterList
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button 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.StatusMessageService
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppTopAppBar 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.ExportImportViewModel
import eu.gaudian.translator.viewmodel.ImportState import eu.gaudian.translator.viewmodel.ImportState
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.PackDownloadState import eu.gaudian.translator.viewmodel.PackDownloadState
import eu.gaudian.translator.viewmodel.PackUiState import eu.gaudian.translator.viewmodel.PackUiState
import eu.gaudian.translator.viewmodel.VocabPacksViewModel import eu.gaudian.translator.viewmodel.VocabPacksViewModel
@@ -84,19 +87,32 @@ import eu.gaudian.translator.viewmodel.VocabPacksViewModel
private const val TAG = "ExplorePacksScreen" 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 val label: String
get() = when (this) { get() = when (this) {
PackFilter.All -> "All" All -> "All"
PackFilter.MostPopular -> "Most Popular" Newest -> "Newest"
PackFilter.Newest -> "Newest" A1 -> "Beginner · A1"
PackFilter.Beginner -> "Beginner" 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 // Gradient palette deterministic per pack ID hash
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -127,26 +143,26 @@ fun ExplorePacksScreen(
) { ) {
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
// VocabPacksViewModel is screen-scoped (not activity-scoped) no persistent pack state needed
val vocabPacksViewModel: VocabPacksViewModel = hiltViewModel() val vocabPacksViewModel: VocabPacksViewModel = hiltViewModel()
// ExportImportViewModel handles full Polly-format import with real conflict strategy
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity) val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val packs by vocabPacksViewModel.packs.collectAsState() val packs by vocabPacksViewModel.packs.collectAsState()
val isLoadingManifest by vocabPacksViewModel.isLoadingManifest.collectAsState() val isLoadingManifest by vocabPacksViewModel.isLoadingManifest.collectAsState()
val manifestError by vocabPacksViewModel.manifestError.collectAsState() val manifestError by vocabPacksViewModel.manifestError.collectAsState()
val importState by exportImportViewModel.importState.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 searchQuery by remember { mutableStateOf("") }
var selectedFilter by remember { mutableStateOf(PackFilter.All) } 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 pendingImportPackState by remember { mutableStateOf<PackUiState?>(null) }
var selectedConflictStrategy by remember { mutableStateOf(ConflictStrategy.MERGE) } var selectedConflictStrategy by remember { mutableStateOf(ConflictStrategy.MERGE) }
var showStrategyDialog by remember { mutableStateOf(false) } var showStrategyDialog by remember { mutableStateOf(false) }
var isImporting 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) { LaunchedEffect(importState) {
val pending = pendingImportPackState ?: return@LaunchedEffect val pending = pendingImportPackState ?: return@LaunchedEffect
when (val state = importState) { when (val state = importState) {
@@ -165,23 +181,43 @@ fun ExplorePacksScreen(
isImporting = false isImporting = false
exportImportViewModel.resetImportState() 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 -> packs.filter { ps ->
val info = ps.info 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() || val matchSearch = searchQuery.isBlank() ||
info.name.contains(searchQuery, ignoreCase = true) || info.name.contains(searchQuery, ignoreCase = true) ||
info.category.contains(searchQuery, ignoreCase = true) info.category.contains(searchQuery, ignoreCase = true)
val matchFilter = when (selectedFilter) {
PackFilter.All -> true val matchFilter = when (val code = selectedFilter.cefrCode) {
PackFilter.MostPopular -> info.itemCount >= 80 null -> true // All or Newest handled by sort below
PackFilter.Newest -> info.version >= 1 else -> info.level.equals(code, ignoreCase = true)
PackFilter.Beginner -> info.itemCount <= 60
} }
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)) Spacer(modifier = Modifier.height(12.dp))
// ── Language selector row ───────────────────────────────────────── // ── Language selector reuses LanguageSelectorBar ────────────────
PackLanguageSelectorRow() 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)) Spacer(modifier = Modifier.height(12.dp))
// ── Filter chips ────────────────────────────────────────────────── // ── Filter chips horizontally scrollable ────────────────────────
Row( LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth() contentPadding = PaddingValues(horizontal = 2.dp)
) { ) {
PackFilter.entries.forEach { filter -> items(PackFilter.entries) { filter ->
FilterChip( FilterChip(
selected = selectedFilter == filter, selected = selectedFilter == filter,
onClick = { selectedFilter = filter }, onClick = { selectedFilter = filter },
@@ -376,7 +422,7 @@ fun ExplorePacksScreen(
} }
}, },
icon = { Icon(Icons.Default.Warning, contentDescription = null) }, icon = { Icon(Icons.Default.Warning, contentDescription = null) },
title = { Text("Import ${packState.info.name} ") }, title = { Text("Import \"${packState.info.name}\"") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (isImporting) { if (isImporting) {
@@ -444,14 +490,13 @@ fun ExplorePacksScreen(
pendingImportPackState = null pendingImportPackState = null
return@TextButton 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 isImporting = true
showStrategyDialog = false // close dialog; LaunchedEffect handles completion showStrategyDialog = false
exportImportViewModel.importFromJson(json, selectedConflictStrategy) exportImportViewModel.importFromJson(json, selectedConflictStrategy)
} }
) { ) { Text("Add to Library") }
Text("Add to Library")
}
}, },
dismissButton = { dismissButton = {
TextButton( TextButton(
@@ -460,53 +505,14 @@ fun ExplorePacksScreen(
showStrategyDialog = false showStrategyDialog = false
pendingImportPackState = null pendingImportPackState = null
} }
) { ) { Text("Cancel") }
Text("Cancel")
}
} }
) )
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Language selector (placeholder — will use SourceLanguageDropdown in future) // Conflict strategy option
// ---------------------------------------------------------------------------
@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 @Composable
@@ -527,9 +533,7 @@ private fun PackConflictStrategyOption(
) )
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(10.dp),
.fillMaxWidth()
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RadioButton(selected = selected, onClick = onSelected) 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) { val badgeData: Pair<Color, String>? = when (packState.downloadState) {
PackDownloadState.DOWNLOADED -> Color(0xFF1565C0) to "Ready" PackDownloadState.DOWNLOADED -> Color(0xFF1565C0) to "Ready"
PackDownloadState.IMPORTED -> Color(0xFF388E3C) to "In Library" PackDownloadState.IMPORTED -> Color(0xFF388E3C) to "In Library"
@@ -671,8 +693,7 @@ private fun PackCard(
contentPadding = PaddingValues(0.dp), contentPadding = PaddingValues(0.dp),
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
) { ) {
Text("Get", Text("Get", style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold) fontWeight = FontWeight.Bold)
} }
} }
@@ -700,11 +721,9 @@ private fun PackCard(
containerColor = MaterialTheme.colorScheme.secondary containerColor = MaterialTheme.colorScheme.secondary
) )
) { ) {
Text( Text("Add ${info.itemCount} words",
"Add ${info.itemCount} words",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold)
)
} }
OutlinedButton( OutlinedButton(
onClick = onDeleteClick, onClick = onDeleteClick,
@@ -745,8 +764,7 @@ private fun PackCard(
containerColor = MaterialTheme.colorScheme.error containerColor = MaterialTheme.colorScheme.error
) )
) { ) {
Text("Retry", Text("Retry", style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold) fontWeight = FontWeight.Bold)
} }
} }

View File

@@ -8,7 +8,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import eu.gaudian.translator.model.communication.FileDownloadManager 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 eu.gaudian.translator.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow