Compare commits

..

5 Commits

62 changed files with 3170 additions and 1195 deletions

View File

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

View File

@@ -3,6 +3,9 @@ package eu.gaudian.translator.model.communication
import android.content.Context
import androidx.core.content.edit
import eu.gaudian.translator.R
import eu.gaudian.translator.model.communication.files_download.FlashcardCollectionInfo
import eu.gaudian.translator.model.communication.files_download.FlashcardManifestResponse
import eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -322,4 +325,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 [eu.gaudian.translator.model.communication.files_download.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"
}

View File

@@ -1,17 +1,25 @@
package eu.gaudian.translator.model.communication
import eu.gaudian.translator.model.communication.files_download.FlashcardManifestResponse
import eu.gaudian.translator.model.communication.files_download.VocabManifestResponse
import retrofit2.Call
import retrofit2.http.GET
/**
* 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>
}

View File

@@ -1,39 +1,68 @@
package eu.gaudian.translator.model.communication
package eu.gaudian.translator.model.communication.files_download
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,
/** CEFR difficulty level: A1, A2, B1, B2, C1, C2 (empty string if not set) */
@SerializedName("level") val level: String = "",
@SerializedName("size_bytes") val sizeBytes: Long,
@SerializedName("checksum_sha256") val checksumSha256: String,
@SerializedName("created_at") val createdAt: String,
@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
)

View File

