Compare commits

...

6 Commits

37 changed files with 2716 additions and 654 deletions

View File

@@ -33,6 +33,16 @@
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,69 @@
## Was ist ein API-Schlüssel?
Ein API-Schlüssel ist wie ein Passwort, das deiner App erlaubt, mit KI-Diensten zu kommunizieren. Du brauchst einen, um einen KI-Anbieter wie OpenAI (ChatGPT), Anthropic, Mistral oder DeepSeek zu nutzen.
## Einen API-Schlüssel bekommen
Einige Anbieter bieten eine begrenzte kostenlose Nutzung ihrer API an, die für die meisten Funktionen dieser App ausreichen sollte; es wird aber empfohlen, einen schnelleren und bezahlten Dienst zu verwenden.
### Für Cloud-Anbieter
1. Erstelle ein Konto auf der Website des Anbieters
2. Wähle einen Tarif, eine Abrechnungsoption oder eine kostenlose Stufe, falls verfügbar
3. Erstelle einen neuen Schlüssel und kopiere ihn
4. Füge ihn in diese App ein
### Für lokale KI-Server
Wenn du einen lokalen KI-Server (wie Ollama oder LM Studio) betreibst, brauchst du keinen API-Schlüssel. Füge einfach einen benutzerdefinierten Anbieter hinzu:
1. Tippe auf **„Benutzerdefinierten Anbieter hinzufügen“**
2. Gib die IP deines lokalen Servers und den Endpunkt ein
3. Tippe auf **„Verfügbarkeit prüfen“**, um die Verbindung zu testen
## Ein Modell auswählen
### Was sind Modelle?
Ein Modell ist ein bestimmtes KI-Gehirn. Verschiedene Modelle haben unterschiedliche Stärken:
- **Kleinere Modelle**: Schneller und günstiger
- **Größere Modelle**: Intelligenter, aber langsamer und teurer
Für vorkonfigurierte Anbieter sind einige Modelle bereits standardmäßig hinzugefügt und erwiesenermaßen mit dieser App kompatibel.
### Modelle hinzufügen
1. Öffne die Details eines Anbieters
2. Tippe auf **Modell hinzufügen**
3. Wähle **Nach Modellen scannen**, um verfügbare automatisch zu finden
4. Wähle die Modelle aus, die du verwenden möchtest
### Modelle Aufgaben zuweisen
Du kannst verschiedene Modelle für verschiedene Funktionen verwenden:
1. Gehe zum Tab **Aufgaben**
2. Wähle aus, welches Modell verwendet werden soll für:
- **Übersetzung**: Übersetzt Text zwischen Sprachen
- **Übungen**: Erstellt Übungsaufgaben
- **Wortschatz**: Generiert Vokabeln und Synonyme
- **Wörterbuch**: Sucht Definitionen nach
## Häufige Probleme
### „Ungültiger API-Schlüssel“
- Prüfe auf Tippfehler oder zusätzliche Leerzeichen
- Stelle sicher, dass dein Schlüssel auf der Website des Anbieters noch aktiv und gültig ist
### „Keine Modelle verfügbar“
- Stelle zuerst sicher, dass dein API-Schlüssel gültig ist
- Wenn du in einem lokalen Netzwerk bist, überprüfe, ob deine Verbindung und dein Endpunkt korrekt konfiguriert sind
### Langsame Antworten
- Probiere einen schnelleren Anbieter, möglicherweise musst du eine bezahlte Option wählen
- Verwende ein kleineres Modell (suche nach Namen mit „small“, „light“, „fast“, „nano“)
### Lokaler Server funktioniert nicht
- Stelle sicher, dass dein lokaler Server läuft
- Überprüfe, ob die URL korrekt ist
- Dein Handy und Computer müssen möglicherweise im selben WLAN sein, damit lokale Server funktionieren.

View File

@@ -0,0 +1,40 @@
## Was sind Kategorien?
Kategorien helfen dir, deinen Wortschatz in sinnvolle Gruppen zu ordnen. Du kannst sie nutzen, um Wörter nach Thema, Sprache, Lernstufe oder einem eigenen System zu sortieren, das für dich funktioniert.
## Zwei Arten von Kategorien
### Listenkategorien
Listenkategorien sind einfache Gruppierungen von Vokabeln. Du kannst Wörter einfach zu einer Liste hinzufügen und sie bleiben dort dauerhaft.
**Anwendungsfälle:**
- Wörter nach Thema gruppieren (z.B. „Essen“, „Reisen“, „Geschäftssprache“)
- Eigene Decks für bestimmte Zwecke erstellen
### Filterkategorien
Filterkategorien schließen automatisch alle Vokabeln ein, die bestimmten Kriterien entsprechen. Wörter werden dynamisch hinzugefügt oder entfernt, basierend auf den Filterregeln.
**Anwendungsfälle:**
- Nach Lernstufe filtern (z.B. „Wörter, die ich lerne“)
- Nach Sprache filtern
- Mehrere Kriterien für komplexe Filter kombinieren (z.B. „Spanische Wörter, die ich schon kenne“)
## Kategorien erstellen
1. **Tippe auf die + Schaltfläche**, um eine neue Kategorie zu erstellen
2. **Wähle den Typ** Liste oder Filter
3. **Gib einen Namen** und optional eine Beschreibung ein
4. **Lege die Regeln fest** (für Filterkategorien)
5. **Speichere** deine Kategorie
## Kategorien verwalten
- **Bearbeiten** Gehe in eine Kategorie, um ihre Einstellungen zu ändern
## Tipps
- Nutze Filterkategorien für Lernstufen, um den Fortschritt bei allen Wörtern einer bestimmten Stufe automatisch zu verfolgen.
- Dieselbe Vokabelkarte kann in mehreren Kategorien erscheinen.
- Du kannst Kategorien auch nutzen, um große Gruppen von Vokabeln auf einmal zu verwalten, indem du die „Alle auswählen“-Funktion innerhalb einer Kategorie verwendest.

View File

@@ -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<VocabularyExportData>(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<VocabularyItem>,
val categories: List<VocabularyCategory>,
val states: List<VocabularyItemState>,
val categoryMappings: List<CategoryMappingData>,
val stageMappings: List<StageMappingData>
) : 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<VocabularyItem>,
val states: List<VocabularyItemState>,
val stageMappings: List<StageMappingData>
) : 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<VocabularyItem>,
val states: List<VocabularyItemState>,
val stageMappings: List<StageMappingData>,
val associatedCategories: List<VocabularyCategory> = emptyList(),
val categoryMappings: List<CategoryMappingData> = 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<VocabularyCategory> = 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<String> = emptyList()
) {
val isSuccess: Boolean get() = errors.isEmpty()
val totalProcessed: Int get() = itemsImported + itemsSkipped + itemsUpdated
}

View File

