implement vocabulary packs exploration and download functionality
This commit is contained in:
@@ -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<VocabCollectionInfo>? = 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"
|
||||
}
|
||||
|
||||
@@ -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<FlashcardManifestResponse>
|
||||
|
||||
// ── 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<VocabManifestResponse>
|
||||
}
|
||||
|
||||
@@ -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<VocabCollectionInfo> = 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<Int>,
|
||||
@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<FlashcardCollectionInfo>
|
||||
)
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Color> =
|
||||
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<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
|
||||
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<Color, String>? = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -254,6 +253,9 @@ fun NewWordScreen(
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<PackUiState>>(emptyList())
|
||||
val packs: StateFlow<List<PackUiState>> = _packs.asStateFlow()
|
||||
|
||||
private val _isLoadingManifest = MutableStateFlow(false)
|
||||
val isLoadingManifest: StateFlow<Boolean> = _isLoadingManifest.asStateFlow()
|
||||
|
||||
private val _manifestError = MutableStateFlow<String?>(null)
|
||||
val manifestError: StateFlow<String?> = _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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -916,5 +916,11 @@
|
||||
<string name="cd_add">Hinzufügen</string>
|
||||
<string name="cd_searchh">Suche</string>
|
||||
<string name="label_learnedd">gelernt</string>
|
||||
|
||||
<!-- Sort Order Options -->
|
||||
<string name="sort_order_newest_first">Neueste zuerst</string>
|
||||
<string name="sort_order_oldest_first">Älteste zuerst</string>
|
||||
<string name="sort_order_alphabetical">Alphabetisch</string>
|
||||
<string name="sort_order_language">Sprache</string>
|
||||
</resources>
|
||||
|
||||
|
||||
@@ -912,5 +912,11 @@
|
||||
<string name="cd_add">Adicionar</string>
|
||||
<string name="cd_searchh">Buscar</string>
|
||||
<string name="label_learnedd">aprendido</string>
|
||||
|
||||
<!-- Sort Order Options -->
|
||||
<string name="sort_order_newest_first">Mais Recentes Primeiro</string>
|
||||
<string name="sort_order_oldest_first">Mais Antigos Primeiro</string>
|
||||
<string name="sort_order_alphabetical">Alfabético</string>
|
||||
<string name="sort_order_language">Idioma</string>
|
||||
</resources>
|
||||
|
||||
|
||||
@@ -1119,4 +1119,10 @@
|
||||
<string name="label_learnedd">learned</string>
|
||||
<string name="label_all_categoriess">All Categories</string>
|
||||
<string name="label_show_more">Show More</string>
|
||||
|
||||
<!-- Sort Order Options -->
|
||||
<string name="sort_order_newest_first">Newest First</string>
|
||||
<string name="sort_order_oldest_first">Oldest First</string>
|
||||
<string name="sort_order_alphabetical">Alphabetical</string>
|
||||
<string name="sort_order_language">Language</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user