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