From 3c1e71d805dddf1e45e67cc4862a3b60954c6e64 Mon Sep 17 00:00:00 2001
From: jonasgaudian <43753916+jonasgaudian@users.noreply.github.com>
Date: Tue, 17 Feb 2026 22:06:14 +0100
Subject: [PATCH] implement a comprehensive vocabulary export/import system
with JSON support and conflict resolution
---
app/src/main/AndroidManifest.xml | 10 +
.../translator/model/VocabularyExport.kt | 261 ++++++++
.../files_download/FileDownloadManager.kt | 134 +++-
.../files_download/FlashcardApiService.kt | 17 +
.../files_download/FlashcardManifestModels.kt | 39 ++
.../model/repository/VocabularyRepository.kt | 599 ++++++++++++++++++
.../view/library/LibraryComponents.kt | 10 +
.../translator/view/library/LibraryScreen.kt | 20 +
.../VocabularyRepositoryOptionsScreen.kt | 468 +++++++++++++-
.../view/vocabulary/CategoryDetailScreen.kt | 58 +-
.../view/vocabulary/NewWordScreen.kt | 14 +-
.../view/vocabulary/VocabularyListScreen.kt | 28 +
.../vocabulary/widgets/ModernStartButton.kt | 0
.../viewmodel/ExportImportViewModel.kt | 290 +++++++++
app/src/main/res/xml/file_paths.xml | 5 +
15 files changed, 1902 insertions(+), 51 deletions(-)
create mode 100644 app/src/main/java/eu/gaudian/translator/model/VocabularyExport.kt
create mode 100644 app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardApiService.kt
create mode 100644 app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardManifestModels.kt
delete mode 100644 app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/ModernStartButton.kt
create mode 100644 app/src/main/java/eu/gaudian/translator/viewmodel/ExportImportViewModel.kt
create mode 100644 app/src/main/res/xml/file_paths.xml
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 964c563..4019a53 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -33,6 +33,16 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/eu/gaudian/translator/model/VocabularyExport.kt b/app/src/main/java/eu/gaudian/translator/model/VocabularyExport.kt
new file mode 100644
index 0000000..dffac70
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/VocabularyExport.kt
@@ -0,0 +1,261 @@
+@file:OptIn(ExperimentalTime::class)
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.model
+
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
+
+/**
+ * # Vocabulary Export/Import Data Models
+ *
+ * This file defines the data structures used for exporting and importing vocabulary data.
+ * The export format is designed to be:
+ * - **Portable**: Can be stored as JSON files, transmitted via REST APIs, shared via messaging apps
+ * - **Flexible**: Supports different export scopes (full repository, categories, individual items)
+ * - **Complete**: Preserves all data including learning stages, categories, and progress statistics
+ * - **Versioned**: Includes format version for future compatibility
+ *
+ * ## Export Scopes
+ *
+ * The system supports multiple export scopes via the [VocabularyExportData] sealed class:
+ * - [FullRepositoryExport]: Complete repository state with all items, categories, and mappings
+ * - [CategoryExport]: Single category with all its vocabulary items and their states
+ * - [ItemListExport]: Arbitrary list of vocabulary items with their associated data
+ * - [SingleItemExport]: Individual vocabulary item with its complete information
+ *
+ * ## Data Preservation
+ *
+ * Each export includes:
+ * - Vocabulary items (words, translations, features, creation dates)
+ * - Learning states (correct/incorrect counts, last answer timestamps)
+ * - Stage mappings (current learning stage for each item)
+ * - Categories (both manual tags and automatic filters)
+ * - Category memberships (which items belong to which categories)
+ * - Metadata (export date, format version, statistics)
+ *
+ * ## Usage Examples
+ *
+ * ### Exporting a full repository:
+ * ```kotlin
+ * val exportData = repository.exportFullRepository()
+ * val jsonString = Json.encodeToString(VocabularyExportData.serializer(), exportData)
+ * // Save to file, send via API, share via WhatsApp, etc.
+ * ```
+ *
+ * ### Exporting a single category:
+ * ```kotlin
+ * val exportData = repository.exportCategory(categoryId)
+ * val jsonString = Json.encodeToString(VocabularyExportData.serializer(), exportData)
+ * ```
+ *
+ * ### Importing data:
+ * ```kotlin
+ * val importData = Json.decodeFromString(jsonString)
+ * repository.importVocabularyData(importData, conflictStrategy = ConflictStrategy.MERGE)
+ * ```
+ */
+
+/**
+ * Sealed class representing different types of vocabulary exports.
+ * Each type contains the appropriate data for its scope.
+ */
+@Serializable
+sealed class VocabularyExportData {
+ abstract val formatVersion: Int
+ abstract val exportDate: @Contextual Instant
+ abstract val metadata: ExportMetadata
+}
+
+/**
+ * Metadata about the export operation.
+ *
+ * @property itemCount Total number of vocabulary items included
+ * @property categoryCount Total number of categories included
+ * @property exportScope Description of what was exported
+ * @property appVersion Version of the app that created the export (optional)
+ */
+@Serializable
+data class ExportMetadata(
+ val itemCount: Int,
+ val categoryCount: Int,
+ val exportScope: String,
+ val appVersion: String? = null
+)
+
+/**
+ * Export format for the complete repository state.
+ *
+ * This includes everything: all vocabulary items, all categories, all learning states,
+ * all mappings. Use this for full backups or transferring complete data between devices.
+ *
+ * @property items All vocabulary items
+ * @property categories All categories (tags and filters)
+ * @property states Learning states for all items
+ * @property categoryMappings Mappings between items and categories
+ * @property stageMappings Current learning stage for each item
+ */
+@Serializable
+@SerialName("FullRepository")
+data class FullRepositoryExport(
+ override val formatVersion: Int = 1,
+ @Contextual override val exportDate: Instant,
+ override val metadata: ExportMetadata,
+ val items: List,
+ val categories: List,
+ val states: List,
+ val categoryMappings: List,
+ val stageMappings: List
+) : VocabularyExportData()
+
+/**
+ * Export format for a single category and its vocabulary items.
+ *
+ * Use this to share a specific vocabulary list or category with others.
+ * All items in the category are included with their complete learning data.
+ *
+ * @property category The category being exported
+ * @property items All vocabulary items belonging to this category
+ * @property states Learning states for the items in this category
+ * @property stageMappings Learning stages for the items in this category
+ */
+@Serializable
+@SerialName("Category")
+data class CategoryExport(
+ override val formatVersion: Int = 1,
+ @Contextual override val exportDate: Instant,
+ override val metadata: ExportMetadata,
+ val category: VocabularyCategory,
+ val items: List,
+ val states: List,
+ val stageMappings: List
+) : VocabularyExportData()
+
+/**
+ * Export format for a custom list of vocabulary items.
+ *
+ * Use this to create custom vocabulary sets for sharing or studying specific words.
+ * Optionally includes category information if the items belong to specific categories.
+ *
+ * @property items The vocabulary items being exported
+ * @property states Learning states for these items
+ * @property stageMappings Learning stages for these items
+ * @property associatedCategories Categories that these items belong to (optional)
+ * @property categoryMappings Mappings between items and categories (optional)
+ */
+@Serializable
+@SerialName("ItemList")
+data class ItemListExport(
+ override val formatVersion: Int = 1,
+ @Contextual override val exportDate: Instant,
+ override val metadata: ExportMetadata,
+ val items: List,
+ val states: List,
+ val stageMappings: List,
+ val associatedCategories: List = emptyList(),
+ val categoryMappings: List = emptyList()
+) : VocabularyExportData()
+
+/**
+ * Export format for a single vocabulary item with all its details.
+ *
+ * Use this for sharing individual words or phrases with complete context.
+ *
+ * @property item The vocabulary item being exported
+ * @property state Learning state for this item (if available)
+ * @property stage Current learning stage for this item
+ * @property categories Categories this item belongs to
+ */
+@Serializable
+@SerialName("SingleItem")
+data class SingleItemExport(
+ override val formatVersion: Int = 1,
+ @Contextual override val exportDate: Instant,
+ override val metadata: ExportMetadata,
+ val item: VocabularyItem,
+ val state: VocabularyItemState?,
+ val stage: VocabularyStage,
+ val categories: List = emptyList()
+) : VocabularyExportData()
+
+/**
+ * Simplified representation of category mapping for export/import.
+ *
+ * Maps a vocabulary item ID to a category ID. During import, IDs may be
+ * remapped if conflicts exist.
+ */
+@Serializable
+data class CategoryMappingData(
+ val vocabularyItemId: Int,
+ val categoryId: Int
+)
+
+/**
+ * Simplified representation of stage mapping for export/import.
+ *
+ * Maps a vocabulary item ID to its current learning stage.
+ */
+@Serializable
+data class StageMappingData(
+ val vocabularyItemId: Int,
+ val stage: VocabularyStage
+)
+
+/**
+ * Strategy for handling conflicts during import operations.
+ *
+ * Conflicts occur when imported data has the same IDs or content as existing data.
+ * Different strategies handle these conflicts in different ways.
+ */
+enum class ConflictStrategy {
+ /**
+ * Skip importing items that already exist (based on ID or content).
+ * Preserves all existing data unchanged.
+ */
+ SKIP,
+
+ /**
+ * Replace existing items with imported versions.
+ * Overwrites local data with imported data when conflicts occur.
+ */
+ REPLACE,
+
+ /**
+ * Merge imported data with existing data.
+ * - For vocabulary items: Keep existing if duplicate, add new ones
+ * - For states: Keep the more advanced learning progress
+ * - For categories: Merge memberships
+ * - For stages: Keep the higher stage
+ */
+ MERGE,
+
+ /**
+ * Assign new IDs to all imported items to avoid conflicts.
+ * Creates duplicates rather than merging or replacing.
+ * Useful when importing the same data multiple times for practice.
+ */
+ RENAME
+}
+
+/**
+ * Result of an import operation with statistics.
+ *
+ * @property itemsImported Number of vocabulary items successfully imported
+ * @property itemsSkipped Number of items skipped due to conflicts
+ * @property itemsUpdated Number of existing items updated
+ * @property categoriesImported Number of categories imported
+ * @property errors List of errors encountered during import (if any)
+ */
+data class ImportResult(
+ val itemsImported: Int,
+ val itemsSkipped: Int,
+ val itemsUpdated: Int,
+ val categoriesImported: Int,
+ val errors: List = emptyList()
+) {
+ val isSuccess: Boolean get() = errors.isEmpty()
+ val totalProcessed: Int get() = itemsImported + itemsSkipped + itemsUpdated
+}
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FileDownloadManager.kt b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FileDownloadManager.kt
index b0c1c35..5f16bc8 100644
--- a/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FileDownloadManager.kt
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FileDownloadManager.kt
@@ -15,6 +15,14 @@ import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
+/**
+ * Enum representing different download sources.
+ */
+enum class DownloadSource(val baseUrl: String, val subdirectory: String) {
+ DICTIONARIES("http://23.88.48.47/", "dictionaries"),
+ FLASHCARDS("http://23.88.48.47/", "flashcard-collections")
+}
+
/**
* Manages downloading files from the server, verifying checksums, and checking versions.
*/
@@ -190,4 +198,128 @@ class FileDownloadManager(private val context: Context) {
fun getLocalVersion(fileId: String): String {
return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
}
-}
\ No newline at end of file
+
+ // ===== Flashcard Collections Support =====
+
+ private val flashcardRetrofit = Retrofit.Builder()
+ .baseUrl(DownloadSource.FLASHCARDS.baseUrl)
+ .addConverterFactory(GsonConverterFactory.create())
+ .client(OkHttpClient.Builder().build())
+ .build()
+
+ private val flashcardApiService = flashcardRetrofit.create()
+
+ /**
+ * Fetches the flashcard collection manifest from the server.
+ */
+ suspend fun fetchFlashcardManifest(): FlashcardManifestResponse? = withContext(Dispatchers.IO) {
+ try {
+ val response = flashcardApiService.getFlashcardManifest().execute()
+ if (response.isSuccessful) {
+ response.body()
+ } else {
+ val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
+ Log.e("FileDownloadManager", "Failed to fetch flashcard manifest: $errorMessage")
+ throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
+ }
+ } catch (e: Exception) {
+ Log.e("FileDownloadManager", "Error fetching flashcard manifest", e)
+ throw e
+ }
+ }
+
+ /**
+ * Downloads a flashcard collection file with checksum verification.
+ */
+ suspend fun downloadFlashcardCollection(
+ flashcardInfo: FlashcardCollectionInfo,
+ onProgress: (Float) -> Unit = {}
+ ): Boolean = withContext(Dispatchers.IO) {
+ val asset = flashcardInfo.asset
+ val source = DownloadSource.FLASHCARDS
+ val fileUrl = "${source.baseUrl}${source.subdirectory}/${asset.filename}"
+ val localFile = File(context.filesDir, "${source.subdirectory}/${asset.filename}")
+
+ // Create subdirectory if it doesn't exist
+ 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()
+ if (contentLength <= 0) {
+ throw Exception("Invalid file size: $contentLength")
+ }
+
+ FileOutputStream(localFile).use { output ->
+ body.byteStream().use { input ->
+ val buffer = ByteArray(8192)
+ var bytesRead: Int
+ var totalBytesRead: Long = 0
+ 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
+ onProgress(totalBytesRead.toFloat() / contentLength)
+ }
+
+ output.flush()
+
+ val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
+
+ if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
+ Log.d("FileDownloadManager", "Flashcard download successful for ${asset.filename}")
+ // Save version
+ sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) }
+ true
+ } else {
+ Log.e("FileDownloadManager", context.getString(
+ R.string.text_checksum_mismatch_for_expected_got,
+ asset.filename,
+ asset.checksumSha256,
+ computedChecksum
+ ))
+ localFile.delete()
+ throw Exception("Checksum verification failed for ${asset.filename}")
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e("FileDownloadManager", "Error downloading flashcard collection", e)
+ if (localFile.exists()) {
+ localFile.delete()
+ }
+ throw e
+ }
+ }
+
+ /**
+ * Checks if a newer version is available for a flashcard collection.
+ */
+ fun isNewerFlashcardVersionAvailable(flashcardInfo: FlashcardCollectionInfo): Boolean {
+ val localVersion = sharedPreferences.getString(flashcardInfo.id, "0.0.0") ?: "0.0.0"
+ return compareVersions(flashcardInfo.version, localVersion) > 0
+ }
+
+ /**
+ * Gets the local version of a flashcard collection.
+ */
+ fun getFlashcardLocalVersion(collectionId: String): String {
+ return sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
+ }
+}
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardApiService.kt b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardApiService.kt
new file mode 100644
index 0000000..f8b18d0
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardApiService.kt
@@ -0,0 +1,17 @@
+package eu.gaudian.translator.model.communication
+
+import retrofit2.Call
+import retrofit2.http.GET
+
+/**
+ * API service for fetching flashcard collection manifests and downloading files.
+ */
+interface FlashcardApiService {
+
+ /**
+ * Fetches the flashcard collection manifest from the server.
+ */
+ @GET("flashcard-collections/manifest.json")
+ fun getFlashcardManifest(): Call
+
+}
diff --git a/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardManifestModels.kt b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardManifestModels.kt
new file mode 100644
index 0000000..a71d1c5
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/model/communication/files_download/FlashcardManifestModels.kt
@@ -0,0 +1,39 @@
+package eu.gaudian.translator.model.communication
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * Data class representing the flashcard collection manifest response from the server.
+ */
+data class FlashcardManifestResponse(
+ @SerializedName("collections")
+ val collections: List
+)
+
+/**
+ * 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
+)
+
+/**
+ * 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
+)
diff --git a/app/src/main/java/eu/gaudian/translator/model/repository/VocabularyRepository.kt b/app/src/main/java/eu/gaudian/translator/model/repository/VocabularyRepository.kt
index 8280bf1..563b17d 100644
--- a/app/src/main/java/eu/gaudian/translator/model/repository/VocabularyRepository.kt
+++ b/app/src/main/java/eu/gaudian/translator/model/repository/VocabularyRepository.kt
@@ -5,9 +5,19 @@ package eu.gaudian.translator.model.repository
import android.content.Context
import androidx.room.withTransaction
+import eu.gaudian.translator.model.CategoryExport
+import eu.gaudian.translator.model.CategoryMappingData
+import eu.gaudian.translator.model.ConflictStrategy
+import eu.gaudian.translator.model.ExportMetadata
+import eu.gaudian.translator.model.FullRepositoryExport
+import eu.gaudian.translator.model.ImportResult
+import eu.gaudian.translator.model.ItemListExport
import eu.gaudian.translator.model.Language
+import eu.gaudian.translator.model.SingleItemExport
+import eu.gaudian.translator.model.StageMappingData
import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory
+import eu.gaudian.translator.model.VocabularyExportData
import eu.gaudian.translator.model.VocabularyFilter
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.model.VocabularyItemState
@@ -45,6 +55,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
+import kotlin.math.max
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@@ -796,6 +807,594 @@ class VocabularyRepository private constructor(context: Context) {
}
Log.d(TAG, "--- END REPOSITORY STATE ---")
}
+
+ // ==================== EXPORT/IMPORT FUNCTIONS ====================
+
+ /**
+ * Exports the complete repository state including all vocabulary items, categories,
+ * learning states, and mappings.
+ *
+ * This creates a full backup that can be used to restore the complete state on another
+ * device or after data loss.
+ *
+ * @return [FullRepositoryExport] containing all repository data
+ *
+ * @see importVocabularyData for importing the exported data
+ * @see exportToJson for converting to JSON string
+ */
+ suspend fun exportFullRepository(): FullRepositoryExport {
+ Log.i(TAG, "exportFullRepository: Creating full repository export")
+ val items = getAllVocabularyItems()
+ val categories = getAllCategories()
+ val states = getAllVocabularyItemStates()
+ val categoryMappings = getCategoryMappings()
+ val stageMapping = loadStageMapping().first()
+
+ return FullRepositoryExport(
+ formatVersion = 1,
+ exportDate = Clock.System.now(),
+ metadata = ExportMetadata(
+ itemCount = items.size,
+ categoryCount = categories.size,
+ exportScope = "Full Repository"
+ ),
+ items = items,
+ categories = categories,
+ states = states,
+ categoryMappings = categoryMappings.map { CategoryMappingData(it.vocabularyItemId, it.categoryId) },
+ stageMappings = stageMapping.map { StageMappingData(it.key, it.value) }
+ ).also {
+ Log.i(TAG, "exportFullRepository: Export complete. Items: ${items.size}, Categories: ${categories.size}")
+ }
+ }
+
+ /**
+ * Exports a single category with all its vocabulary items and associated data.
+ *
+ * @param categoryId The ID of the category to export
+ * @return [CategoryExport] containing the category and its items, or null if category not found
+ *
+ * @see importVocabularyData for importing the exported data
+ */
+ suspend fun exportCategory(categoryId: Int): CategoryExport? {
+ Log.i(TAG, "exportCategory: Exporting category id=$categoryId")
+ val category = getCategoryById(categoryId) ?: run {
+ Log.w(TAG, "exportCategory: Category id=$categoryId not found")
+ return null
+ }
+
+ val items = getVocabularyItemsByCategory(categoryId)
+ val itemIds = items.map { it.id }
+ val states = getAllVocabularyItemStates().filter { it.vocabularyItemId in itemIds }
+ val stageMapping = loadStageMapping().first().filterKeys { it in itemIds }
+
+ return CategoryExport(
+ formatVersion = 1,
+ exportDate = Clock.System.now(),
+ metadata = ExportMetadata(
+ itemCount = items.size,
+ categoryCount = 1,
+ exportScope = "Category: ${category.name}"
+ ),
+ category = category,
+ items = items,
+ states = states,
+ stageMappings = stageMapping.map { StageMappingData(it.key, it.value) }
+ ).also {
+ Log.i(TAG, "exportCategory: Export complete. Category: ${category.name}, Items: ${items.size}")
+ }
+ }
+
+ /**
+ * Exports a list of vocabulary items by their IDs.
+ *
+ * @param itemIds List of vocabulary item IDs to export
+ * @param includeCategories Whether to include category information for these items
+ * @return [ItemListExport] containing the items and their data
+ *
+ * @see importVocabularyData for importing the exported data
+ */
+ suspend fun exportItemList(itemIds: List, includeCategories: Boolean = true): ItemListExport {
+ Log.i(TAG, "exportItemList: Exporting ${itemIds.size} items")
+ val items = itemDao.getItemsByIds(itemIds)
+ val states = getAllVocabularyItemStates().filter { it.vocabularyItemId in itemIds }
+ val stageMapping = loadStageMapping().first().filterKeys { it in itemIds }
+
+ val associatedCategories = if (includeCategories) {
+ val mappings = getCategoryMappings().filter { it.vocabularyItemId in itemIds }
+ val categoryIds = mappings.map { it.categoryId }.distinct()
+ getAllCategories().filter { it.id in categoryIds }
+ } else {
+ emptyList()
+ }
+
+ val categoryMappings = if (includeCategories) {
+ getCategoryMappings().filter { it.vocabularyItemId in itemIds }
+ .map { CategoryMappingData(it.vocabularyItemId, it.categoryId) }
+ } else {
+ emptyList()
+ }
+
+ return ItemListExport(
+ formatVersion = 1,
+ exportDate = Clock.System.now(),
+ metadata = ExportMetadata(
+ itemCount = items.size,
+ categoryCount = associatedCategories.size,
+ exportScope = "Item List (${items.size} items)"
+ ),
+ items = items,
+ states = states,
+ stageMappings = stageMapping.map { StageMappingData(it.key, it.value) },
+ associatedCategories = associatedCategories,
+ categoryMappings = categoryMappings
+ ).also {
+ Log.i(TAG, "exportItemList: Export complete. Items: ${items.size}")
+ }
+ }
+
+ /**
+ * Exports a single vocabulary item with all its details.
+ *
+ * @param itemId The ID of the vocabulary item to export
+ * @return [SingleItemExport] containing the item and its data, or null if item not found
+ *
+ * @see importVocabularyData for importing the exported data
+ */
+ suspend fun exportSingleItem(itemId: Int): SingleItemExport? {
+ Log.i(TAG, "exportSingleItem: Exporting item id=$itemId")
+ val item = getVocabularyItemById(itemId) ?: run {
+ Log.w(TAG, "exportSingleItem: Item id=$itemId not found")
+ return null
+ }
+
+ val state = getVocabularyItemStateById(itemId)
+ val stage = loadStageMapping().first()[itemId] ?: VocabularyStage.NEW
+ val mappings = getCategoryMappings().filter { it.vocabularyItemId == itemId }
+ val categoryIds = mappings.map { it.categoryId }
+ val categories = getAllCategories().filter { it.id in categoryIds }
+
+ return SingleItemExport(
+ formatVersion = 1,
+ exportDate = Clock.System.now(),
+ metadata = ExportMetadata(
+ itemCount = 1,
+ categoryCount = categories.size,
+ exportScope = "Single Item: ${item.wordFirst}"
+ ),
+ item = item,
+ state = state,
+ stage = stage,
+ categories = categories
+ ).also {
+ Log.i(TAG, "exportSingleItem: Export complete. Item: ${item.wordFirst}")
+ }
+ }
+
+ /**
+ * Converts any [VocabularyExportData] to a JSON string.
+ *
+ * The resulting JSON can be:
+ * - Saved to a file
+ * - Sent via REST API
+ * - Shared through messaging apps (WhatsApp, Telegram, etc.)
+ * - Stored in cloud storage (Google Drive, Dropbox, etc.)
+ * - Transmitted via any text-based protocol
+ *
+ * @param exportData The export data to convert
+ * @param prettyPrint Whether to format the JSON for human readability (default: false)
+ * @return JSON string representation of the export data
+ *
+ * @see importFromJson for parsing JSON back into export data
+ */
+ fun exportToJson(exportData: VocabularyExportData, prettyPrint: Boolean = false): String {
+ val json = Json {
+ ignoreUnknownKeys = true
+ this.prettyPrint = prettyPrint
+ }
+ return json.encodeToString(VocabularyExportData.serializer(), exportData)
+ }
+
+ /**
+ * Parses a JSON string into [VocabularyExportData].
+ *
+ * @param jsonString The JSON string to parse
+ * @return Parsed export data
+ * @throws kotlinx.serialization.SerializationException if JSON is invalid
+ *
+ * @see exportToJson for converting export data to JSON
+ * @see importVocabularyData for importing the parsed data
+ */
+ fun importFromJson(jsonString: String): VocabularyExportData {
+ val json = Json { ignoreUnknownKeys = true }
+ return json.decodeFromString(VocabularyExportData.serializer(), jsonString)
+ }
+
+ /**
+ * Imports vocabulary data from an export.
+ *
+ * This function handles different export types (full repository, category, item list, single item)
+ * and applies the specified conflict resolution strategy.
+ *
+ * @param exportData The export data to import
+ * @param strategy The conflict resolution strategy to use (default: MERGE)
+ * @return [ImportResult] with statistics about the import operation
+ *
+ * @see ConflictStrategy for available strategies
+ * @see exportFullRepository, exportCategory, exportItemList, exportSingleItem for creating exports
+ */
+ suspend fun importVocabularyData(
+ exportData: VocabularyExportData,
+ strategy: ConflictStrategy = ConflictStrategy.MERGE
+ ): ImportResult {
+ Log.i(TAG, "importVocabularyData: Starting import with strategy=$strategy, scope=${exportData.metadata.exportScope}")
+
+ return when (exportData) {
+ is FullRepositoryExport -> importFullRepository(exportData, strategy)
+ is CategoryExport -> importCategory(exportData, strategy)
+ is ItemListExport -> importItemList(exportData, strategy)
+ is SingleItemExport -> importSingleItem(exportData, strategy)
+ }.also { result ->
+ Log.i(TAG, "importVocabularyData: Import complete. Imported: ${result.itemsImported}, " +
+ "Skipped: ${result.itemsSkipped}, Updated: ${result.itemsUpdated}, " +
+ "Categories: ${result.categoriesImported}, Errors: ${result.errors.size}")
+ }
+ }
+
+ /**
+ * Internal function to import a full repository export.
+ */
+ private suspend fun importFullRepository(
+ export: FullRepositoryExport,
+ strategy: ConflictStrategy
+ ): ImportResult {
+ val errors = mutableListOf()
+ var itemsImported = 0
+ var itemsSkipped = 0
+ var itemsUpdated = 0
+ var categoriesImported = 0
+
+ try {
+ db.withTransaction {
+ // Import categories first (they're referenced by items)
+ val categoryIdMap = importCategories(export.categories, strategy)
+ categoriesImported = categoryIdMap.size
+
+ // Import items
+ val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
+ itemsImported = itemIdMap.count { it.value >= 0 }
+ itemsSkipped = itemIdMap.count { it.value == -1 }
+
+ // Import category mappings with remapped IDs
+ importCategoryMappings(export.categoryMappings, itemIdMap, categoryIdMap)
+ }
+
+ requestUpdateMappings()
+ } catch (e: Exception) {
+ Log.e(TAG, "importFullRepository: Error during import", e)
+ errors.add("Import failed: ${e.message}")
+ }
+
+ return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
+ }
+
+ /**
+ * Internal function to import a category export.
+ */
+ private suspend fun importCategory(
+ export: CategoryExport,
+ strategy: ConflictStrategy
+ ): ImportResult {
+ val errors = mutableListOf()
+ var itemsImported = 0
+ var itemsSkipped = 0
+ var itemsUpdated = 0
+ var categoriesImported = 0
+
+ try {
+ db.withTransaction {
+ // Import the category
+ val categoryIdMap = importCategories(listOf(export.category), strategy)
+ categoriesImported = categoryIdMap.size
+ val newCategoryId = categoryIdMap[export.category.id] ?: export.category.id
+
+ // Import items
+ val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
+ itemsImported = itemIdMap.count { it.value >= 0 }
+ itemsSkipped = itemIdMap.count { it.value == -1 }
+
+ // Create category mappings for all imported items
+ val mappings = itemIdMap.filter { it.value >= 0 }.map { (oldId, newId) ->
+ CategoryMappingData(newId, newCategoryId)
+ }
+ importCategoryMappings(mappings, mapOf(), mapOf())
+ }
+
+ requestUpdateMappings()
+ } catch (e: Exception) {
+ Log.e(TAG, "importCategory: Error during import", e)
+ errors.add("Import failed: ${e.message}")
+ }
+
+ return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
+ }
+
+ /**
+ * Internal function to import an item list export.
+ */
+ private suspend fun importItemList(
+ export: ItemListExport,
+ strategy: ConflictStrategy
+ ): ImportResult {
+ val errors = mutableListOf()
+ var itemsImported = 0
+ var itemsSkipped = 0
+ var itemsUpdated = 0
+ var categoriesImported = 0
+
+ try {
+ db.withTransaction {
+ // Import associated categories if present
+ val categoryIdMap = if (export.associatedCategories.isNotEmpty()) {
+ importCategories(export.associatedCategories, strategy).also {
+ categoriesImported = it.size
+ }
+ } else {
+ emptyMap()
+ }
+
+ // Import items
+ val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
+ itemsImported = itemIdMap.count { it.value >= 0 }
+ itemsSkipped = itemIdMap.count { it.value == -1 }
+
+ // Import category mappings if present
+ if (export.categoryMappings.isNotEmpty()) {
+ importCategoryMappings(export.categoryMappings, itemIdMap, categoryIdMap)
+ }
+ }
+
+ requestUpdateMappings()
+ } catch (e: Exception) {
+ Log.e(TAG, "importItemList: Error during import", e)
+ errors.add("Import failed: ${e.message}")
+ }
+
+ return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
+ }
+
+ /**
+ * Internal function to import a single item export.
+ */
+ private suspend fun importSingleItem(
+ export: SingleItemExport,
+ strategy: ConflictStrategy
+ ): ImportResult {
+ val errors = mutableListOf()
+ var itemsImported = 0
+ var itemsSkipped = 0
+ var itemsUpdated = 0
+ var categoriesImported = 0
+
+ try {
+ db.withTransaction {
+ // Import categories if present
+ val categoryIdMap = if (export.categories.isNotEmpty()) {
+ importCategories(export.categories, strategy).also {
+ categoriesImported = it.size
+ }
+ } else {
+ emptyMap()
+ }
+
+ // Import the single item
+ val states = if (export.state != null) listOf(export.state) else emptyList()
+ val stageMappings = listOf(StageMappingData(export.item.id, export.stage))
+ val itemIdMap = importItems(listOf(export.item), states, stageMappings, strategy)
+ itemsImported = itemIdMap.count { it.value >= 0 }
+ itemsSkipped = itemIdMap.count { it.value == -1 }
+
+ // Create category mappings
+ val newItemId = itemIdMap[export.item.id] ?: export.item.id
+ if (newItemId >= 0) {
+ val mappings = export.categories.map { category ->
+ val newCategoryId = categoryIdMap[category.id] ?: category.id
+ CategoryMappingData(newItemId, newCategoryId)
+ }
+ importCategoryMappings(mappings, mapOf(), mapOf())
+ }
+ }
+
+ requestUpdateMappings()
+ } catch (e: Exception) {
+ Log.e(TAG, "importSingleItem: Error during import", e)
+ errors.add("Import failed: ${e.message}")
+ }
+
+ return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
+ }
+
+ /**
+ * Helper function to import categories with conflict resolution.
+ * Returns a map of old category IDs to new category IDs.
+ */
+ private suspend fun importCategories(
+ categories: List,
+ strategy: ConflictStrategy
+ ): Map {
+ val idMap = mutableMapOf()
+ val existingCategories = getAllCategories()
+
+ for (category in categories) {
+ val existing = existingCategories.find { it.name == category.name && it::class == category::class }
+
+ when {
+ existing != null && strategy == ConflictStrategy.SKIP -> {
+ // Skip, but map old ID to existing ID
+ idMap[category.id] = existing.id
+ Log.d(TAG, "importCategories: Skipping existing category '${category.name}'")
+ }
+ existing != null && strategy == ConflictStrategy.REPLACE -> {
+ // Replace existing category
+ val updated = when (category) {
+ is TagCategory -> category.copy(id = existing.id)
+ is VocabularyFilter -> category.copy(id = existing.id)
+ }
+ saveCategory(updated)
+ idMap[category.id] = existing.id
+ Log.d(TAG, "importCategories: Replaced category '${category.name}'")
+ }
+ existing != null && strategy == ConflictStrategy.MERGE -> {
+ // Keep existing, map old ID to existing ID
+ idMap[category.id] = existing.id
+ Log.d(TAG, "importCategories: Merged with existing category '${category.name}'")
+ }
+ strategy == ConflictStrategy.RENAME || existing == null -> {
+ // Assign new ID
+ val maxId = categoryDao.getAllCategories().maxOfOrNull { it.id } ?: 0
+ val newId = maxId + 1
+ val newCategory = when (category) {
+ is TagCategory -> category.copy(id = newId)
+ is VocabularyFilter -> category.copy(id = newId)
+ }
+ saveCategory(newCategory)
+ idMap[category.id] = newId
+ Log.d(TAG, "importCategories: Created new category '${category.name}' with id=$newId")
+ }
+ }
+ }
+
+ return idMap
+ }
+
+ /**
+ * Helper function to import vocabulary items with their states and stage mappings.
+ * Returns a map of old item IDs to new item IDs (-1 means skipped).
+ */
+ private suspend fun importItems(
+ items: List,
+ states: List,
+ stageMappings: List,
+ strategy: ConflictStrategy
+ ): Map {
+ val idMap = mutableMapOf()
+ val existingItems = getAllVocabularyItems()
+ val stateMap = states.associateBy { it.vocabularyItemId }
+ val stageMap = stageMappings.associate { it.vocabularyItemId to it.stage }
+
+ for (item in items) {
+ val duplicate = existingItems.find { it.isDuplicate(item) }
+
+ when {
+ duplicate != null && strategy == ConflictStrategy.SKIP -> {
+ // Skip this item
+ idMap[item.id] = -1
+ Log.d(TAG, "importItems: Skipping duplicate item '${item.wordFirst}'")
+ }
+ duplicate != null && strategy == ConflictStrategy.REPLACE -> {
+ // Replace with imported version
+ val updated = item.copy(id = duplicate.id)
+ itemDao.upsertItem(updated)
+ idMap[item.id] = duplicate.id
+
+ // Update state and stage
+ stateMap[item.id]?.let { state ->
+ stateDao.upsertState(state.copy(vocabularyItemId = duplicate.id))
+ }
+ stageMap[item.id]?.let { stage ->
+ mappingDao.upsertStageMapping(StageMappingEntity(duplicate.id, stage))
+ }
+ Log.d(TAG, "importItems: Replaced item '${item.wordFirst}'")
+ }
+ duplicate != null && strategy == ConflictStrategy.MERGE -> {
+ // Merge: keep item, merge states (keep better progress)
+ idMap[item.id] = duplicate.id
+
+ stateMap[item.id]?.let { importedState ->
+ val existingState = getVocabularyItemStateById(duplicate.id)
+ val mergedState = mergeStates(existingState, importedState, duplicate.id)
+ stateDao.upsertState(mergedState)
+ }
+
+ stageMap[item.id]?.let { importedStage ->
+ val existingStage = loadStageMapping().first()[duplicate.id] ?: VocabularyStage.NEW
+ val mergedStage = maxOf(importedStage, existingStage)
+ mappingDao.upsertStageMapping(StageMappingEntity(duplicate.id, mergedStage))
+ }
+ Log.d(TAG, "importItems: Merged item '${item.wordFirst}'")
+ }
+ strategy == ConflictStrategy.RENAME || duplicate == null -> {
+ // Assign new ID
+ val maxId = itemDao.getMaxItemId() ?: 0
+ val newId = maxId + idMap.size + 1
+ val newItem = item.copy(id = newId)
+ itemDao.upsertItem(newItem)
+ idMap[item.id] = newId
+
+ // Import state and stage with new ID
+ stateMap[item.id]?.let { state ->
+ stateDao.upsertState(state.copy(vocabularyItemId = newId))
+ }
+ stageMap[item.id]?.let { stage ->
+ mappingDao.upsertStageMapping(StageMappingEntity(newId, stage))
+ }
+ Log.d(TAG, "importItems: Created new item '${item.wordFirst}' with id=$newId")
+ }
+ }
+ }
+
+ return idMap
+ }
+
+ /**
+ * Helper function to import category mappings with remapped IDs.
+ */
+ private suspend fun importCategoryMappings(
+ mappings: List,
+ itemIdMap: Map,
+ categoryIdMap: Map
+ ) {
+ for (mapping in mappings) {
+ val newItemId = itemIdMap[mapping.vocabularyItemId] ?: mapping.vocabularyItemId
+ val newCategoryId = categoryIdMap[mapping.categoryId] ?: mapping.categoryId
+
+ // Skip if item was skipped during import
+ if (newItemId < 0) continue
+
+ mappingDao.addCategoryMapping(CategoryMappingEntity(newItemId, newCategoryId))
+ }
+ }
+
+ /**
+ * Helper function to merge two vocabulary item states.
+ * Keeps the more advanced learning progress.
+ */
+ private fun mergeStates(
+ existing: VocabularyItemState?,
+ imported: VocabularyItemState,
+ itemId: Int
+ ): VocabularyItemState {
+ if (existing == null) return imported.copy(vocabularyItemId = itemId)
+
+ return VocabularyItemState(
+ vocabularyItemId = itemId,
+ lastCorrectAnswer = maxOfNullable(existing.lastCorrectAnswer, imported.lastCorrectAnswer),
+ lastIncorrectAnswer = maxOfNullable(existing.lastIncorrectAnswer, imported.lastIncorrectAnswer),
+ correctAnswerCount = max(existing.correctAnswerCount, imported.correctAnswerCount),
+ incorrectAnswerCount = max(existing.incorrectAnswerCount, imported.incorrectAnswerCount)
+ )
+ }
+
+ /**
+ * Helper function to get the maximum of two nullable Instants.
+ */
+ private fun maxOfNullable(a: Instant?, b: Instant?): Instant? {
+ return when {
+ a == null -> b
+ b == null -> a
+ else -> if (a > b) a else b
+ }
+ }
}
@Serializable
diff --git a/app/src/main/java/eu/gaudian/translator/view/library/LibraryComponents.kt b/app/src/main/java/eu/gaudian/translator/view/library/LibraryComponents.kt
index 68541c0..fc69400 100644
--- a/app/src/main/java/eu/gaudian/translator/view/library/LibraryComponents.kt
+++ b/app/src/main/java/eu/gaudian/translator/view/library/LibraryComponents.kt
@@ -118,6 +118,7 @@ fun SelectionTopBar(
onDeleteClick: () -> Unit,
onMoveToCategoryClick: () -> Unit,
onMoveToStageClick: () -> Unit,
+ onExportClick: () -> Unit,
isRemoveEnabled: Boolean,
onRemoveFromCategoryClick: () -> Unit,
modifier: Modifier = Modifier
@@ -159,6 +160,14 @@ fun SelectionTopBar(
expanded = showOverflowMenu,
onDismissRequest = { showOverflowMenu = false }
) {
+ DropdownMenuItem(
+ text = { Text("Export Selected") },
+ onClick = {
+ onExportClick()
+ showOverflowMenu = false
+ },
+ leadingIcon = { Icon(AppIcons.Share, contentDescription = null) }
+ )
DropdownMenuItem(
text = { Text(stringResource(R.string.move_to_category)) },
onClick = {
@@ -651,6 +660,7 @@ fun SelectionTopBarPreview() {
onDeleteClick = {},
onMoveToCategoryClick = {},
onMoveToStageClick = {},
+ onExportClick = {},
isRemoveEnabled = true,
onRemoveFromCategoryClick = {}
)
diff --git a/app/src/main/java/eu/gaudian/translator/view/library/LibraryScreen.kt b/app/src/main/java/eu/gaudian/translator/view/library/LibraryScreen.kt
index 4467348..724c186 100644
--- a/app/src/main/java/eu/gaudian/translator/view/library/LibraryScreen.kt
+++ b/app/src/main/java/eu/gaudian/translator/view/library/LibraryScreen.kt
@@ -73,6 +73,7 @@ import eu.gaudian.translator.view.composable.MultipleLanguageDropdown
import eu.gaudian.translator.view.dialogs.AddCategoryDialog
import eu.gaudian.translator.view.dialogs.CategorySelectionDialog
import eu.gaudian.translator.view.dialogs.StageSelectionDialog
+import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
@@ -97,11 +98,14 @@ fun LibraryScreen(
modifier: Modifier = Modifier
) {
val scope = rememberCoroutineScope()
+ val context = LocalContext.current
val activity = LocalContext.current.findActivity()
val lazyListState = rememberLazyListState()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
+ val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
+
var filterState by rememberSaveable { mutableStateOf(LibraryFilterState()) }
var showFilterSheet by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -119,6 +123,7 @@ fun LibraryScreen(
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet())
val categories by vocabularyViewModel.categories.collectAsStateWithLifecycle()
+ val exportState by exportImportViewModel.exportState.collectAsStateWithLifecycle()
val vocabularyItemsFlow = remember(filterState) {
vocabularyViewModel.filterVocabularyItems(
@@ -134,6 +139,16 @@ fun LibraryScreen(
val vocabularyItems by vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
+ // Handle export state
+ LaunchedEffect(exportState) {
+ if (exportState is eu.gaudian.translator.viewmodel.ExportState.Success) {
+ exportImportViewModel.createShareIntent()?.let { intent ->
+ context.startActivity(intent)
+ }
+ exportImportViewModel.resetExportState()
+ }
+ }
+
var isHeaderVisible by remember { mutableStateOf(true) }
var previousIndex by remember { mutableIntStateOf(0) }
var previousScrollOffset by remember { mutableIntStateOf(0) }
@@ -195,6 +210,11 @@ fun LibraryScreen(
},
onMoveToCategoryClick = { showCategoryDialog = true },
onMoveToStageClick = { showStageDialog = true },
+ onExportClick = {
+ val selectedIds = selection.map { it.toInt() }
+ exportImportViewModel.exportItemList(selectedIds)
+ selection = emptySet()
+ },
isRemoveEnabled = false,
onRemoveFromCategoryClick = {}
)
diff --git a/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyRepositoryOptionsScreen.kt b/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyRepositoryOptionsScreen.kt
index c490713..b8df9a0 100644
--- a/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyRepositoryOptionsScreen.kt
+++ b/app/src/main/java/eu/gaudian/translator/view/settings/VocabularyRepositoryOptionsScreen.kt
@@ -1,84 +1,167 @@
-@file:Suppress("AssignedValueIsNeverRead")
+@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
package eu.gaudian.translator.view.settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.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.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import eu.gaudian.translator.R
+import eu.gaudian.translator.model.ConflictStrategy
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCard
+import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.PrimaryButton
import eu.gaudian.translator.view.composable.SecondaryButton
import eu.gaudian.translator.view.composable.SingleLanguageDropDown
+import eu.gaudian.translator.viewmodel.CategoryViewModel
+import eu.gaudian.translator.viewmodel.ExportImportViewModel
+import eu.gaudian.translator.viewmodel.ExportState
+import eu.gaudian.translator.viewmodel.ImportState
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
+import kotlinx.coroutines.launch
@Composable
fun VocabularyRepositoryOptionsScreen(
navController: NavController
) {
-
val activity = LocalContext.current.findActivity()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
+ val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
+ val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
+ val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val statusMessageService = StatusMessageService
-
-
+
val context = LocalContext.current
- val repositoryStateImportedFrom = stringResource(R.string.repository_state_imported_from)
+ val scope = rememberCoroutineScope()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ // State management
+ val exportState by exportImportViewModel.exportState.collectAsState()
+ val importState by exportImportViewModel.importState.collectAsState()
+ val categories by categoryViewModel.categories.collectAsState()
+
+ // Dialog states
+ var showExportDialog by remember { mutableStateOf(false) }
+ var showImportDialog by remember { mutableStateOf(false) }
+ var showConflictStrategyDialog by remember { mutableStateOf(false) }
+ var pendingImportJson by remember { mutableStateOf(null) }
+ var selectedConflictStrategy by remember { mutableStateOf(ConflictStrategy.MERGE) }
+ // Export options
+ val selectedCategories = remember { mutableStateListOf() }
+
+ // Handle export/import state changes
+ LaunchedEffect(exportState) {
+ when (exportState) {
+ is ExportState.Success -> {
+ val shareIntent = exportImportViewModel.createShareIntent()
+ if (shareIntent != null) {
+ context.startActivity(shareIntent)
+ }
+ scope.launch {
+ snackbarHostState.showSnackbar("Export successful!")
+ }
+ exportImportViewModel.resetExportState()
+ }
+ is ExportState.Error -> {
+ scope.launch {
+ snackbarHostState.showSnackbar((exportState as ExportState.Error).message)
+ }
+ exportImportViewModel.resetExportState()
+ }
+ else -> {}
+ }
+ }
+
+ LaunchedEffect(importState) {
+ when (importState) {
+ is ImportState.Success -> {
+ val result = (importState as ImportState.Success).result
+ scope.launch {
+ snackbarHostState.showSnackbar(
+ "Imported: ${result.itemsImported}, Skipped: ${result.itemsSkipped}, Errors: ${result.errors.size}"
+ )
+ }
+ exportImportViewModel.resetImportState()
+ }
+ is ImportState.Error -> {
+ scope.launch {
+ snackbarHostState.showSnackbar((importState as ImportState.Error).message)
+ }
+ exportImportViewModel.resetImportState()
+ }
+ else -> {}
+ }
+ }
+
+ // File picker for import
val importFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri ->
uri?.let {
context.contentResolver.openInputStream(it)?.use { inputStream ->
val jsonString = inputStream.bufferedReader().use { reader -> reader.readText() }
- vocabularyViewModel.importVocabulary(jsonString)
- statusMessageService.showInfoMessage(repositoryStateImportedFrom + " " +it.path)
+ pendingImportJson = jsonString
+ showConflictStrategyDialog = true
}
}
}
)
// CSV/Excel import state
- val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val showTableImportDialog = remember { mutableStateOf(false) }
var parsedTable by remember { mutableStateOf>>(emptyList()) }
var selectedColFirst by remember { mutableIntStateOf(0) }
@@ -90,7 +173,6 @@ fun VocabularyRepositoryOptionsScreen(
fun parseCsv(text: String): List> {
if (text.isBlank()) return emptyList()
- // Detect delimiter by highest occurrence among comma, semicolon, tab
val candidates = listOf(',', ';', '\t')
val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList()
val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } }
@@ -106,14 +188,13 @@ fun VocabularyRepositoryOptionsScreen(
'"' -> {
if (inQuotes && i + 1 < text.length && text[i + 1] == '"') {
current.append('"')
- i++ // skip escaped quote
+ i++
} else {
inQuotes = !inQuotes
}
}
- '\r' -> { /* ignore, handle on \n */ }
+ '\r' -> { /* ignore */ }
'\n' -> {
- // end of line
val field = current.toString()
current = StringBuilder()
currentRow.add(if (inQuotes) field else field)
@@ -133,12 +214,10 @@ fun VocabularyRepositoryOptionsScreen(
}
i++
}
- // flush last field/row if any
if (current.isNotEmpty() || currentRow.isNotEmpty()) {
currentRow.add(current.toString())
rows.add(currentRow.toList())
}
- // Normalize: trim and drop trailing empty columns
return rows.map { row ->
row.map { it.trim().trim('"') }
}.filter { r -> r.any { it.isNotBlank() } }
@@ -193,8 +272,8 @@ fun VocabularyRepositoryOptionsScreen(
vocabularyViewModel.initializeSaveFileLauncher(exportFileLauncher)
}
-
AppScaffold(
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
AppTopAppBar(
title = stringResource(R.string.vocabulary_repository),
@@ -209,31 +288,95 @@ fun VocabularyRepositoryOptionsScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
+ // Export Section
item {
- // Backup and Restore Section
AppCard {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = "Export Vocabulary",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ if (exportState is ExportState.Loading) {
+ CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
+ }
+ }
+
Text(
- text = stringResource(R.string.label_backup_and_restore),
- style = MaterialTheme.typography.titleMedium
+ text = "Export your vocabulary data to share or backup",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
+
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+
PrimaryButton(
- onClick = { vocabularyViewModel.saveRepositoryState() },
- text = stringResource(R.string.export_vocabulary_data),
- modifier = Modifier.fillMaxWidth()
+ onClick = { exportImportViewModel.exportFullRepository() },
+ text = "Export Complete Repository",
+ icon = AppIcons.Download,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = exportState !is ExportState.Loading
)
+
SecondaryButton(
- onClick = { importFileLauncher.launch(arrayOf("application/json")) },
- text = stringResource(R.string.import_vocabulary_data),
- modifier = Modifier.fillMaxWidth()
+ onClick = { showExportDialog = true },
+ text = "Export Selected Categories",
+ icon = AppIcons.Category,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = exportState !is ExportState.Loading && categories.isNotEmpty()
)
+ }
+ }
+ }
+
+ // Import Section
+ item {
+ AppCard {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = "Import Vocabulary",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ if (importState is ImportState.Loading) {
+ CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
+ }
+ }
+
+ Text(
+ text = "Import vocabulary from JSON files. Duplicates will be handled based on your chosen strategy.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+
+ PrimaryButton(
+ onClick = { importFileLauncher.launch(arrayOf("application/json", "text/plain")) },
+ text = "Import from File",
+ icon = AppIcons.Upload,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = importState !is ImportState.Loading
+ )
+
SecondaryButton(
onClick = {
- // Allow CSV and Excel mime types, but we only support CSV parsing in-app
- @Suppress("HardCodedStringLiteral")
importTableLauncher.launch(
arrayOf(
"text/csv",
@@ -246,11 +389,43 @@ fun VocabularyRepositoryOptionsScreen(
)
},
text = stringResource(R.string.label_import_table_csv_excel),
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.fillMaxWidth(),
+ enabled = importState !is ImportState.Loading
)
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Conflict Strategy:",
+ style = MaterialTheme.typography.bodySmall
+ )
+ TextButton(onClick = { showImportDialog = true }) {
+ Text(
+ text = when (selectedConflictStrategy) {
+ ConflictStrategy.MERGE -> "Merge (Recommended)"
+ ConflictStrategy.SKIP -> "Skip Duplicates"
+ ConflictStrategy.REPLACE -> "Replace Existing"
+ ConflictStrategy.RENAME -> "Keep Both"
+ },
+ style = MaterialTheme.typography.bodySmall
+ )
+ Icon(
+ imageVector = AppIcons.Settings,
+ contentDescription = "Change strategy",
+ modifier = Modifier.padding(start = 4.dp)
+ )
+ }
+ }
}
}
}
+
+ // Danger Zone
item {
AppCard {
Column(
@@ -263,7 +438,7 @@ fun VocabularyRepositoryOptionsScreen(
color = MaterialTheme.colorScheme.error
)
- val showConfirm = androidx.compose.runtime.remember { mutableStateOf(false) }
+ val showConfirm = remember { mutableStateOf(false) }
AppButton(
onClick = { showConfirm.value = true },
@@ -304,7 +479,185 @@ fun VocabularyRepositoryOptionsScreen(
}
}
-
+ // Export Dialog
+ if (showExportDialog) {
+ AlertDialog(
+ onDismissRequest = { showExportDialog = false },
+ title = { Text("Export Categories") },
+ text = {
+ LazyColumn {
+ item {
+ Text(
+ "Select categories to export:",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ }
+ items(categories) { category ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = selectedCategories.contains(category.id),
+ onCheckedChange = { checked ->
+ if (checked) {
+ selectedCategories.add(category.id)
+ } else {
+ selectedCategories.removeAt(selectedCategories.indexOf(category.id))
+ }
+ }
+ )
+ Text(
+ text = category.name,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ if (selectedCategories.size == 1) {
+ exportImportViewModel.exportCategory(selectedCategories.first())
+ } else if (selectedCategories.isNotEmpty()) {
+ // Simplified: export first selected category
+ exportImportViewModel.exportCategory(selectedCategories.first())
+ }
+ showExportDialog = false
+ selectedCategories.clear()
+ },
+ enabled = selectedCategories.isNotEmpty()
+ ) {
+ Text("Export")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ showExportDialog = false
+ selectedCategories.clear()
+ }) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
+
+ // Import Strategy Selection Dialog
+ if (showImportDialog) {
+ AlertDialog(
+ onDismissRequest = { showImportDialog = false },
+ title = { Text("Import Conflict Strategy") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ "Choose how to handle duplicates during import:",
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ ConflictStrategyOption(
+ strategy = ConflictStrategy.MERGE,
+ selected = selectedConflictStrategy == ConflictStrategy.MERGE,
+ onSelected = { selectedConflictStrategy = ConflictStrategy.MERGE },
+ title = "Merge (Recommended)",
+ description = "Keep existing items, merge progress and categories"
+ )
+
+ ConflictStrategyOption(
+ strategy = ConflictStrategy.SKIP,
+ selected = selectedConflictStrategy == ConflictStrategy.SKIP,
+ onSelected = { selectedConflictStrategy = ConflictStrategy.SKIP },
+ title = "Skip Duplicates",
+ description = "Keep existing items unchanged, only add new ones"
+ )
+
+ ConflictStrategyOption(
+ strategy = ConflictStrategy.REPLACE,
+ selected = selectedConflictStrategy == ConflictStrategy.REPLACE,
+ onSelected = { selectedConflictStrategy = ConflictStrategy.REPLACE },
+ title = "Replace Existing",
+ description = "Overwrite existing items with imported versions"
+ )
+
+ ConflictStrategyOption(
+ strategy = ConflictStrategy.RENAME,
+ selected = selectedConflictStrategy == ConflictStrategy.RENAME,
+ onSelected = { selectedConflictStrategy = ConflictStrategy.RENAME },
+ title = "Keep Both",
+ description = "Create duplicates with new IDs"
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { showImportDialog = false }) {
+ Text("Done")
+ }
+ }
+ )
+ }
+
+ // Conflict Strategy Confirmation Dialog
+ if (showConflictStrategyDialog && pendingImportJson != null) {
+ AlertDialog(
+ onDismissRequest = {
+ showConflictStrategyDialog = false
+ pendingImportJson = null
+ },
+ icon = { Icon(AppIcons.Warning, contentDescription = null) },
+ title = { Text("Confirm Import") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Import strategy: ${
+ when (selectedConflictStrategy) {
+ ConflictStrategy.MERGE -> "Merge"
+ ConflictStrategy.SKIP -> "Skip Duplicates"
+ ConflictStrategy.REPLACE -> "Replace"
+ ConflictStrategy.RENAME -> "Keep Both"
+ }
+ }")
+
+ Text(
+ when (selectedConflictStrategy) {
+ ConflictStrategy.MERGE -> "Existing items will be kept. Progress and categories will be merged intelligently."
+ ConflictStrategy.SKIP -> "Only new items will be added. Existing items remain unchanged."
+ ConflictStrategy.REPLACE -> "Existing items will be replaced with imported versions."
+ ConflictStrategy.RENAME -> "All imported items will be added as new entries."
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ TextButton(onClick = { showImportDialog = true }) {
+ Text("Change Strategy")
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ pendingImportJson?.let { json ->
+ exportImportViewModel.importFromJson(json, selectedConflictStrategy)
+ }
+ showConflictStrategyDialog = false
+ pendingImportJson = null
+ }) {
+ Text("Import")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ showConflictStrategyDialog = false
+ pendingImportJson = null
+ }) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
+
+ // CSV Import Dialog
if (showTableImportDialog.value) {
AlertDialog(
onDismissRequest = { showTableImportDialog.value = false },
@@ -312,7 +665,6 @@ fun VocabularyRepositoryOptionsScreen(
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
- // Column selectors
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
var menu1Expanded by remember { mutableStateOf(false) }
@@ -341,7 +693,6 @@ fun VocabularyRepositoryOptionsScreen(
}
}
}
- // Language selectors
Text(stringResource(R.string.label_languages))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
@@ -361,13 +712,11 @@ fun VocabularyRepositoryOptionsScreen(
)
}
}
- // Header toggle
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.label_header_row))
}
- // Previews
val startIdx = if (skipHeader) 1 else 0
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
@@ -424,4 +773,55 @@ fun VocabularyRepositoryOptionsScreen(
)
}
}
-}
\ No newline at end of file
+}
+
+@Composable
+private fun ConflictStrategyOption(
+ strategy: ConflictStrategy,
+ selected: Boolean,
+ onSelected: () -> Unit,
+ title: String,
+ description: String,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .clickable { onSelected() },
+ colors = CardDefaults.cardColors(
+ containerColor = if (selected) {
+ MaterialTheme.colorScheme.primaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ }
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selected,
+ onClick = { onSelected() }
+ )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 8.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/CategoryDetailScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/CategoryDetailScreen.kt
index 9beac63..bc7e8f2 100644
--- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/CategoryDetailScreen.kt
+++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/CategoryDetailScreen.kt
@@ -15,18 +15,23 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -53,9 +58,12 @@ import eu.gaudian.translator.view.dialogs.EditCategoryDialog
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle
import eu.gaudian.translator.viewmodel.CategoryProgress
import eu.gaudian.translator.viewmodel.CategoryViewModel
+import eu.gaudian.translator.viewmodel.ExportImportViewModel
+import eu.gaudian.translator.viewmodel.ExportState
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
+import kotlinx.coroutines.launch
@SuppressLint("ContextCastToActivity")
@Composable
@@ -71,12 +79,16 @@ fun CategoryDetailScreen(
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
+ val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val category by categoryViewModel.getCategoryById(categoryId).collectAsState(initial = null)
val categoryProgressList by progressViewModel.categoryProgressList.collectAsState()
val categoryProgress = categoryProgressList.find { it.vocabularyCategory.id == categoryId }
+ val exportState by exportImportViewModel.exportState.collectAsState()
val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val snackbarHostState = remember { SnackbarHostState() }
val title = when (val cat = category) {
is TagCategory -> cat.name
is VocabularyFilter -> cat.name
@@ -115,8 +127,32 @@ fun CategoryDetailScreen(
val showDeleteItemsDialog by categoryViewModel.showDeleteItemsDialog.collectAsState(initial = false)
val showEditCategoryDialog by categoryViewModel.showEditCategoryDialog.collectAsState(initial = false)
+ // Handle export state changes
+ LaunchedEffect(exportState) {
+ when (exportState) {
+ is ExportState.Success -> {
+ // Create and launch share intent
+ val shareIntent = exportImportViewModel.createShareIntent()
+ if (shareIntent != null) {
+ context.startActivity(shareIntent)
+ }
+ exportImportViewModel.resetExportState()
+ }
+ is ExportState.Error -> {
+ scope.launch {
+ snackbarHostState.showSnackbar(
+ message = (exportState as ExportState.Error).message
+ )
+ }
+ exportImportViewModel.resetExportState()
+ }
+ else -> { /* Idle or Loading */ }
+ }
+ }
+
AppScaffold(
modifier = modifier,
+ snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
@@ -137,15 +173,29 @@ fun CategoryDetailScreen(
modifier = Modifier.width(220.dp)
) {
DropdownMenuItem(
- text = { Text(stringResource(R.string.text_export_category)) },
+ text = {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Export Category")
+ if (exportState is ExportState.Loading) {
+ CircularProgressIndicator(
+ modifier = Modifier.width(16.dp).height(16.dp),
+ strokeWidth = 2.dp
+ )
+ }
+ }
+ },
onClick = {
- vocabularyViewModel.saveCategory(categoryId)
+ exportImportViewModel.exportCategory(categoryId)
showMenu = false
},
- leadingIcon = { Icon(AppIcons.Share, contentDescription = null) }
+ leadingIcon = { Icon(AppIcons.Share, contentDescription = null) },
+ enabled = exportState !is ExportState.Loading
)
DropdownMenuItem(
- text = { Text(stringResource(R.string.delete_items_category)) },
+ text = { Text("Delete Items") },
onClick = {
categoryViewModel.setShowDeleteItemsDialog(true, categoryId)
showMenu = false
diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/NewWordScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/NewWordScreen.kt
index e124ae2..a4af4a3 100644
--- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/NewWordScreen.kt
+++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/NewWordScreen.kt
@@ -255,17 +255,7 @@ fun NewWordScreen(
BottomActionCardsRow(
onImportCsvClick = {
- @Suppress("HardCodedStringLiteral")
- importTableLauncher.launch(
- arrayOf(
- "text/csv",
- "text/comma-separated-values",
- "text/tab-separated-values",
- "text/plain",
- "application/vnd.ms-excel",
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
- )
- )
+ navController.navigate("settings_vocabulary_repository_options")
}
)
@@ -770,7 +760,7 @@ fun BottomActionCardsRow(
}
Spacer(modifier = Modifier.height(12.dp))
Text(
- text = stringResource(R.string.label_import_csv),
+ text = "Import Lists or CSV",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyListScreen.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyListScreen.kt
index 248619b..829d411 100644
--- a/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyListScreen.kt
+++ b/app/src/main/java/eu/gaudian/translator/view/vocabulary/VocabularyListScreen.kt
@@ -73,6 +73,7 @@ import eu.gaudian.translator.view.dialogs.CategorySelectionDialog
import eu.gaudian.translator.view.dialogs.StageSelectionDialog
import eu.gaudian.translator.view.library.AllCardsView
import eu.gaudian.translator.viewmodel.CategoryViewModel
+import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
@@ -110,6 +111,7 @@ fun AllCardsListScreen(
val activity = LocalContext.current.findActivity()
val lazyListState = rememberLazyListState()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
+ val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val context = LocalContext.current
@@ -144,6 +146,7 @@ fun AllCardsListScreen(
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet())
val categoryNameFlow = remember(categoryId) { categoryId?.let { categoryViewModel.getCategoryById(it) } }
+ val exportState by exportImportViewModel.exportState.collectAsStateWithLifecycle()
val vocabularyItemsFlow: Flow> = remember(filterState) {
vocabularyViewModel.filterVocabularyItems(
@@ -161,6 +164,16 @@ fun AllCardsListScreen(
vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList()).value
}
+ // Handle export state
+ LaunchedEffect(exportState) {
+ if (exportState is eu.gaudian.translator.viewmodel.ExportState.Success) {
+ exportImportViewModel.createShareIntent()?.let { intent ->
+ context.startActivity(intent)
+ }
+ exportImportViewModel.resetExportState()
+ }
+ }
+
LaunchedEffect(categoryId, showDueTodayOnly, stage) {
filterState = filterState.copy(
categoryIds = categoryId?.let { listOf(it) } ?: emptyList(),
@@ -219,6 +232,11 @@ fun AllCardsListScreen(
},
onMoveToCategoryClick = { showCategoryDialog = true },
onMoveToStageClick = { showStageDialog = true },
+ onExportClick = {
+ val selectedIds = selection.map { it.toInt() }
+ exportImportViewModel.exportItemList(selectedIds)
+ selection = emptySet()
+ },
onRemoveFromCategoryClick = {
if (categoryId != null) {
val itemsToRemove = vocabularyItems.filter { selection.contains(it.id.toLong()) }
@@ -502,6 +520,7 @@ private fun ContextualTopAppBar(
onDeleteClick: () -> Unit,
onMoveToCategoryClick: () -> Unit,
onMoveToStageClick: () -> Unit,
+ onExportClick: () -> Unit,
onRemoveFromCategoryClick: () -> Unit
) {
var showOverflowMenu by remember { mutableStateOf(false) }
@@ -534,6 +553,14 @@ private fun ContextualTopAppBar(
expanded = showOverflowMenu,
onDismissRequest = { showOverflowMenu = false }
) {
+ DropdownMenuItem(
+ text = { Text("Export Selected") },
+ onClick = {
+ onExportClick()
+ showOverflowMenu = false
+ },
+ leadingIcon = { Icon(AppIcons.Share, contentDescription = null) }
+ )
DropdownMenuItem(
text = { Text(stringResource(R.string.move_to_category)) },
onClick = {
@@ -585,6 +612,7 @@ fun ContextualTopAppBarPreview() {
onDeleteClick = {},
onMoveToCategoryClick = {},
onMoveToStageClick = {},
+ onExportClick = {},
onRemoveFromCategoryClick = {}
)
}
diff --git a/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/ModernStartButton.kt b/app/src/main/java/eu/gaudian/translator/view/vocabulary/widgets/ModernStartButton.kt
deleted file mode 100644
index e69de29..0000000
diff --git a/app/src/main/java/eu/gaudian/translator/viewmodel/ExportImportViewModel.kt b/app/src/main/java/eu/gaudian/translator/viewmodel/ExportImportViewModel.kt
new file mode 100644
index 0000000..07531c1
--- /dev/null
+++ b/app/src/main/java/eu/gaudian/translator/viewmodel/ExportImportViewModel.kt
@@ -0,0 +1,290 @@
+@file:Suppress("HardCodedStringLiteral")
+
+package eu.gaudian.translator.viewmodel
+
+import android.app.Application
+import android.content.Intent
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import eu.gaudian.translator.model.ConflictStrategy
+import eu.gaudian.translator.model.ImportResult
+import eu.gaudian.translator.model.VocabularyExportData
+import eu.gaudian.translator.model.repository.VocabularyRepository
+import eu.gaudian.translator.utils.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.io.File
+import javax.inject.Inject
+
+private const val TAG = "ExportImportViewModel"
+
+/**
+ * ViewModel for handling vocabulary export and import operations.
+ *
+ * This ViewModel provides a clean separation between UI and business logic for:
+ * - Exporting vocabulary data (full repository, categories, items)
+ * - Importing vocabulary data with conflict resolution
+ * - Sharing exported data via Android intents
+ * - Managing export/import operation states
+ */
+@HiltViewModel
+class ExportImportViewModel @Inject constructor(
+ application: Application,
+ private val repository: VocabularyRepository
+) : AndroidViewModel(application) {
+
+ // UI State for export operations
+ private val _exportState = MutableStateFlow(ExportState.Idle)
+ val exportState: StateFlow = _exportState.asStateFlow()
+
+ // UI State for import operations
+ private val _importState = MutableStateFlow(ImportState.Idle)
+ val importState: StateFlow = _importState.asStateFlow()
+
+ /**
+ * Exports the full repository and prepares it for sharing.
+ *
+ * @param prettyPrint Whether to format JSON for human readability
+ */
+ fun exportFullRepository(prettyPrint: Boolean = false) {
+ viewModelScope.launch {
+ try {
+ _exportState.value = ExportState.Loading
+ Log.i(TAG, "exportFullRepository: Starting full repository export")
+
+ val exportData = repository.exportFullRepository()
+ val jsonString = repository.exportToJson(exportData, prettyPrint)
+
+ _exportState.value = ExportState.Success(
+ exportData = exportData,
+ jsonData = jsonString
+ )
+ Log.i(TAG, "exportFullRepository: Export successful. Size: ${jsonString.length} chars")
+ } catch (e: Exception) {
+ Log.e(TAG, "exportFullRepository: Export failed", e)
+ _exportState.value = ExportState.Error("Failed to export repository: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Exports a specific category.
+ *
+ * @param categoryId The ID of the category to export
+ * @param prettyPrint Whether to format JSON for human readability
+ */
+ fun exportCategory(categoryId: Int, prettyPrint: Boolean = false) {
+ viewModelScope.launch {
+ try {
+ _exportState.value = ExportState.Loading
+ Log.i(TAG, "exportCategory: Starting export for category $categoryId")
+
+ val exportData: VocabularyExportData? = repository.exportCategory(categoryId)
+ if (exportData == null) {
+ _exportState.value = ExportState.Error("Category not found")
+ Log.w(TAG, "exportCategory: Category $categoryId not found")
+ return@launch
+ }
+
+ val jsonString = repository.exportToJson(exportData, prettyPrint)
+
+ _exportState.value = ExportState.Success(
+ exportData = exportData,
+ jsonData = jsonString
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "exportCategory: Export failed", e)
+ _exportState.value = ExportState.Error("Failed to export category: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Exports a list of vocabulary items.
+ *
+ * @param itemIds List of item IDs to export
+ * @param includeCategories Whether to include category information
+ * @param prettyPrint Whether to format JSON for human readability
+ */
+ fun exportItemList(itemIds: List, includeCategories: Boolean = true, prettyPrint: Boolean = false) {
+ viewModelScope.launch {
+ try {
+ _exportState.value = ExportState.Loading
+ Log.i(TAG, "exportItemList: Starting export for ${itemIds.size} items")
+
+ val exportData: VocabularyExportData = repository.exportItemList(itemIds, includeCategories)
+ val jsonString = repository.exportToJson(exportData, prettyPrint)
+
+ _exportState.value = ExportState.Success(
+ exportData = exportData,
+ jsonData = jsonString
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "exportItemList: Export failed", e)
+ _exportState.value = ExportState.Error("Failed to export items: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Exports a single vocabulary item.
+ *
+ * @param itemId The ID of the item to export
+ * @param prettyPrint Whether to format JSON for human readability
+ */
+ fun exportSingleItem(itemId: Int, prettyPrint: Boolean = false) {
+ viewModelScope.launch {
+ try {
+ _exportState.value = ExportState.Loading
+ Log.i(TAG, "exportSingleItem: Starting export for item $itemId")
+
+ val exportData: VocabularyExportData? = repository.exportSingleItem(itemId)
+ if (exportData == null) {
+ _exportState.value = ExportState.Error("Item not found")
+ Log.w(TAG, "exportSingleItem: Item $itemId not found")
+ return@launch
+ }
+
+ val jsonString = repository.exportToJson(exportData, prettyPrint)
+
+ _exportState.value = ExportState.Success(
+ exportData = exportData,
+ jsonData = jsonString
+ )
+ Log.i(TAG, "exportSingleItem: Export successful")
+ } catch (e: Exception) {
+ Log.e(TAG, "exportSingleItem: Export failed", e)
+ _exportState.value = ExportState.Error("Failed to export item: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Creates a share intent for the exported data file.
+ * Uses FileProvider to share large files without hitting binder transaction limits.
+ */
+ fun createShareIntent(): Intent? {
+ val currentState = _exportState.value
+ if (currentState !is ExportState.Success) {
+ Log.w(TAG, "createShareIntent: No export data available")
+ return null
+ }
+
+ return try {
+ // Write to cache file
+ val context = getApplication()
+ val cacheDir = File(context.cacheDir, "exports")
+ if (!cacheDir.exists()) {
+ cacheDir.mkdirs()
+ }
+
+ val timestamp = System.currentTimeMillis()
+ val fileName = "polly_vocabulary_export_$timestamp.json"
+ val file = File(cacheDir, fileName)
+
+ file.writeText(currentState.jsonData)
+ Log.i(TAG, "createShareIntent: Wrote export to file: ${file.absolutePath}, size: ${file.length()} bytes")
+
+ // Create content URI using FileProvider
+ val contentUri = androidx.core.content.FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ file
+ )
+
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "application/json"
+ putExtra(Intent.EXTRA_STREAM, contentUri)
+ putExtra(Intent.EXTRA_SUBJECT, "Polly Vocabulary Export")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ Intent.createChooser(intent, "Share Vocabulary Export")
+ } catch (e: Exception) {
+ Log.e(TAG, "createShareIntent: Error creating share intent", e)
+ _exportState.value = ExportState.Error("Failed to create share intent: ${e.message}")
+ null
+ }
+ }
+
+ /**
+ * Imports vocabulary data from JSON string.
+ *
+ * @param jsonString The JSON string to import
+ * @param strategy The conflict resolution strategy to use
+ */
+ fun importFromJson(jsonString: String, strategy: ConflictStrategy = ConflictStrategy.MERGE) {
+ viewModelScope.launch {
+ try {
+ _importState.value = ImportState.Loading
+ Log.i(TAG, "importFromJson: Starting import with strategy=$strategy")
+
+ val exportData = repository.importFromJson(jsonString)
+ val result = repository.importVocabularyData(exportData, strategy)
+
+ _importState.value = ImportState.Success(result)
+ Log.i(TAG, "importFromJson: Import complete. Imported: ${result.itemsImported}, " +
+ "Skipped: ${result.itemsSkipped}, Errors: ${result.errors.size}")
+ } catch (e: Exception) {
+ Log.e(TAG, "importFromJson: Import failed", e)
+ _importState.value = ImportState.Error("Failed to import: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Resets the export state to idle.
+ * Call this after handling export results.
+ */
+ fun resetExportState() {
+ _exportState.value = ExportState.Idle
+ }
+
+ /**
+ * Resets the import state to idle.
+ * Call this after handling import results.
+ */
+ fun resetImportState() {
+ _importState.value = ImportState.Idle
+ }
+}
+
+/**
+ * Sealed class representing the state of an export operation.
+ */
+sealed class ExportState {
+ /** No export operation in progress */
+ object Idle : ExportState()
+
+ /** Export operation in progress */
+ object Loading : ExportState()
+
+ /** Export completed successfully */
+ data class Success(
+ val exportData: VocabularyExportData,
+ val jsonData: String
+ ) : ExportState()
+
+ /** Export failed with an error */
+ data class Error(val message: String) : ExportState()
+}
+
+/**
+ * Sealed class representing the state of an import operation.
+ */
+sealed class ImportState {
+ /** No import operation in progress */
+ object Idle : ImportState()
+
+ /** Import operation in progress */
+ object Loading : ImportState()
+
+ /** Import completed successfully */
+ data class Success(val result: ImportResult) : ImportState()
+
+ /** Import failed with an error */
+ data class Error(val message: String) : ImportState()
+}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..84d798a
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+