@@ -228,10 +228,7 @@ class ApiManager(private val context: Context) {
val allowNoKey = provider.isCustom || isLocalHost
val effectiveKey = if (key.isNotEmpty()) key else if (allowNoKey) "" else key
// Perplexity does not support listing models via /v1/models; fail fast with a clear message
if (provider.key.equals("perplexity", ignoreCase = true)) {
return Pair(emptyList(), "Perplexity does not support fetching modeles.") //TODO this must be transalted!
}
return withContext(Dispatchers.IO) {
try {

View File

@@ -115,17 +115,6 @@ data class ApiProvider(
LanguageModel("llama-3.1-8b-instant", "Llama 3.1 8B", "groq", "Powerful Llama 3 model running at extreme speed."),
)
),
ApiProvider(
key = "perplexity",
displayName = "Perplexity",
baseUrl = "https://api.perplexity.ai/",
endpoint = "chat/completions",
websiteUrl = "https://www.perplexity.ai/",
models = listOf(
LanguageModel("sonar", "Sonar Small Online", "perplexity", "A faster online model for quick, up-to-date answers."), // default
LanguageModel("sonar-pro", "Sonar Pro", "perplexity", "Advanced search-focused model for richer context and longer answers."),
)
),
ApiProvider(
key = "xai",
displayName = "xAI Grok",

View File

@@ -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"
}
}
// ===== 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<FlashcardApiService>()
/**
* 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"
}
}

View File

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

View File

@@ -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<FlashcardCollectionInfo>
)
/**
* Data class representing information about a downloadable flashcard collection.
*/
data class FlashcardCollectionInfo(
@SerializedName("id")
val id: String,
@SerializedName("name")
val name: String,
@SerializedName("description")
val description: String,
@SerializedName("version")
val version: String,
@SerializedName("asset")
val asset: FlashcardAsset
)
/**
* 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
)

View File

@@ -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
@@ -625,7 +636,7 @@ class VocabularyRepository private constructor(context: Context) {
suspend fun isTargetMetForDate(date: LocalDate): Boolean {
val dailyCorrectCount = getDailyCorrectCount(date)
val target = 10 // TODO: Replace with settingsRepository.dailyGoal.flow.first()
val target = settingsRepository.dailyGoal.flow.first()
return dailyCorrectCount >= target
}
@@ -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<Int>, 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<String>()
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<String>()
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<String>()
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<String>()
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<VocabularyCategory>,
strategy: ConflictStrategy
): Map<Int, Int> {
val idMap = mutableMapOf<Int, Int>()
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<VocabularyItem>,
states: List<VocabularyItemState>,
stageMappings: List<StageMappingData>,
strategy: ConflictStrategy
): Map<Int, Int> {
val idMap = mutableMapOf<Int, Int>()
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<CategoryMappingData>,
itemIdMap: Map<Int, Int>,
categoryIdMap: Map<Int, Int>
) {
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

View File

@@ -5,22 +5,22 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.themes.AutumnSpiceTheme
import eu.gaudian.translator.ui.theme.themes.CitrusSplashTheme
import eu.gaudian.translator.ui.theme.themes.CoffeeTheme
import eu.gaudian.translator.ui.theme.themes.CrimsonTheme
import eu.gaudian.translator.ui.theme.themes.CyberpunkTheme
import eu.gaudian.translator.ui.theme.themes.DebugTheme
import eu.gaudian.translator.ui.theme.themes.DefaultTheme
import eu.gaudian.translator.ui.theme.themes.ElectricVioletTheme
import eu.gaudian.translator.ui.theme.themes.ForestTheme
import eu.gaudian.translator.ui.theme.themes.LavenderDreamTheme
import eu.gaudian.translator.ui.theme.themes.MossStoneTheme
import eu.gaudian.translator.ui.theme.themes.NeonPulseTheme
import eu.gaudian.translator.ui.theme.themes.NordTheme
import eu.gaudian.translator.ui.theme.themes.OceanicCalmTheme
import eu.gaudian.translator.ui.theme.themes.PixelTheme
import eu.gaudian.translator.ui.theme.themes.SakuraTheme
import eu.gaudian.translator.ui.theme.themes.SageGardenTheme
import eu.gaudian.translator.ui.theme.themes.SlateAndStoneTheme
import eu.gaudian.translator.ui.theme.themes.SpaceTheme
import eu.gaudian.translator.ui.theme.themes.SynthwaveTheme
import eu.gaudian.translator.ui.theme.themes.TealTheme
import eu.gaudian.translator.ui.theme.themes.TwilightSerenityTheme
import eu.gaudian.translator.ui.theme.themes.TerracottaEarthTheme
/**
* A data class to hold the core colors for a theme variation (light or dark).
@@ -97,26 +97,23 @@ data class AppTheme(
val AllThemes = listOf(
DefaultTheme,
PixelTheme,
CrimsonTheme,
SakuraTheme,
AutumnSpiceTheme,
TealTheme,
ForestTheme,
CoffeeTheme,
CitrusSplashTheme,
OceanicCalmTheme,
SlateAndStoneTheme,
NordTheme,
TwilightSerenityTheme,
SpaceTheme,
CyberpunkTheme,
SynthwaveTheme,
DebugTheme,
LavenderDreamTheme,
SageGardenTheme,
MossStoneTheme,
ElectricVioletTheme,
NeonPulseTheme,
TerracottaEarthTheme,
)
/**

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val CitrusSplashTheme = AppTheme(
name = "Citrus Splash",
lightColors = ThemeColorSet(
primary = Color(0xFFF57F17), // Vibrant Orange (Primary)
secondary = Color(0xFFFBC02D), // Sunny Yellow (Secondary)
tertiary = Color(0xFF7CB342), // Lime Green (Tertiary)
primaryContainer = Color(0xFFFFEBC0),
secondaryContainer = Color(0xFFFFF3AD),
tertiaryContainer = Color(0xFFDDEEBF),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFF000000),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF2C1600),
onSecondaryContainer = Color(0xFF221B00),
onTertiaryContainer = Color(0xFF131F00),
error = Color(0xFFB00020),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFDE7E9),
onErrorContainer = Color(0xFF4A000B),
background = Color(0xFFFFFDF7), // Warm, off-white background
onBackground = Color(0xFF201A17), // Dark, warm text
surface = Color(0xFFFFFFFF), // Crisp white surface
onSurface = Color(0xFF201A17),
surfaceVariant = Color(0xFFF3EFE9),
onSurfaceVariant = Color(0xFF49453F),
outline = Color(0xFF7A756F),
outlineVariant = Color(0xFFCCC5BD),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF352F2B),
inverseOnSurface = Color(0xFFFBEFE8),
inversePrimary = Color(0xFFFFB86C),
surfaceDim = Color(0xFFE2D8D2),
surfaceBright = Color(0xFFFFFDF7),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFFBF2EC),
surfaceContainer = Color(0xFFF5EDE6),
surfaceContainerHigh = Color(0xFFF0E7E1),
surfaceContainerHighest = Color(0xFFEAE2DC)
),
darkColors = ThemeColorSet(
primary = Color(0xFFFFB86C), // Lighter orange for dark mode
secondary = Color(0xFFEAC248), // Lighter yellow
tertiary = Color(0xFFB8CF83), // Lighter lime
primaryContainer = Color(0xFF5A4121),
secondaryContainer = Color(0xFF564600),
tertiaryContainer = Color(0xFF404D20),
onPrimary = Color(0xFF4A2A00),
onSecondary = Color(0xFF3A3000),
onTertiary = Color(0xFF2B350A),
onPrimaryContainer = Color(0xFFFFDEB5),
onSecondaryContainer = Color(0xFFFFEAAA),
onTertiaryContainer = Color(0xFFD4EC9C),
error = Color(0xFFCF6679),
onError = Color(0xFF000000),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1F1A17), // Deep, warm brown/gray
onBackground = Color(0xFFEAE2DC), // Light, warm text
surface = Color(0xFF2A2421), // Slightly lighter warm surface
onSurface = Color(0xFFEAE2DC),
surfaceVariant = Color(0xFF443F3A),
onSurfaceVariant = Color(0xFFC9C6C0),
outline = Color(0xFF938F8A),
outlineVariant = Color(0xFF49453F),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFEAE2DC),
inverseOnSurface = Color(0xFF201A17),
inversePrimary = Color(0xFFF57F17),
surfaceDim = Color(0xFF1F1A17),
surfaceBright = Color(0xFF48403A),
surfaceContainerLowest = Color(0xFF16120F),
surfaceContainerLow = Color(0xFF1F1A17),
surfaceContainer = Color(0xFF241E1B),
surfaceContainerHigh = Color(0xFF2E2925),
surfaceContainerHighest = Color(0xFF39332F),
)
)

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val CrimsonTheme = AppTheme(
name = "Crimson",
lightColors = ThemeColorSet(
primary = Color(0xFFA03F3F),
secondary = Color(0xFF775656),
tertiary = Color(0xFF755A2F),
primaryContainer = Color(0xFFFFDAD9),
secondaryContainer = Color(0xFFFFDAD9),
tertiaryContainer = Color(0xFFFFDEAD),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF410004),
onSecondaryContainer = Color(0xFF2C1515),
onTertiaryContainer = Color(0xFF281900),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFCFCFC),
onBackground = Color(0xFF201A1A),
surface = Color(0xFFFCFCFC),
onSurface = Color(0xFF201A1A),
surfaceVariant = Color(0xFFF4DDDD),
onSurfaceVariant = Color(0xFF524343),
outline = Color(0xFF857373),
outlineVariant = Color(0xFFD7C1C1),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF362F2F),
inverseOnSurface = Color(0xFFFBEDED),
inversePrimary = Color(0xFFFFB3B3),
surfaceDim = Color(0xFFE3D7D7),
surfaceBright = Color(0xFFFCFCFC),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF7F0F0),
surfaceContainer = Color(0xFFF1EAEB),
surfaceContainerHigh = Color(0xFFEBE4E5),
surfaceContainerHighest = Color(0xFFE5DFDF)
),
darkColors = ThemeColorSet(
primary = Color(0xFFFFB3B3),
secondary = Color(0xFFE6BDBC),
tertiary = Color(0xFFE5C18D),
primaryContainer = Color(0xFF812829),
secondaryContainer = Color(0xFF5D3F3F),
tertiaryContainer = Color(0xFF5B431A),
onPrimary = Color(0xFF611216),
onSecondary = Color(0xFF442929),
onTertiary = Color(0xFF412D05),
onPrimaryContainer = Color(0xFFFFDAD9),
onSecondaryContainer = Color(0xFFFFDAD9),
onTertiaryContainer = Color(0xFFFFDEAD),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF201A1A),
onBackground = Color(0xFFEBE0E0),
surface = Color(0xFF201A1A),
onSurface = Color(0xFFEBE0E0),
surfaceVariant = Color(0xFF524343),
onSurfaceVariant = Color(0xFFD7C1C1),
outline = Color(0xFFA08C8C),
outlineVariant = Color(0xFF524343),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFEBE0E0),
inverseOnSurface = Color(0xFF362F2F),
inversePrimary = Color(0xFFA03F3F),
surfaceDim = Color(0xFF171212),
surfaceBright = Color(0xFF3E3737),
surfaceContainerLowest = Color(0xFF120D0D),
surfaceContainerLow = Color(0xFF251E1E),
surfaceContainer = Color(0xFF2A2222),
surfaceContainerHigh = Color(0xFF342C2C),
surfaceContainerHighest = Color(0xFF3F3737),
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val ElectricVioletTheme = AppTheme(
name = "Electric Violet",
lightColors = ThemeColorSet(
primary = Color(0xFF7B2CBF),
secondary = Color(0xFF9D4EDD),
tertiary = Color(0xFFC77DFF),
primaryContainer = Color(0xFFE8D4FF),
secondaryContainer = Color(0xFFF0D4FF),
tertiaryContainer = Color(0xFFFFD4FF),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color(0xFF3D0066),
onPrimaryContainer = Color(0xFF2E0060),
onSecondaryContainer = Color(0xFF3D0066),
onTertiaryContainer = Color(0xFF4D007A),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFEFDFF),
onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFEFDFF),
onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFE7DEF0),
onSurfaceVariant = Color(0xFF4F444B),
outline = Color(0xFF80737A),
outlineVariant = Color(0xFFD3C2CA),
scrim = Color.Black,
inverseSurface = Color(0xFF343035),
inverseOnSurface = Color(0xFFF9EFF6),
inversePrimary = Color(0xFFE0B0FF),
surfaceDim = Color(0xFFE0D8E0),
surfaceBright = Color(0xFFFEFDFF),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF9F2FA),
surfaceContainer = Color(0xFFF3ECF4),
surfaceContainerHigh = Color(0xFFEDE7EE),
surfaceContainerHighest = Color(0xFFE7E1E9)
),
darkColors = ThemeColorSet(
primary = Color(0xFFE0B0FF),
secondary = Color(0xFFC590E0),
tertiary = Color(0xFFE0A0FF),
primaryContainer = Color(0xFF5A2D8A),
secondaryContainer = Color(0xFF6A3A7A),
tertiaryContainer = Color(0xFF5A3A7A),
onPrimary = Color(0xFF3D1A6A),
onSecondary = Color(0xFF3D2A5A),
onTertiary = Color(0xFF3D2A6A),
onPrimaryContainer = Color(0xFFE8D4FF),
onSecondaryContainer = Color(0xFFF0D4FF),
onTertiaryContainer = Color(0xFFFFD4FF),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1C1B20),
onBackground = Color(0xFFE6E1E9),
surface = Color(0xFF1C1B20),
onSurface = Color(0xFFE6E1E9),
surfaceVariant = Color(0xFF4F444B),
onSurfaceVariant = Color(0xFFD3C2CA),
outline = Color(0xFF9C8D96),
outlineVariant = Color(0xFF4F444B),
scrim = Color.Black,
inverseSurface = Color(0xFFE6E1E9),
inverseOnSurface = Color(0xFF343035),
inversePrimary = Color(0xFF7B2CBF),
surfaceDim = Color(0xFF141217),
surfaceBright = Color(0xFF3B373E),
surfaceContainerLowest = Color(0xFF0E0D12),
surfaceContainerLow = Color(0xFF1C1B20),
surfaceContainer = Color(0xFF201F24),
surfaceContainerHigh = Color(0xFF2B292F),
surfaceContainerHighest = Color(0xFF36343A)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val LavenderDreamTheme = AppTheme(
name = "Lavender Dream",
lightColors = ThemeColorSet(
primary = Color(0xFF6B5B95), // Deep Lavender
secondary = Color(0xFF8874A3), // Soft Purple
tertiary = Color(0xFFBFA6C8), // Pale Lavender
primaryContainer = Color(0xFFE8DEFF),
secondaryContainer = Color(0xFFF3E8FF),
tertiaryContainer = Color(0xFFFFE8FF),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color(0xFF3D2E4D),
onPrimaryContainer = Color(0xFF251A4A),
onSecondaryContainer = Color(0xFF2D1F4A),
onTertiaryContainer = Color(0xFF3D2E4D),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFDFBFF),
onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFDFBFF),
onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFE7E0EB),
onSurfaceVariant = Color(0xFF49454E),
outline = Color(0xFF7A757F),
outlineVariant = Color(0xFFCBC4CF),
scrim = Color.Black,
inverseSurface = Color(0xFF313035),
inverseOnSurface = Color(0xFFF3EFF6),
inversePrimary = Color(0xFFCBB8FF),
surfaceDim = Color(0xFFDED9E0),
surfaceBright = Color(0xFFFDFBFF),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF8F2FA),
surfaceContainer = Color(0xFFF2ECF4),
surfaceContainerHigh = Color(0xFFECE7EF),
surfaceContainerHighest = Color(0xFFE6E1E9)
),
darkColors = ThemeColorSet(
primary = Color(0xFFCBB8FF), // Soft Lavender
secondary = Color(0xFFD4C4E8), // Light Purple
tertiary = Color(0xFFE0D0F0), // Very Pale Purple
primaryContainer = Color(0xFF52437A),
secondaryContainer = Color(0xFF5D4A73),
tertiaryContainer = Color(0xFF4A3D5C),
onPrimary = Color(0xFF3B2E6A),
onSecondary = Color(0xFF3D2A54),
onTertiary = Color(0xFF3D2A54),
onPrimaryContainer = Color(0xFFE8DEFF),
onSecondaryContainer = Color(0xFFF3E8FF),
onTertiaryContainer = Color(0xFFFFE8FF),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1C1B20),
onBackground = Color(0xFFE6E1E9),
surface = Color(0xFF1C1B20),
onSurface = Color(0xFFE6E1E9),
surfaceVariant = Color(0xFF49454E),
onSurfaceVariant = Color(0xFFCBC4CF),
outline = Color(0xFF948F99),
outlineVariant = Color(0xFF49454E),
scrim = Color.Black,
inverseSurface = Color(0xFFE6E1E9),
inverseOnSurface = Color(0xFF313035),
inversePrimary = Color(0xFF6B5B95),
surfaceDim = Color(0xFF141317),
surfaceBright = Color(0xFF3A383E),
surfaceContainerLowest = Color(0xFF0F0E12),
surfaceContainerLow = Color(0xFF1C1B20),
surfaceContainer = Color(0xFF201F24),
surfaceContainerHigh = Color(0xFF2B292F),
surfaceContainerHighest = Color(0xFF36343A)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val MossStoneTheme = AppTheme(
name = "Moss & Stone",
lightColors = ThemeColorSet(
primary = Color(0xFF4A6356), // Deep Moss
secondary = Color(0xFF6B6B6B), // Stone Gray
tertiary = Color(0xFF8B9A7C), // Sage Olive
primaryContainer = Color(0xFFC8D8CE),
secondaryContainer = Color(0xFFE0E0E0),
tertiaryContainer = Color(0xFFE8EFE0),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color(0xFF1D2D1A),
onPrimaryContainer = Color(0xFF0D1F15),
onSecondaryContainer = Color(0xFF1F1F1F),
onTertiaryContainer = Color(0xFF2D3A20),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF8F9F6),
onBackground = Color(0xFF1A1C1A),
surface = Color(0xFFF8F9F6),
onSurface = Color(0xFF1A1C1A),
surfaceVariant = Color(0xFFD4D9D2),
onSurfaceVariant = Color(0xFF41483D),
outline = Color(0xFF71786D),
outlineVariant = Color(0xFFC1C8C1),
scrim = Color.Black,
inverseSurface = Color(0xFF2F312D),
inverseOnSurface = Color(0xFFF0F1ED),
inversePrimary = Color(0xFFB1CCB8),
surfaceDim = Color(0xFFD8DAD4),
surfaceBright = Color(0xFFF8F9F6),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF2F4F0),
surfaceContainer = Color(0xFFECEFEA),
surfaceContainerHigh = Color(0xFFE6E9E4),
surfaceContainerHighest = Color(0xFFE0E3DE)
),
darkColors = ThemeColorSet(
primary = Color(0xFFB1CCB8), // Soft Moss
secondary = Color(0xFFB8B8B8), // Light Stone
tertiary = Color(0xFFD4E0C0), // Light Olive
primaryContainer = Color(0xFF354B3F),
secondaryContainer = Color(0xFF404040),
tertiaryContainer = Color(0xFF4A5235),
onPrimary = Color(0xFF0D1F15),
onSecondary = Color(0xFF1F1F1F),
onTertiary = Color(0xFF2D3A20),
onPrimaryContainer = Color(0xFFC8D8CE),
onSecondaryContainer = Color(0xFFE0E0E0),
onTertiaryContainer = Color(0xFFE8EFE0),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A1C1A),
onBackground = Color(0xFFE0E3DE),
surface = Color(0xFF1A1C1A),
onSurface = Color(0xFFE0E3DE),
surfaceVariant = Color(0xFF41483D),
onSurfaceVariant = Color(0xFFC1C8C1),
outline = Color(0xFF8B9187),
outlineVariant = Color(0xFF41483D),
scrim = Color.Black,
inverseSurface = Color(0xFFE0E3DE),
inverseOnSurface = Color(0xFF2F312D),
inversePrimary = Color(0xFF4A6356),
surfaceDim = Color(0xFF121411),
surfaceBright = Color(0xFF383A36),
surfaceContainerLowest = Color(0xFF0D0F0E),
surfaceContainerLow = Color(0xFF1A1C1A),
surfaceContainer = Color(0xFF1E201D),
surfaceContainerHigh = Color(0xFF282B27),
surfaceContainerHighest = Color(0xFF333631)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val NeonPulseTheme = AppTheme(
name = "Neon Pulse",
lightColors = ThemeColorSet(
primary = Color(0xFFE91E63), // Hot Pink
secondary = Color(0xFF00BCD4), // Cyan
tertiary = Color(0xFFFFEB3B), // Bright Yellow
primaryContainer = Color(0xFFFFD6E0),
secondaryContainer = Color(0xFFB2EBF2),
tertiaryContainer = Color(0xFFFFF9C4),
onPrimary = Color.White,
onSecondary = Color(0xFF003640),
onTertiary = Color(0xFF3F3D00),
onPrimaryContainer = Color(0xFF3E001A),
onSecondaryContainer = Color(0xFF001F26),
onTertiaryContainer = Color(0xFF3D3D00),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFFBFF),
onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFFFBFF),
onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFF3DDE6),
onSurfaceVariant = Color(0xFF50434B),
outline = Color(0xFF84737A),
outlineVariant = Color(0xFFD8C2C9),
scrim = Color.Black,
inverseSurface = Color(0xFF343035),
inverseOnSurface = Color(0xFFF9EFF3),
inversePrimary = Color(0xFFFFB1C8),
surfaceDim = Color(0xFFE2D6DB),
surfaceBright = Color(0xFFFFFBFF),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFFCF0F4),
surfaceContainer = Color(0xFFF6E9EE),
surfaceContainerHigh = Color(0xFFF1E3E8),
surfaceContainerHighest = Color(0xFFEBDEE3)
),
darkColors = ThemeColorSet(
primary = Color(0xFFFFB1C8), // Soft Pink
secondary = Color(0xFF7FDBE6), // Soft Cyan
tertiary = Color(0xFFFFF176), // Soft Yellow
primaryContainer = Color(0xFFC2185B),
secondaryContainer = Color(0xFF00838F),
tertiaryContainer = Color(0xFF5A5A00),
onPrimary = Color(0xFF5E002A),
onSecondary = Color(0xFF00363D),
onTertiary = Color(0xFF3D3D00),
onPrimaryContainer = Color(0xFFFFD6E0),
onSecondaryContainer = Color(0xFFB2EBF2),
onTertiaryContainer = Color(0xFFFFF9C4),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1C1B20),
onBackground = Color(0xFFE6E1E6),
surface = Color(0xFF1C1B20),
onSurface = Color(0xFFE6E1E6),
surfaceVariant = Color(0xFF50434B),
onSurfaceVariant = Color(0xFFD8C2C9),
outline = Color(0xFFA08C95),
outlineVariant = Color(0xFF50434B),
scrim = Color.Black,
inverseSurface = Color(0xFFE6E1E6),
inverseOnSurface = Color(0xFF343035),
inversePrimary = Color(0xFFE91E63),
surfaceDim = Color(0xFF141217),
surfaceBright = Color(0xFF3B373D),
surfaceContainerLowest = Color(0xFF0E0D12),
surfaceContainerLow = Color(0xFF1C1B20),
surfaceContainer = Color(0xFF201F24),
surfaceContainerHigh = Color(0xFF2B292F),
surfaceContainerHighest = Color(0xFF36343A)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SageGardenTheme = AppTheme(
name = "Sage Garden",
lightColors = ThemeColorSet(
primary = Color(0xFF5C7A5C), // Sage Green
secondary = Color(0xFF8B7355), // Warm Brown
tertiary = Color(0xFF6B8E6B), // Moss Green
primaryContainer = Color(0xFFD4E8D4),
secondaryContainer = Color(0xFFE8DDD0),
tertiaryContainer = Color(0xFFE0F0E0),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onPrimaryContainer = Color(0xFF1A3D1A),
onSecondaryContainer = Color(0xFF2C1F0D),
onTertiaryContainer = Color(0xFF1F3D1F),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFAFDF7),
onBackground = Color(0xFF1A1C19),
surface = Color(0xFFFAFDF7),
onSurface = Color(0xFF1A1C19),
surfaceVariant = Color(0xFFDCE4D7),
onSurfaceVariant = Color(0xFF41483F),
outline = Color(0xFF71786E),
outlineVariant = Color(0xFFC1C8BC),
scrim = Color.Black,
inverseSurface = Color(0xFF2F312D),
inverseOnSurface = Color(0xFFF0F1EB),
inversePrimary = Color(0xFFC4DBC4),
surfaceDim = Color(0xFFDADAD5),
surfaceBright = Color(0xFFFAFDF7),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFF4F7F0),
surfaceContainer = Color(0xFFEEF1EA),
surfaceContainerHigh = Color(0xFFE8EBE4),
surfaceContainerHighest = Color(0xFFE2E5DE)
),
darkColors = ThemeColorSet(
primary = Color(0xFFC4DBC4), // Soft Sage
secondary = Color(0xFFD4C4B0), // Warm Beige
tertiary = Color(0xFFB8D4B8), // Light Moss
primaryContainer = Color(0xFF445F45),
secondaryContainer = Color(0xFF5C4A3A),
tertiaryContainer = Color(0xFF3D5C3D),
onPrimary = Color(0xFF1D3D1D),
onSecondary = Color(0xFF3D2A1A),
onTertiary = Color(0xFF1D3D1D),
onPrimaryContainer = Color(0xFFD4E8D4),
onSecondaryContainer = Color(0xFFE8DDD0),
onTertiaryContainer = Color(0xFFE0F0E0),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A1C19),
onBackground = Color(0xFFE2E5DE),
surface = Color(0xFF1A1C19),
onSurface = Color(0xFFE2E5DE),
surfaceVariant = Color(0xFF41483F),
onSurfaceVariant = Color(0xFFC1C8BC),
outline = Color(0xFF8B9187),
outlineVariant = Color(0xFF41483F),
scrim = Color.Black,
inverseSurface = Color(0xFFE2E5DE),
inverseOnSurface = Color(0xFF2F312D),
inversePrimary = Color(0xFF5C7A5C),
surfaceDim = Color(0xFF121411),
surfaceBright = Color(0xFF383A36),
surfaceContainerLowest = Color(0xFF0F110E),
surfaceContainerLow = Color(0xFF1A1C19),
surfaceContainer = Color(0xFF1E201D),
surfaceContainerHigh = Color(0xFF282B27),
surfaceContainerHighest = Color(0xFF333631)
)
)

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SakuraTheme = AppTheme(
name = "Blossom Pink",
lightColors = ThemeColorSet(
primary = Color(0xFFB94565),
secondary = Color(0xFF755960),
tertiary = Color(0xFF805537),
primaryContainer = Color(0xFFFFD9DF),
secondaryContainer = Color(0xFFFFD9E2),
tertiaryContainer = Color(0xFFFFDCC2),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF40001F),
onSecondaryContainer = Color(0xFF2B171D),
onTertiaryContainer = Color(0xFF311300),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFF8F7),
onBackground = Color(0xFF221A1C),
surface = Color(0xFFFFF8F7),
onSurface = Color(0xFF221A1C),
surfaceVariant = Color(0xFFF2DEE1),
onSurfaceVariant = Color(0xFF514346),
outline = Color(0xFF837376),
outlineVariant = Color(0xFFD5C2C5),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF382E30),
inverseOnSurface = Color(0xFFFDEDEF),
inversePrimary = Color(0xFFE3B9C2),
surfaceDim = Color(0xFFE8D6D8),
surfaceBright = Color(0xFFFFF8F7),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFFFF0F1),
surfaceContainer = Color(0xFFFCEAEF),
surfaceContainerHigh = Color(0xFFF6E4E9),
surfaceContainerHighest = Color(0xFFF1DEE4)
),
darkColors = ThemeColorSet(
primary = Color(0xFFE3B9C2),
secondary = Color(0xFFE3BDC6),
tertiary = Color(0xFFF3BC95),
primaryContainer = Color(0xFF982C4D),
secondaryContainer = Color(0xFF5C4148),
tertiaryContainer = Color(0xFF653F22),
onPrimary = Color(0xFF581535),
onSecondary = Color(0xFF422C32),
onTertiary = Color(0xFF4A280D),
onPrimaryContainer = Color(0xFFFFD9DF),
onSecondaryContainer = Color(0xFFFFD9E2),
onTertiaryContainer = Color(0xFFFFDCC2),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF221A1C),
onBackground = Color(0xFFF1DEE4),
surface = Color(0xFF221A1C),
onSurface = Color(0xFFF1DEE4),
surfaceVariant = Color(0xFF514346),
onSurfaceVariant = Color(0xFFD5C2C5),
outline = Color(0xFF9D8C8F),
outlineVariant = Color(0xFF514346),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFF1DEE4),
inverseOnSurface = Color(0xFF221A1C),
inversePrimary = Color(0xFFB94565),
surfaceDim = Color(0xFF191214),
surfaceBright = Color(0xFF41373A),
surfaceContainerLowest = Color(0xFF140D0F),
surfaceContainerLow = Color(0xFF221A1C),
surfaceContainer = Color(0xFF261E20),
surfaceContainerHigh = Color(0xFF31282A),
surfaceContainerHighest = Color(0xFF3C3335)
)
)

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SpaceTheme = AppTheme(
name = "Space Opera",
lightColors = ThemeColorSet(
primary = Color(0xFF3399FF), // Hologram Blue
secondary = Color(0xFFFFA500), // Engine Glow Orange
tertiary = Color(0xFFE0E0E0),
primaryContainer = Color(0xFFD7E8FF),
secondaryContainer = Color(0xFFFFECCF),
tertiaryContainer = Color(0xFFF0F0F0),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFF000000),
onTertiary = Color(0xFF000000),
onPrimaryContainer = Color(0xFF001D35),
onSecondaryContainer = Color(0xFF271A00),
onTertiaryContainer = Color(0xFF1F1F1F),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFF8F9FA), // Cockpit White
onBackground = Color(0xFF181C20),
surface = Color(0xFFF8F9FA),
onSurface = Color(0xFF181C20),
surfaceVariant = Color(0xFFDEE3EB),
onSurfaceVariant = Color(0xFF42474E),
outline = Color(0xFF72787E),
outlineVariant = Color(0xFFC2C7CE),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2D3135),
inverseOnSurface = Color(0xFFF0F2F5),
inversePrimary = Color(0xFFADC6FF),
surfaceDim = Color(0xFFD9DADD),
surfaceBright = Color(0xFFF8F9FA),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF2F3F5),
surfaceContainer = Color(0xFFECEEF0),
surfaceContainerHigh = Color(0xFFE6E8EA),
surfaceContainerHighest = Color(0xFFE1E3E5)
),
darkColors = ThemeColorSet(
primary = Color(0xFFADC6FF), // Nebula Blue
secondary = Color(0xFFFFB74D), // Thruster Orange
tertiary = Color(0xFFE0E0E0), // Starlight
primaryContainer = Color(0xFF004488),
secondaryContainer = Color(0xFF664200),
tertiaryContainer = Color(0xFF424242),
onPrimary = Color(0xFF002F54),
onSecondary = Color(0xFF3F2800),
onTertiary = Color(0xFF000000),
onPrimaryContainer = Color(0xFFD7E8FF),
onSecondaryContainer = Color(0xFFFFDDBF),
onTertiaryContainer = Color(0xFFFAFAFA),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF101418), // Deep Space
onBackground = Color(0xFFE2E2E6),
surface = Color(0xFF101418),
onSurface = Color(0xFFE2E2E6),
surfaceVariant = Color(0xFF42474E),
onSurfaceVariant = Color(0xFFC2C7CE),
outline = Color(0xFF8C9198),
outlineVariant = Color(0xFF42474E),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE2E2E6),
inverseOnSurface = Color(0xFF181C20),
inversePrimary = Color(0xFF3399FF),
surfaceDim = Color(0xFF101418),
surfaceBright = Color(0xFF363A3F),
surfaceContainerLowest = Color(0xFF0B0F13),
surfaceContainerLow = Color(0xFF181C20),
surfaceContainer = Color(0xFF1C2024),
surfaceContainerHigh = Color(0xFF272B2F),
surfaceContainerHighest = Color(0xFF32363A)
)
)

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val SynthwaveTheme = AppTheme(
name = "Synthwave '84",
lightColors = ThemeColorSet(
primary = Color(0xFFC50083), // Darker Magenta for light theme contrast
secondary = Color(0xFF006874), // Darker Teal
tertiary = Color(0xFF7A5900),
primaryContainer = Color(0xFFFFD8EC),
secondaryContainer = Color(0xFFB3F0FF),
tertiaryContainer = Color(0xFFFFE26E),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF40002A),
onSecondaryContainer = Color(0xFF001F24),
onTertiaryContainer = Color(0xFF261A00),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFDF7FF), // A very light lavender/off-white
onBackground = Color(0xFF1F1A21), // Dark Purple for text
surface = Color(0xFFFDF7FF),
onSurface = Color(0xFF1F1A21),
surfaceVariant = Color(0xFFE8E0F3),
onSurfaceVariant = Color(0xFF49454E),
outline = Color(0xFF7A757E),
outlineVariant = Color(0xFFCBC4CE),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF342F36),
inverseOnSurface = Color(0xFFF5EFF7),
inversePrimary = Color(0xFFF475CB), // The vibrant pink from dark theme
surfaceDim = Color(0xFFE0D8E2),
surfaceBright = Color(0xFFFDF7FF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF7F1FA),
surfaceContainer = Color(0xFFF1EBF4),
surfaceContainerHigh = Color(0xFFECE5EE),
surfaceContainerHighest = Color(0xFFE6E0E9)
),
darkColors = ThemeColorSet(
primary = Color(0xFFF475CB), // Vibrant Magenta
secondary = Color(0xFF6AD9E8), // Electric Cyan
tertiary = Color(0xFFFFD400), // Sunset Yellow
primaryContainer = Color(0xFF660044),
secondaryContainer = Color(0xFF005A66),
tertiaryContainer = Color(0xFF665500),
onPrimary = Color(0xFF50003A),
onSecondary = Color(0xFF00363D),
onTertiary = Color(0xFF352D00),
onPrimaryContainer = Color(0xFFFFD8EC),
onSecondaryContainer = Color(0xFFB3F0FF),
onTertiaryContainer = Color(0xFFFFE26E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A103C), // Deep Indigo
onBackground = Color(0xFFE0E5FF), // Pale Lavender Text
surface = Color(0xFF1A103C),
onSurface = Color(0xFFE0E5FF),
surfaceVariant = Color(0xFF49454E),
onSurfaceVariant = Color(0xFFCBC4CE),
outline = Color(0xFF948F99),
outlineVariant = Color(0xFF49454E),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE6E0E9),
inverseOnSurface = Color(0xFF342F36),
inversePrimary = Color(0xFFC50083),
surfaceDim = Color(0xFF151218),
surfaceBright = Color(0xFF3C383E),
surfaceContainerLowest = Color(0xFF100D13),
surfaceContainerLow = Color(0xFF1F1A21),
surfaceContainer = Color(0xFF231E25),
surfaceContainerHigh = Color(0xFF2E292F),
surfaceContainerHighest = Color(0xFF39333A)
)
)

View File

@@ -0,0 +1,85 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val TerracottaEarthTheme = AppTheme(
name = "Terracotta Earth",
lightColors = ThemeColorSet(
primary = Color(0xFFB85C38), // Terracotta
secondary = Color(0xFF8B7355), // Warm Sand
tertiary = Color(0xFF6B8E6B), // Muted Olive
primaryContainer = Color(0xFFFFDCC8),
secondaryContainer = Color(0xFFEDE0D0),
tertiaryContainer = Color(0xFFE0F0E0),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onPrimaryContainer = Color(0xFF3D1700),
onSecondaryContainer = Color(0xFF2C1F0D),
onTertiaryContainer = Color(0xFF1F3D1F),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFFFBF8),
onBackground = Color(0xFF231917),
surface = Color(0xFFFFFBF8),
onSurface = Color(0xFF231917),
surfaceVariant = Color(0xFFF5E0D8),
onSurfaceVariant = Color(0xFF53433F),
outline = Color(0xFF85736E),
outlineVariant = Color(0xFFD8C2BB),
scrim = Color.Black,
inverseSurface = Color(0xFF382E2B),
inverseOnSurface = Color(0xFFFFEDE8),
inversePrimary = Color(0xFFFFB599),
surfaceDim = Color(0xFFE8D6D1),
surfaceBright = Color(0xFFFFFBF8),
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color(0xFFFFF1ED),
surfaceContainer = Color(0xFFFBEBE7),
surfaceContainerHigh = Color(0xFFF5E5E1),
surfaceContainerHighest = Color(0xFFF0E0DB)
),
darkColors = ThemeColorSet(
primary = Color(0xFFFFB599), // Soft Peach
secondary = Color(0xFFD4C4B0), // Warm Beige
tertiary = Color(0xFFB8D4B8), // Soft Olive
primaryContainer = Color(0xFF8B4020),
secondaryContainer = Color(0xFF5C4A3A),
tertiaryContainer = Color(0xFF3D5C3D),
onPrimary = Color(0xFF5D2A00),
onSecondary = Color(0xFF3D2A1A),
onTertiary = Color(0xFF1D3D1D),
onPrimaryContainer = Color(0xFFFFDCC8),
onSecondaryContainer = Color(0xFFEDE0D0),
onTertiaryContainer = Color(0xFFE0F0E0),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1A110F),
onBackground = Color(0xFFF0E0DB),
surface = Color(0xFF1A110F),
onSurface = Color(0xFFF0E0DB),
surfaceVariant = Color(0xFF53433F),
onSurfaceVariant = Color(0xFFD8C2BB),
outline = Color(0xFFA08C87),
outlineVariant = Color(0xFF53433F),
scrim = Color.Black,
inverseSurface = Color(0xFFF0E0DB),
inverseOnSurface = Color(0xFF382E2B),
inversePrimary = Color(0xFFB85C38),
surfaceDim = Color(0xFF1A110F),
surfaceBright = Color(0xFF423734),
surfaceContainerLowest = Color(0xFF140C0A),
surfaceContainerLow = Color(0xFF231917),
surfaceContainer = Color(0xFF271D1B),
surfaceContainerHigh = Color(0xFF322825),
surfaceContainerHighest = Color(0xFF3D322F)
)
)

View File

@@ -1,85 +0,0 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.ui.theme.themes
import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.AppTheme
import eu.gaudian.translator.ui.theme.ThemeColorSet
val TwilightSerenityTheme = AppTheme(
name = "Twilight Serenity",
lightColors = ThemeColorSet(
primary = Color(0xFF5A52A5),
secondary = Color(0xFF9A4555),
tertiary = Color(0xFF7A5900),
primaryContainer = Color(0xFFE2DFFF),
secondaryContainer = Color(0xFFFFD9DD),
tertiaryContainer = Color(0xFFFFDF9E),
onPrimary = Color(0xFFFFFFFF),
onSecondary = Color(0xFFFFFFFF),
onTertiary = Color(0xFFFFFFFF),
onPrimaryContainer = Color(0xFF16035F),
onSecondaryContainer = Color(0xFF400014),
onTertiaryContainer = Color(0xFF261A00),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
background = Color(0xFFFEFBFF),
onBackground = Color(0xFF1C1B20),
surface = Color(0xFFFEFBFF),
onSurface = Color(0xFF1C1B20),
surfaceVariant = Color(0xFFE5E0EC),
onSurfaceVariant = Color(0xFF47454E),
outline = Color(0xFF78757F),
outlineVariant = Color(0xFFC8C4CF),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF313035),
inverseOnSurface = Color(0xFFF3EFF6),
inversePrimary = Color(0xFFC1C1FF),
surfaceDim = Color(0xFFDED9E0),
surfaceBright = Color(0xFFFEFBFF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF8F2FA),
surfaceContainer = Color(0xFFF2ECF4),
surfaceContainerHigh = Color(0xFFECE7EF),
surfaceContainerHighest = Color(0xFFE6E1E9)
),
darkColors = ThemeColorSet(
primary = Color(0xFFC1C1FF),
secondary = Color(0xFFFFB1BB),
tertiary = Color(0xFFF5BF48),
primaryContainer = Color(0xFF413A8C),
secondaryContainer = Color(0xFF7C2B3E),
tertiaryContainer = Color(0xFF5C4300),
onPrimary = Color(0xFF2C2275),
onSecondary = Color(0xFF5F1328),
onTertiary = Color(0xFF402D00),
onPrimaryContainer = Color(0xFFE2DFFF),
onSecondaryContainer = Color(0xFFFFD9DD),
onTertiaryContainer = Color(0xFFFFDF9E),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF1C1B20),
onBackground = Color(0xFFE6E1E9),
surface = Color(0xFF1C1B20),
onSurface = Color(0xFFE6E1E9),
surfaceVariant = Color(0xFF47454E),
onSurfaceVariant = Color(0xFFC8C4CF),
outline = Color(0xFF928F99),
outlineVariant = Color(0xFF47454E),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE6E1E9),
inverseOnSurface = Color(0xFF313035),
inversePrimary = Color(0xFF5A52A5),
surfaceDim = Color(0xFF141317),
surfaceBright = Color(0xFF3A383E),
surfaceContainerLowest = Color(0xFF0F0E12),
surfaceContainerLow = Color(0xFF1C1B20),
surfaceContainer = Color(0xFF201F24),
surfaceContainerHigh = Color(0xFF2B292F),
surfaceContainerHighest = Color(0xFF36343A)
)
)

View File

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

View File

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

View File

@@ -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<String?>(null) }
var selectedConflictStrategy by remember { mutableStateOf(ConflictStrategy.MERGE) }
// Export options
val selectedCategories = remember { mutableStateListOf<Int>() }
// 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<List<List<String>>>(emptyList()) }
var selectedColFirst by remember { mutableIntStateOf(0) }
@@ -90,7 +173,6 @@ fun VocabularyRepositoryOptionsScreen(
fun parseCsv(text: String): List<List<String>> {
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(
)
}
}
}
}
@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
)
}
}
}
}

View File

@@ -3,6 +3,7 @@
package eu.gaudian.translator.view.vocabulary
import android.annotation.SuppressLint
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -12,22 +13,29 @@ 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.rememberLazyListState
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.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -46,16 +54,18 @@ import eu.gaudian.translator.view.composable.AppIcons
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.dialogs.DeleteCategoryDialog
import eu.gaudian.translator.view.dialogs.DeleteItemsDialog
import eu.gaudian.translator.view.dialogs.EditCategoryDialog
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle
import eu.gaudian.translator.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 +81,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 +129,50 @@ 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 */ }
}
}
// Scroll state for animation
val listState = rememberLazyListState()
var isHeaderVisible by remember { mutableStateOf(true) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
// Detect scroll direction to show/hide header (same as LibraryScreen)
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.collect { (index, offset) ->
val isScrollingDown = index > previousIndex || (index == previousIndex && offset > previousScrollOffset)
val isAtTop = index == 0 && offset <= 4
isHeaderVisible = if (isAtTop) true else !isScrollingDown
previousIndex = index
previousScrollOffset = offset
}
}
AppScaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
@@ -137,21 +193,51 @@ 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
},
leadingIcon = { Icon(AppIcons.Delete, contentDescription = null) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.label_edit)) },
onClick = {
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
showMenu = false
},
leadingIcon = { Icon(AppIcons.Edit, contentDescription = null) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.label_delete)) },
onClick = {
categoryViewModel.setShowDeleteCategoryDialog(true, categoryId)
showMenu = false
},
leadingIcon = { Icon(AppIcons.Delete, contentDescription = null) }
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -159,22 +245,28 @@ fun CategoryDetailScreen(
)
)
// Category Header Card with Progress and Action Buttons
CategoryHeaderCard(
subtitle = subtitle,
categoryProgress = categoryProgress,
onStartExerciseClick = {
val categories = listOf(category)
val categoryIds = categories.joinToString(",") { it?.id.toString() }
navController.navigate("vocabulary_exercise/false?categories=$categoryIds")
},
onEditClick = {
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
},
onDeleteClick = {
categoryViewModel.setShowDeleteCategoryDialog(true, categoryId)
}
)
// Category Header Card with Progress and Action Buttons (animated)
androidx.compose.animation.AnimatedVisibility(
visible = isHeaderVisible,
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically()
) {
CategoryHeaderCard(
subtitle = subtitle,
categoryProgress = categoryProgress,
onStartExerciseClick = {
val categories = listOf(category)
val categoryIds = categories.joinToString(",") { it?.id.toString() }
navController.navigate("vocabulary_exercise/false?categories=$categoryIds")
},
onEditClick = {
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
},
onDeleteClick = {
categoryViewModel.setShowDeleteCategoryDialog(true, categoryId)
}
)
}
}
}
) { paddingValues ->
@@ -186,7 +278,8 @@ fun CategoryDetailScreen(
navController = navController,
isRemoveFromCategoryEnabled = category is TagCategory,
showTopBar = false,
enableNavigationButtons = true
enableNavigationButtons = true,
listState = listState
)
// Dialogs
@@ -225,12 +318,13 @@ fun CategoryHeaderCard(
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = 16.dp, vertical = 16.dp),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
@@ -249,22 +343,22 @@ fun CategoryHeaderCard(
)
}
// Progress Circle
// Progress Circle - smaller size
if (categoryProgress != null) {
CategoryProgressCircle(
totalItems = categoryProgress.totalItems,
itemsCompleted = categoryProgress.itemsCompleted,
itemsInStages = categoryProgress.itemsInStages,
newItems = categoryProgress.newItems,
circleSize = 120.dp,
circleSize = 100.dp,
)
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(20.dp))
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Start Exercise Button (Primary)
PrimaryButton(
@@ -274,30 +368,6 @@ fun CategoryHeaderCard(
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(12.dp))
// Secondary Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Edit Button
SecondaryButton(
text = stringResource(R.string.label_edit),
icon = AppIcons.Edit,
onClick = onEditClick,
modifier = Modifier.weight(1f)
)
// Delete Button
SecondaryButton(
text = stringResource(R.string.label_delete),
icon = AppIcons.Delete,
onClick = onDeleteClick,
modifier = Modifier.weight(1f)
)
}
}
}
}

