Compare commits
6 Commits
64dcc5d0d5
...
ebfd097bf8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebfd097bf8 | ||
|
|
f2a6a58c05 | ||
|
|
3966901da2 | ||
|
|
3c1e71d805 | ||
|
|
ff77086ab1 | ||
|
|
dc4c62ef0b |
@@ -33,6 +33,16 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
69
app/src/main/assets/hints-de-rDE/api_key_hint.md
Normal file
69
app/src/main/assets/hints-de-rDE/api_key_hint.md
Normal 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.
|
||||||
40
app/src/main/assets/hints-de-rDE/category_hint.md
Normal file
40
app/src/main/assets/hints-de-rDE/category_hint.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -228,10 +228,7 @@ class ApiManager(private val context: Context) {
|
|||||||
val allowNoKey = provider.isCustom || isLocalHost
|
val allowNoKey = provider.isCustom || isLocalHost
|
||||||
val effectiveKey = if (key.isNotEmpty()) key else if (allowNoKey) "" else key
|
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) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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."),
|
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(
|
ApiProvider(
|
||||||
key = "xai",
|
key = "xai",
|
||||||
displayName = "xAI Grok",
|
displayName = "xAI Grok",
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ import java.io.File
|
|||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.security.MessageDigest
|
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.
|
* 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 {
|
fun getLocalVersion(fileId: String): String {
|
||||||
return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -5,9 +5,19 @@ package eu.gaudian.translator.model.repository
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.withTransaction
|
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.Language
|
||||||
|
import eu.gaudian.translator.model.SingleItemExport
|
||||||
|
import eu.gaudian.translator.model.StageMappingData
|
||||||
import eu.gaudian.translator.model.TagCategory
|
import eu.gaudian.translator.model.TagCategory
|
||||||
import eu.gaudian.translator.model.VocabularyCategory
|
import eu.gaudian.translator.model.VocabularyCategory
|
||||||
|
import eu.gaudian.translator.model.VocabularyExportData
|
||||||
import eu.gaudian.translator.model.VocabularyFilter
|
import eu.gaudian.translator.model.VocabularyFilter
|
||||||
import eu.gaudian.translator.model.VocabularyItem
|
import eu.gaudian.translator.model.VocabularyItem
|
||||||
import eu.gaudian.translator.model.VocabularyItemState
|
import eu.gaudian.translator.model.VocabularyItemState
|
||||||
@@ -45,6 +55,7 @@ import kotlinx.datetime.plus
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.math.max
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.ExperimentalTime
|
import kotlin.time.ExperimentalTime
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -625,7 +636,7 @@ class VocabularyRepository private constructor(context: Context) {
|
|||||||
|
|
||||||
suspend fun isTargetMetForDate(date: LocalDate): Boolean {
|
suspend fun isTargetMetForDate(date: LocalDate): Boolean {
|
||||||
val dailyCorrectCount = getDailyCorrectCount(date)
|
val dailyCorrectCount = getDailyCorrectCount(date)
|
||||||
val target = 10 // TODO: Replace with settingsRepository.dailyGoal.flow.first()
|
val target = settingsRepository.dailyGoal.flow.first()
|
||||||
return dailyCorrectCount >= target
|
return dailyCorrectCount >= target
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,6 +807,594 @@ class VocabularyRepository private constructor(context: Context) {
|
|||||||
}
|
}
|
||||||
Log.d(TAG, "--- END REPOSITORY STATE ---")
|
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
|
@Serializable
|
||||||
|
|||||||
@@ -5,22 +5,22 @@ import androidx.compose.material3.darkColorScheme
|
|||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import eu.gaudian.translator.ui.theme.themes.AutumnSpiceTheme
|
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.CoffeeTheme
|
||||||
import eu.gaudian.translator.ui.theme.themes.CrimsonTheme
|
|
||||||
import eu.gaudian.translator.ui.theme.themes.CyberpunkTheme
|
import eu.gaudian.translator.ui.theme.themes.CyberpunkTheme
|
||||||
import eu.gaudian.translator.ui.theme.themes.DebugTheme
|
import eu.gaudian.translator.ui.theme.themes.DebugTheme
|
||||||
import eu.gaudian.translator.ui.theme.themes.DefaultTheme
|
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.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.NordTheme
|
||||||
import eu.gaudian.translator.ui.theme.themes.OceanicCalmTheme
|
import eu.gaudian.translator.ui.theme.themes.OceanicCalmTheme
|
||||||
import eu.gaudian.translator.ui.theme.themes.PixelTheme
|
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.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.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).
|
* 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(
|
val AllThemes = listOf(
|
||||||
|
|
||||||
DefaultTheme,
|
DefaultTheme,
|
||||||
PixelTheme,
|
PixelTheme,
|
||||||
CrimsonTheme,
|
|
||||||
SakuraTheme,
|
|
||||||
AutumnSpiceTheme,
|
AutumnSpiceTheme,
|
||||||
TealTheme,
|
TealTheme,
|
||||||
ForestTheme,
|
ForestTheme,
|
||||||
CoffeeTheme,
|
CoffeeTheme,
|
||||||
CitrusSplashTheme,
|
|
||||||
OceanicCalmTheme,
|
OceanicCalmTheme,
|
||||||
SlateAndStoneTheme,
|
SlateAndStoneTheme,
|
||||||
NordTheme,
|
NordTheme,
|
||||||
TwilightSerenityTheme,
|
|
||||||
SpaceTheme,
|
|
||||||
CyberpunkTheme,
|
CyberpunkTheme,
|
||||||
SynthwaveTheme,
|
|
||||||
DebugTheme,
|
DebugTheme,
|
||||||
|
LavenderDreamTheme,
|
||||||
|
SageGardenTheme,
|
||||||
|
MossStoneTheme,
|
||||||
|
ElectricVioletTheme,
|
||||||
|
NeonPulseTheme,
|
||||||
|
TerracottaEarthTheme,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -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),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -118,6 +118,7 @@ fun SelectionTopBar(
|
|||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
onMoveToCategoryClick: () -> Unit,
|
onMoveToCategoryClick: () -> Unit,
|
||||||
onMoveToStageClick: () -> Unit,
|
onMoveToStageClick: () -> Unit,
|
||||||
|
onExportClick: () -> Unit,
|
||||||
isRemoveEnabled: Boolean,
|
isRemoveEnabled: Boolean,
|
||||||
onRemoveFromCategoryClick: () -> Unit,
|
onRemoveFromCategoryClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
@@ -159,6 +160,14 @@ fun SelectionTopBar(
|
|||||||
expanded = showOverflowMenu,
|
expanded = showOverflowMenu,
|
||||||
onDismissRequest = { showOverflowMenu = false }
|
onDismissRequest = { showOverflowMenu = false }
|
||||||
) {
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Export Selected") },
|
||||||
|
onClick = {
|
||||||
|
onExportClick()
|
||||||
|
showOverflowMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) }
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.move_to_category)) },
|
text = { Text(stringResource(R.string.move_to_category)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -651,6 +660,7 @@ fun SelectionTopBarPreview() {
|
|||||||
onDeleteClick = {},
|
onDeleteClick = {},
|
||||||
onMoveToCategoryClick = {},
|
onMoveToCategoryClick = {},
|
||||||
onMoveToStageClick = {},
|
onMoveToStageClick = {},
|
||||||
|
onExportClick = {},
|
||||||
isRemoveEnabled = true,
|
isRemoveEnabled = true,
|
||||||
onRemoveFromCategoryClick = {}
|
onRemoveFromCategoryClick = {}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import eu.gaudian.translator.view.composable.MultipleLanguageDropdown
|
|||||||
import eu.gaudian.translator.view.dialogs.AddCategoryDialog
|
import eu.gaudian.translator.view.dialogs.AddCategoryDialog
|
||||||
import eu.gaudian.translator.view.dialogs.CategorySelectionDialog
|
import eu.gaudian.translator.view.dialogs.CategorySelectionDialog
|
||||||
import eu.gaudian.translator.view.dialogs.StageSelectionDialog
|
import eu.gaudian.translator.view.dialogs.StageSelectionDialog
|
||||||
|
import eu.gaudian.translator.viewmodel.ExportImportViewModel
|
||||||
import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
|
import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
|
||||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||||
@@ -97,11 +98,14 @@ fun LibraryScreen(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = LocalContext.current
|
||||||
val activity = LocalContext.current.findActivity()
|
val activity = LocalContext.current.findActivity()
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
|
||||||
|
|
||||||
var filterState by rememberSaveable { mutableStateOf(LibraryFilterState()) }
|
var filterState by rememberSaveable { mutableStateOf(LibraryFilterState()) }
|
||||||
var showFilterSheet by remember { mutableStateOf(false) }
|
var showFilterSheet by remember { mutableStateOf(false) }
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
@@ -119,6 +123,7 @@ fun LibraryScreen(
|
|||||||
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
|
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet())
|
val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet())
|
||||||
val categories by vocabularyViewModel.categories.collectAsStateWithLifecycle()
|
val categories by vocabularyViewModel.categories.collectAsStateWithLifecycle()
|
||||||
|
val exportState by exportImportViewModel.exportState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val vocabularyItemsFlow = remember(filterState) {
|
val vocabularyItemsFlow = remember(filterState) {
|
||||||
vocabularyViewModel.filterVocabularyItems(
|
vocabularyViewModel.filterVocabularyItems(
|
||||||
@@ -134,6 +139,16 @@ fun LibraryScreen(
|
|||||||
|
|
||||||
val vocabularyItems by vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
|
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 isHeaderVisible by remember { mutableStateOf(true) }
|
||||||
var previousIndex by remember { mutableIntStateOf(0) }
|
var previousIndex by remember { mutableIntStateOf(0) }
|
||||||
var previousScrollOffset by remember { mutableIntStateOf(0) }
|
var previousScrollOffset by remember { mutableIntStateOf(0) }
|
||||||
@@ -195,6 +210,11 @@ fun LibraryScreen(
|
|||||||
},
|
},
|
||||||
onMoveToCategoryClick = { showCategoryDialog = true },
|
onMoveToCategoryClick = { showCategoryDialog = true },
|
||||||
onMoveToStageClick = { showStageDialog = true },
|
onMoveToStageClick = { showStageDialog = true },
|
||||||
|
onExportClick = {
|
||||||
|
val selectedIds = selection.map { it.toInt() }
|
||||||
|
exportImportViewModel.exportItemList(selectedIds)
|
||||||
|
selection = emptySet()
|
||||||
|
},
|
||||||
isRemoveEnabled = false,
|
isRemoveEnabled = false,
|
||||||
onRemoveFromCategoryClick = {}
|
onRemoveFromCategoryClick = {}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,84 +1,167 @@
|
|||||||
@file:Suppress("AssignedValueIsNeverRead")
|
@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
|
||||||
|
|
||||||
package eu.gaudian.translator.view.settings
|
package eu.gaudian.translator.view.settings
|
||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Divider
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
|
import eu.gaudian.translator.model.ConflictStrategy
|
||||||
import eu.gaudian.translator.model.Language
|
import eu.gaudian.translator.model.Language
|
||||||
import eu.gaudian.translator.utils.StatusMessageId
|
import eu.gaudian.translator.utils.StatusMessageId
|
||||||
import eu.gaudian.translator.utils.StatusMessageService
|
import eu.gaudian.translator.utils.StatusMessageService
|
||||||
import eu.gaudian.translator.utils.findActivity
|
import eu.gaudian.translator.utils.findActivity
|
||||||
import eu.gaudian.translator.view.composable.AppButton
|
import eu.gaudian.translator.view.composable.AppButton
|
||||||
import eu.gaudian.translator.view.composable.AppCard
|
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.AppOutlinedButton
|
||||||
import eu.gaudian.translator.view.composable.AppScaffold
|
import eu.gaudian.translator.view.composable.AppScaffold
|
||||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
import eu.gaudian.translator.view.composable.PrimaryButton
|
import eu.gaudian.translator.view.composable.PrimaryButton
|
||||||
import eu.gaudian.translator.view.composable.SecondaryButton
|
import eu.gaudian.translator.view.composable.SecondaryButton
|
||||||
import eu.gaudian.translator.view.composable.SingleLanguageDropDown
|
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.LanguageViewModel
|
||||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VocabularyRepositoryOptionsScreen(
|
fun VocabularyRepositoryOptionsScreen(
|
||||||
navController: NavController
|
navController: NavController
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val activity = LocalContext.current.findActivity()
|
val activity = LocalContext.current.findActivity()
|
||||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
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 statusMessageService = StatusMessageService
|
||||||
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
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(
|
val importFileLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
onResult = { uri ->
|
onResult = { uri ->
|
||||||
uri?.let {
|
uri?.let {
|
||||||
context.contentResolver.openInputStream(it)?.use { inputStream ->
|
context.contentResolver.openInputStream(it)?.use { inputStream ->
|
||||||
val jsonString = inputStream.bufferedReader().use { reader -> reader.readText() }
|
val jsonString = inputStream.bufferedReader().use { reader -> reader.readText() }
|
||||||
vocabularyViewModel.importVocabulary(jsonString)
|
pendingImportJson = jsonString
|
||||||
statusMessageService.showInfoMessage(repositoryStateImportedFrom + " " +it.path)
|
showConflictStrategyDialog = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// CSV/Excel import state
|
// CSV/Excel import state
|
||||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
|
||||||
val showTableImportDialog = remember { mutableStateOf(false) }
|
val showTableImportDialog = remember { mutableStateOf(false) }
|
||||||
var parsedTable by remember { mutableStateOf<List<List<String>>>(emptyList()) }
|
var parsedTable by remember { mutableStateOf<List<List<String>>>(emptyList()) }
|
||||||
var selectedColFirst by remember { mutableIntStateOf(0) }
|
var selectedColFirst by remember { mutableIntStateOf(0) }
|
||||||
@@ -90,7 +173,6 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
|
|
||||||
fun parseCsv(text: String): List<List<String>> {
|
fun parseCsv(text: String): List<List<String>> {
|
||||||
if (text.isBlank()) return emptyList()
|
if (text.isBlank()) return emptyList()
|
||||||
// Detect delimiter by highest occurrence among comma, semicolon, tab
|
|
||||||
val candidates = listOf(',', ';', '\t')
|
val candidates = listOf(',', ';', '\t')
|
||||||
val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList()
|
val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList()
|
||||||
val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } }
|
val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } }
|
||||||
@@ -106,14 +188,13 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
'"' -> {
|
'"' -> {
|
||||||
if (inQuotes && i + 1 < text.length && text[i + 1] == '"') {
|
if (inQuotes && i + 1 < text.length && text[i + 1] == '"') {
|
||||||
current.append('"')
|
current.append('"')
|
||||||
i++ // skip escaped quote
|
i++
|
||||||
} else {
|
} else {
|
||||||
inQuotes = !inQuotes
|
inQuotes = !inQuotes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
'\r' -> { /* ignore, handle on \n */ }
|
'\r' -> { /* ignore */ }
|
||||||
'\n' -> {
|
'\n' -> {
|
||||||
// end of line
|
|
||||||
val field = current.toString()
|
val field = current.toString()
|
||||||
current = StringBuilder()
|
current = StringBuilder()
|
||||||
currentRow.add(if (inQuotes) field else field)
|
currentRow.add(if (inQuotes) field else field)
|
||||||
@@ -133,12 +214,10 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
}
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
// flush last field/row if any
|
|
||||||
if (current.isNotEmpty() || currentRow.isNotEmpty()) {
|
if (current.isNotEmpty() || currentRow.isNotEmpty()) {
|
||||||
currentRow.add(current.toString())
|
currentRow.add(current.toString())
|
||||||
rows.add(currentRow.toList())
|
rows.add(currentRow.toList())
|
||||||
}
|
}
|
||||||
// Normalize: trim and drop trailing empty columns
|
|
||||||
return rows.map { row ->
|
return rows.map { row ->
|
||||||
row.map { it.trim().trim('"') }
|
row.map { it.trim().trim('"') }
|
||||||
}.filter { r -> r.any { it.isNotBlank() } }
|
}.filter { r -> r.any { it.isNotBlank() } }
|
||||||
@@ -193,8 +272,8 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
vocabularyViewModel.initializeSaveFileLauncher(exportFileLauncher)
|
vocabularyViewModel.initializeSaveFileLauncher(exportFileLauncher)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
AppScaffold(
|
AppScaffold(
|
||||||
|
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
AppTopAppBar(
|
AppTopAppBar(
|
||||||
title = stringResource(R.string.vocabulary_repository),
|
title = stringResource(R.string.vocabulary_repository),
|
||||||
@@ -209,31 +288,95 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
|
// Export Section
|
||||||
item {
|
item {
|
||||||
// Backup and Restore Section
|
|
||||||
AppCard {
|
AppCard {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.label_backup_and_restore),
|
text = "Export Vocabulary",
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
|
if (exportState is ExportState.Loading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
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(
|
PrimaryButton(
|
||||||
onClick = { vocabularyViewModel.saveRepositoryState() },
|
onClick = { exportImportViewModel.exportFullRepository() },
|
||||||
text = stringResource(R.string.export_vocabulary_data),
|
text = "Export Complete Repository",
|
||||||
modifier = Modifier.fillMaxWidth()
|
icon = AppIcons.Download,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = exportState !is ExportState.Loading
|
||||||
)
|
)
|
||||||
|
|
||||||
SecondaryButton(
|
SecondaryButton(
|
||||||
onClick = { importFileLauncher.launch(arrayOf("application/json")) },
|
onClick = { showExportDialog = true },
|
||||||
text = stringResource(R.string.import_vocabulary_data),
|
text = "Export Selected Categories",
|
||||||
modifier = Modifier.fillMaxWidth()
|
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(
|
SecondaryButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
// Allow CSV and Excel mime types, but we only support CSV parsing in-app
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
importTableLauncher.launch(
|
importTableLauncher.launch(
|
||||||
arrayOf(
|
arrayOf(
|
||||||
"text/csv",
|
"text/csv",
|
||||||
@@ -246,11 +389,43 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
text = stringResource(R.string.label_import_table_csv_excel),
|
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 {
|
item {
|
||||||
AppCard {
|
AppCard {
|
||||||
Column(
|
Column(
|
||||||
@@ -263,7 +438,7 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
|
|
||||||
val showConfirm = androidx.compose.runtime.remember { mutableStateOf(false) }
|
val showConfirm = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
AppButton(
|
AppButton(
|
||||||
onClick = { showConfirm.value = true },
|
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) {
|
if (showTableImportDialog.value) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showTableImportDialog.value = false },
|
onDismissRequest = { showTableImportDialog.value = false },
|
||||||
@@ -312,7 +665,6 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
|
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
|
||||||
// Column selectors
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
|
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
|
||||||
var menu1Expanded by remember { mutableStateOf(false) }
|
var menu1Expanded by remember { mutableStateOf(false) }
|
||||||
@@ -341,7 +693,6 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Language selectors
|
|
||||||
Text(stringResource(R.string.label_languages))
|
Text(stringResource(R.string.label_languages))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
@@ -361,13 +712,11 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Header toggle
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
|
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(stringResource(R.string.label_header_row))
|
Text(stringResource(R.string.label_header_row))
|
||||||
}
|
}
|
||||||
// Previews
|
|
||||||
val startIdx = if (skipHeader) 1 else 0
|
val startIdx = if (skipHeader) 1 else 0
|
||||||
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
|
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
|
||||||
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
|
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
|
||||||
@@ -425,3 +774,54 @@ 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary
|
package eu.gaudian.translator.view.vocabulary
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.AppScaffold
|
||||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
import eu.gaudian.translator.view.composable.PrimaryButton
|
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.DeleteCategoryDialog
|
||||||
import eu.gaudian.translator.view.dialogs.DeleteItemsDialog
|
import eu.gaudian.translator.view.dialogs.DeleteItemsDialog
|
||||||
import eu.gaudian.translator.view.dialogs.EditCategoryDialog
|
import eu.gaudian.translator.view.dialogs.EditCategoryDialog
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle
|
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle
|
||||||
import eu.gaudian.translator.viewmodel.CategoryProgress
|
import eu.gaudian.translator.viewmodel.CategoryProgress
|
||||||
import eu.gaudian.translator.viewmodel.CategoryViewModel
|
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.LanguageViewModel
|
||||||
import eu.gaudian.translator.viewmodel.ProgressViewModel
|
import eu.gaudian.translator.viewmodel.ProgressViewModel
|
||||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@SuppressLint("ContextCastToActivity")
|
@SuppressLint("ContextCastToActivity")
|
||||||
@Composable
|
@Composable
|
||||||
@@ -71,12 +81,16 @@ fun CategoryDetailScreen(
|
|||||||
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
val vocabularyViewModel: VocabularyViewModel = 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 category by categoryViewModel.getCategoryById(categoryId).collectAsState(initial = null)
|
||||||
val categoryProgressList by progressViewModel.categoryProgressList.collectAsState()
|
val categoryProgressList by progressViewModel.categoryProgressList.collectAsState()
|
||||||
val categoryProgress = categoryProgressList.find { it.vocabularyCategory.id == categoryId }
|
val categoryProgress = categoryProgressList.find { it.vocabularyCategory.id == categoryId }
|
||||||
|
val exportState by exportImportViewModel.exportState.collectAsState()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val title = when (val cat = category) {
|
val title = when (val cat = category) {
|
||||||
is TagCategory -> cat.name
|
is TagCategory -> cat.name
|
||||||
is VocabularyFilter -> cat.name
|
is VocabularyFilter -> cat.name
|
||||||
@@ -115,8 +129,50 @@ fun CategoryDetailScreen(
|
|||||||
val showDeleteItemsDialog by categoryViewModel.showDeleteItemsDialog.collectAsState(initial = false)
|
val showDeleteItemsDialog by categoryViewModel.showDeleteItemsDialog.collectAsState(initial = false)
|
||||||
val showEditCategoryDialog by categoryViewModel.showEditCategoryDialog.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(
|
AppScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
|
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
|
||||||
@@ -137,21 +193,51 @@ fun CategoryDetailScreen(
|
|||||||
modifier = Modifier.width(220.dp)
|
modifier = Modifier.width(220.dp)
|
||||||
) {
|
) {
|
||||||
DropdownMenuItem(
|
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 = {
|
onClick = {
|
||||||
vocabularyViewModel.saveCategory(categoryId)
|
exportImportViewModel.exportCategory(categoryId)
|
||||||
showMenu = false
|
showMenu = false
|
||||||
},
|
},
|
||||||
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) }
|
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) },
|
||||||
|
enabled = exportState !is ExportState.Loading
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.delete_items_category)) },
|
text = { Text("Delete Items") },
|
||||||
onClick = {
|
onClick = {
|
||||||
categoryViewModel.setShowDeleteItemsDialog(true, categoryId)
|
categoryViewModel.setShowDeleteItemsDialog(true, categoryId)
|
||||||
showMenu = false
|
showMenu = false
|
||||||
},
|
},
|
||||||
leadingIcon = { Icon(AppIcons.Delete, contentDescription = null) }
|
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(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
@@ -159,7 +245,12 @@ fun CategoryDetailScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Category Header Card with Progress and Action Buttons
|
// 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(
|
CategoryHeaderCard(
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
categoryProgress = categoryProgress,
|
categoryProgress = categoryProgress,
|
||||||
@@ -177,6 +268,7 @@ fun CategoryDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(modifier = Modifier.padding(paddingValues)) {
|
Column(modifier = Modifier.padding(paddingValues)) {
|
||||||
AllCardsListScreen(
|
AllCardsListScreen(
|
||||||
@@ -186,7 +278,8 @@ fun CategoryDetailScreen(
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
isRemoveFromCategoryEnabled = category is TagCategory,
|
isRemoveFromCategoryEnabled = category is TagCategory,
|
||||||
showTopBar = false,
|
showTopBar = false,
|
||||||
enableNavigationButtons = true
|
enableNavigationButtons = true,
|
||||||
|
listState = listState
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
@@ -225,12 +318,13 @@ fun CategoryHeaderCard(
|
|||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
colors = CardDefaults.cardColors(
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -249,22 +343,22 @@ fun CategoryHeaderCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress Circle
|
// Progress Circle - smaller size
|
||||||
if (categoryProgress != null) {
|
if (categoryProgress != null) {
|
||||||
CategoryProgressCircle(
|
CategoryProgressCircle(
|
||||||
totalItems = categoryProgress.totalItems,
|
totalItems = categoryProgress.totalItems,
|
||||||
itemsCompleted = categoryProgress.itemsCompleted,
|
itemsCompleted = categoryProgress.itemsCompleted,
|
||||||
itemsInStages = categoryProgress.itemsInStages,
|
itemsInStages = categoryProgress.itemsInStages,
|
||||||
newItems = categoryProgress.newItems,
|
newItems = categoryProgress.newItems,
|
||||||
circleSize = 120.dp,
|
circleSize = 100.dp,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action Buttons
|
// Action Buttons
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Start Exercise Button (Primary)
|
// Start Exercise Button (Primary)
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
@@ -274,30 +368,6 @@ fun CategoryHeaderCard(
|
|||||||
modifier = Modifier.weight(1f)
|
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ fun CategoryListScreen(
|
|||||||
AppScaffold(
|
AppScaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
AppTopAppBar(
|
AppTopAppBar(
|
||||||
title = "TODO",
|
title = stringResource(R.string.label_all_categories),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
|
|||||||
@@ -255,17 +255,7 @@ fun NewWordScreen(
|
|||||||
|
|
||||||
BottomActionCardsRow(
|
BottomActionCardsRow(
|
||||||
onImportCsvClick = {
|
onImportCsvClick = {
|
||||||
@Suppress("HardCodedStringLiteral")
|
navController.navigate("settings_vocabulary_repository_options")
|
||||||
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"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -770,7 +760,7 @@ fun BottomActionCardsRow(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.label_import_csv),
|
text = "Import Lists or CSV",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import eu.gaudian.translator.view.dialogs.CategorySelectionDialog
|
|||||||
import eu.gaudian.translator.view.dialogs.StageSelectionDialog
|
import eu.gaudian.translator.view.dialogs.StageSelectionDialog
|
||||||
import eu.gaudian.translator.view.library.AllCardsView
|
import eu.gaudian.translator.view.library.AllCardsView
|
||||||
import eu.gaudian.translator.viewmodel.CategoryViewModel
|
import eu.gaudian.translator.viewmodel.CategoryViewModel
|
||||||
|
import eu.gaudian.translator.viewmodel.ExportImportViewModel
|
||||||
import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
|
import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
|
||||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||||
@@ -104,12 +105,14 @@ fun AllCardsListScreen(
|
|||||||
itemsToShow: List<VocabularyItem> = emptyList(),
|
itemsToShow: List<VocabularyItem> = emptyList(),
|
||||||
isRemoveFromCategoryEnabled: Boolean = false,
|
isRemoveFromCategoryEnabled: Boolean = false,
|
||||||
showTopBar: Boolean = true,
|
showTopBar: Boolean = true,
|
||||||
enableNavigationButtons: Boolean = false
|
enableNavigationButtons: Boolean = false,
|
||||||
|
listState: androidx.compose.foundation.lazy.LazyListState = rememberLazyListState()
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val activity = LocalContext.current.findActivity()
|
val activity = LocalContext.current.findActivity()
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = listState
|
||||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -144,6 +147,7 @@ fun AllCardsListScreen(
|
|||||||
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
|
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet())
|
val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet())
|
||||||
val categoryNameFlow = remember(categoryId) { categoryId?.let { categoryViewModel.getCategoryById(it) } }
|
val categoryNameFlow = remember(categoryId) { categoryId?.let { categoryViewModel.getCategoryById(it) } }
|
||||||
|
val exportState by exportImportViewModel.exportState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val vocabularyItemsFlow: Flow<List<VocabularyItem>> = remember(filterState) {
|
val vocabularyItemsFlow: Flow<List<VocabularyItem>> = remember(filterState) {
|
||||||
vocabularyViewModel.filterVocabularyItems(
|
vocabularyViewModel.filterVocabularyItems(
|
||||||
@@ -161,6 +165,16 @@ fun AllCardsListScreen(
|
|||||||
vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList()).value
|
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) {
|
LaunchedEffect(categoryId, showDueTodayOnly, stage) {
|
||||||
filterState = filterState.copy(
|
filterState = filterState.copy(
|
||||||
categoryIds = categoryId?.let { listOf(it) } ?: emptyList(),
|
categoryIds = categoryId?.let { listOf(it) } ?: emptyList(),
|
||||||
@@ -219,6 +233,11 @@ fun AllCardsListScreen(
|
|||||||
},
|
},
|
||||||
onMoveToCategoryClick = { showCategoryDialog = true },
|
onMoveToCategoryClick = { showCategoryDialog = true },
|
||||||
onMoveToStageClick = { showStageDialog = true },
|
onMoveToStageClick = { showStageDialog = true },
|
||||||
|
onExportClick = {
|
||||||
|
val selectedIds = selection.map { it.toInt() }
|
||||||
|
exportImportViewModel.exportItemList(selectedIds)
|
||||||
|
selection = emptySet()
|
||||||
|
},
|
||||||
onRemoveFromCategoryClick = {
|
onRemoveFromCategoryClick = {
|
||||||
if (categoryId != null) {
|
if (categoryId != null) {
|
||||||
val itemsToRemove = vocabularyItems.filter { selection.contains(it.id.toLong()) }
|
val itemsToRemove = vocabularyItems.filter { selection.contains(it.id.toLong()) }
|
||||||
@@ -502,6 +521,7 @@ private fun ContextualTopAppBar(
|
|||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
onMoveToCategoryClick: () -> Unit,
|
onMoveToCategoryClick: () -> Unit,
|
||||||
onMoveToStageClick: () -> Unit,
|
onMoveToStageClick: () -> Unit,
|
||||||
|
onExportClick: () -> Unit,
|
||||||
onRemoveFromCategoryClick: () -> Unit
|
onRemoveFromCategoryClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
var showOverflowMenu by remember { mutableStateOf(false) }
|
var showOverflowMenu by remember { mutableStateOf(false) }
|
||||||
@@ -534,6 +554,14 @@ private fun ContextualTopAppBar(
|
|||||||
expanded = showOverflowMenu,
|
expanded = showOverflowMenu,
|
||||||
onDismissRequest = { showOverflowMenu = false }
|
onDismissRequest = { showOverflowMenu = false }
|
||||||
) {
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Export Selected") },
|
||||||
|
onClick = {
|
||||||
|
onExportClick()
|
||||||
|
showOverflowMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) }
|
||||||
|
)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.move_to_category)) },
|
text = { Text(stringResource(R.string.move_to_category)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -585,6 +613,7 @@ fun ContextualTopAppBarPreview() {
|
|||||||
onDeleteClick = {},
|
onDeleteClick = {},
|
||||||
onMoveToCategoryClick = {},
|
onMoveToCategoryClick = {},
|
||||||
onMoveToStageClick = {},
|
onMoveToStageClick = {},
|
||||||
|
onExportClick = {},
|
||||||
onRemoveFromCategoryClick = {}
|
onRemoveFromCategoryClick = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -335,7 +335,6 @@ class VocabularyViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fun deleteVocabularyItemsById(list: List<Int>) {
|
fun deleteVocabularyItemsById(list: List<Int>) {
|
||||||
Log.d(TAG, "Deleting vocabulary items with IDs: $list")
|
Log.d(TAG, "Deleting vocabulary items with IDs: $list")
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -407,18 +406,116 @@ class VocabularyViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the merging of two duplicate vocabulary items.
|
* Handles the merging of two duplicate vocabulary items intelligently.
|
||||||
* Placeholder logic: Deletes the new item, effectively keeping the original.
|
* Merges learning progress (stages, counts), categories, and features.
|
||||||
* A full implementation would merge properties like stage, 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) {
|
fun mergeDuplicateItems(newItem: VocabularyItem, existingItem: VocabularyItem) {
|
||||||
Log.d(TAG, "mergeDuplicateItems: Merging ${newItem.id} into ${existingItem.id}. (Placeholder logic)")
|
Log.d(TAG, "mergeDuplicateItems: Intelligently merging ${newItem.id} into ${existingItem.id}")
|
||||||
//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.
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
statusService.showSuccessMessage("Items merged!", 2)
|
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))
|
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}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1113,4 +1113,5 @@
|
|||||||
<string name="cd_searchh">Search</string>
|
<string name="cd_searchh">Search</string>
|
||||||
<string name="label_search_cards">Search cards</string>
|
<string name="label_search_cards">Search cards</string>
|
||||||
<string name="label_learnedd">learned</string>
|
<string name="label_learnedd">learned</string>
|
||||||
|
<string name="label_all_categoriess">All Categories</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
5
app/src/main/res/xml/file_paths.xml
Normal file
5
app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user