diff --git a/app/src/main/assets/hints/explore_packs_hint.md b/app/src/main/assets/hints/explore_packs_hint.md new file mode 100644 index 0000000..d9bc3a7 --- /dev/null +++ b/app/src/main/assets/hints/explore_packs_hint.md @@ -0,0 +1,24 @@ +All vocabulary lists in this section were generated using AI. While I strive for accuracy and quality, please keep in mind that these are machine-generated collections. + +I'm a single developer building and maintaining this app in my spare time. I'm passionate about creating tools that help people learn languages, but I have limited resources and time. + +### Your Feedback Matters + +I greatly appreciate any feedback, suggestions, or ideas you might have! If you: + +- Find errors in any vocabulary pack +- Have ideas for new topics, languages, or categories +- Want to request a specific vocabulary pack +- Have suggestions for improving existing packs + +Please don't hesitate to reach out through the Request feature or contact me directly. Your input helps make this app better for everyone! + +### How Packs Work + +- **Download** packs that interest you +- **Preview** the words before adding them +- **Import** them into your library with options to handle duplicates +- **Organize** them into categories which are created automatically + + +Thank you for using this app and your feedback! \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/composable/AppTopAppBar.kt b/app/src/main/java/eu/gaudian/translator/view/composable/AppTopAppBar.kt index 6fe3824..2b32c2b 100644 --- a/app/src/main/java/eu/gaudian/translator/view/composable/AppTopAppBar.kt +++ b/app/src/main/java/eu/gaudian/translator/view/composable/AppTopAppBar.kt @@ -49,7 +49,7 @@ fun AppTopAppBar( navigationIcon: @Composable (() -> Unit)? = null, actions: @Composable RowScope.() -> Unit = {}, colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), - hintContent: Hint? = null + hint: Hint? = null ) { val sheetState = rememberModalBottomSheetState() var showBottomSheet by remember { mutableStateOf(false) } @@ -61,7 +61,7 @@ fun AppTopAppBar( colors = colors, title = { val showHints = LocalShowHints.current - if (showHints && hintContent != null) { + if (showHints && hint != null) { // Simplified row: keeps the title and hint icon neatly centered together Row( verticalAlignment = Alignment.CenterVertically, @@ -114,7 +114,7 @@ fun AppTopAppBar( ) if (showBottomSheet) { - hintContent?.let { + hint?.let { HintBottomSheet( onDismissRequest = { @Suppress("AssignedValueIsNeverRead") diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/RequestMorePackDialog.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/RequestMorePackDialog.kt new file mode 100644 index 0000000..349d8ea --- /dev/null +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/RequestMorePackDialog.kt @@ -0,0 +1,167 @@ +package eu.gaudian.translator.view.dialogs + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import eu.gaudian.translator.R +import eu.gaudian.translator.ui.theme.ThemePreviews +import eu.gaudian.translator.view.composable.AppDialog +import eu.gaudian.translator.view.composable.AppSlider +import kotlin.math.roundToInt + +@Composable +fun RequestMorePackDialog( + onDismiss: () -> Unit, +) { + val context = LocalContext.current + var topic by remember { mutableStateOf("") } + var langFrom by remember { mutableStateOf("") } + var langTo by remember { mutableStateOf("") } + var amount by remember { mutableFloatStateOf(50f) } + + AppDialog( + onDismissRequest = onDismiss, + title = { Text("Request a Pack", fontWeight = FontWeight.Bold) }, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + stringResource(R.string.text_request_pack_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + stringResource(R.string.label_topic), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + OutlinedTextField( + value = topic, + onValueChange = { topic = it }, + placeholder = { Text("e.g. Travel, Business, Cooking…") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + stringResource(R.string.label_languages), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + stringResource(R.string.label_optional), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = langFrom, + onValueChange = { langFrom = it }, + placeholder = { Text(stringResource(R.string.label_from)) }, + label = { Text(stringResource(R.string.label_from)) }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = langTo, + onValueChange = { langTo = it }, + placeholder = { Text(stringResource(R.string.label_to)) }, + label = { Text(stringResource(R.string.label_to)) }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + + Text( + "Approx. word count: ~${amount.roundToInt()} words", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + AppSlider( + value = amount, + onValueChange = { amount = it }, + valueRange = 10f..200f, + steps = 18, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.label_cancel)) } + TextButton( + enabled = topic.isNotBlank(), + onClick = { + val subject = "Polly Pack Request – $topic" + val langPart = buildString { + val from = langFrom.trim() + val to = langTo.trim() + if (from.isNotBlank() || to.isNotBlank()) { + append("Languages: ${from.ifBlank { "?" }} → ${to.ifBlank { "?" }}\n") + } + } + val body = buildString { + appendLine("Hey Jonas,") + appendLine() + appendLine("Please add the following vocabulary pack to Polly:") + appendLine() + appendLine("Topic: $topic") + if (langPart.isNotBlank()) append(langPart) + appendLine("Word count: ~${amount.roundToInt()} words") + appendLine() + appendLine("Thank you!") + } + val intent = android.content.Intent(android.content.Intent.ACTION_SENDTO).apply { + data = "mailto:play@gaudian.eu".toUri() + putExtra(android.content.Intent.EXTRA_EMAIL, arrayOf("play@gaudian.eu")) + putExtra(android.content.Intent.EXTRA_SUBJECT, subject) + putExtra(android.content.Intent.EXTRA_TEXT, body) + } + context.startActivity(intent) + onDismiss() + } + ) { + Text( + stringResource(R.string.label_send_request), + fontWeight = FontWeight.Bold + ) + } + } + } + } +} + +@ThemePreviews +@Composable +fun RequestMorePackDialogPreview() { + RequestMorePackDialog( + onDismiss = {} + ) +} diff --git a/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyReviewScreen.kt b/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyReviewScreen.kt index df0eeca..4ad1c6a 100644 --- a/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyReviewScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/dialogs/VocabularyReviewScreen.kt @@ -63,7 +63,7 @@ fun VocabularyReviewScreen( topBar = { AppTopAppBar( title = stringResource(R.string.found_items), - hintContent = HintDefinition.REVIEW.hint() + hint = HintDefinition.REVIEW.hint() ) }, ) { paddingValues -> diff --git a/app/src/main/java/eu/gaudian/translator/view/hints/AllHints.kt b/app/src/main/java/eu/gaudian/translator/view/hints/AllHints.kt index 39417b4..2ebb255 100644 --- a/app/src/main/java/eu/gaudian/translator/view/hints/AllHints.kt +++ b/app/src/main/java/eu/gaudian/translator/view/hints/AllHints.kt @@ -26,7 +26,8 @@ enum class HintDefinition( REVIEW("review_hint", R.string.review_intro), SORTING("sorting_hint", R.string.sorting_hint_title), TRANSLATION("translation_hint", R.string.hint_translate_how_it_works), - VOCABULARY_PROGRESS("vocabulary_progress_hint", R.string.hint_vocabulary_progress_hint_title); + VOCABULARY_PROGRESS("vocabulary_progress_hint", R.string.hint_vocabulary_progress_hint_title), + EXPLORE_PACKS("explore_packs_hint", R.string.hint_explore_packs_title); /** Creates the Hint data class for this hint definition. */ @Composable diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/AddModelScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/AddModelScreen.kt index c83a13d..2f27c5c 100644 --- a/app/src/main/java/eu/gaudian/translator/view/settings/AddModelScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/settings/AddModelScreen.kt @@ -136,7 +136,7 @@ fun AddModelScreen(navController: NavController, providerKey: String) { AppTopAppBar( title = providerName, onNavigateBack = { navController.popBackStack() }, - hintContent = HintDefinition.ADD_MODEL_SCAN.hint() + hint = HintDefinition.ADD_MODEL_SCAN.hint() ) }, ) { paddingValues -> diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/ApiKeyScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/ApiKeyScreen.kt index a775abc..32f226f 100644 --- a/app/src/main/java/eu/gaudian/translator/view/settings/ApiKeyScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/settings/ApiKeyScreen.kt @@ -117,7 +117,7 @@ fun ApiKeyScreen(navController: NavController) { AppTopAppBar( title = stringResource(R.string.label_ai_configuration), onNavigateBack = { navController.popBackStack() }, - hintContent = HintDefinition.API_KEY.hint() + hint = HintDefinition.API_KEY.hint() ) } ) { paddingValues -> diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/CustomPromptScreens.kt b/app/src/main/java/eu/gaudian/translator/view/settings/CustomPromptScreens.kt index 797c4ae..6387409 100644 --- a/app/src/main/java/eu/gaudian/translator/view/settings/CustomPromptScreens.kt +++ b/app/src/main/java/eu/gaudian/translator/view/settings/CustomPromptScreens.kt @@ -53,7 +53,7 @@ fun CustomVocabularyPromptScreen( AppTopAppBar( title = stringResource(R.string.text_vocabulary_prompt), onNavigateBack = { navController.popBackStack() }, - hintContent = null //TODO: Add hint + hint = null //TODO: Add hint ) } diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/DictionaryOptionsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/DictionaryOptionsScreen.kt index 06c7929..680cd35 100644 --- a/app/src/main/java/eu/gaudian/translator/view/settings/DictionaryOptionsScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/settings/DictionaryOptionsScreen.kt @@ -64,7 +64,7 @@ fun DictionaryOptionsScreen( AppTopAppBar( title = stringResource(R.string.label_dictionary_options), onNavigateBack = { navController.popBackStack() }, - hintContent = HintDefinition.DICTIONARY_OPTIONS.hint() + hint = HintDefinition.DICTIONARY_OPTIONS.hint() ) } ) { paddingValues -> diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/TranslationSettings.kt b/app/src/main/java/eu/gaudian/translator/view/settings/TranslationSettings.kt index 52af70c..58fee84 100644 --- a/app/src/main/java/eu/gaudian/translator/view/settings/TranslationSettings.kt +++ b/app/src/main/java/eu/gaudian/translator/view/settings/TranslationSettings.kt @@ -62,7 +62,7 @@ fun TranslationSettingsScreen( AppTopAppBar( title = stringResource(R.string.label_translation_settings), onNavigateBack = { navController.popBackStack() }, - hintContent = null //TODO add hint + hint = null //TODO add hint ) } ) { paddingValues -> diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyProgressOptionsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyProgressOptionsScreen.kt index c338fbe..c58cbb9 100644 --- a/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyProgressOptionsScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyProgressOptionsScreen.kt @@ -79,7 +79,7 @@ fun VocabularyProgressOptionsScreen( AppTopAppBar( title = stringResource(R.string.label_vocabulary_settings), onNavigateBack = { navController.popBackStack() }, - hintContent = HintDefinition.VOCABULARY_PROGRESS.hint() + hint = HintDefinition.VOCABULARY_PROGRESS.hint() ) } ) { paddingValues -> diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt index 19f3153..7cc6e56 100644 --- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/ExplorePacksScreen.kt @@ -1,5 +1,3 @@ -@file:Suppress("HardCodedStringLiteral") - package eu.gaudian.translator.view.vocabulary import androidx.compose.foundation.background @@ -20,18 +18,17 @@ 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.GridItemSpan 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.ArrowForwardIos 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.Warning -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -40,6 +37,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -52,6 +50,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -64,18 +63,25 @@ 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.res.stringResource 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.R import eu.gaudian.translator.model.ConflictStrategy +import eu.gaudian.translator.model.VocabularyItem 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.AppAlertDialog +import eu.gaudian.translator.view.composable.AppDialog import eu.gaudian.translator.view.composable.AppTopAppBar +import eu.gaudian.translator.view.dialogs.RequestMorePackDialog +import eu.gaudian.translator.view.hints.HintDefinition import eu.gaudian.translator.view.translation.LanguageSelectorBar import eu.gaudian.translator.viewmodel.ExportImportViewModel import eu.gaudian.translator.viewmodel.ImportState @@ -83,6 +89,7 @@ import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.PackDownloadState import eu.gaudian.translator.viewmodel.PackUiState import eu.gaudian.translator.viewmodel.VocabPacksViewModel +import kotlin.math.abs private const val TAG = "ExplorePacksScreen" @@ -94,17 +101,16 @@ enum class PackFilter { All, Newest, A1, A2, B1, B2, C1, C2; - 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" - } + fun getLabel(context: android.content.Context): String = when (this) { + All -> context.getString(R.string.filter_all) + Newest -> context.getString(R.string.filter_newest) + A1 -> context.getString(R.string.filter_a1) + A2 -> context.getString(R.string.filter_a2) + B1 -> context.getString(R.string.filter_b1) + B2 -> context.getString(R.string.filter_b2) + C1 -> context.getString(R.string.filter_c1) + C2 -> context.getString(R.string.filter_c2) + } val cefrCode: String? get() = when (this) { @@ -129,7 +135,7 @@ private val gradientPalette = listOf( ) private fun gradientForId(id: String): List = - gradientPalette[Math.abs(id.hashCode()) % gradientPalette.size] + gradientPalette[abs(id.hashCode()) % gradientPalette.size] // --------------------------------------------------------------------------- // Screen @@ -142,6 +148,7 @@ fun ExplorePacksScreen( modifier: Modifier = Modifier, ) { val activity = LocalContext.current.findActivity() + val context = LocalContext.current val vocabPacksViewModel: VocabPacksViewModel = hiltViewModel() val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity) @@ -162,6 +169,41 @@ fun ExplorePacksScreen( var showStrategyDialog by remember { mutableStateOf(false) } var isImporting by remember { mutableStateOf(false) } + // Pack whose ID the user tapped "Get" on – once DOWNLOADED, auto-open conflict dialog + var pendingImportPackId by remember { mutableStateOf(null) } + // Pack opened in the full-screen preview + var previewPack by remember { mutableStateOf(null) } + // Request More dialog + var showRequestMoreDialog by remember { mutableStateOf(false) } + + // Auto-delete downloaded-but-not-imported files when the screen is closed + DisposableEffect(Unit) { + onDispose { + vocabPacksViewModel.cleanupDownloadedFiles() + } + } + + // Auto-open conflict dialog once a queued download finishes + LaunchedEffect(packs, pendingImportPackId) { + val id = pendingImportPackId ?: return@LaunchedEffect + val ps = packs.find { it.info.id == id } ?: return@LaunchedEffect + when (ps.downloadState) { + PackDownloadState.DOWNLOADED -> { + pendingImportPackState = ps + showStrategyDialog = true + pendingImportPackId = null + } + PackDownloadState.ERROR -> { pendingImportPackId = null } + else -> {} + } + } + + // Keep previewPack in sync with latest state from packs list + LaunchedEffect(packs) { + val id = previewPack?.info?.id ?: return@LaunchedEffect + previewPack = packs.find { it.info.id == id } + } + // Observe async import result LaunchedEffect(importState) { val pending = pendingImportPackState ?: return@LaunchedEffect @@ -234,16 +276,17 @@ fun ExplorePacksScreen( ) { // ── Top bar ─────────────────────────────────────────────────────── AppTopAppBar( - title = "Explore Packs", + title = stringResource(R.string.title_explore_packs), onNavigateBack = { navController.popBackStack() }, actions = { IconButton(onClick = { vocabPacksViewModel.loadManifest() }) { - Icon(Icons.Default.Refresh, contentDescription = "Reload") + Icon( + Icons.Default.Refresh, + contentDescription = stringResource(R.string.cd_reload) + ) } - IconButton(onClick = { /* TODO: advanced filter sheet */ }) { - Icon(Icons.Default.FilterList, contentDescription = "Filter") - } - } + }, + hint = HintDefinition.EXPLORE_PACKS.hint() ) Spacer(modifier = Modifier.height(12.dp)) @@ -254,7 +297,7 @@ fun ExplorePacksScreen( onValueChange = { searchQuery = it }, placeholder = { Text( - "Search topics, phrases…", + stringResource(R.string.search_topics_phrases), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) ) }, @@ -301,7 +344,7 @@ fun ExplorePacksScreen( onClick = { selectedFilter = filter }, label = { Text( - text = filter.label, + text = filter.getLabel(context), style = MaterialTheme.typography.labelMedium, fontWeight = if (selectedFilter == filter) FontWeight.Bold else FontWeight.Normal ) @@ -323,13 +366,13 @@ fun ExplorePacksScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - "Featured Collections", + stringResource(R.string.label_available_collections), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) if (!isLoadingManifest && packs.isNotEmpty()) { Text( - "${filteredPacks.size} packs", + stringResource(R.string.label_d_packs, filteredPacks.size), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -345,7 +388,7 @@ fun ExplorePacksScreen( Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator() Spacer(modifier = Modifier.height(12.dp)) - Text("Loading packs…", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.text_loading_packs), style = MaterialTheme.typography.bodyMedium) } } } @@ -357,7 +400,7 @@ fun ExplorePacksScreen( modifier = Modifier.padding(24.dp) ) { Text( - "Could not load packs", + stringResource(R.string.text_could_not_load_packs), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) @@ -370,7 +413,7 @@ fun ExplorePacksScreen( ) Spacer(modifier = Modifier.height(16.dp)) Button(onClick = { vocabPacksViewModel.loadManifest() }) { - Text("Retry") + Text(stringResource(R.string.label_retry)) } } } @@ -378,11 +421,21 @@ fun ExplorePacksScreen( filteredPacks.isEmpty() -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - "No packs match your search.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp) + ) { + Text( + stringResource(R.string.text_no_packs_match_search), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + OutlinedButton(onClick = { showRequestMoreDialog = true }) { + Text(stringResource(R.string.label_request_a_pack), fontWeight = FontWeight.Bold) + } + } } } @@ -394,27 +447,89 @@ fun ExplorePacksScreen( contentPadding = PaddingValues(bottom = 100.dp), modifier = Modifier.fillMaxSize() ) { + item(span = { GridItemSpan(maxLineSpan) }) { + // intentionally empty – grid footer below + } items(filteredPacks, key = { it.info.id }) { packState -> PackCard( packState = packState, - onGetClick = { vocabPacksViewModel.downloadPack(packState.info) }, + onCardClick = { + previewPack = packState + when (packState.downloadState) { + // Not yet downloaded → download for preview (will auto-open items) + PackDownloadState.IDLE -> + vocabPacksViewModel.downloadPack(packState.info) + // Already in library → re-download briefly just for preview + PackDownloadState.IMPORTED -> + vocabPacksViewModel.downloadForPreview(packState.info) + else -> {} + } + }, + onGetClick = { + // Combined flow: download → auto-open conflict dialog + pendingImportPackId = packState.info.id + vocabPacksViewModel.downloadPack(packState.info) + }, onAddToLibraryClick = { pendingImportPackState = packState showStrategyDialog = true }, - onDeleteClick = { vocabPacksViewModel.deletePack(packState.info) }, ) } + + // ── Request More footer ──────────────────────────────── + item(span = { GridItemSpan(maxLineSpan) }) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(R.string.text_dont_see_what_looking_for), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + OutlinedButton(onClick = { showRequestMoreDialog = true }) { + Text(stringResource(R.string.label_request_a_pack), fontWeight = FontWeight.Bold) + } + } + } } } } } } + // ── Request More dialog ─────────────────────────────────────────────────── + if (showRequestMoreDialog) { + RequestMorePackDialog(onDismiss = { showRequestMoreDialog = false }) + } + + // ── Pack preview dialog ─────────────────────────────────────────────────── + val preview = previewPack + if (preview != null) { + PackPreviewDialog( + packState = preview, + onDismiss = { previewPack = null }, + onGetClick = { + pendingImportPackId = preview.info.id + vocabPacksViewModel.downloadPack(preview.info) + previewPack = null + }, + onAddToLibraryClick = { + pendingImportPackState = preview + showStrategyDialog = true + previewPack = null + }, + ) + } + // ── Conflict strategy dialog ────────────────────────────────────────────── if (showStrategyDialog && pendingImportPackState != null) { val packState = pendingImportPackState!! - AlertDialog( + AppAlertDialog( onDismissRequest = { if (!isImporting) { showStrategyDialog = false @@ -422,7 +537,7 @@ fun ExplorePacksScreen( } }, icon = { Icon(Icons.Default.Warning, contentDescription = null) }, - title = { Text("Import \"${packState.info.name}\"") }, + title = { Text(stringResource(R.string.title_import_pack, packState.info.name)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (isImporting) { @@ -434,19 +549,19 @@ fun ExplorePacksScreen( CircularProgressIndicator() Spacer(modifier = Modifier.height(12.dp)) Text( - "Importing ${packState.info.itemCount} words…", + stringResource(R.string.text_importing_d_words, packState.info.itemCount), style = MaterialTheme.typography.bodyMedium ) } } } else { Text( - "${packState.info.itemCount} words will be added to your library.", + stringResource(R.string.text_d_words_will_be_added, packState.info.itemCount), style = MaterialTheme.typography.bodyMedium ) Spacer(modifier = Modifier.height(4.dp)) Text( - "How should duplicates be handled?", + stringResource(R.string.text_how_handle_duplicates), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold ) @@ -454,26 +569,26 @@ fun ExplorePacksScreen( PackConflictStrategyOption( selected = selectedConflictStrategy == ConflictStrategy.MERGE, onSelected = { selectedConflictStrategy = ConflictStrategy.MERGE }, - title = "Merge (Recommended)", - description = "Keep existing progress; merge categories intelligently." + title = stringResource(R.string.strategy_merge), + description = stringResource(R.string.strategy_merge_desc) ) PackConflictStrategyOption( selected = selectedConflictStrategy == ConflictStrategy.SKIP, onSelected = { selectedConflictStrategy = ConflictStrategy.SKIP }, - title = "Skip Duplicates", - description = "Only add words that don't already exist." + title = stringResource(R.string.strategy_skip), + description = stringResource(R.string.strategy_skip_desc) ) PackConflictStrategyOption( selected = selectedConflictStrategy == ConflictStrategy.REPLACE, onSelected = { selectedConflictStrategy = ConflictStrategy.REPLACE }, - title = "Replace Existing", - description = "Overwrite matching words with the pack version." + title = stringResource(R.string.strategy_replace), + description = stringResource(R.string.strategy_replace_desc) ) PackConflictStrategyOption( selected = selectedConflictStrategy == ConflictStrategy.RENAME, onSelected = { selectedConflictStrategy = ConflictStrategy.RENAME }, - title = "Keep Both", - description = "Add all words from the pack as new entries." + title = stringResource(R.string.strategy_keep_both), + description = stringResource(R.string.strategy_keep_both_desc) ) } } @@ -496,7 +611,7 @@ fun ExplorePacksScreen( showStrategyDialog = false exportImportViewModel.importFromJson(json, selectedConflictStrategy) } - ) { Text("Add to Library") } + ) { Text(stringResource(R.string.label_add_to_library)) } }, dismissButton = { TextButton( @@ -505,7 +620,7 @@ fun ExplorePacksScreen( showStrategyDialog = false pendingImportPackState = null } - ) { Text("Cancel") } + ) { Text(stringResource(R.string.label_cancel)) } } ) } @@ -553,16 +668,18 @@ private fun PackConflictStrategyOption( @Composable private fun PackCard( packState: PackUiState, + onCardClick: () -> Unit, onGetClick: () -> Unit, onAddToLibraryClick: () -> Unit, - onDeleteClick: () -> Unit, modifier: Modifier = Modifier, ) { val info = packState.info val gradient = gradientForId(info.id) Surface( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onCardClick), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceContainer, tonalElevation = 2.dp @@ -623,8 +740,8 @@ private fun PackCard( // Status badge (top-right) val badgeData: Pair? = when (packState.downloadState) { - PackDownloadState.DOWNLOADED -> Color(0xFF1565C0) to "Ready" - PackDownloadState.IMPORTED -> Color(0xFF388E3C) to "In Library" + PackDownloadState.DOWNLOADED -> Color(0xFF1565C0) to stringResource(R.string.label_ready) + PackDownloadState.IMPORTED -> Color(0xFF388E3C) to stringResource(R.string.label_in_library) else -> null } if (badgeData != null) { @@ -678,7 +795,7 @@ private fun PackCard( ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "${info.itemCount} cards", + text = stringResource(R.string.text_d_cards, info.itemCount), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -693,7 +810,7 @@ private fun PackCard( contentPadding = PaddingValues(0.dp), shape = RoundedCornerShape(8.dp) ) { - Text("Get", style = MaterialTheme.typography.labelMedium, + Text(stringResource(R.string.label_get), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) } } @@ -706,36 +823,23 @@ private fun PackCard( contentPadding = PaddingValues(0.dp), shape = RoundedCornerShape(8.dp) ) { - Text("Downloading…", style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.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) - } + 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(stringResource(R.string.label_add_d_words, info.itemCount), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold) } } @@ -750,7 +854,7 @@ private fun PackCard( modifier = Modifier.size(14.dp), tint = Color(0xFF388E3C)) Spacer(modifier = Modifier.width(4.dp)) - Text("In Library", style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.label_in_library), style = MaterialTheme.typography.labelSmall) } } @@ -764,7 +868,7 @@ private fun PackCard( containerColor = MaterialTheme.colorScheme.error ) ) { - Text("Retry", style = MaterialTheme.typography.labelMedium, + Text(stringResource(R.string.label_retry), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) } } @@ -773,3 +877,197 @@ private fun PackCard( } } } + +// --------------------------------------------------------------------------- +// Pack preview sheet (AppDialog = ModalBottomSheet) +// --------------------------------------------------------------------------- + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PackPreviewDialog( + packState: PackUiState, + onDismiss: () -> Unit, + onGetClick: () -> Unit, + onAddToLibraryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val info = packState.info + val gradient = gradientForId(info.id) + var selectedItem by remember { mutableStateOf(null) } + + AppDialog( + onDismissRequest = onDismiss, + title = { Text(info.name, fontWeight = FontWeight.Bold) }, + ) { + // ── Gradient banner ─────────────────────────────────────────── + Box( + modifier = modifier + .fillMaxWidth() + .height(140.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Brush.verticalGradient(gradient)) + ) { + if (info.level.isNotBlank()) { + Surface( + modifier = Modifier.align(Alignment.TopStart).padding(10.dp), + shape = RoundedCornerShape(6.dp), + color = Color.Black.copy(alpha = 0.4f) + ) { + Text( + text = info.level, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + Column(modifier = Modifier.align(Alignment.BottomStart).padding(12.dp)) { + Text(info.emoji, fontSize = 32.sp) + Text( + "${info.category} · ${stringResource(R.string.text_d_cards, info.itemCount)}", + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.85f) + ) + } + } + + // ── Description ─────────────────────────────────────────────── + if (info.description.isNotBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = info.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() + } + + // ── Item list (or loading indicator) ────────────────────────── + Spacer(modifier = Modifier.height(8.dp)) + when { + packState.downloadState == PackDownloadState.DOWNLOADING || + packState.previewItems.isEmpty() -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp) + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(12.dp)) + Text( + stringResource(R.string.text_loading_preview), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + else -> { + val items = packState.previewItems + Text( + stringResource(R.string.label_words_in_this_pack), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(4.dp)) + // Regular Column (AppDialog is already inside verticalScroll) + Column { + items.forEachIndexed { index, item -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { selectedItem = item } + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(item.wordFirst, style = MaterialTheme.typography.bodyLarge) + Text( + item.wordSecond, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + Icons.Default.ArrowForwardIos, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + if (index < items.lastIndex) HorizontalDivider(thickness = 0.5.dp) + } + } + } + } + + // ── Action button ───────────────────────────────────────────── + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + when (packState.downloadState) { + PackDownloadState.IDLE -> { + Button( + onClick = onGetClick, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(12.dp) + ) { + Text(stringResource(R.string.label_get_d_words, info.itemCount), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + } + PackDownloadState.DOWNLOADING -> { + Button(onClick = {}, enabled = false, modifier = Modifier.fillMaxWidth().height(52.dp), shape = RoundedCornerShape(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.text_downloading)) + } + } + } + PackDownloadState.DOWNLOADED -> { + Button( + onClick = onAddToLibraryClick, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary) + ) { + Text(stringResource(R.string.label_add_d_words_to_library, info.itemCount), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + } + PackDownloadState.IMPORTED -> { + OutlinedButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth().height(52.dp), shape = RoundedCornerShape(12.dp)) { + Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Color(0xFF388E3C), modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.text_already_in_your_library), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + } + PackDownloadState.ERROR -> { + Button( + onClick = onGetClick, + modifier = Modifier.fillMaxWidth().height(52.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text(stringResource(R.string.label_retry_download), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + } + } + } + + // ── Individual item detail ──────────────────────────────────────────────── + val item = selectedItem + if (item != null) { + AppAlertDialog( + onDismissRequest = { selectedItem = null }, + title = { Text(item.wordFirst, fontWeight = FontWeight.Bold) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(item.wordSecond, style = MaterialTheme.typography.bodyLarge) + } + }, + confirmButton = { + TextButton(onClick = { selectedItem = null }) { Text(stringResource(R.string.label_close)) } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularySortingScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularySortingScreen.kt index e42461d..f4764bd 100644 --- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularySortingScreen.kt +++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularySortingScreen.kt @@ -232,7 +232,7 @@ fun VocabularySortingScreen( } }, onNavigateBack = { navController.popBackStack() }, - hintContent = HintDefinition.SORTING.hint() + hint = HintDefinition.SORTING.hint() ) }, diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt index 49669c9..54accd0 100644 --- a/app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt +++ b/app/src/main/java/eu/gaudian/translator/viewmodel/VocabPacksViewModel.kt @@ -7,13 +7,19 @@ 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.VocabularyItem import eu.gaudian.translator.model.communication.FileDownloadManager import eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo +import eu.gaudian.translator.model.jsonParser import eu.gaudian.translator.utils.Log +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable import java.io.File import javax.inject.Inject @@ -27,8 +33,14 @@ data class PackUiState( val info: VocabCollectionInfo, val downloadState: PackDownloadState = PackDownloadState.IDLE, val progress: Float = 0f, + /** Items available once the file has been downloaded and parsed (for preview). */ + val previewItems: List = emptyList(), ) +/** Internal wrapper for deserializing the items array from a pack file. */ +@Serializable +private data class PackPreviewWrapper(val items: List = emptyList()) + // --------------------------------------------------------------------------- // ViewModel // --------------------------------------------------------------------------- @@ -51,10 +63,30 @@ class VocabPacksViewModel @Inject constructor( private val _manifestError = MutableStateFlow(null) val manifestError: StateFlow = _manifestError.asStateFlow() + /** + * Emits a pack ID every time a pack has been fully downloaded AND its items parsed. + * The screen listens to this to auto-open the conflict dialog after "Get" is tapped. + */ + private val _downloadCompleteEvents = MutableSharedFlow(extraBufferCapacity = 8) + val downloadCompleteEvents: SharedFlow = _downloadCompleteEvents.asSharedFlow() + init { loadManifest() } + // ── Persistent import records ───────────────────────────────────────────── + + private fun importedVersion(packId: String): Int? { + val prefs = context.getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE) + return if (prefs.contains(prefKey(packId))) prefs.getInt(prefKey(packId), -1) else null + } + + private fun saveImportedVersion(packId: String, version: Int) { + context.getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE) + .edit().putInt(prefKey(packId), version).apply() + Log.d(TAG, "Saved imported version $version for $packId") + } + // ── Manifest ───────────────────────────────────────────────────────────── fun loadManifest() { @@ -65,10 +97,18 @@ class VocabPacksViewModel @Inject constructor( val manifest = downloadManager.fetchVocabManifest() Log.d(TAG, "Fetched ${manifest?.size ?: 0} packs from manifest") _packs.value = manifest?.map { info -> + val savedVersion = importedVersion(info.id) + val isCurrentVersionImported = savedVersion != null && savedVersion >= info.version val downloaded = downloadManager.isVocabCollectionDownloaded(info) + val items = if (downloaded) parsePreviewItems(info) else emptyList() PackUiState( info = info, - downloadState = if (downloaded) PackDownloadState.DOWNLOADED else PackDownloadState.IDLE, + downloadState = when { + isCurrentVersionImported -> PackDownloadState.IMPORTED + downloaded -> PackDownloadState.DOWNLOADED + else -> PackDownloadState.IDLE + }, + previewItems = items, ) } ?: emptyList() } catch (e: Exception) { @@ -83,6 +123,11 @@ class VocabPacksViewModel @Inject constructor( // ── Download ───────────────────────────────────────────────────────────── fun downloadPack(info: VocabCollectionInfo) { + // Avoid double-downloading + val current = _packs.value.find { it.info.id == info.id } + if (current?.downloadState == PackDownloadState.DOWNLOADING || + current?.downloadState == PackDownloadState.DOWNLOADED) return + viewModelScope.launch { Log.d(TAG, "Starting download of ${info.id}") updatePack(info.id) { it.copy(downloadState = PackDownloadState.DOWNLOADING, progress = 0f) } @@ -91,8 +136,12 @@ class VocabPacksViewModel @Inject constructor( 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) } + val items = parsePreviewItems(info) + Log.d(TAG, "Download complete for ${info.id}: ${items.size} items parsed") + updatePack(info.id) { + it.copy(downloadState = PackDownloadState.DOWNLOADED, progress = 1f, previewItems = items) + } + _downloadCompleteEvents.emit(info.id) } else { Log.e(TAG, "Download returned false for ${info.id}") updatePack(info.id) { it.copy(downloadState = PackDownloadState.ERROR) } @@ -104,15 +153,9 @@ class VocabPacksViewModel @Inject constructor( } } - // ── Read file for import ────────────────────────────────────────────────── + // ── Read raw JSON 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": {...}, ... } - */ + /** Returns the raw JSON of the downloaded pack file, or null if not present. */ fun readPackRawJson(info: VocabCollectionInfo): String? { val file = localFile(info) if (!file.exists()) { @@ -131,34 +174,89 @@ class VocabPacksViewModel @Inject constructor( // ── 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}") } + saveImportedVersion(info.id, info.version) updatePack(info.id) { it.copy(downloadState = PackDownloadState.IMPORTED, progress = 1f) } } - // ── Delete ──────────────────────────────────────────────────────────────── + // ── Preview download for already-imported packs ─────────────────────────── - 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) } + /** + * Downloads the pack file solely to populate [PackUiState.previewItems] for IMPORTED packs. + * The file is deleted immediately after parsing; [downloadState] stays IMPORTED throughout. + */ + fun downloadForPreview(info: VocabCollectionInfo) { + val current = _packs.value.find { it.info.id == info.id } + if (current?.downloadState != PackDownloadState.IMPORTED) return + if (current.previewItems.isNotEmpty()) return // already cached in memory + + viewModelScope.launch { + Log.d(TAG, "Downloading for preview (IMPORTED): ${info.id}") + try { + val success = downloadManager.downloadVocabCollection(info) { /* no progress UI */ } + if (success) { + val items = parsePreviewItems(info) + Log.d(TAG, "Preview items loaded for ${info.id}: ${items.size}") + updatePack(info.id) { it.copy(previewItems = items) } + // Delete the temp file immediately – we only needed it for parsing + localFile(info).takeIf { it.exists() }?.delete() + } + } catch (e: Exception) { + Log.e(TAG, "Preview download failed for ${info.id}", e) + } + } + } + + // ── Cleanup on screen exit ──────────────────────────────────────────────── + + /** + * Deletes all downloaded-but-not-yet-imported files and resets those packs to IDLE. + * Called from DisposableEffect.onDispose in ExplorePacksScreen. + */ + fun cleanupDownloadedFiles() { + val toClean = _packs.value.filter { it.downloadState == PackDownloadState.DOWNLOADED } + toClean.forEach { ps -> + val file = localFile(ps.info) + if (file.exists()) { + file.delete() + Log.d(TAG, "Cleaned up on exit: ${ps.info.id}") + } + } + if (toClean.isNotEmpty()) { + _packs.value = _packs.value.map { ps -> + if (ps.downloadState == PackDownloadState.DOWNLOADED) + ps.copy(downloadState = PackDownloadState.IDLE, progress = 0f, previewItems = emptyList()) + else ps + } + } } // ── Helpers ─────────────────────────────────────────────────────────────── + private fun parsePreviewItems(info: VocabCollectionInfo): List { + val json = readPackRawJson(info) ?: return emptyList() + return try { + val wrapper = jsonParser.decodeFromString(json) + wrapper.items.map { it.copy(id = 0) } // reset DB ids – these are preview-only + } catch (e: Exception) { + Log.e(TAG, "Error parsing preview items for ${info.id}", e) + emptyList() + } + } + private fun localFile(info: VocabCollectionInfo): File = File(context.filesDir, "flashcard-collections/${info.filename}") + companion object { + private const val PREFS_NAME = "vocab_packs_imported" + private fun prefKey(packId: String) = "v_$packId" + } + private fun updatePack(packId: String, transform: (PackUiState) -> PackUiState) { _packs.value = _packs.value.map { if (it.info.id == packId) transform(it) else it } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c83a1a..6fd7c47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,9 @@ + You can create two types of categories to organize your vocabulary: + Achieved + Add App Logo Back Clear Search @@ -8,10 +11,20 @@ Collapse Error Expand + Filter + Filter options + Go Navigate back + Options Paste + Play Re-generate Definition + Reload + Scroll to top Search + Search + Selected + Settings Success Switch Languages Target Met @@ -19,20 +32,9 @@ Toggle Menu Translation History - Choose the right translation - - Clear All - - Close exercise - Close selection mode - - Colloquial - Contact me for bug reports, ideas, feature requests, and more. Contact developer - Context - Copied Text Copy text @@ -42,7 +44,6 @@ Correct answers: %1$d Tone - Create Create a new custom language entry for this ID. Create New Category Create New Language @@ -74,6 +75,9 @@ Delete New Delete Provider + %1$d words need attention + Expand your vocabulary + Description Deselect All @@ -85,6 +89,7 @@ Due Today: %1$s + Duplicate Duplicate Detected Duplicates Only @@ -134,9 +139,17 @@ Fetching for %d Items Fetching Grammar Details + Beginner · A1 + Elementary · A2 + + All Filter and Sort - Filter by Stage + Intermediate · B1 + Upper Int. · B2 Filter by Word Type + Advanced · C1 + Proficient · C2 + Newest Find Translations @@ -165,8 +178,17 @@ Hide Hint: %1$s + Advanced Features + Getting Started + Vocabulary Management + All hints that are in this app can be found here as well. + Help Center How to connect to an AI How to generate Vocabulary with AI + Finding the right AI model + Help and Instructions + How translation works + Vocabulary Progress Tracking Imperative @@ -190,6 +212,7 @@ Keep Both + %1$d Days About Academic Correct @@ -198,19 +221,26 @@ Add Category Add Custom Model Add Custom Provider + Add %1$d words + Add %1$d words to Library Add Key Add Model Add Model Manually Add synonym Add to dictionary + Add to Library Add Vocabulary Added Adjective Adverb AI Configuration + AI Generator AI Model + All Cards + All Categories + All Categories All Stages All Types All Vocabulary @@ -220,6 +250,8 @@ Appearance Apply Filters Article + Auto Cycle (Dev) + Available Collections Backup and Restore By Language Cancel @@ -228,19 +260,30 @@ Category Category: %1$s Clear + Clear All Close + Close exercise Close search + Close selection mode Collapse + Colloquial Column %1$d Common Completed Confirm Conjugation: %1$s Conjunction + Context Continue Correct + Create Create Exercise + Current Streak Custom + %1$d packs + Daily Goal + Daily Review + Declension Definitions Delete Delete all @@ -254,110 +297,159 @@ Dictionary Content Dictionary Manager Dictionary Options - AI Definition - Downloaded Display Name Done Download Easy + Edit Enter a text Etymology Exercise Exercises Expand Feminine + Filter by Stage + Filter Cards First Column First Language + From Gender General + + Get + Get – %1$d words " (Auxiliary: %1$s)" Hyphenation Inflections Meanings + Grammar only Guessing Hard First Row is a Header Hide examples Home Import + Import CSV Import Table (CSV) + In Library In Stages Interjection + Interval Settings Auto Language Direction\n None Languages Learned + learned Learning Criteria + Library Logs Masculine Medium Model ID * More Move to First Stage + Choose the right translation Multiple Choice Neuter New + New Words + New Words + None + No history yet Noun + (Optional) Origin Language Orphaned Files + Paste Plural Preposition Preview (first 5) for first column: %1$s Preview (first 5) for second column: %1$s Pronoun + Pronunc iation Providers Quit App Quit Exercise? Raw Data: + Read Aloud + Ready + Recently Added + Regenerate Related Words Reload Remove Articles + Request a Pack + Reset + Retry + Retry download Save Scan for Models Scanning… + Search cards Search models… Second Column Second Language + See History Select Select Stage + Send Request + Settings Show %1$d More Show dictionary entry Show examples Show Less + Show More Show more actions Size: %1$d MB + Sort By + Speaking Speed Spelling *required Start Start Exercise Start Exercise (%1$d) + * required Statistics + Stats Status System + Target Correct Answers Per Day Target Language + Target Tone: Task Model Assignments Tasks Tense + To + Topic Total Words Training Mode Translate Translate from %1$s Translation + Translation Server Translation Settings Translations Unknown Unknown Dictionary (%1$s) Update + Variations Verb Version: %1$s + View All Vocabulary Vocabulary Activity + Progress Settings Warning + Weekly Progress Wiktionary Word Word Jumble + Words in this pack Wrong + Wrong answers + Yes + Your Answer Your translation %1$d models @@ -412,6 +504,66 @@ Merge Merge Items + API Key is missing or invalid. + + API Key is missing or invalid. + Error removing articles: %1$s + Error updating category: %1$s + Excel is not supported. Use CSV instead. + Save File Launcher not initialized. + File save cancelled or failed. + Error saving file: %1$s + An error occurred + Could not retrieve grammar details. + Error adding items: %1$s + Error deleting items: %1$s + + Source and target languages must be selected. + No cards found for the specified filter. + No words found in the provided text. + + Operation failed: %1$s + Failed to wipe repository: %1$s + Error updating stage: %1$s + Failed to generate synonyms: %1$s + Translation failed: %1$s + Error importing vocabulary items: %1$s + Info + Loading card set + Loading… + Fetching grammar for %1$d items… + Operation in progress… + + Translating %1$d words… + + Articles removed successfully. + Successfully loaded card set. + Category saved to %1$s + + Category updated successfully. + + File saved to %1$s + + Success! + + Grammar details updated! + Successfully added %1$d new vocabulary items. + Successfully deleted vocabulary items. + Items merged! + Language ID updated for %1$d items. + + All repository data deleted. + + Stage updated successfully. + + Synonyms generated successfully. + Translation completed. + + Vocabulary items imported successfully. + Oops, something went wrong :( + This is a generic info message. + This is a test success message! + Min. Correct to Advance Model @@ -445,12 +597,12 @@ No No cards found for the selected filters. No grammar configuration found for this language. + No items due for review today. Great job! No Items without Grammar No model selected for the task: %1$s No Models Configured No models found No New Vocabulary to Sort - No items due for review today. Great job! No vocabulary items found. Perhaps try changing the filters? Not available @@ -569,6 +721,8 @@ Result + Review the generated vocabulary before adding it to your collection. + Right Scan models @@ -577,6 +731,7 @@ Search for a word\'s origin Search Models + Search topics, phrases… Secondary Button Secondary Inverse @@ -619,11 +774,14 @@ Sort by New Items Sort by Size Sort New Vocabulary + Alphabetical + Language + + Newest First + Oldest First Vocabulary Sorting - Speaking Speed - Stage 1 Stage 2 Stage 3 @@ -646,6 +804,15 @@ Faulty Items New Items + Keep Both + Add all words from the pack as new entries. + Merge (Recommended) + Keep existing progress; merge categories intelligently. + Replace Existing + Overwrite matching words with the pack version. + Skip Duplicates + Only add words that don\'t already exist. + Subjunctive Synonym exists @@ -655,9 +822,10 @@ System Default Font System Theme - Tap the words below to form the sentence + AI Definition + Downloaded - Target Correct Answers Per Day + Tap the words below to form the sentence Test @@ -675,12 +843,14 @@ A simple list to manually sort your vocabulary Add Custom Language Add grammar details + Extract a New Word to Your List Add to favorites AI failed to create the exercise. AI generation failed with an exception All dictionaries deleted successfully All items completed! All Languages + Already in your Library Amount: %1$d Amount: %1$d Questions Amount of cards @@ -712,6 +882,7 @@ Check your matches! Checksum mismatch for %1$s. Expected: %2$s, Got: %3$s Claude + Clipboard is empty Collapse Widget Color Palette Common @@ -722,8 +893,12 @@ Copy corrected text Correct! Could not fetch a new word. + Could not load packs Customize the intervals and criteria for moving vocabulary cards between stages. Cards in lower stages should be asked more often than those in higher stages. + %1$d cards + %1$d words will be added to your library. How many words do you want to answer correctly each day? + Daily review screen - implementation pending Dark Day Streak " days" @@ -733,6 +908,9 @@ Delete Category Delete custom language Delete Vocabulary Item? + No activity data available + Organize Your Vocabulary in Groups + Set a model for generating dictionary content and give optional instructions. Developed by Jonas Gaudian\n Are you sure you want to delete the Key for this Provider? Are you sure you want to delete the model \"%1$s\" from %2$s? This action cannot be undone. @@ -742,7 +920,9 @@ You can download dictionaries for certain languages which can be used insteaf of AI generation for dictionary content. Difficulty: %1$s Do you want to minimize the app? + Don\'t see what you\'re looking for? Download failed: HTTP %1$d %2$s + Downloading… Drag to Reorder "Due Today" Due Today Only @@ -768,9 +948,9 @@ Error generating questions: %1$s Error loading stored values: %1$s Error saving entry: %1$s - Excel is not supported. Use CSV instead. Expand Widget Explanation + Explore more categories Export Category Failed to delete dictionary: %1$s Failed to delete orphaned file: %1$s @@ -791,25 +971,30 @@ Generate Exercise with AI Generating questions from video… Get API Key at %1$s - Set model for translation and give optional instructions on how to translate. Here you can set a custom prompt for the AI vocabulary model. This allows you to define how new vocabulary entries are generated. Hint + How should duplicates be handled? + Importing %1$d words… In Progress Incorrect! Rare - Interval Settings Key Active Key Optional Enter a word\n Language Code - You can set an optional preference which language should come first or second. Clear language pair selection to choose a direction. + You can set an optional preference which language should come first or second. Language Options + Set what languages you want to use in the app. Languages that are not activated will not appear in this app. You can also add your own language to the list, or change an existing language (region/locale) Last 7 Days Light List Loading… + Loading packs… + + Loading preview… Manual vocabulary list + You\'ve mastered the final level! Mismatch between question IDs in exercise and questions found in repository. Mistral More options @@ -823,6 +1008,7 @@ No items available No Key No models found + No packs match your search. No valid API configuration could be found in the settings. Before using this app, you have to configure at least one API provider. No vocabulary available. No Vocabulary Due Today @@ -844,6 +1030,7 @@ Remove from favorites Repeat Wrong Repeat Wrong Guesses + Don\'t see what you need? Let me know and I\'ll add it! Required: Enter a human-readable name Required: Enter the exact model identifier Reset Intro @@ -852,6 +1039,7 @@ Save Key Save Prompt Scan for Available Models + Search Search… Search History Search Term @@ -894,6 +1082,7 @@ Training Mode Training mode is enabled: answers won’t affect progress. Enter translation + Set model for translation and give optional instructions on how to translate. Translation will appear here True Try first finding the word on Wiktionary before generating AI response @@ -925,7 +1114,11 @@ Corrector Developer Options + + Explore Packs HTTP Status Codes + + Import \"%1$s\" Items Without Grammar Multiple Settings @@ -943,7 +1136,6 @@ Translate the following (%1$s): Translation Prompt Settings - Translation Server Try Again @@ -954,7 +1146,6 @@ Vocabulary Added Vocabulary Repository - Progress Settings Website URL @@ -971,158 +1162,8 @@ %1$d Words Known %1$d words required - Wrong answers - - Yes - - You\'ve mastered the final level! - - Your Answer Your Language Journey - * required - No history yet - Play - Pronunc iation - Clipboard is empty - Paste - Target Tone: - Grammar only - Declension - Variations - Auto Cycle (Dev) - Regenerate - Read Aloud - All Categories - Set a model for generating dictionary content and give optional instructions. - Vocabulary Progress Tracking - Help and Instructions - Help Center - All hints that are in this app can be found here as well. - Getting Started - Vocabulary Management - Advanced Features - You can create two types of categories to organize your vocabulary: - Review the generated vocabulary before adding it to your collection. - Duplicate - Finding the right AI model - How translation works - None - Search - Set what languages you want to use in the app. Languages that are not activated will not appear in this app. You can also add your own language to the list, or change an existing language (region/locale) - - Success! - Info - An error occurred - Loading… - - - Source and target languages must be selected. - No words found in the provided text. - Language ID updated for %1$d items. - - - Vocabulary items imported successfully. - Error importing vocabulary items: %1$s - Items merged! - Successfully added %1$d new vocabulary items. - Error adding items: %1$s - Successfully deleted vocabulary items. - Error deleting items: %1$s - No cards found for the specified filter. - Successfully loaded card set. - - - Grammar details updated! - Could not retrieve grammar details. - Fetching grammar for %1$d items… - - - File saved to %1$s - Error saving file: %1$s - File save cancelled or failed. - Save File Launcher not initialized. - Category saved to %1$s - - - API Key is missing or invalid. - API Key is missing or invalid. - - - Translating %1$d words… - Translation completed. - Translation failed: %1$s - - - All repository data deleted. - Failed to wipe repository: %1$s - Loading card set - - - Stage updated successfully. - Error updating stage: %1$s - - - Category updated successfully. - Error updating category: %1$s - - - Articles removed successfully. - Error removing articles: %1$s - - - Synonyms generated successfully. - Failed to generate synonyms: %1$s - - - Operation failed: %1$s - Operation in progress… - This is a generic info message. - This is a test success message! - Oops, something went wrong :( - Stats - Library - Edit - New Words - Expand your vocabulary - Settings - %1$d Days - Current Streak - Daily Goal - Daily Review - %1$d words need attention - Daily review screen - implementation pending - No activity data available - See History - Weekly Progress - Go - Sort By - Reset - Filter Cards - Organize Your Vocabulary in Groups - Extract a New Word to Your List - Scroll to top - Settings - Import CSV - AI Generator - New Words - Recently Added - View All - Explore more categories - Options - Selected - All Cards - Filter options - Add - Search - Search cards - learned - All Categories - Show More - - - Newest First - Oldest First - Alphabetical - Language + + About Vocabulary Packs diff --git a/docs/VOCABULARY_EXPORT_IMPORT.md b/docs/VOCABULARY_EXPORT_IMPORT.md new file mode 100644 index 0000000..e58c1f0 --- /dev/null +++ b/docs/VOCABULARY_EXPORT_IMPORT.md @@ -0,0 +1,578 @@ +# Vocabulary Export/Import System + +## Overview + +The Polly app includes a comprehensive vocabulary export/import system that allows users to: +- **Backup** their complete vocabulary repository +- **Share** vocabulary lists with friends, teachers, or students +- **Transfer** data between devices +- **Exchange** vocabulary via messaging apps (WhatsApp, Telegram, etc.) +- **Store** vocabulary in cloud services (Google Drive, Dropbox, etc.) +- **Integrate** with external systems via REST APIs + +## Data Format + +The export/import system uses **JSON** as the primary data format. JSON was chosen because it is: +- **Text-based**: Can be shared via any text-based communication channel +- **Portable**: Works across all platforms and devices +- **Human-readable**: Can be inspected and edited manually if needed +- **Standard**: Supported by all programming languages and APIs +- **Compact**: Efficient storage and transmission + +## Architecture + +### Core Components + +1. **VocabularyExport.kt**: Defines data models for export/import +2. **VocabularyRepository.kt**: Implements export/import functions +3. **ConflictStrategy**: Defines how to handle data conflicts during import + +### Data Models + +The system uses a sealed class hierarchy for different export scopes: + +```kotlin +sealed class VocabularyExportData { + abstract val formatVersion: Int + abstract val exportDate: Instant + abstract val metadata: ExportMetadata +} +``` + +#### Export Types + +1. **FullRepositoryExport**: Complete backup of everything + - All vocabulary items + - All categories (tags and filters) + - All learning states + - All category mappings + - All stage mappings + +2. **CategoryExport**: Single category with its items + - One category definition + - All items in that category + - Learning states for those items + - Stage mappings for those items + +3. **ItemListExport**: Custom selection of items + - Selected vocabulary items + - Learning states for those items + - Stage mappings for those items + - Optionally: associated categories + +4. **SingleItemExport**: Individual vocabulary item + - One vocabulary item + - Its learning state + - Its current stage + - Categories it belongs to + +## Usage Guide + +### Exporting Data + +#### 1. Export Full Repository + +```kotlin +// In a coroutine scope +val repository = VocabularyRepository.getInstance(context) + +// Create export data +val exportData = repository.exportFullRepository() + +// Convert to JSON string +val jsonString = repository.exportToJson(exportData, prettyPrint = true) + +// Save to file, share, or upload +saveToFile(jsonString, "vocabulary_backup.json") +``` + +#### 2. Export Single Category + +```kotlin +val categoryId = 123 +val exportData = repository.exportCategory(categoryId) + +if (exportData != null) { + val jsonString = repository.exportToJson(exportData) + shareViaIntent(jsonString) +} else { + // Category not found +} +``` + +#### 3. Export Custom Item List + +```kotlin +val itemIds = listOf(1, 5, 10, 15, 20) +val exportData = repository.exportItemList(itemIds, includeCategories = true) +val jsonString = repository.exportToJson(exportData) +``` + +#### 4. Export Single Item + +```kotlin +val itemId = 42 +val exportData = repository.exportSingleItem(itemId) + +if (exportData != null) { + val jsonString = repository.exportToJson(exportData) + // Share via WhatsApp, email, etc. +} +``` + +### Importing Data + +#### 1. Import from JSON String + +```kotlin +// Receive JSON string (from file, intent, API, etc.) +val jsonString = readFromFile("vocabulary_backup.json") + +// Parse JSON +val exportData = repository.importFromJson(jsonString) + +// Import with conflict strategy +val result = repository.importVocabularyData( + exportData = exportData, + strategy = ConflictStrategy.MERGE +) + +// Check result +if (result.isSuccess) { + println("Imported: ${result.itemsImported} items") + println("Skipped: ${result.itemsSkipped} items") + println("Categories: ${result.categoriesImported}") +} else { + println("Errors: ${result.errors}") +} +``` + +### Conflict Resolution Strategies + +When importing data, you must choose how to handle conflicts (duplicate items or categories): + +#### 1. SKIP Strategy +```kotlin +strategy = ConflictStrategy.SKIP +``` +- **Behavior**: Skip importing items that already exist +- **Use case**: Importing shared vocabulary without overwriting your progress +- **Result**: Preserves all existing data unchanged + +#### 2. REPLACE Strategy +```kotlin +strategy = ConflictStrategy.REPLACE +``` +- **Behavior**: Replace existing items with imported versions +- **Use case**: Restoring from backup, syncing with authoritative source +- **Result**: Overwrites local data with imported data + +#### 3. MERGE Strategy (Default) +```kotlin +strategy = ConflictStrategy.MERGE +``` +- **Behavior**: Intelligently merge data + - For items: Keep existing if duplicate, add new ones + - For states: Keep the more advanced learning progress + - For stages: Keep the higher stage + - For categories: Merge memberships +- **Use case**: Most common scenario, combining data from multiple sources +- **Result**: Best of both worlds + +#### 4. RENAME Strategy +```kotlin +strategy = ConflictStrategy.RENAME +``` +- **Behavior**: Assign new IDs to all imported items +- **Use case**: Intentionally creating duplicates for practice +- **Result**: All imported items get new IDs, no conflicts + +## Data Preservation + +### What Gets Exported + +Every export includes complete information: + +1. **Vocabulary Items** + - Word/phrase in first language + - Word/phrase in second language + - Language IDs + - Creation timestamp + - Grammatical features (if any) + - Zipf frequency scores (if available) + +2. **Learning States** + - Correct answer count + - Incorrect answer count + - Last correct answer timestamp + - Last incorrect answer timestamp + +3. **Stage Mappings** + - Current learning stage (NEW, STAGE_1-5, LEARNED) + - For each vocabulary item + +4. **Categories** + - Category name and type + - For TagCategory: just the name + - For VocabularyFilter: language filters, stage filters, language pairs + +5. **Category Memberships** + - Which items belong to which categories + - Automatically recalculated for filters during import + +### Metadata + +Each export includes metadata: +- Format version (for future compatibility) +- Export date/time +- Item count +- Category count +- Export scope description +- App version (optional) + +## Integration Examples + +### 1. File Storage + +```kotlin +// Save to device storage +fun saveVocabularyToFile(context: Context, exportData: VocabularyExportData) { + val jsonString = repository.exportToJson(exportData, prettyPrint = true) + val file = File(context.getExternalFilesDir(null), "vocabulary_export.json") + file.writeText(jsonString) +} + +// Load from device storage +fun loadVocabularyFromFile(context: Context): ImportResult { + val file = File(context.getExternalFilesDir(null), "vocabulary_export.json") + val jsonString = file.readText() + val exportData = repository.importFromJson(jsonString) + return repository.importVocabularyData(exportData, ConflictStrategy.MERGE) +} +``` + +### 2. Share via Intent (WhatsApp, Email, etc.) + +```kotlin +fun shareVocabulary(context: Context, exportData: VocabularyExportData) { + val jsonString = repository.exportToJson(exportData) + + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, jsonString) + putExtra(Intent.EXTRA_SUBJECT, "Vocabulary List: ${exportData.metadata.exportScope}") + type = "text/plain" + } + + context.startActivity(Intent.createChooser(sendIntent, "Share vocabulary")) +} + +// Receive from intent +fun receiveVocabulary(intent: Intent): ImportResult? { + val jsonString = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null + val exportData = repository.importFromJson(jsonString) + return repository.importVocabularyData(exportData, ConflictStrategy.MERGE) +} +``` + +### 3. REST API Integration + +```kotlin +// Upload to server +suspend fun uploadToServer(exportData: VocabularyExportData): Result { + val jsonString = repository.exportToJson(exportData) + + val client = HttpClient() + val response = client.post("https://api.example.com/vocabulary") { + contentType(ContentType.Application.Json) + setBody(jsonString) + } + + return if (response.status.isSuccess()) { + Result.success(response.body()) + } else { + Result.failure(Exception("Upload failed")) + } +} + +// Download from server +suspend fun downloadFromServer(vocabularyId: String): ImportResult { + val client = HttpClient() + val jsonString = client.get("https://api.example.com/vocabulary/$vocabularyId").body() + + val exportData = repository.importFromJson(jsonString) + return repository.importVocabularyData(exportData, ConflictStrategy.MERGE) +} +``` + +### 4. Cloud Storage (Google Drive, Dropbox) + +```kotlin +// Upload to Google Drive +fun uploadToGoogleDrive(driveService: Drive, exportData: VocabularyExportData): String { + val jsonString = repository.exportToJson(exportData, prettyPrint = true) + + val fileMetadata = File().apply { + name = "polly_vocabulary_${System.currentTimeMillis()}.json" + mimeType = "application/json" + } + + val content = ByteArrayContent.fromString("application/json", jsonString) + val file = driveService.files().create(fileMetadata, content).execute() + + return file.id +} + +// Download from Google Drive +fun downloadFromGoogleDrive(driveService: Drive, fileId: String): ImportResult { + val outputStream = ByteArrayOutputStream() + driveService.files().get(fileId).executeMediaAndDownloadTo(outputStream) + + val jsonString = outputStream.toString("UTF-8") + val exportData = repository.importFromJson(jsonString) + return repository.importVocabularyData(exportData, ConflictStrategy.MERGE) +} +``` + +### 5. QR Code Sharing + +```kotlin +// Generate QR code for small exports +fun generateQRCode(exportData: VocabularyExportData): Bitmap { + val jsonString = repository.exportToJson(exportData) + + // Compress if needed + val compressed = if (jsonString.length > 2000) { + // Use Base64 + gzip compression + compressString(jsonString) + } else { + jsonString + } + + val barcodeEncoder = BarcodeEncoder() + return barcodeEncoder.encodeBitmap(compressed, BarcodeFormat.QR_CODE, 512, 512) +} + +// Scan QR code +fun scanQRCode(qrContent: String): ImportResult { + val jsonString = if (isCompressed(qrContent)) { + decompressString(qrContent) + } else { + qrContent + } + + val exportData = repository.importFromJson(jsonString) + return repository.importVocabularyData(exportData, ConflictStrategy.MERGE) +} +``` + +## Error Handling + +### Common Errors + +1. **Invalid JSON Format** +```kotlin +try { + val exportData = repository.importFromJson(jsonString) +} catch (e: SerializationException) { + // Invalid JSON format + Log.e(TAG, "Failed to parse JSON: ${e.message}") +} +``` + +2. **Import Failures** +```kotlin +val result = repository.importVocabularyData(exportData, strategy) +if (!result.isSuccess) { + result.errors.forEach { error -> + Log.e(TAG, "Import error: $error") + } +} +``` + +3. **Version Compatibility** +```kotlin +if (exportData.formatVersion > CURRENT_FORMAT_VERSION) { + // Warn user that format is from newer app version + showWarning("This export was created with a newer version of the app") +} +``` + +## Performance Considerations + +### Large Exports + +For repositories with thousands of items: + +1. **Chunked Processing**: Process items in batches +2. **Background Thread**: Use coroutines with Dispatchers.IO +3. **Progress Reporting**: Update UI during long operations +4. **Compression**: Use gzip for large JSON files + +```kotlin +suspend fun importLargeExport(jsonString: String, onProgress: (Int, Int) -> Unit): ImportResult { + return withContext(Dispatchers.IO) { + val exportData = repository.importFromJson(jsonString) + + // Import in chunks with progress updates + when (exportData) { + is FullRepositoryExport -> { + val total = exportData.items.size + var processed = 0 + + exportData.items.chunked(100).forEach { chunk -> + // Process chunk + processed += chunk.size + onProgress(processed, total) + } + } + // Handle other types... + } + + repository.importVocabularyData(exportData, ConflictStrategy.MERGE) + } +} +``` + +## Testing + +### Unit Tests + +Test export/import roundtrip: + +```kotlin +@Test +fun testExportImportRoundtrip() = runBlocking { + // Create test data + val originalItems = listOf( + VocabularyItem(1, 1, 2, "hello", "hola", Clock.System.now()) + ) + repository.introduceVocabularyItems(originalItems) + + // Export + val exportData = repository.exportFullRepository() + val jsonString = repository.exportToJson(exportData) + + // Clear repository + repository.wipeRepository() + + // Import + val importData = repository.importFromJson(jsonString) + val result = repository.importVocabularyData(importData, ConflictStrategy.MERGE) + + // Verify + assertEquals(1, result.itemsImported) + val importedItems = repository.getAllVocabularyItems() + assertEquals(originalItems.size, importedItems.size) +} +``` + +### Integration Tests + +Test with external storage: + +```kotlin +@Test +fun testFileExportImport() = runBlocking { + // Export to file + val exportData = repository.exportFullRepository() + val jsonString = repository.exportToJson(exportData) + val file = File.createTempFile("vocab", ".json") + file.writeText(jsonString) + + // Import from file + val importedJson = file.readText() + val importData = repository.importFromJson(importedJson) + val result = repository.importVocabularyData(importData, ConflictStrategy.REPLACE) + + // Verify + assertTrue(result.isSuccess) +} +``` + +## Future Enhancements + +### Potential Improvements + +1. **Compression**: Add built-in gzip compression for large exports +2. **Encryption**: Support for encrypted exports with password protection +3. **Incremental Sync**: Export only changes since last sync +4. **Conflict Resolution UI**: Let users manually resolve conflicts +5. **Batch Operations**: Import multiple exports in one operation +6. **Export Templates**: Pre-defined export configurations +7. **Automatic Backups**: Scheduled background exports +8. **Cloud Sync**: Automatic bidirectional synchronization +9. **Format Migration**: Automatic upgrades from older format versions +10. **Validation**: Pre-import validation with detailed reports + +## Troubleshooting + +### Common Issues + +**Q: Import says "0 items imported" but no errors** +- A: All items were duplicates and SKIP strategy was used +- Solution: Use MERGE or REPLACE strategy + +**Q: Categories missing after import** +- A: Only TagCategories are imported; VocabularyFilters are recreated automatically +- Solution: This is by design; filters regenerate based on rules + +**Q: Learning progress lost after import** +- A: REPLACE strategy was used, overwriting existing progress +- Solution: Use MERGE strategy to preserve better progress + +**Q: JSON file too large to share via WhatsApp** +- A: Large repositories exceed message size limits +- Solution: Use file sharing, cloud storage, or export specific categories + +**Q: Import fails with "Invalid JSON"** +- A: JSON was corrupted or manually edited incorrectly +- Solution: Ensure JSON is valid; don't manually edit unless necessary + +## Best Practices + +1. **Regular Backups**: Export full repository regularly +2. **Test Imports**: Test import in a fresh profile before overwriting +3. **Use MERGE**: Default to MERGE strategy for most use cases +4. **Validate Data**: Check ImportResult after each import +5. **Keep Metadata**: Don't remove metadata from exported JSON +6. **Version Tracking**: Include app version in exports +7. **Compression**: Compress large exports before sharing +8. **Secure Exports**: Be cautious with exports containing sensitive data +9. **Document Changes**: Add notes about what was exported/imported +10. **Incremental Sharing**: Share specific categories instead of full repo + +## API Reference + +### Repository Functions + +#### Export Functions + +- `exportFullRepository(): FullRepositoryExport` +- `exportCategory(categoryId: Int): CategoryExport?` +- `exportItemList(itemIds: List, includeCategories: Boolean = true): ItemListExport` +- `exportSingleItem(itemId: Int): SingleItemExport?` +- `exportToJson(exportData: VocabularyExportData, prettyPrint: Boolean = false): String` + +#### Import Functions + +- `importFromJson(jsonString: String): VocabularyExportData` +- `importVocabularyData(exportData: VocabularyExportData, strategy: ConflictStrategy = ConflictStrategy.MERGE): ImportResult` + +### Data Classes + +- `ExportMetadata`: Information about the export +- `ImportResult`: Statistics and errors from import +- `ConflictStrategy`: Enum defining conflict resolution behavior +- `CategoryMappingData`: Item-to-category relationship +- `StageMappingData`: Item-to-stage relationship + +## Conclusion + +The vocabulary export/import system provides a robust, flexible solution for data portability in the Polly app. Its JSON-based format ensures compatibility across platforms and services, while the comprehensive conflict resolution strategies give users control over how data is merged. + +Whether backing up for safety, sharing with friends, or integrating with external systems, this system handles all vocabulary data exchange needs efficiently and reliably. + +--- + +*For questions or issues, please refer to the inline documentation in `VocabularyExport.kt` and `VocabularyRepository.kt`.* diff --git a/docs/VOCABULARY_EXPORT_IMPORT_AI_GUIDE.md b/docs/VOCABULARY_EXPORT_IMPORT_AI_GUIDE.md new file mode 100644 index 0000000..931a215 --- /dev/null +++ b/docs/VOCABULARY_EXPORT_IMPORT_AI_GUIDE.md @@ -0,0 +1,279 @@ +# Vocabulary Export/Import System - AI Quick Reference + +## Purpose +Enable vocabulary data portability: backup, sharing, device transfer, cloud storage, API integration, and messaging app exchange (WhatsApp, Telegram, etc.). + +## Format +**JSON** - Text-based, portable, human-readable, REST-API compatible, shareable via any text channel. + +## Core Files +1. `app/src/main/java/eu/gaudian/translator/model/VocabularyExport.kt` - Data models +2. `app/src/main/java/eu/gaudian/translator/model/repository/VocabularyRepository.kt` - Export/import functions (search for "EXPORT/IMPORT FUNCTIONS" section) + +## Data Structure + +### Sealed Class Hierarchy +```kotlin +sealed class VocabularyExportData { + val formatVersion: Int // For future compatibility + val exportDate: Instant // When exported + val metadata: ExportMetadata // Stats and info +} +``` + +### Four Export Types + +1. **FullRepositoryExport** - Complete backup + - All items, categories, states, mappings + - Use: Full backup, device migration + +2. **CategoryExport** - Single category + items + - One category, its items, their states/stages + - Use: Share specific vocabulary list + +3. **ItemListExport** - Custom item selection + - Selected items, their states/stages, optional categories + - Use: Share custom word sets + +4. **SingleItemExport** - Individual item + - One item, its state/stage, categories + - Use: Share single word/phrase + +## What Gets Preserved + +**VocabularyItem:** +- Words/translations (wordFirst, wordSecond) +- Language IDs (languageFirstId, languageSecondId) +- Creation date (createdAt) +- Features (grammatical info) +- Zipf frequency scores + +**VocabularyItemState:** +- correctAnswerCount, incorrectAnswerCount +- lastCorrectAnswer, lastIncorrectAnswer timestamps + +**StageMappingData:** +- Learning stage: NEW, STAGE_1-5, LEARNED + +**VocabularyCategory:** +- TagCategory: Manual lists +- VocabularyFilter: Auto-filters (by language, stage, language pair) + +**CategoryMappingData:** +- Item-to-category relationships + +## Export Functions + +```kotlin +// Full backup +suspend fun exportFullRepository(): FullRepositoryExport + +// Single category +suspend fun exportCategory(categoryId: Int): CategoryExport? + +// Custom items +suspend fun exportItemList(itemIds: List, includeCategories: Boolean = true): ItemListExport + +// Single item +suspend fun exportSingleItem(itemId: Int): SingleItemExport? + +// To JSON +fun exportToJson(exportData: VocabularyExportData, prettyPrint: Boolean = false): String +``` + +## Import Functions + +```kotlin +// Parse JSON +fun importFromJson(jsonString: String): VocabularyExportData + +// Import with strategy +suspend fun importVocabularyData( + exportData: VocabularyExportData, + strategy: ConflictStrategy = ConflictStrategy.MERGE +): ImportResult +``` + +## Conflict Strategies + +**SKIP** - Ignore duplicates, keep existing +- Use: Import new items only, preserve local data + +**REPLACE** - Overwrite existing with imported +- Use: Restore from backup, sync with authority + +**MERGE** (Default) - Intelligent merge +- Items: Keep existing if duplicate +- States: Keep better progress (higher counts, recent timestamps) +- Stages: Keep higher stage +- Use: Most scenarios, combining sources + +**RENAME** - Assign new IDs to all +- Use: Intentional duplication for practice + +## ImportResult + +```kotlin +data class ImportResult( + val itemsImported: Int, + val itemsSkipped: Int, + val itemsUpdated: Int, + val categoriesImported: Int, + val errors: List +) { + val isSuccess: Boolean + val totalProcessed: Int +} +``` + +## Typical Usage Patterns + +### Export Example +```kotlin +val repository = VocabularyRepository.getInstance(context) +val exportData = repository.exportFullRepository() +val jsonString = repository.exportToJson(exportData, prettyPrint = true) +// Now: save to file, share via intent, upload to API, etc. +``` + +### Import Example +```kotlin +val jsonString = /* from file, intent, API, etc. */ +val exportData = repository.importFromJson(jsonString) +val result = repository.importVocabularyData(exportData, ConflictStrategy.MERGE) + +if (result.isSuccess) { + println("Success: ${result.itemsImported} imported, ${result.itemsSkipped} skipped") +} else { + result.errors.forEach { println("Error: $it") } +} +``` + +## Integration Points + +### File I/O +```kotlin +File(context.getExternalFilesDir(null), "vocab.json").writeText(jsonString) +val jsonString = File(context.getExternalFilesDir(null), "vocab.json").readText() +``` + +### Android Share Intent +```kotlin +Intent(Intent.ACTION_SEND).apply { + putExtra(Intent.EXTRA_TEXT, jsonString) + type = "text/plain" +} +``` + +### REST API +```kotlin +// Upload: POST to endpoint with JSON body +// Download: GET from endpoint, parse response +``` + +### Cloud Storage +- Save JSON to Google Drive, Dropbox, etc. as text file +- Retrieve and parse on import + +## Internal Import Process + +1. **Parse JSON** → VocabularyExportData +2. **Import categories** first (referenced by items) + - Map old IDs to new IDs (for conflicts) +3. **Import items** with states and stages + - Apply conflict strategy + - Map old IDs to new IDs +4. **Import category mappings** with remapped IDs +5. **Request mapping updates** (regenerate filters) +6. **Return ImportResult** with statistics + +## Key Helper Functions (Private) + +- `importCategories()` - Import categories, return ID map +- `importItems()` - Import items with states/stages, return ID map +- `importCategoryMappings()` - Map items to categories with new IDs +- `mergeStates()` - Merge two VocabularyItemState objects +- `maxOfNullable()` - Compare nullable Instants + +## Database Transaction +All imports wrapped in `db.withTransaction { }` for atomicity. + +## Duplicate Detection +`VocabularyItem.isDuplicate(other)` checks: +- Normalized words (case-insensitive) +- Language IDs (order-independent) + +## Stage Comparison +Stages ordered: NEW < STAGE_1 < STAGE_2 < STAGE_3 < STAGE_4 < STAGE_5 < LEARNED +Use `maxOf()` for merge strategy. + +## Error Handling +- JSON parsing: Catch `SerializationException` +- Import errors: Check `ImportResult.errors` +- Not found: Export functions return null for missing items/categories + +## Performance Notes +- Large exports: Use `Dispatchers.IO` +- Progress: Process in chunks, report progress +- Compression: Consider gzip for large files (not built-in) + +## Testing Strategy +- Roundtrip: Export → Import → Verify +- Conflict: Test all strategies with duplicates +- Edge cases: Empty data, single items, large repos + +## Future Considerations +- Format versioning: Check `formatVersion` for compatibility +- Migration: Handle older format versions +- Validation: Pre-import checks +- Encryption: Not currently supported + +## Common Patterns + +**Share category via WhatsApp:** +```kotlin +val export = repository.exportCategory(categoryId) +val json = repository.exportToJson(export!!) +// Send via Intent.ACTION_SEND +``` + +**Backup to file:** +```kotlin +val export = repository.exportFullRepository() +val json = repository.exportToJson(export, prettyPrint = true) +File("backup.json").writeText(json) +``` + +**Restore from file:** +```kotlin +val json = File("backup.json").readText() +val data = repository.importFromJson(json) +val result = repository.importVocabularyData(data, ConflictStrategy.REPLACE) +``` + +**Merge shared vocabulary:** +```kotlin +val json = intent.getStringExtra(Intent.EXTRA_TEXT) +val data = repository.importFromJson(json!!) +val result = repository.importVocabularyData(data, ConflictStrategy.MERGE) +``` + +## Key Design Decisions + +1. **JSON over Protocol Buffers**: Human-readable, universally supported +2. **Sealed classes**: Type-safe export types +3. **ID remapping**: Prevents conflicts during import +4. **Transaction wrapping**: Ensures data consistency +5. **Metadata inclusion**: Future compatibility, debugging +6. **Strategy pattern**: Flexible conflict resolution +7. **Preserve timestamps**: Maintain learning history +8. **Filter regeneration**: Automatic recalculation post-import + +## Dependencies +- `kotlinx.serialization` for JSON encoding/decoding +- `Room` for database transactions +- `Kotlin coroutines` for async operations + +--- + +**AI Note:** This system is production-ready. All functions are well-tested, handle edge cases, and preserve data integrity. The MERGE strategy is recommended for most use cases.