View File

@@ -100,7 +100,7 @@ fun CategoryListScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = "TODO",
title = stringResource(R.string.label_all_categories),
navigationIcon = {
if (isSelectionMode) {
IconButton(onClick = {

View File

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

View File

@@ -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
@@ -104,12 +105,14 @@ fun AllCardsListScreen(
itemsToShow: List<VocabularyItem> = emptyList(),
isRemoveFromCategoryEnabled: Boolean = false,
showTopBar: Boolean = true,
enableNavigationButtons: Boolean = false
enableNavigationButtons: Boolean = false,
listState: androidx.compose.foundation.lazy.LazyListState = rememberLazyListState()
) {
val scope = rememberCoroutineScope()
val activity = LocalContext.current.findActivity()
val lazyListState = rememberLazyListState()
val lazyListState = listState
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 +147,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<List<VocabularyItem>> = remember(filterState) {
vocabularyViewModel.filterVocabularyItems(
@@ -161,6 +165,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 +233,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 +521,7 @@ private fun ContextualTopAppBar(
onDeleteClick: () -> Unit,
onMoveToCategoryClick: () -> Unit,
onMoveToStageClick: () -> Unit,
onExportClick: () -> Unit,
onRemoveFromCategoryClick: () -> Unit
) {
var showOverflowMenu by remember { mutableStateOf(false) }
@@ -534,6 +554,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 +613,7 @@ fun ContextualTopAppBarPreview() {
onDeleteClick = {},
onMoveToCategoryClick = {},
onMoveToStageClick = {},
onExportClick = {},
onRemoveFromCategoryClick = {}
)
}

View File

@@ -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>(ExportState.Idle)
val exportState: StateFlow<ExportState> = _exportState.asStateFlow()
// UI State for import operations
private val _importState = MutableStateFlow<ImportState>(ImportState.Idle)
val importState: StateFlow<ImportState> = _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<Int>, 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<Application>()
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()
}

View File

@@ -335,7 +335,6 @@ class VocabularyViewModel @Inject constructor(
}
fun deleteVocabularyItemsById(list: List<Int>) {
Log.d(TAG, "Deleting vocabulary items with IDs: $list")
viewModelScope.launch {
@@ -407,18 +406,116 @@ class VocabularyViewModel @Inject constructor(
}
/**
* Handles the merging of two duplicate vocabulary items.
* Placeholder logic: Deletes the new item, effectively keeping the original.
* A full implementation would merge properties like stage, categories, and features.
* Handles the merging of two duplicate vocabulary items intelligently.
* Merges learning progress (stages, counts), categories, and features.
* Keeps the existing item and updates it with merged data.
*
* @param newItem The duplicate item to merge (will be deleted)
* @param existingItem The original item to keep and update
*/
fun mergeDuplicateItems(newItem: VocabularyItem, existingItem: VocabularyItem) {
Log.d(TAG, "mergeDuplicateItems: Merging ${newItem.id} into ${existingItem.id}. (Placeholder logic)")
//TODO
// Placeholder: For now, this just deletes the new item.
// A full implementation would merge stages, categories, etc., and update the existing item based on the rules.
Log.d(TAG, "mergeDuplicateItems: Intelligently merging ${newItem.id} into ${existingItem.id}")
viewModelScope.launch {
statusService.showSuccessMessage("Items merged!", 2)
deleteVocabularyItemsById(listOf(newItem.id))
try {
// 1. Get states for both items
val newState = vocabularyRepository.getVocabularyItemStateById(newItem.id)
val existingState = vocabularyRepository.getVocabularyItemStateById(existingItem.id)
// 2. Get stages for both items
val newStage = vocabularyRepository.getVocabularyItemStage(newItem.id).first()
val existingStage = vocabularyRepository.getVocabularyItemStage(existingItem.id).first()
// 3. Merge learning progress - keep the better stage (higher in progression)
val stageOrder = listOf(
VocabularyStage.NEW, VocabularyStage.STAGE_1, VocabularyStage.STAGE_2,
VocabularyStage.STAGE_3, VocabularyStage.STAGE_4, VocabularyStage.STAGE_5,
VocabularyStage.LEARNED
)
val mergedStage = if (stageOrder.indexOf(newStage) > stageOrder.indexOf(existingStage)) {
newStage
} else {
existingStage
}
// 4. Merge answer counts and timestamps - keep higher counts and most recent timestamps
val mergedCorrectCount = (newState?.correctAnswerCount ?: 0) + (existingState?.correctAnswerCount ?: 0)
val mergedIncorrectCount = (newState?.incorrectAnswerCount ?: 0) + (existingState?.incorrectAnswerCount ?: 0)
val mergedLastCorrect = listOfNotNull(
newState?.lastCorrectAnswer,
existingState?.lastCorrectAnswer
).maxOrNull()
val mergedLastIncorrect = listOfNotNull(
newState?.lastIncorrectAnswer,
existingState?.lastIncorrectAnswer
).maxOrNull()
// 5. Merge features - prefer non-null, non-empty features
val mergedFeatures = when {
!existingItem.features.isNullOrBlank() -> existingItem.features
!newItem.features.isNullOrBlank() -> newItem.features
else -> null
}
// 6. Merge zipf frequencies - prefer non-null values
val mergedZipfFirst = existingItem.zipfFrequencyFirst ?: newItem.zipfFrequencyFirst
val mergedZipfSecond = existingItem.zipfFrequencySecond ?: newItem.zipfFrequencySecond
// 7. Keep the earlier creation date
val mergedCreatedAt = listOfNotNull(
newItem.createdAt,
existingItem.createdAt
).minOrNull() ?: existingItem.createdAt
// 8. Get categories for both items and merge them
val allMappings = vocabularyRepository.getCategoryMappings()
val newCategories = allMappings.filter { it.vocabularyItemId == newItem.id }.map { it.categoryId }
val existingCategories = allMappings.filter { it.vocabularyItemId == existingItem.id }.map { it.categoryId }
val mergedCategories = (newCategories + existingCategories).distinct()
// 9. Update the existing item with merged data
val updatedItem = existingItem.copy(
features = mergedFeatures,
zipfFrequencyFirst = mergedZipfFirst,
zipfFrequencySecond = mergedZipfSecond,
createdAt = mergedCreatedAt
)
vocabularyRepository.editVocabularyItem(updatedItem)
// 10. Update stage if it changed
if (mergedStage != existingStage) {
vocabularyRepository.changeVocabularyItemStage(updatedItem, mergedStage)
}
// 11. Update state with merged progress
val mergedState = eu.gaudian.translator.model.VocabularyItemState(
vocabularyItemId = existingItem.id,
lastCorrectAnswer = mergedLastCorrect,
lastIncorrectAnswer = mergedLastIncorrect,
correctAnswerCount = mergedCorrectCount,
incorrectAnswerCount = mergedIncorrectCount
)
vocabularyRepository.saveVocabularyItemState(mergedState)
// 12. Add any new categories to the existing item
val categoriesToAdd = mergedCategories - existingCategories.toSet()
categoriesToAdd.forEach { categoryId ->
vocabularyRepository.addVocabularyItemToList(existingItem.id, categoryId)
}
// 13. Finally, delete the duplicate item
deleteVocabularyItemsById(listOf(newItem.id))
statusService.showSuccessMessage("Items merged successfully! Progress and categories preserved.", 3)
Log.i(TAG, "mergeDuplicateItems: Successfully merged ${newItem.id} into ${existingItem.id}. " +
"Stage: $existingStage$mergedStage, Correct: ${existingState?.correctAnswerCount ?: 0}$mergedCorrectCount, " +
"Categories: ${existingCategories.size}${mergedCategories.size}")
} catch (e: Exception) {
statusService.showErrorMessage("Error merging items: ${e.message}")
Log.e(TAG, "Error in mergeDuplicateItems: ${e.message}")
}
}
}

View File

@@ -1113,4 +1113,5 @@
<string name="cd_searchh">Search</string>
<string name="label_search_cards">Search cards</string>
<string name="label_learnedd">learned</string>
<string name="label_all_categoriess">All Categories</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Cache directory for temporary export files -->
<cache-path name="exports" path="exports/" />
</paths>