@@ -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,19 +87,32 @@ 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
val label : String
get ( ) = when ( this ) {
PackFilter . All -> " All "
PackFilter . MostPopular -> " Most Popular "
PackFilter . Newest -> " Newest "
PackFilter . Beginner -> " Beginner "
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 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 selectedSourceLanguage by languageViewModel . selectedSourceLanguage . collectAsState ( )
val selectedTargetLanguage by languageViewModel . selectedTargetLanguage . collectAsState ( )
var searchQuery by remember { mutableStateOf ( " " ) }
var selectedFilter by remember { mutableStateOf ( PackFilter . All ) }
// Which pack is being imported right now (captured when dialog opens)
var pendingImportPackState by remember { mutableStateOf < PackUiState ? > ( null ) }
var selectedConflictStrategy by remember { mutableStateOf ( ConflictStrategy . MERGE ) }
var showStrategyDialog by remember { mutableStateOf ( false ) }
var isImporting by remember { mutableStateOf ( false ) }
// Observe importState and react when the async import finishes
// 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 ────────────────────────
Lazy Row(
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 } ,
@@ -376,7 +422,7 @@ fun ExplorePacksScreen(
}
} ,
icon = { Icon ( Icons . Default . Warning , contentDescription = null ) } ,
title = { Text ( " Import ${packState.info.name} " ) } ,
title = { Text ( " Import \" ${packState.info.name} \" " ) } ,
text = {
Column ( verticalArrangement = Arrangement . spacedBy ( 8. dp ) ) {
if ( isImporting ) {
@@ -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 " ,
Text ( " Add ${info.itemCount} words " ,
style = MaterialTheme . typography . labelSmall ,
fontWeight = FontWeight . Bold
)
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 )
}
}