@@ -20,27 +20,28 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navigation
import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.view.categories.CategoryDetailScreen
import eu.gaudian.translator.view.categories.CategoryListScreen
import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.dictionary.DictionaryResultScreen
import eu.gaudian.translator.view.dictionary.EtymologyResultScreen
import eu.gaudian.translator.view.dictionary.MainDictionaryScreen
import eu.gaudian.translator.view.exercises.ExerciseSessionScreen
import eu.gaudian.translator.view.exercises.MainExerciseScreen
import eu.gaudian.translator.view.exercises.StartExerciseScreen
import eu.gaudian.translator.view.exercises.YouTubeBrowserScreen
import eu.gaudian.translator.view.exercises.YouTubeExerciseScreen
import eu.gaudian.translator.view.home.DailyReviewScreen
import eu.gaudian.translator.view.home.HomeScreen
import eu.gaudian.translator.view.library.LibraryScreen
import eu.gaudian.translator.view.new_ecercises.ExerciseSessionScreen
import eu.gaudian.translator.view.new_ecercises.MainExerciseScreen
import eu.gaudian.translator.view.new_ecercises.StartExerciseScreen
import eu.gaudian.translator.view.new_ecercises.YouTubeBrowserScreen
import eu.gaudian.translator.view.new_ecercises.YouTubeExerciseScreen
import eu.gaudian.translator.view.settings.DictionaryOptionsScreen
import eu.gaudian.translator.view.settings.TranslationSettingsScreen
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.CategoryDetailScreen
import eu.gaudian.translator.view.vocabulary.CategoryListScreen
import eu.gaudian.translator.view.vocabulary.LanguageProgressScreen
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
import eu.gaudian.translator.view.vocabulary.NoGrammarItemsScreen
@@ -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(
@@ -269,7 +275,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
)
}
composable("language_progress") {
LanguageProgressScreen(
LanguageJourneyScreen(
navController = navController
)
@@ -439,7 +445,7 @@ fun NavGraphBuilder.statsGraph(
)
}
composable(NavigationRoutes.STATS_LANGUAGE_PROGRESS) {
LanguageProgressScreen(
LanguageJourneyScreen(
navController = navController
)
}

View File

@@ -1,8 +1,13 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.vocabulary
package eu.gaudian.translator.view.categories
import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -54,8 +59,9 @@ import eu.gaudian.translator.view.composable.PrimaryButton
import eu.gaudian.translator.view.dialogs.DeleteCategoryDialog
import eu.gaudian.translator.view.dialogs.DeleteItemsDialog
import eu.gaudian.translator.view.dialogs.EditCategoryDialog
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle
import eu.gaudian.translator.view.vocabulary.widgets.ChartLegend
import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
import eu.gaudian.translator.view.stats.widgets.CategoryProgressCircle
import eu.gaudian.translator.view.stats.widgets.ChartLegend
import eu.gaudian.translator.viewmodel.CategoryProgress
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ExportImportViewModel
@@ -244,10 +250,10 @@ fun CategoryDetailScreen(
)
// Category Header Card with Progress and Action Buttons (animated)
androidx.compose.animation.AnimatedVisibility(
AnimatedVisibility(
visible = isHeaderVisible,
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically()
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
CategoryHeaderCard(
subtitle = subtitle,

View File

@@ -1,6 +1,6 @@
@file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.vocabulary
package eu.gaudian.translator.view.categories
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@@ -44,8 +44,8 @@ import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.dialogs.AddCategoryDialog
import eu.gaudian.translator.view.dialogs.DeleteMultipleCategoriesDialog
import eu.gaudian.translator.view.vocabulary.widgets.CategoryCircleType
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle
import eu.gaudian.translator.view.stats.widgets.CategoryCircleType
import eu.gaudian.translator.view.stats.widgets.CategoryProgressCircle
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel

View File

@@ -0,0 +1,249 @@
package eu.gaudian.translator.view.composable
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.view.composable.ComponentDefaults.DefaultElevation
import eu.gaudian.translator.view.hints.Hint
import eu.gaudian.translator.view.hints.HintBottomSheet
import eu.gaudian.translator.view.hints.LocalShowHints
/**
* A styled card container for displaying content with a consistent floating look.
*
* @param modifier The modifier to be applied to the card.
* @param content The content to be displayed inside the card.
*/
@Composable
fun AppCard(
modifier: Modifier = Modifier,
title: String? = null,
icon: ImageVector? = null,
text: String? = null,
expandable: Boolean = false,
initiallyExpanded: Boolean = false,
onClick: (() -> Unit)? = null,
hintContent : Hint? = null,
content: @Composable ColumnScope.() -> Unit,
) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
val showHints = LocalShowHints.current
val rotationState by animateFloatAsState(
targetValue = if (isExpanded) 180f else 0f,
label = "Chevron Rotation"
)
// Check if we need to render the header row
// Updated to include icon in the check
val hasHeader = title != null || text != null || expandable || icon != null
val canClickHeader = expandable || onClick != null
var showBottomSheet by remember { mutableStateOf(false) }
if (showBottomSheet) {
hintContent?.let {
HintBottomSheet(
onDismissRequest = { showBottomSheet = false },
content = it,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
)
}
}
Surface(
modifier = modifier
.fillMaxWidth()
.shadow(
DefaultElevation,
shape = ComponentDefaults.CardShape
)
.clip(ComponentDefaults.CardClipShape)
// Animate height changes when expanding/collapsing
.animateContentSize(),
shape = ComponentDefaults.CardShape,
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column {
// --- Header Row ---
if (hasHeader) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = canClickHeader) {
if (expandable) {
isExpanded = !isExpanded
}
onClick?.invoke()
}
.padding(ComponentDefaults.CardPadding),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Optional Icon on the left
if (icon != null) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
}
// 2. Title and Text Column
Column(modifier = Modifier.weight(1f)) {
if (!title.isNullOrBlank()) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
// Only show spacer if both title and text exist
if (!title.isNullOrBlank() && !text.isNullOrBlank()) {
Spacer(Modifier.size(4.dp))
}
if (!text.isNullOrBlank()) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showHints && hintContent != null) {
IconButton(onClick = { showBottomSheet = true }) {
Icon(
imageVector = AppIcons.Help,
contentDescription = stringResource(R.string.show_hint),
tint = MaterialTheme.colorScheme.secondary
)
}
}
// 3. Expand Chevron (Far right)
if (expandable) {
Icon(
imageVector = AppIcons.ArrowDropDown,
contentDescription = if (isExpanded) "Collapse" else "Expand",
modifier = Modifier.rotate(rotationState),
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
// --- Content Area ---
if (!expandable || isExpanded) {
val contentModifier = Modifier
.padding(
start = ComponentDefaults.CardPadding,
end = ComponentDefaults.CardPadding,
bottom = ComponentDefaults.CardPadding,
// If we have a header, remove the top padding so content sits closer to the title.
// If no header (legacy behavior), keep the top padding.
top = if (hasHeader) 0.dp else ComponentDefaults.CardPadding
)
if (!hasHeader && onClick != null) {
Column(
modifier = contentModifier.clickable { onClick() },
content = content
)
} else {
Column(
modifier = contentModifier,
content = content
)
}
}
}
}
}
@Preview
@Composable
fun AppCardPreview() {
AppCard {
Text(stringResource(R.string.this_is_the_content_inside_the_card))
PrimaryButton(onClick = { }, text = stringResource(R.string.label_continue))
}
}
@Preview(showBackground = true)
@Composable
fun AppCardPreview2() {
MaterialTheme {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 1. Expandable Card (Initially Collapsed)
AppCard(
title = "Advanced Settings",
text = "Click to reveal more options",
expandable = true,
initiallyExpanded = false
) {
Text("Here are some hidden settings.")
Text("They are only visible when expanded.")
}
// 2. Expandable Card (Initially Expanded)
AppCard(
title = "Translation History",
text = "Recent items",
expandable = true,
initiallyExpanded = true
) {
Text("• Hello -> Hallo")
Text("• World -> Welt")
Text("• Sun -> Sonne")
}
// 3. Static Card (No Title/Expand logic - Legacy behavior)
AppCard {
Text("This is a standard card without a header.")
}
}
}
}

View File

@@ -52,6 +52,7 @@ data class FabMenuItem(
)
@Deprecated("We don't want to use floating butto menus anymore")
@Composable
fun AppFabMenu(
items: List<FabMenuItem>,

View File

@@ -49,6 +49,7 @@ interface TabItem {
val title: String
val icon: ImageVector
}
@Deprecated("Migrate to new (like used in LibraryScreen")
@SuppressLint("UnusedBoxWithConstraintsScope", "LocalContextResourcesRead", "DiscouragedApi",
"SuspiciousIndentation"
)

View File

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

View File

@@ -2,23 +2,17 @@
package eu.gaudian.translator.view.composable
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
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.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
@@ -28,26 +22,19 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
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.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
@@ -57,10 +44,6 @@ import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.ui.theme.semanticColors
import eu.gaudian.translator.view.composable.ComponentDefaults.DefaultElevation
import eu.gaudian.translator.view.hints.Hint
import eu.gaudian.translator.view.hints.HintBottomSheet
import eu.gaudian.translator.view.hints.LocalShowHints
object ComponentDefaults {
@@ -90,218 +73,6 @@ object ComponentDefaults {
const val ALPHA_LOW = 0.3f
}
/**
* A styled card container for displaying content with a consistent floating look.
*
* @param modifier The modifier to be applied to the card.
* @param content The content to be displayed inside the card.
*/
@Composable
fun AppCard(
modifier: Modifier = Modifier,
title: String? = null,
icon: ImageVector? = null,
text: String? = null,
expandable: Boolean = false,
initiallyExpanded: Boolean = false,
onClick: (() -> Unit)? = null,
hintContent : Hint? = null,
content: @Composable ColumnScope.() -> Unit,
) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
val showHints = LocalShowHints.current
val rotationState by animateFloatAsState(
targetValue = if (isExpanded) 180f else 0f,
label = "Chevron Rotation"
)
// Check if we need to render the header row
// Updated to include icon in the check
val hasHeader = title != null || text != null || expandable || icon != null
val canClickHeader = expandable || onClick != null
var showBottomSheet by remember { mutableStateOf(false) }
if (showBottomSheet) {
hintContent?.let {
HintBottomSheet(
onDismissRequest = { showBottomSheet = false },
content = it,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
)
}
}
Surface(
modifier = modifier
.fillMaxWidth()
.shadow(
DefaultElevation,
shape = ComponentDefaults.CardShape
)
.clip(ComponentDefaults.CardClipShape)
// Animate height changes when expanding/collapsing
.animateContentSize(),
shape = ComponentDefaults.CardShape,
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column {
// --- Header Row ---
if (hasHeader) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = canClickHeader) {
if (expandable) {
isExpanded = !isExpanded
}
onClick?.invoke()
}
.padding(ComponentDefaults.CardPadding),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Optional Icon on the left
if (icon != null) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
}
// 2. Title and Text Column
Column(modifier = Modifier.weight(1f)) {
if (!title.isNullOrBlank()) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
// Only show spacer if both title and text exist
if (!title.isNullOrBlank() && !text.isNullOrBlank()) {
Spacer(Modifier.size(4.dp))
}
if (!text.isNullOrBlank()) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showHints && hintContent != null) {
IconButton(onClick = { showBottomSheet = true }) {
Icon(
imageVector = AppIcons.Help,
contentDescription = stringResource(R.string.show_hint),
tint = MaterialTheme.colorScheme.secondary
)
}
}
// 3. Expand Chevron (Far right)
if (expandable) {
Icon(
imageVector = AppIcons.ArrowDropDown,
contentDescription = if (isExpanded) "Collapse" else "Expand",
modifier = Modifier.rotate(rotationState),
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
// --- Content Area ---
if (!expandable || isExpanded) {
val contentModifier = Modifier
.padding(
start = ComponentDefaults.CardPadding,
end = ComponentDefaults.CardPadding,
bottom = ComponentDefaults.CardPadding,
// If we have a header, remove the top padding so content sits closer to the title.
// If no header (legacy behavior), keep the top padding.
top = if (hasHeader) 0.dp else ComponentDefaults.CardPadding
)
if (!hasHeader && onClick != null) {
Column(
modifier = contentModifier.clickable { onClick() },
content = content
)
} else {
Column(
modifier = contentModifier,
content = content
)
}
}
}
}
}
@Preview
@Composable
fun AppCardPreview() {
AppCard {
Text(stringResource(R.string.this_is_the_content_inside_the_card))
PrimaryButton(onClick = { }, text = stringResource(R.string.label_continue))
}
}
@Preview(showBackground = true)
@Composable
fun AppCardPreview2() {
MaterialTheme {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 1. Expandable Card (Initially Collapsed)
AppCard(
title = "Advanced Settings",
text = "Click to reveal more options",
expandable = true,
initiallyExpanded = false
) {
Text("Here are some hidden settings.")
Text("They are only visible when expanded.")
}
// 2. Expandable Card (Initially Expanded)
AppCard(
title = "Translation History",
text = "Recent items",
expandable = true,
initiallyExpanded = true
) {
Text("• Hello -> Hallo")
Text("• World -> Welt")
Text("• Sun -> Sonne")
}
// 3. Static Card (No Title/Expand logic - Legacy behavior)
AppCard {
Text("This is a standard card without a header.")
}
}
}
}
/**
* The primary button for the most important actions.
*
@@ -636,6 +407,7 @@ fun WrongOutlinedButtonPreview(){
WrongOutlinedButton(onClick = { }, text = stringResource(R.string.label_continue))
}
//This is basically just a wrapper for screens to control width (tablet mode) etc.
@Composable
fun AppOutlinedCard(
modifier: Modifier = Modifier,

View File

@@ -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 = {}
)
}

View File

@@ -63,7 +63,7 @@ fun VocabularyReviewScreen(
topBar = {
AppTopAppBar(
title = stringResource(R.string.found_items),
hintContent = HintDefinition.REVIEW.hint()
hint = HintDefinition.REVIEW.hint()
)
},
) { paddingValues ->

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary
package eu.gaudian.translator.view.exercises
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -23,6 +23,8 @@ import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.CorrectButton
import eu.gaudian.translator.view.composable.WrongButton
import eu.gaudian.translator.view.vocabulary.VocabularyExerciseAction
import eu.gaudian.translator.view.vocabulary.VocabularyExerciseState
@Composable
fun ExerciseControls(

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary
package eu.gaudian.translator.view.exercises
import androidx.compose.animation.core.animateFloatAsState

View File

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

View File

@@ -49,7 +49,7 @@ import eu.gaudian.translator.view.NavigationRoutes
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.view.stats.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.viewmodel.ProgressViewModel
@Composable
@@ -364,7 +364,7 @@ fun WeeklyProgressSection(
) {
Text(text = stringResource(R.string.label_weekly_progress), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
TextButton(onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }) {
Text(stringResource(R.string.label_see_history))
Text(stringResource(R.string.label_see_history), softWrap = false)
}
}

View File

@@ -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()))
}
)
}

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
@@ -57,7 +57,7 @@ import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.ComponentDefaults
import eu.gaudian.translator.view.vocabulary.ExerciseProgressIndicator
import eu.gaudian.translator.view.exercises.ExerciseProgressIndicator
import eu.gaudian.translator.viewmodel.AnswerResult
import eu.gaudian.translator.viewmodel.ExerciseSessionState
import eu.gaudian.translator.viewmodel.ExerciseViewModel

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth

View File

@@ -1,6 +1,6 @@
@file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import android.annotation.SuppressLint
import androidx.compose.foundation.background

View File

@@ -1,6 +1,6 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -737,9 +737,9 @@ fun NumberOfCardsSection(
availableQuickSelections.forEach { value ->
AppOutlinedButton(
onClick = { onAmountChanged(value) },
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f).padding(0.dp)
) {
Text(value.toString())
Text(text = value.toString(), softWrap = false)
}
}
}
@@ -773,7 +773,7 @@ fun QuestionTypesSection(
Spacer(modifier = Modifier.height(12.dp))
QuestionTypeCard(
title = stringResource(R.string.label_multiple_choice_exercise),
subtitle = stringResource(R.string.label_choose_exercise_types),
subtitle = stringResource(R.string.label_multiple_choice_desc),
icon = AppIcons.CheckList,
isSelected = selectedTypes.contains(VocabularyExerciseType.MULTIPLE_CHOICE),
onClick = { onTypeSelected(VocabularyExerciseType.MULTIPLE_CHOICE) }

View File

@@ -1,6 +1,6 @@
@file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import android.annotation.SuppressLint
import android.graphics.Bitmap

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box

View File

@@ -1,6 +1,6 @@
@file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.exercises
package eu.gaudian.translator.view.new_ecercises
import android.annotation.SuppressLint
import android.widget.Toast

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,13 +65,13 @@ import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.dialogs.MissingLanguageDialog
import eu.gaudian.translator.view.vocabulary.widgets.AllVocabularyWidget
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressWidget
import eu.gaudian.translator.view.vocabulary.widgets.DueTodayWidget
import eu.gaudian.translator.view.vocabulary.widgets.LevelWidget
import eu.gaudian.translator.view.vocabulary.widgets.StatusWidget
import eu.gaudian.translator.view.vocabulary.widgets.StreakWidget
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.view.stats.widgets.AllVocabularyWidget
import eu.gaudian.translator.view.stats.widgets.CategoryProgressWidget
import eu.gaudian.translator.view.stats.widgets.DueTodayWidget
import eu.gaudian.translator.view.stats.widgets.LevelWidget
import eu.gaudian.translator.view.stats.widgets.StatusWidget
import eu.gaudian.translator.view.stats.widgets.StreakWidget
import eu.gaudian.translator.view.stats.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel
import eu.gaudian.translator.viewmodel.SettingsViewModel

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import android.annotation.SuppressLint
import androidx.compose.foundation.clickable

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement

View File

@@ -1,4 +1,4 @@
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement

View File

@@ -1,6 +1,6 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween

View File

@@ -1,6 +1,6 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.vocabulary.widgets
package eu.gaudian.translator.view.stats.widgets
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement

View File

@@ -1,671 +0,0 @@
@file:Suppress("HardCodedStringLiteral", "AssignedValueIsNeverRead", "UnusedMaterial3ScaffoldPaddingParameter")
package eu.gaudian.translator.view.vocabulary
import android.annotation.SuppressLint
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import eu.gaudian.translator.R
import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.model.WidgetType
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.dialogs.MissingLanguageDialog
import eu.gaudian.translator.view.vocabulary.widgets.AllVocabularyWidget
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressWidget
import eu.gaudian.translator.view.vocabulary.widgets.DueTodayWidget
import eu.gaudian.translator.view.vocabulary.widgets.LevelWidget
import eu.gaudian.translator.view.vocabulary.widgets.StatusWidget
import eu.gaudian.translator.view.vocabulary.widgets.StreakWidget
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel
import eu.gaudian.translator.viewmodel.SettingsViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
@SuppressLint("FrequentlyChangingValue")
@Composable
fun DashboardContent(
navController: NavController,
onShowCustomExerciseDialog: () -> Unit,
startDailyExercise: (Boolean) -> Unit,
onNavigateToCategoryDetail: (Int) -> Unit,
onNavigateToCategoryList: () -> Unit,
onShowWordPairExerciseDialog: () -> Unit,
onScroll: (Boolean) -> Unit = {},
) {
val activity = LocalContext.current.findActivity()
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity)
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
var showMissingLanguageDialog by remember { mutableStateOf(false) }
var selectedMissingLanguageId by remember { mutableStateOf<Int?>(null) }
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val affectedItems by remember(selectedMissingLanguageId) {
selectedMissingLanguageId?.let {
vocabularyViewModel.getItemsForLanguage(it)
} ?: flowOf(emptyList())
}.collectAsState(initial = emptyList())
if (showMissingLanguageDialog && selectedMissingLanguageId != null) {
MissingLanguageDialog(
showDialog = true,
missingLanguageId = selectedMissingLanguageId!!,
affectedItems = affectedItems,
onDismiss = { showMissingLanguageDialog = false },
onDelete = { items ->
vocabularyViewModel.deleteVocabularyItemsById(items.map { it.id })
showMissingLanguageDialog = false
},
onReplace = { oldId, newId ->
vocabularyViewModel.replaceLanguageId(oldId, newId)
showMissingLanguageDialog = false
},
onCreate = { newLanguage ->
languageViewModel.addCustomLanguage(newLanguage)
},
languageViewModel = languageViewModel
)
}
AppOutlinedCard {
// We collect the order from DB initially
val initialWidgetOrder by settingsViewModel.widgetOrder.collectAsState(initial = null)
val collapsedWidgetIds by settingsViewModel.collapsedWidgetIds.collectAsState(initial = emptySet())
val dashboardScrollState by settingsViewModel.dashboardScrollState.collectAsState()
val scope = rememberCoroutineScope()
if (initialWidgetOrder == null) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 64.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
// BEST PRACTICE: Use a SnapshotStateList for immediate UI updates.
// We only initialize this once, so DB updates don't reset the list while dragging.
val orderedWidgets = remember { mutableStateListOf<WidgetType>() }
// Sync with DB only on first load
LaunchedEffect(initialWidgetOrder) {
if (orderedWidgets.isEmpty() && !initialWidgetOrder.isNullOrEmpty()) {
orderedWidgets.addAll(initialWidgetOrder!!)
} else if (orderedWidgets.isEmpty()) {
orderedWidgets.addAll(WidgetType.DEFAULT_ORDER)
}
}
val lazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = dashboardScrollState.first,
initialFirstVisibleItemScrollOffset = dashboardScrollState.second
)
// Save scroll state
LaunchedEffect(lazyListState.firstVisibleItemIndex, lazyListState.firstVisibleItemScrollOffset) {
settingsViewModel.saveDashboardScrollState(
lazyListState.firstVisibleItemIndex,
lazyListState.firstVisibleItemScrollOffset
)
}
// Detect scroll and notify parent
LaunchedEffect(lazyListState.isScrollInProgress) {
onScroll(lazyListState.isScrollInProgress)
}
DisposableEffect(Unit) {
onDispose {
settingsViewModel.saveDashboardScrollState(
lazyListState.firstVisibleItemIndex,
lazyListState.firstVisibleItemScrollOffset
)
}
}
// --- Robust Drag and Drop State ---
val dragDropState = rememberDragDropState(
lazyListState = lazyListState,
onSwap = { fromIndex, toIndex ->
// Swap data immediately for responsiveness
orderedWidgets.apply {
add(toIndex, removeAt(fromIndex))
}
},
onDragEnd = {
// Persist to DB only when user drops
settingsViewModel.saveWidgetOrder(orderedWidgets.toList())
}
)
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxSize()
.dragContainer(dragDropState),
contentPadding = PaddingValues(bottom = 160.dp)
) {
itemsIndexed(
items = orderedWidgets,
key = { _, widget -> widget.id }
) { index, widgetType ->
val isDragging = index == dragDropState.draggingItemIndex
// Calculate translation: distinct logic for dragged vs. stationary items
val translationY = if (isDragging) {
dragDropState.draggingItemOffset
} else {
0f
}
Box(
modifier = Modifier
.zIndex(if (isDragging) 1f else 0f)
.graphicsLayer {
this.translationY = translationY
this.shadowElevation = if (isDragging) 8.dp.toPx() else 0f
this.scaleX = if (isDragging) 1.02f else 1f
this.scaleY = if (isDragging) 1.02f else 1f
}
// CRITICAL FIX: Only apply animation to items NOT being dragged.
// This prevents the "flicker" by stopping the layout animation
// from fighting your manual drag offset.
.then(
if (!isDragging) {
Modifier.animateItem(
placementSpec = spring(
stiffness = Spring.StiffnessLow,
visibilityThreshold = IntOffset.VisibilityThreshold
)
)
} else {
Modifier
}
)
) {
WidgetContainer(
widgetType = widgetType,
isExpanded = widgetType.id !in collapsedWidgetIds,
onExpandedChange = { newExpandedState ->
scope.launch {
settingsViewModel.setWidgetExpandedState(widgetType.id, newExpandedState)
}
},
onDragStart = { dragDropState.onDragStart(index) },
onDrag = { dragAmount -> dragDropState.onDrag(dragAmount) },
onDragEnd = { dragDropState.onDragEnd() },
onDragCancel = { dragDropState.onDragInterrupted() },
modifier = Modifier.fillMaxWidth()
) {
LazyWidget(
widgetType = widgetType,
navController = navController,
vocabularyViewModel = vocabularyViewModel,
progressViewModel = progressViewModel,
onShowCustomExerciseDialog = onShowCustomExerciseDialog,
startDailyExercise = startDailyExercise,
onNavigateToCategoryDetail = onNavigateToCategoryDetail,
onNavigateToCategoryList = onNavigateToCategoryList,
onShowWordPairExerciseDialog = onShowWordPairExerciseDialog,
onMissingLanguage = { missingId ->
selectedMissingLanguageId = missingId
showMissingLanguageDialog = true
}
)
}
}
}
}
}
}
}
@Composable
private fun WidgetContainer(
widgetType: WidgetType,
isExpanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
onDragStart: () -> Unit,
onDrag: (Float) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
content: @Composable () -> Unit
) {
AppCard(
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.animateContentSize(animationSpec = tween(300, easing = FastOutSlowInEasing))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(widgetType.titleRes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { onExpandedChange(!isExpanded) }) {
Icon(
imageVector = if (isExpanded) AppIcons.ArrowDropUp
else AppIcons.ArrowDropDown,
contentDescription = if (isExpanded) stringResource(R.string.text_collapse_widget)
else stringResource(R.string.text_expand_widget)
)
}
// Drag Handle with specific pointer input
Icon(
imageVector = AppIcons.DragHandle,
contentDescription = stringResource(R.string.text_drag_to_reorder),
tint = if (isExpanded) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(end = 8.dp, start = 8.dp)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { _ -> onDragStart() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount.y)
},
onDragEnd = { onDragEnd() },
onDragCancel = { onDragCancel() }
)
}
)
}
if (isExpanded) {
content()
}
}
}
}
// --------------------------------------------------------------------------------
// Fixed Drag and Drop Logic
// --------------------------------------------------------------------------------
@Composable
fun rememberDragDropState(
lazyListState: LazyListState,
onSwap: (Int, Int) -> Unit,
onDragEnd: () -> Unit
): DragDropState {
val scope = rememberCoroutineScope()
return remember(lazyListState, scope) {
DragDropState(
state = lazyListState,
onSwap = onSwap,
onDragFinished = onDragEnd,
scope = scope
)
}
}
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
return this.pointerInput(dragDropState) {
// Just allows the modifier to exist in the chain, logic is in the handle
}
}
class DragDropState(
private val state: LazyListState,
private val onSwap: (Int, Int) -> Unit,
private val onDragFinished: () -> Unit,
private val scope: CoroutineScope
) {
var draggingItemIndex by mutableIntStateOf(-1)
private set
private val _draggingItemOffset = Animatable(0f)
val draggingItemOffset: Float
get() = _draggingItemOffset.value
private val scrollChannel = Channel<Float>(Channel.CONFLATED)
init {
scope.launch {
for (scrollAmount in scrollChannel) {
if (scrollAmount != 0f) {
state.scrollBy(scrollAmount)
checkSwap()
}
}
}
}
fun onDragStart(index: Int) {
draggingItemIndex = index
scope.launch { _draggingItemOffset.snapTo(0f) }
}
fun onDrag(dragAmount: Float) {
if (draggingItemIndex == -1) return
scope.launch {
_draggingItemOffset.snapTo(_draggingItemOffset.value + dragAmount)
checkSwap()
checkOverscroll()
}
}
private fun checkSwap() {
val draggedIndex = draggingItemIndex
if (draggedIndex == -1) return
val visibleItems = state.layoutInfo.visibleItemsInfo
val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return
// Calculate the visual center of the dragged item
val draggedCenter = draggedItemInfo.offset + (draggedItemInfo.size / 2) + _draggingItemOffset.value
// Find a target to swap with
// FIX: We strictly check if we have crossed the CENTER of the target item.
// This acts as a hysteresis buffer to prevent flickering at the edges.
val targetItem = visibleItems.find { item ->
item.index != draggedIndex &&
draggedCenter > item.offset &&
draggedCenter < (item.offset + item.size)
}
if (targetItem != null) {
// Extra Check: Ensure we have actually crossed the midpoint of the target
val targetCenter = itemCenter(targetItem.offset, targetItem.size)
val isAboveAndMovingDown = draggedIndex < targetItem.index && draggedCenter > targetCenter
val isBelowAndMovingUp = draggedIndex > targetItem.index && draggedCenter < targetCenter
if (isAboveAndMovingDown || isBelowAndMovingUp) {
val targetIndex = targetItem.index
// 1. Swap Data
onSwap(draggedIndex, targetIndex)
// 2. Adjust Offset
// We calculate the physical distance the item moved in the layout (e.g. 150px).
// We subtract this from the current drag offset to keep the item visually stationary under the finger.
val layoutJumpDistance = (targetItem.offset - draggedItemInfo.offset).toFloat()
scope.launch {
_draggingItemOffset.snapTo(_draggingItemOffset.value - layoutJumpDistance)
}
// 3. Update Index
draggingItemIndex = targetIndex
}
}
}
private fun itemCenter(offset: Int, size: Int): Float {
return offset + (size / 2f)
}
private fun checkOverscroll() {
val draggedIndex = draggingItemIndex
if (draggedIndex == -1) {
scrollChannel.trySend(0f)
return
}
val layoutInfo = state.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return
val viewportStart = layoutInfo.viewportStartOffset
val viewportEnd = layoutInfo.viewportEndOffset
// Increased threshold slightly for smoother top-edge scrolling
val boundsStart = viewportStart + (viewportEnd * 0.15f)
val boundsEnd = viewportEnd - (viewportEnd * 0.15f)
val itemTop = draggedItemInfo.offset + _draggingItemOffset.value
val itemBottom = itemTop + draggedItemInfo.size
val scrollAmount = when {
itemTop < boundsStart -> -10f // Slower, more controlled scroll speed
itemBottom > boundsEnd -> 10f
else -> 0f
}
scrollChannel.trySend(scrollAmount)
}
fun onDragEnd() {
resetDrag()
onDragFinished()
}
fun onDragInterrupted() {
resetDrag()
}
private fun resetDrag() {
draggingItemIndex = -1
scrollChannel.trySend(0f)
scope.launch { _draggingItemOffset.snapTo(0f) }
}
}
// --------------------------------------------------------------------------------
// Remainder of your existing components
// --------------------------------------------------------------------------------
@Composable
private fun LazyWidget(
widgetType: WidgetType,
navController: NavController,
vocabularyViewModel: VocabularyViewModel,
progressViewModel: ProgressViewModel,
onShowCustomExerciseDialog: () -> Unit,
startDailyExercise: (Boolean) -> Unit,
onNavigateToCategoryDetail: (Int) -> Unit,
onNavigateToCategoryList: () -> Unit,
onShowWordPairExerciseDialog: () -> Unit,
onMissingLanguage: (Int) -> Unit
) {
when (widgetType) {
WidgetType.Status -> LazyStatusWidget(
vocabularyViewModel = vocabularyViewModel,
onNavigateToNew = { navController.navigate("vocabulary_sorting?mode=NEW") },
onNavigateToDuplicates = { navController.navigate("vocabulary_sorting?mode=DUPLICATES") },
onNavigateToFaulty = { navController.navigate("vocabulary_sorting?mode=FAULTY") },
onNavigateToNoGrammar = { navController.navigate("no_grammar_items") },
onNavigateToMissingLanguage = onMissingLanguage
)
else -> {
// Regular widgets that load immediately
when (widgetType) {
WidgetType.Streak -> StreakWidget(
streak = progressViewModel.streak.collectAsState(initial = 0).value,
lastSevenDays = progressViewModel.lastSevenDays.collectAsState().value,
dueTodayCount = progressViewModel.dueTodayCount.collectAsState().value,
wordsCompleted = progressViewModel.totalWordsCompleted.collectAsState().value,
onStatisticsClicked = { navController.navigate("vocabulary_heatmap") }
)
WidgetType.WeeklyActivityChart -> WeeklyActivityChartWidget(
weeklyStats = progressViewModel.weeklyActivityStats.collectAsState().value
)
WidgetType.AllVocabulary -> AllVocabularyWidget(
vocabularyViewModel = vocabularyViewModel,
onOpenAllVocabulary = { navController.navigate("vocabulary_list/false/null") },
onStageClicked = { stage -> navController.navigate("vocabulary_list/false/$stage") }
)
WidgetType.DueToday -> DueTodayWidget(
vocabularyViewModel = vocabularyViewModel,
onStageClicked = { stage -> navController.navigate("vocabulary_list/false/$stage") }
)
WidgetType.CategoryProgress -> CategoryProgressWidget(
onCategoryClicked = { category ->
category?.let { onNavigateToCategoryDetail(it.id) }
},
onViewAllClicked = onNavigateToCategoryList
)
WidgetType.Levels -> LevelWidget(
totalWords = vocabularyViewModel.vocabularyItems.collectAsState().value.size,
learnedWords = vocabularyViewModel.stageStats.collectAsState().value
.firstOrNull { it.stage == VocabularyStage.LEARNED }?.itemCount ?: 0,
onNavigateToProgress = { navController.navigate("language_progress") }
)
}
}
}
}
@Composable
private fun LazyStatusWidget(
vocabularyViewModel: VocabularyViewModel,
onNavigateToNew: () -> Unit,
onNavigateToDuplicates: () -> Unit,
onNavigateToFaulty: () -> Unit,
onNavigateToNoGrammar: () -> Unit,
onNavigateToMissingLanguage: (Int) -> Unit
) {
var isLoading by remember { mutableStateOf(true) }
// Collect all flows asynchronously
val newItemsCount by vocabularyViewModel.newItemsCount.collectAsState()
val duplicateCount by vocabularyViewModel.duplicateItemsCount.collectAsState()
val faultyItemsCount by vocabularyViewModel.faultyItemsCount.collectAsState()
val itemsWithoutGrammarCount by vocabularyViewModel.itemsWithoutGrammarCount.collectAsState()
val missingLanguageInfo by vocabularyViewModel.missingLanguageInfo.collectAsState()
LaunchedEffect(
newItemsCount,
duplicateCount,
faultyItemsCount,
itemsWithoutGrammarCount,
missingLanguageInfo
) {
delay(100)
isLoading = false
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
} else {
StatusWidget(
onNavigateToNew = onNavigateToNew,
onNavigateToDuplicates = onNavigateToDuplicates,
onNavigateToFaulty = onNavigateToFaulty,
onNavigateToNoGrammar = onNavigateToNoGrammar,
onNavigateToMissingLanguage = onNavigateToMissingLanguage
)
}
}
@Preview
@Composable
fun DashboardContentPreview() {
val navController = rememberNavController()
DashboardContent(
navController = navController,
onShowCustomExerciseDialog = {},
onNavigateToCategoryDetail = {},
startDailyExercise = {},
onNavigateToCategoryList = {},
onShowWordPairExerciseDialog = {},
)
}
@Preview
@Composable
fun WidgetContainerPreview() {
WidgetContainer(
widgetType = WidgetType.Streak,
isExpanded = true,
onExpandedChange = {},
onDragStart = { } ,
onDrag = { },
onDragEnd = { },
onDragCancel = { }
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text("Preview Content")
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -67,7 +67,7 @@ import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.viewmodel.ProgressViewModel
@Composable
fun LanguageProgressScreen(navController: NavController) {
fun LanguageJourneyScreen(navController: NavController) {
val activity = LocalContext.current.findActivity()
val progressViewModel : ProgressViewModel = hiltViewModel(activity)
@@ -379,6 +379,6 @@ private fun LevelDetailDialog(level: MyAppLanguageLevel, onDismiss: () -> Unit)
@Preview(showBackground = true)
@Composable
fun LanguageProgressScreenPreview() {
LanguageProgressScreen(navController = NavController(LocalContext.current))
fun LanguageJourneyScreenPreview() {
LanguageJourneyScreen(navController = NavController(LocalContext.current))
}

View File

@@ -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
@@ -253,7 +252,10 @@ fun NewWordScreen(
Spacer(modifier = Modifier.height(24.dp))
BottomActionCardsRow(
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
)
}
}

View File

@@ -16,7 +16,7 @@ import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.vocabulary.widgets.DetailedStageProgressBar
import eu.gaudian.translator.view.stats.widgets.DetailedStageProgressBar
import eu.gaudian.translator.viewmodel.VocabularyViewModel
@Composable

View File

@@ -33,6 +33,8 @@ import eu.gaudian.translator.utils.Log
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppAlertDialog
import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.exercises.ExerciseControls
import eu.gaudian.translator.view.exercises.ExerciseProgressIndicator
import eu.gaudian.translator.viewmodel.ScreenState
import eu.gaudian.translator.viewmodel.VocabularyExerciseViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel

View File

@@ -232,7 +232,7 @@ fun VocabularySortingScreen(
}
},
onNavigateBack = { navController.popBackStack() },
hintContent = HintDefinition.SORTING.hint()
hint = HintDefinition.SORTING.hint()
)
},

View File

@@ -0,0 +1,263 @@
@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.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
// ---------------------------------------------------------------------------
// 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,
/** Items available once the file has been downloaded and parsed (for preview). */
val previewItems: List<VocabularyItem> = emptyList(),
)
/** Internal wrapper for deserializing the items array from a pack file. */
@Serializable
private data class PackPreviewWrapper(val items: List<VocabularyItem> = emptyList())
// ---------------------------------------------------------------------------
// 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()
/**
* 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<String>(extraBufferCapacity = 8)
val downloadCompleteEvents: SharedFlow<String> = _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() {
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 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 = when {
isCurrentVersionImported -> PackDownloadState.IMPORTED
downloaded -> PackDownloadState.DOWNLOADED
else -> PackDownloadState.IDLE
},
previewItems = items,
)
} ?: 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) {
// 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) }
try {
val success = downloadManager.downloadVocabCollection(info) { progress ->
updatePack(info.id) { it.copy(progress = progress) }
}
if (success) {
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) }
}
} catch (e: Exception) {
Log.e(TAG, "Download exception for ${info.id}", e)
updatePack(info.id) { it.copy(downloadState = PackDownloadState.ERROR) }
}
}
}
// ── Read raw JSON for import ──────────────────────────────────────────────
/** 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()) {
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 ───────────────────────────────────────────────────
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) }
}
// ── Preview download for already-imported packs ───────────────────────────
/**
* 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<VocabularyItem> {
val json = readPackRawJson(info) ?: return emptyList()
return try {
val wrapper = jsonParser.decodeFromString<PackPreviewWrapper>(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 }
}
}

View File

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

View File

@@ -282,7 +282,7 @@
<string name="label_start_exercise_2d">Übung starten (%1$d)</string>
<string name="number_of_cards">Anzahl der Karten: %1$d / %2$d</string>
<string name="no_cards_found_for_the_selected_filters">Keine Karten für die gewählten Filter gefunden.</string>
<string name="label_choose_exercise_types">Die richtige Antwort wählen</string>
<string name="label_multiple_choice_desc">Die richtige Antwort wählen</string>
<string name="options">Optionen</string>
<string name="shuffle_cards">Karten mischen</string>
<string name="quit">Beenden</string>
@@ -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>

View File

@@ -281,7 +281,6 @@
<string name="label_start_exercise_2d">Iniciar Exercício (%1$d)</string>
<string name="number_of_cards">Número de Cartões: %1$d / %2$d</string>
<string name="no_cards_found_for_the_selected_filters">Nenhum cartão encontrado para os filtros selecionados.</string>
<string name="label_choose_exercise_types">Escolher Tipos de Exercício</string>
<string name="options">Opções</string>
<string name="shuffle_cards">Embaralhar Cartões</string>
<string name="quit">Sair</string>
@@ -326,7 +325,6 @@
<string name="statistics_are_loading">Carregando estatísticas…</string>
<string name="to_d">para %1$s</string>
<string name="label_translate_from_2d">Traduzir de %1$s</string>
<string name="text_assemble_the_word_here">Monte a palavra aqui</string>
<string name="correct_answer">Resposta correta: %1$s</string>
<string name="label_quit_exercise_qm">Sair do Exercício?</string>
<string name="text_are_you_sure_you_want_to_quit_your_progress_in_this_session_will_be_lost">Tem certeza de que quer sair? O seu progresso nesta sessão será perdido.</string>
@@ -914,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>

View File

@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="category_hint_intro">You can create two types of categories to organize your vocabulary:</string>
<string name="cd_achieved">Achieved</string>
<string name="cd_add">Add</string>
<string name="cd_app_logo">App Logo</string>
<string name="cd_back">Back</string>
<string name="cd_clear_search">Clear Search</string>
@@ -8,10 +11,20 @@
<string name="cd_collapse">Collapse</string>
<string name="cd_error">Error</string>
<string name="cd_expand">Expand</string>
<string name="cd_filter">Filter</string>
<string name="cd_filter_options">Filter options</string>
<string name="cd_go">Go</string>
<string name="cd_navigate_back">Navigate back</string>
<string name="cd_options">Options</string>
<string name="cd_paste">Paste</string>
<string name="cd_play">Play</string>
<string name="cd_re_generate_definition">Re-generate Definition</string>
<string name="cd_reload">Reload</string>
<string name="cd_scroll_to_top">Scroll to top</string>
<string name="cd_search">Search</string>
<string name="cd_searchh">Search</string>
<string name="cd_selected">Selected</string>
<string name="cd_settings">Settings</string>
<string name="cd_success">Success</string>
<string name="cd_switch_languages">Switch Languages</string>
<string name="cd_target_met">Target Met</string>
@@ -19,20 +32,9 @@
<string name="cd_toggle_menu">Toggle Menu</string>
<string name="cd_translation_history">Translation History</string>
<string name="label_choose_exercise_types">Choose Exercise Types</string>
<string name="label_clear_all">Clear All</string>
<string name="label_close_exercise">Close exercise</string>
<string name="label_close_selection_mode">Close selection mode</string>
<string name="label_colloquial">Colloquial</string>
<string name="contact_developer_description">Contact me for bug reports, ideas, feature requests, and more.</string>
<string name="contact_developer_title">Contact developer</string>
<string name="label_context">Context</string>
<string name="copied_text">Copied Text</string>
<string name="copy_text">Copy text</string>
@@ -42,7 +44,6 @@
<string name="correct_answers_">Correct answers: %1$d</string>
<string name="correct_tone">Tone</string>
<string name="label_create">Create</string>
<string name="create_a_new_custom_language_entry_for_this_id">Create a new custom language entry for this ID.</string>
<string name="create_new_category">Create New Category</string>
<string name="create_new_language">Create New Language</string>
@@ -74,6 +75,9 @@
<string name="delete_new">Delete New</string>
<string name="delete_provider">Delete Provider</string>
<string name="desc_daily_review_due">%1$d words need attention</string>
<string name="desc_expand_your_vocabulary">Expand your vocabulary</string>
<string name="description">Description</string>
<string name="deselect_all">Deselect All</string>
@@ -85,6 +89,7 @@
<string name="due_today_">Due Today: %1$s</string>
<string name="duplicate">Duplicate</string>
<string name="duplicate_detected">Duplicate Detected</string>
<string name="duplicates_only">Duplicates Only</string>
@@ -134,9 +139,17 @@
<string name="fetching_for_d_items">Fetching for %d Items</string>
<string name="fetching_grammar_details">Fetching Grammar Details</string>
<string name="filter_a1">Beginner · A1</string>
<string name="filter_a2">Elementary · A2</string>
<!-- Pack Filter Labels -->
<string name="filter_all">All</string>
<string name="filter_and_sort">Filter and Sort</string>
<string name="label_filter_by_stage">Filter by Stage</string>
<string name="filter_b1">Intermediate · B1</string>
<string name="filter_b2">Upper Int. · B2</string>
<string name="filter_by_word_type">Filter by Word Type</string>
<string name="filter_c1">Advanced · C1</string>
<string name="filter_c2">Proficient · C2</string>
<string name="filter_newest">Newest</string>
<string name="find_translations">Find Translations</string>
@@ -165,8 +178,17 @@
<string name="hide_context">Hide</string>
<string name="hint">Hint: %1$s</string>
<string name="hint_hints_header_advanced">Advanced Features</string>
<string name="hint_hints_header_basics">Getting Started</string>
<string name="hint_hints_header_vocabulary">Vocabulary Management</string>
<string name="hint_hints_overview_description">All hints that are in this app can be found here as well.</string>
<string name="hint_hints_overview_intro">Help Center</string>
<string name="hint_how_to_connect_to_an_ai">How to connect to an AI</string>
<string name="hint_how_to_generate_vocabulary_with_ai">How to generate Vocabulary with AI</string>
<string name="hint_scan_hint_title">Finding the right AI model</string>
<string name="hint_title_hints_overview">Help and Instructions</string>
<string name="hint_translate_how_it_works">How translation works</string>
<string name="hint_vocabulary_progress_hint_title">Vocabulary Progress Tracking</string>
<string name="imperative">Imperative</string>
@@ -190,6 +212,7 @@
<string name="keep_both">Keep Both</string>
<string name="label_2d_days">%1$d Days</string>
<string name="label_about">About</string>
<string name="label_academic">Academic</string>
<string name="label_action_correct">Correct</string>
@@ -198,19 +221,26 @@
<string name="label_add_category">Add Category</string>
<string name="label_add_custom_model">Add Custom Model</string>
<string name="label_add_custom_provider">Add Custom Provider</string>
<string name="label_add_d_words">Add %1$d words</string>
<string name="label_add_d_words_to_library">Add %1$d words to Library</string>
<string name="label_add_key">Add Key</string>
<string name="label_add_model">Add Model</string>
<string name="label_add_model_manually">Add Model Manually</string>
<string name="label_add_synonym">Add synonym</string>
<string name="label_add_to_dictionary">Add to dictionary</string>
<string name="label_add_to_library">Add to Library</string>
<string name="label_add_validate"><![CDATA[Add & Validate]]></string>
<string name="label_add_vocabulary">Add Vocabulary</string>
<string name="label_added">Added</string>
<string name="label_adjective">Adjective</string>
<string name="label_adverb">Adverb</string>
<string name="label_ai_configuration">AI Configuration</string>
<string name="label_ai_generator">AI Generator</string>
<string name="label_ai_model">AI Model</string>
<string name="label_ai_model_and_prompt"><![CDATA[AI Model & Prompt]]></string>
<string name="label_all_cards">All Cards</string>
<string name="label_all_categories">All Categories</string>
<string name="label_all_categoriess">All Categories</string>
<string name="label_all_stages">All Stages</string>
<string name="label_all_types">All Types</string>
<string name="label_all_vocabulary">All Vocabulary</string>
@@ -220,6 +250,8 @@
<string name="label_appearance">Appearance</string>
<string name="label_apply_filters">Apply Filters</string>
<string name="label_article">Article</string>
<string name="label_auto_cycle_dev">Auto Cycle (Dev)</string>
<string name="label_available_collections">Available Collections</string>
<string name="label_backup_and_restore">Backup and Restore</string>
<string name="label_by_language">By Language</string>
<string name="label_cancel">Cancel</string>
@@ -228,19 +260,30 @@
<string name="label_category">Category</string>
<string name="label_category_2d">Category: %1$s</string>
<string name="label_clear">Clear</string>
<string name="label_clear_all">Clear All</string>
<string name="label_close">Close</string>
<string name="label_close_exercise">Close exercise</string>
<string name="label_close_search">Close search</string>
<string name="label_close_selection_mode">Close selection mode</string>
<string name="label_collapse">Collapse</string>
<string name="label_colloquial">Colloquial</string>
<string name="label_column_n">Column %1$d</string>
<string name="label_common">Common</string>
<string name="label_completed">Completed</string>
<string name="label_confirm">Confirm</string>
<string name="label_conjugation">Conjugation: %1$s</string>
<string name="label_conjunction">Conjunction</string>
<string name="label_context">Context</string>
<string name="label_continue">Continue</string>
<string name="label_correct">Correct</string>
<string name="label_create">Create</string>
<string name="label_create_exercise">Create Exercise</string>
<string name="label_current_streak">Current Streak</string>
<string name="label_custom">Custom</string>
<string name="label_d_packs">%1$d packs</string>
<string name="label_daily_goal">Daily Goal</string>
<string name="label_daily_review">Daily Review</string>
<string name="label_declension">Declension</string>
<string name="label_definitions">Definitions</string>
<string name="label_delete">Delete</string>
<string name="label_delete_all">Delete all</string>
@@ -254,110 +297,159 @@
<string name="label_dictionary_content">Dictionary Content</string>
<string name="label_dictionary_manager">Dictionary Manager</string>
<string name="label_dictionary_options">Dictionary Options</string>
<string name="tab_ai_definition">AI Definition</string>
<string name="tab_downloaded">Downloaded</string>
<string name="label_display_name">Display Name</string>
<string name="label_done">Done</string>
<string name="label_download">Download</string>
<string name="label_easy">Easy</string>
<string name="label_edit">Edit</string>
<string name="label_enter_a_text">Enter a text</string>
<string name="label_etymology">Etymology</string>
<string name="label_exercise">Exercise</string>
<string name="label_exercises">Exercises</string>
<string name="label_expand">Expand</string>
<string name="label_feminine">Feminine</string>
<string name="label_filter_by_stage">Filter by Stage</string>
<string name="label_filter_cards">Filter Cards</string>
<string name="label_first_column">First Column</string>
<string name="label_first_language">First Language</string>
<string name="label_from">From</string>
<string name="label_gender">Gender</string>
<string name="label_general">General</string>
<!-- Pack Card -->
<string name="label_get">Get</string>
<string name="label_get_d_words">Get %1$d words</string>
<string name="label_grammar_auxiliary">" (Auxiliary: %1$s)"</string>
<string name="label_grammar_hyphenation">Hyphenation</string>
<string name="label_grammar_inflections">Inflections</string>
<string name="label_grammar_meanings">Meanings</string>
<string name="label_grammar_only">Grammar only</string>
<string name="label_guessing_exercise">Guessing</string>
<string name="label_hard">Hard</string>
<string name="label_header_row">First Row is a Header</string>
<string name="label_hide_examples">Hide examples</string>
<string name="label_home">Home</string>
<string name="label_import">Import</string>
<string name="label_import_csv">Import CSV</string>
<string name="label_import_table_csv_excel">Import Table (CSV)</string>
<string name="label_in_library">In Library</string>
<string name="label_in_stages">In Stages</string>
<string name="label_interjection">Interjection</string>
<string name="label_interval_settings_in_days">Interval Settings</string>
<string name="label_language_auto">Auto</string>
<string name="label_language_direction">Language Direction\n</string>
<string name="label_language_none">None</string>
<string name="label_languages">Languages</string>
<string name="label_learned">Learned</string>
<string name="label_learnedd">learned</string>
<string name="label_learning_criteria">Learning Criteria</string>
<string name="label_library">Library</string>
<string name="label_logs">Logs</string>
<string name="label_masculine">Masculine</string>
<string name="label_medium">Medium</string>
<string name="label_model_id_star">Model ID *</string>
<string name="label_more">More</string>
<string name="label_move_first_stage">Move to First Stage</string>
<string name="label_multiple_choice_desc">Choose the right translation</string>
<string name="label_multiple_choice_exercise">Multiple Choice</string>
<string name="label_neuter">Neuter</string>
<string name="label_new">New</string>
<string name="label_new_words">New Words</string>
<string name="label_new_wordss">New Words</string>
<string name="label_no_category">None</string>
<string name="label_no_history_yet">No history yet</string>
<string name="label_noun">Noun</string>
<string name="label_optional">(Optional)</string>
<string name="label_origin_language">Origin Language</string>
<string name="label_orphaned_files">Orphaned Files</string>
<string name="label_paste">Paste</string>
<string name="label_plural">Plural</string>
<string name="label_preposition">Preposition</string>
<string name="label_preview_first">Preview (first 5) for first column: %1$s</string>
<string name="label_preview_second">Preview (first 5) for second column: %1$s</string>
<string name="label_pronoun">Pronoun</string>
<string name="label_pronunciation">Pronunc iation</string>
<string name="label_providers">Providers</string>
<string name="label_quit_app">Quit App</string>
<string name="label_quit_exercise_qm">Quit Exercise?</string>
<string name="label_raw_data_2d">Raw Data:</string>
<string name="label_read_aloud">Read Aloud</string>
<string name="label_ready">Ready</string>
<string name="label_recently_added">Recently Added</string>
<string name="label_regenerate">Regenerate</string>
<string name="label_related_words">Related Words</string>
<string name="label_reload">Reload</string>
<string name="label_remove_articles">Remove Articles</string>
<string name="label_request_a_pack">Request a Pack</string>
<string name="label_reset">Reset</string>
<string name="label_retry">Retry</string>
<string name="label_retry_download">Retry download</string>
<string name="label_save">Save</string>
<string name="label_scan_for_models">Scan for Models</string>
<string name="label_scanning">Scanning…</string>
<string name="label_search_cards">Search cards</string>
<string name="label_search_models">Search models…</string>
<string name="label_second_column">Second Column</string>
<string name="label_second_language">Second Language</string>
<string name="label_see_history">See History</string>
<string name="label_select">Select</string>
<string name="label_select_stage">Select Stage</string>
<string name="label_send_request">Send Request</string>
<string name="label_settings">Settings</string>
<string name="label_show_2d_more">Show %1$d More</string>
<string name="label_show_dictionary_entry">Show dictionary entry</string>
<string name="label_show_examples">Show examples</string>
<string name="label_show_less">Show Less</string>
<string name="label_show_more">Show More</string>
<string name="label_show_more_actions">Show more actions</string>
<string name="label_size_2d_mb">Size: %1$d MB</string>
<string name="label_sort_by">Sort By</string>
<string name="label_speaking_speed">Speaking Speed</string>
<string name="label_spelling_exercise">Spelling</string>
<string name="label_star_required">*required</string>
<string name="label_start">Start</string>
<string name="label_start_exercise">Start Exercise</string>
<string name="label_start_exercise_2d">Start Exercise (%1$d)</string>
<string name="label_start_required">* required</string>
<string name="label_statistics">Statistics</string>
<string name="label_stats">Stats</string>
<string name="label_status">Status</string>
<string name="label_system">System</string>
<string name="label_target_correct_answers_per_day">Target Correct Answers Per Day</string>
<string name="label_target_language">Target Language</string>
<string name="label_target_tone">Target Tone:</string>
<string name="label_task_model_assignments">Task Model Assignments</string>
<string name="label_tasks">Tasks</string>
<string name="label_tense">Tense</string>
<string name="label_to">To</string>
<string name="label_topic">Topic</string>
<string name="label_total_words">Total Words</string>
<string name="label_training_mode">Training Mode</string>
<string name="label_translate">Translate</string>
<string name="label_translate_from_2d">Translate from %1$s</string>
<string name="label_translation">Translation</string>
<string name="label_translation_server">Translation Server</string>
<string name="label_translation_settings">Translation Settings</string>
<string name="label_translations">Translations</string>
<string name="label_unknown">Unknown</string>
<string name="label_unknown_dictionary_d">Unknown Dictionary (%1$s)</string>
<string name="label_update">Update</string>
<string name="label_variations">Variations</string>
<string name="label_verb">Verb</string>
<string name="label_version_2d">Version: %1$s</string>
<string name="label_view_all">View All</string>
<string name="label_vocabulary">Vocabulary</string>
<string name="label_vocabulary_activity">Vocabulary Activity</string>
<string name="label_vocabulary_settings">Progress Settings</string>
<string name="label_warning">Warning</string>
<string name="label_weekly_progress">Weekly Progress</string>
<string name="label_wiktionary">Wiktionary</string>
<string name="label_word">Word</string>
<string name="label_word_jumble_exercise">Word Jumble</string>
<string name="label_words_in_this_pack">Words in this pack</string>
<string name="label_wrong">Wrong</string>
<string name="label_wrong_answers">Wrong answers</string>
<string name="label_yes">Yes</string>
<string name="label_your_answer">Your Answer</string>
<string name="label_your_translation">Your translation</string>
<string name="labels_1d_models">%1$d models</string>
@@ -412,6 +504,66 @@
<string name="merge">Merge</string>
<string name="merge_items">Merge Items</string>
<string name="message_error_api_key_invalid">API Key is missing or invalid.</string>
<!-- API Key related -->
<string name="message_error_api_key_missing">API Key is missing or invalid.</string>
<string name="message_error_articles_remove_failed">Error removing articles: %1$s</string>
<string name="message_error_category_update_failed">Error updating category: %1$s</string>
<string name="message_error_excel_not_supported">Excel is not supported. Use CSV instead.</string>
<string name="message_error_file_picker_not_initialized">Save File Launcher not initialized.</string>
<string name="message_error_file_save_cancelled">File save cancelled or failed.</string>
<string name="message_error_file_save_failed">Error saving file: %1$s</string>
<string name="message_error_generic">An error occurred</string>
<string name="message_error_grammar_fetch_failed">Could not retrieve grammar details.</string>
<string name="message_error_items_add_failed">Error adding items: %1$s</string>
<string name="message_error_items_delete_failed">Error deleting items: %1$s</string>
<!-- Language related -->
<string name="message_error_language_not_selected">Source and target languages must be selected.</string>
<string name="message_error_no_cards_found">No cards found for the specified filter.</string>
<string name="message_error_no_words_found">No words found in the provided text.</string>
<!-- Operation status -->
<string name="message_error_operation_failed">Operation failed: %1$s</string>
<string name="message_error_repository_wipe_failed">Failed to wipe repository: %1$s</string>
<string name="message_error_stage_update_failed">Error updating stage: %1$s</string>
<string name="message_error_synonyms_generation_failed">Failed to generate synonyms: %1$s</string>
<string name="message_error_translation_failed">Translation failed: %1$s</string>
<string name="message_error_vocabulary_import_failed">Error importing vocabulary items: %1$s</string>
<string name="message_info_generic">Info</string>
<string name="message_loading_card_set">Loading card set</string>
<string name="message_loading_generic">Loading…</string>
<string name="message_loading_grammar_fetch">Fetching grammar for %1$d items…</string>
<string name="message_loading_operation_in_progress">Operation in progress…</string>
<!-- Translation related -->
<string name="message_loading_translating">Translating %1$d words…</string>
<!-- Article removal -->
<string name="message_success_articles_removed">Articles removed successfully.</string>
<string name="message_success_cards_loaded">Successfully loaded card set.</string>
<string name="message_success_category_saved">Category saved to %1$s</string>
<!-- Category operations -->
<string name="message_success_category_updated">Category updated successfully.</string>
<!-- File operations -->
<string name="message_success_file_saved">File saved to %1$s</string>
<!-- Status Messages (for internationalization) -->
<string name="message_success_generic">Success!</string>
<!-- Grammar related -->
<string name="message_success_grammar_updated">Grammar details updated!</string>
<string name="message_success_items_added">Successfully added %1$d new vocabulary items.</string>
<string name="message_success_items_deleted">Successfully deleted vocabulary items.</string>
<string name="message_success_items_merged">Items merged!</string>
<string name="message_success_language_replaced">Language ID updated for %1$d items.</string>
<!-- Repository operations -->
<string name="message_success_repository_wiped">All repository data deleted.</string>
<!-- Stage operations -->
<string name="message_success_stage_updated">Stage updated successfully.</string>
<!-- Synonyms -->
<string name="message_success_synonyms_generated">Synonyms generated successfully.</string>
<string name="message_success_translation_completed">Translation completed.</string>
<!-- Vocabulary related -->
<string name="message_success_vocabulary_imported">Vocabulary items imported successfully.</string>
<string name="message_test_error">Oops, something went wrong :(</string>
<string name="message_test_info">This is a generic info message.</string>
<string name="message_test_success">This is a test success message!</string>
<string name="min_correct_to_advance">Min. Correct to Advance</string>
<string name="model">Model</string>
@@ -445,12 +597,12 @@
<string name="no">No</string>
<string name="no_cards_found_for_the_selected_filters">No cards found for the selected filters.</string>
<string name="no_grammar_configuration_found_for_this_language">No grammar configuration found for this language.</string>
<string name="no_items_due_for_review">No items due for review today. Great job!</string>
<string name="no_items_without_grammar">No Items without Grammar</string>
<string name="no_model_selected_for_the_task">No model selected for the task: %1$s</string>
<string name="no_models_configured">No Models Configured</string>
<string name="no_models_found">No models found</string>
<string name="no_new_vocabulary_to_sort">No New Vocabulary to Sort</string>
<string name="no_items_due_for_review">No items due for review today. Great job!</string>
<string name="no_vocabulary_items_found_perhaps_try_changing_the_filters">No vocabulary items found. Perhaps try changing the filters?</string>
<string name="not_available">Not available</string>
@@ -569,6 +721,8 @@
<string name="result">Result</string>
<string name="review_intro">Review the generated vocabulary before adding it to your collection.</string>
<string name="right">Right</string>
<string name="scan_models">Scan models</string>
@@ -577,6 +731,7 @@
<string name="search_for_a_word_s_origin">Search for a word\'s origin</string>
<string name="search_models">Search Models</string>
<string name="search_topics_phrases">Search topics, phrases…</string>
<string name="secondary_button">Secondary Button</string>
<string name="secondary_inverse">Secondary Inverse</string>
@@ -619,11 +774,14 @@
<string name="sort_by_new_items">Sort by New Items</string>
<string name="sort_by_size">Sort by Size</string>
<string name="sort_new_vocabulary">Sort New Vocabulary</string>
<string name="sort_order_alphabetical">Alphabetical</string>
<string name="sort_order_language">Language</string>
<!-- Sort Order Options -->
<string name="sort_order_newest_first">Newest First</string>
<string name="sort_order_oldest_first">Oldest First</string>
<string name="sorting_hint_title">Vocabulary Sorting</string>
<string name="label_speaking_speed">Speaking Speed</string>
<string name="stage_1">Stage 1</string>
<string name="stage_2">Stage 2</string>
<string name="stage_3">Stage 3</string>
@@ -646,6 +804,15 @@
<string name="status_widget_faulty_items">Faulty Items</string>
<string name="status_widget_new_items">New Items</string>
<string name="strategy_keep_both">Keep Both</string>
<string name="strategy_keep_both_desc">Add all words from the pack as new entries.</string>
<string name="strategy_merge">Merge (Recommended)</string>
<string name="strategy_merge_desc">Keep existing progress; merge categories intelligently.</string>
<string name="strategy_replace">Replace Existing</string>
<string name="strategy_replace_desc">Overwrite matching words with the pack version.</string>
<string name="strategy_skip">Skip Duplicates</string>
<string name="strategy_skip_desc">Only add words that don\'t already exist.</string>
<string name="subjunctive">Subjunctive</string>
<string name="synonym_exists">Synonym exists</string>
@@ -655,9 +822,10 @@
<string name="system_default_font">System Default Font</string>
<string name="system_theme">System Theme</string>
<string name="tap_the_words_below_to_form_the_sentence">Tap the words below to form the sentence</string>
<string name="tab_ai_definition">AI Definition</string>
<string name="tab_downloaded">Downloaded</string>
<string name="label_target_correct_answers_per_day">Target Correct Answers Per Day</string>
<string name="tap_the_words_below_to_form_the_sentence">Tap the words below to form the sentence</string>
<string name="test">Test</string>
@@ -675,12 +843,14 @@
<string name="text_a_simple_list_to">A simple list to manually sort your vocabulary</string>
<string name="text_add_custom_language">Add Custom Language</string>
<string name="text_add_grammar_details">Add grammar details</string>
<string name="text_add_new_word_to_list">Extract a New Word to Your List</string>
<string name="text_add_to_favorites">Add to favorites</string>
<string name="text_ai_failed_to_create_the_exercise">AI failed to create the exercise.</string>
<string name="text_ai_generation_failed_with_an_exception">AI generation failed with an exception</string>
<string name="text_all_dictionaries_deleted_successfully">All dictionaries deleted successfully</string>
<string name="text_all_items_completed">All items completed!</string>
<string name="text_all_languages">All Languages</string>
<string name="text_already_in_your_library">Already in your Library</string>
<string name="text_amount_2d">Amount: %1$d</string>
<string name="text_amount_2d_questions">Amount: %1$d Questions</string>
<string name="text_amount_of_cards">Amount of cards</string>
@@ -694,7 +864,7 @@
<string name="text_are_you_sure_you_want_to_delete_this_category">Are you sure you want to delete this category?</string>
<string name="text_are_you_sure_you_want_to_quit">Are you sure you want to quit?</string>
<string name="text_are_you_sure_you_want_to_quit_your_progress_in_this_session_will_be_lost">Are you sure you want to quit? Your progress in this session will be lost.</string>
<string name="text_assemble_the_word_here">Assemble the word here</string>
<string name="text_assemble_the_word_here">Bring the letters into the right order</string>
<string name="text_assign_a_different_language_items">Assign a different language to these items.</string>
<string name="text_assign_these_items_2d">Assign these items:</string>
<string name="text_authentication_is_required_and_has_failed">Authentication is required and has failed or has not yet been provided.</string>
@@ -712,6 +882,7 @@
<string name="text_check_your_matches">Check your matches!</string>
<string name="text_checksum_mismatch_for_expected_got">Checksum mismatch for %1$s. Expected: %2$s, Got: %3$s</string>
<string name="text_claude">Claude</string>
<string name="text_clipboard_empty">Clipboard is empty</string>
<string name="text_collapse_widget">Collapse Widget</string>
<string name="text_color_palette">Color Palette</string>
<string name="text_common">Common</string>
@@ -722,8 +893,12 @@
<string name="text_copy_corrected_text">Copy corrected text</string>
<string name="text_correct_em">Correct!</string>
<string name="text_could_not_fetch_a_new_word">Could not fetch a new word.</string>
<string name="text_could_not_load_packs">Could not load packs</string>
<string name="text_customize_the_intervals">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.</string>
<string name="text_d_cards">%1$d cards</string>
<string name="text_d_words_will_be_added">%1$d words will be added to your library.</string>
<string name="text_daily_goal_description">How many words do you want to answer correctly each day?</string>
<string name="text_daily_review_placeholder">Daily review screen - implementation pending</string>
<string name="text_dark">Dark</string>
<string name="text_day_streak">Day Streak</string>
<string name="text_days">" days"</string>
@@ -733,6 +908,9 @@
<string name="text_delete_category">Delete Category</string>
<string name="text_delete_custom_language">Delete custom language</string>
<string name="text_delete_vocabulary_item">Delete Vocabulary Item?</string>
<string name="text_desc_no_activity_data_available">No activity data available</string>
<string name="text_desc_organize_vocabulary_groups">Organize Your Vocabulary in Groups</string>
<string name="text_description_dictionary_prompt">Set a model for generating dictionary content and give optional instructions.</string>
<string name="text_developed_by_jonas_gaudian">Developed by Jonas Gaudian\n</string>
<string name="text_dialog_delete_key">Are you sure you want to delete the Key for this Provider?</string>
<string name="text_dialog_delete_model">Are you sure you want to delete the model \"%1$s\" from %2$s? This action cannot be undone.</string>
@@ -742,7 +920,9 @@
<string name="text_dictionary_manager_description">You can download dictionaries for certain languages which can be used insteaf of AI generation for dictionary content.</string>
<string name="text_difficulty_2d">Difficulty: %1$s</string>
<string name="text_do_you_want_to_minimize_the_app">Do you want to minimize the app?</string>
<string name="text_dont_see_what_looking_for">Don\'t see what you\'re looking for?</string>
<string name="text_download_failed_http">Download failed: HTTP %1$d %2$s</string>
<string name="text_downloading">Downloading…</string>
<string name="text_drag_to_reorder">Drag to Reorder</string>
<string name="text_due_today">"Due Today"</string>
<string name="text_due_today_only">Due Today Only</string>
@@ -768,9 +948,9 @@
<string name="text_error_generating_questions">Error generating questions: %1$s</string>
<string name="text_error_loading_stored_values">Error loading stored values: %1$s</string>
<string name="text_error_saving_entry">Error saving entry: %1$s</string>
<string name="message_error_excel_not_supported">Excel is not supported. Use CSV instead.</string>
<string name="text_expand_widget">Expand Widget</string>
<string name="text_explanation">Explanation</string>
<string name="text_explore_more_categories">Explore more categories</string>
<string name="text_export_category">Export Category</string>
<string name="text_failed_to_delete_dictionary">Failed to delete dictionary: %1$s</string>
<string name="text_failed_to_delete_orphaned_file">Failed to delete orphaned file: %1$s</string>
@@ -791,25 +971,30 @@
<string name="text_generate_exercise_with_ai">Generate Exercise with AI</string>
<string name="text_generating_questions_from_video">Generating questions from video…</string>
<string name="text_get_api_key_at">Get API Key at %1$s</string>
<string name="text_translation_instructions">Set model for translation and give optional instructions on how to translate.</string>
<string name="text_here_you_can_set_a_custom_">Here you can set a custom prompt for the AI vocabulary model. This allows you to define how new vocabulary entries are generated.</string>
<string name="text_hint">Hint</string>
<string name="text_how_handle_duplicates">How should duplicates be handled?</string>
<string name="text_importing_d_words">Importing %1$d words…</string>
<string name="text_in_progress">In Progress</string>
<string name="text_incorrect_em">Incorrect!</string>
<string name="text_infrequent">Rare</string>
<string name="label_interval_settings_in_days">Interval Settings</string>
<string name="text_key_active">Key Active</string>
<string name="text_key_optional">Key Optional</string>
<string name="text_label_word">Enter a word\n</string>
<string name="text_language_code">Language Code</string>
<string name="text_language_direction_explanation">You can set an optional preference which language should come first or second.</string>
<string name="text_language_direction_disabled_with_pairs">Clear language pair selection to choose a direction.</string>
<string name="text_language_direction_explanation">You can set an optional preference which language should come first or second.</string>
<string name="text_language_options">Language Options</string>
<string name="text_language_settings_description">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)</string>
<string name="text_last_7_days">Last 7 Days</string>
<string name="text_light">Light</string>
<string name="text_list">List</string>
<string name="text_loading_3d">Loading…</string>
<string name="text_loading_packs">Loading packs…</string>
<!-- Pack Preview Dialog -->
<string name="text_loading_preview">Loading preview…</string>
<string name="text_manual_vocabulary_list">Manual vocabulary list</string>
<string name="text_mastered_final_level">You\'ve mastered the final level!</string>
<string name="text_mismatch_between_question_ids_in_exercise_and_questions_found_in_repository">Mismatch between question IDs in exercise and questions found in repository.</string>
<string name="text_mistral">Mistral</string>
<string name="text_more_options">More options</string>
@@ -823,6 +1008,7 @@
<string name="text_no_items_available">No items available</string>
<string name="text_no_key">No Key</string>
<string name="text_no_models_found">No models found</string>
<string name="text_no_packs_match_search">No packs match your search.</string>
<string name="text_no_valid_api_configuration_could_be_found">No valid API configuration could be found in the settings. Before using this app, you have to configure at least one API provider.</string>
<string name="text_no_vocabulary_available">No vocabulary available.</string>
<string name="text_no_vocabulary_due_today">No Vocabulary Due Today</string>
@@ -844,6 +1030,7 @@
<string name="text_remove_from_favorites">Remove from favorites</string>
<string name="text_repeat_wrong">Repeat Wrong</string>
<string name="text_repeat_wrong_guesses">Repeat Wrong Guesses</string>
<string name="text_request_pack_desc">Don\'t see what you need? Let me know and I\'ll add it!</string>
<string name="text_required_enter_a_human_readable_name">Required: Enter a human-readable name</string>
<string name="text_required_enter_the_exact_model_identifier">Required: Enter the exact model identifier</string>
<string name="text_reset_intro">Reset Intro</string>
@@ -852,6 +1039,7 @@
<string name="text_save_key">Save Key</string>
<string name="text_save_prompt">Save Prompt</string>
<string name="text_scan_for_available_models">Scan for Available Models</string>
<string name="text_search">Search</string>
<string name="text_search_3d">Search…</string>
<string name="text_search_history">Search History</string>
<string name="text_search_term">Search Term</string>
@@ -894,6 +1082,7 @@
<string name="text_training_mode">Training Mode</string>
<string name="text_training_mode_description">Training mode is enabled: answers wont affect progress.</string>
<string name="text_translation">Enter translation</string>
<string name="text_translation_instructions">Set model for translation and give optional instructions on how to translate.</string>
<string name="text_translation_will_appear_here">Translation will appear here</string>
<string name="text_true">True</string>
<string name="text_try_first_finding_the_word_on">Try first finding the word on Wiktionary before generating AI response</string>
@@ -925,7 +1114,11 @@
<string name="title_corrector">Corrector</string>
<string name="title_developer_options">Developer Options</string>
<!-- Explore Packs Screen -->
<string name="title_explore_packs">Explore Packs</string>
<string name="title_http_status_codes">HTTP Status Codes</string>
<!-- Conflict Strategy Dialog -->
<string name="title_import_pack">Import \"%1$s\"</string>
<string name="title_items_without_grammar">Items Without Grammar</string>
<string name="title_multiple">Multiple</string>
<string name="title_settings">Settings</string>
@@ -943,7 +1136,6 @@
<string name="translate_the_following_d">Translate the following (%1$s):</string>
<string name="translation_prompt_settings">Translation Prompt Settings</string>
<string name="label_translation_server">Translation Server</string>
<string name="try_again">Try Again</string>
@@ -954,7 +1146,6 @@
<string name="vocabulary_added_successfully">Vocabulary Added</string>
<string name="vocabulary_repository">Vocabulary Repository</string>
<string name="label_vocabulary_settings">Progress Settings</string>
<string name="website_url">Website URL</string>
@@ -971,152 +1162,8 @@
<string name="words_known">%1$d Words Known</string>
<string name="words_required">%1$d words required</string>
<string name="label_wrong_answers">Wrong answers</string>
<string name="label_yes">Yes</string>
<string name="text_mastered_final_level">You\'ve mastered the final level!</string>
<string name="label_your_answer">Your Answer</string>
<string name="your_language_journey">Your Language Journey</string>
<string name="label_start_required">* required</string>
<string name="label_no_history_yet">No history yet</string>
<string name="cd_play">Play</string>
<string name="label_pronunciation">Pronunc iation</string>
<string name="text_clipboard_empty">Clipboard is empty</string>
<string name="label_paste">Paste</string>
<string name="label_target_tone">Target Tone:</string>
<string name="label_grammar_only">Grammar only</string>
<string name="label_declension">Declension</string>
<string name="label_variations">Variations</string>
<string name="label_auto_cycle_dev">Auto Cycle (Dev)</string>
<string name="label_regenerate">Regenerate</string>
<string name="label_read_aloud">Read Aloud</string>
<string name="label_all_categories">All Categories</string>
<string name="text_description_dictionary_prompt">Set a model for generating dictionary content and give optional instructions.</string>
<string name="hint_vocabulary_progress_hint_title">Vocabulary Progress Tracking</string>
<string name="hint_title_hints_overview">Help and Instructions</string>
<string name="hint_hints_overview_intro">Help Center</string>
<string name="hint_hints_overview_description">All hints that are in this app can be found here as well.</string>
<string name="hint_hints_header_basics">Getting Started</string>
<string name="hint_hints_header_vocabulary">Vocabulary Management</string>
<string name="hint_hints_header_advanced">Advanced Features</string>
<string name="category_hint_intro">You can create two types of categories to organize your vocabulary:</string>
<string name="review_intro">Review the generated vocabulary before adding it to your collection.</string>
<string name="duplicate">Duplicate</string>
<string name="hint_scan_hint_title">Finding the right AI model</string>
<string name="hint_translate_how_it_works">How translation works</string>
<string name="label_no_category">None</string>
<string name="text_search">Search</string>
<string name="text_language_settings_description">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)</string>
<!-- Status Messages (for internationalization) -->
<string name="message_success_generic">Success!</string>
<string name="message_info_generic">Info</string>
<string name="message_error_generic">An error occurred</string>
<string name="message_loading_generic">Loading…</string>
<!-- Language related -->
<string name="message_error_language_not_selected">Source and target languages must be selected.</string>
<string name="message_error_no_words_found">No words found in the provided text.</string>
<string name="message_success_language_replaced">Language ID updated for %1$d items.</string>
<!-- Vocabulary related -->
<string name="message_success_vocabulary_imported">Vocabulary items imported successfully.</string>
<string name="message_error_vocabulary_import_failed">Error importing vocabulary items: %1$s</string>
<string name="message_success_items_merged">Items merged!</string>
<string name="message_success_items_added">Successfully added %1$d new vocabulary items.</string>
<string name="message_error_items_add_failed">Error adding items: %1$s</string>
<string name="message_success_items_deleted">Successfully deleted vocabulary items.</string>
<string name="message_error_items_delete_failed">Error deleting items: %1$s</string>
<string name="message_error_no_cards_found">No cards found for the specified filter.</string>
<string name="message_success_cards_loaded">Successfully loaded card set.</string>
<!-- Grammar related -->
<string name="message_success_grammar_updated">Grammar details updated!</string>
<string name="message_error_grammar_fetch_failed">Could not retrieve grammar details.</string>
<string name="message_loading_grammar_fetch">Fetching grammar for %1$d items…</string>
<!-- File operations -->
<string name="message_success_file_saved">File saved to %1$s</string>
<string name="message_error_file_save_failed">Error saving file: %1$s</string>
<string name="message_error_file_save_cancelled">File save cancelled or failed.</string>
<string name="message_error_file_picker_not_initialized">Save File Launcher not initialized.</string>
<string name="message_success_category_saved">Category saved to %1$s</string>
<!-- API Key related -->
<string name="message_error_api_key_missing">API Key is missing or invalid.</string>
<string name="message_error_api_key_invalid">API Key is missing or invalid.</string>
<!-- Translation related -->
<string name="message_loading_translating">Translating %1$d words…</string>
<string name="message_success_translation_completed">Translation completed.</string>
<string name="message_error_translation_failed">Translation failed: %1$s</string>
<!-- Repository operations -->
<string name="message_success_repository_wiped">All repository data deleted.</string>
<string name="message_error_repository_wipe_failed">Failed to wipe repository: %1$s</string>
<string name="message_loading_card_set">Loading card set</string>
<!-- Stage operations -->
<string name="message_success_stage_updated">Stage updated successfully.</string>
<string name="message_error_stage_update_failed">Error updating stage: %1$s</string>
<!-- Category operations -->
<string name="message_success_category_updated">Category updated successfully.</string>
<string name="message_error_category_update_failed">Error updating category: %1$s</string>
<!-- Article removal -->
<string name="message_success_articles_removed">Articles removed successfully.</string>
<string name="message_error_articles_remove_failed">Error removing articles: %1$s</string>
<!-- Synonyms -->
<string name="message_success_synonyms_generated">Synonyms generated successfully.</string>
<string name="message_error_synonyms_generation_failed">Failed to generate synonyms: %1$s</string>
<!-- Operation status -->
<string name="message_error_operation_failed">Operation failed: %1$s</string>
<string name="message_loading_operation_in_progress">Operation in progress…</string>
<string name="message_test_info">This is a generic info message.</string>
<string name="message_test_success">This is a test success message!</string>
<string name="message_test_error">Oops, something went wrong :(</string>
<string name="label_stats">Stats</string>
<string name="label_library">Library</string>
<string name="label_edit">Edit</string>
<string name="label_new_words">New Words</string>
<string name="desc_expand_your_vocabulary">Expand your vocabulary</string>
<string name="label_settings">Settings</string>
<string name="label_2d_days">%1$d Days</string>
<string name="label_current_streak">Current Streak</string>
<string name="label_daily_goal">Daily Goal</string>
<string name="label_daily_review">Daily Review</string>
<string name="desc_daily_review_due">%1$d words need attention</string>
<string name="text_daily_review_placeholder">Daily review screen - implementation pending</string>
<string name="text_desc_no_activity_data_available">No activity data available</string>
<string name="label_see_history">See History</string>
<string name="label_weekly_progress">Weekly Progress</string>
<string name="cd_go">Go</string>
<string name="label_sort_by">Sort By</string>
<string name="label_reset">Reset</string>
<string name="label_filter_cards">Filter Cards</string>
<string name="text_desc_organize_vocabulary_groups">Organize Your Vocabulary in Groups</string>
<string name="text_add_new_word_to_list">Extract a New Word to Your List</string>
<string name="cd_scroll_to_top">Scroll to top</string>
<string name="cd_settings">Settings</string>
<string name="label_import_csv">Import CSV</string>
<string name="label_ai_generator">AI Generator</string>
<string name="label_new_wordss">New Words</string>
<string name="label_recently_added">Recently Added</string>
<string name="label_view_all">View All</string>
<string name="text_explore_more_categories">Explore more categories</string>
<string name="cd_options">Options</string>
<string name="cd_selected">Selected</string>
<string name="label_all_cards">All Cards</string>
<string name="cd_filter_options">Filter options</string>
<string name="cd_add">Add</string>
<string name="cd_searchh">Search</string>
<string name="label_search_cards">Search cards</string>
<string name="label_learnedd">learned</string>
<string name="label_all_categoriess">All Categories</string>
<string name="label_show_more">Show More</string>
<!-- Explore Packs Hint -->
<string name="hint_explore_packs_title">About Vocabulary Packs</string>
</resources>

View File

@@ -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<String> {
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<String>()
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<Int>, 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`.*

View File

@@ -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<Int>, 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<String>
) {
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.