Compare commits
26 Commits
64dcc5d0d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
199f5ae33f | ||
|
|
cfd71162a0 | ||
|
|
c94b29073f | ||
|
|
95dfd3c7eb | ||
|
|
d6a9ccf4e3 | ||
|
|
863920143d | ||
|
|
15d03ef57f | ||
|
|
f737657cdb | ||
|
|
b75f5f32a0 | ||
|
|
0f8d605df7 | ||
|
|
0a202191eb | ||
|
|
d12a21909c | ||
|
|
37d8c2a6c5 | ||
|
|
8f42fa79ef | ||
|
|
9600ef84ae | ||
|
|
c81e0886b8 | ||
|
|
9db538bf0a | ||
|
|
4cd014957f | ||
|
|
4b572f8773 | ||
|
|
c4fbfdf0ed | ||
|
|
ebfd097bf8 | ||
|
|
f2a6a58c05 | ||
|
|
3966901da2 | ||
|
|
3c1e71d805 | ||
|
|
ff77086ab1 | ||
|
|
dc4c62ef0b |
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -4,10 +4,10 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-02-16T10:13:39.492968600Z">
|
<DropdownSelection timestamp="2026-02-20T17:14:10.736481200Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCW11ML1WV" />
|
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\jonas\.android\avd\Pixel_6.avd" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime
|
implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime
|
||||||
implementation(libs.androidx.room.ktx)
|
implementation(libs.androidx.room.ktx)
|
||||||
implementation(libs.core.ktx)
|
implementation(libs.core.ktx)
|
||||||
|
implementation(libs.androidx.compose.runtime)
|
||||||
ksp(libs.room.compiler)
|
ksp(libs.room.compiler)
|
||||||
|
|
||||||
// Networking
|
// Networking
|
||||||
|
|||||||
@@ -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.
|
||||||
24
app/src/main/assets/hints/explore_packs_hint.md
Normal file
24
app/src/main/assets/hints/explore_packs_hint.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
All vocabulary lists in this section were generated automatically. While I strive for accuracy and quality, please keep in mind that these are machine-generated collections.
|
||||||
|
|
||||||
|
I'm a single developer building and maintaining this app in my spare time. I'm passionate about creating tools that help people learn languages, but I have limited resources and time.
|
||||||
|
|
||||||
|
### Your Feedback Matters
|
||||||
|
|
||||||
|
I greatly appreciate any feedback, suggestions, or ideas you might have! If you:
|
||||||
|
|
||||||
|
- Find errors in any vocabulary pack
|
||||||
|
- Have ideas for new topics, languages, or categories
|
||||||
|
- Want to request a specific vocabulary pack
|
||||||
|
- Have suggestions for improving existing packs
|
||||||
|
|
||||||
|
Please don't hesitate to reach out through the Request feature or contact me directly. Your input helps make this app better for everyone!
|
||||||
|
|
||||||
|
### How Packs Work
|
||||||
|
|
||||||
|
- **Download** packs that interest you
|
||||||
|
- **Preview** the words before adding them
|
||||||
|
- **Import** them into your library with options to handle duplicates
|
||||||
|
- **Organize** them into categories which are created automatically
|
||||||
|
|
||||||
|
|
||||||
|
Thank you for using this app and your feedback!
|
||||||
@@ -57,6 +57,10 @@ data class VocabularyItem(
|
|||||||
features = switchedFeaturesJson
|
features = switchedFeaturesJson
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hasFeatures(): Boolean {
|
||||||
|
return !features.isNullOrBlank() && features != "{}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import eu.gaudian.translator.model.repository.SettingsRepository
|
|||||||
import eu.gaudian.translator.utils.ApiCallback
|
import eu.gaudian.translator.utils.ApiCallback
|
||||||
import eu.gaudian.translator.utils.Log
|
import eu.gaudian.translator.utils.Log
|
||||||
import eu.gaudian.translator.utils.StatusAction
|
import eu.gaudian.translator.utils.StatusAction
|
||||||
|
import eu.gaudian.translator.utils.StatusMessageId
|
||||||
import eu.gaudian.translator.utils.StatusMessageService
|
import eu.gaudian.translator.utils.StatusMessageService
|
||||||
import eu.gaudian.translator.viewmodel.MessageAction
|
import eu.gaudian.translator.viewmodel.MessageAction
|
||||||
import eu.gaudian.translator.viewmodel.MessageDisplayType
|
import eu.gaudian.translator.viewmodel.MessageDisplayType
|
||||||
@@ -228,10 +229,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 {
|
||||||
@@ -406,11 +404,7 @@ class ApiManager(private val context: Context) {
|
|||||||
|
|
||||||
if (languageModel == null) {
|
if (languageModel == null) {
|
||||||
val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context))
|
val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context))
|
||||||
StatusMessageService.trigger(StatusAction.ShowActionableMessage(
|
StatusMessageService.showErrorById(StatusMessageId.ERROR_NO_MODEL_CONFIGURED)
|
||||||
text = errorMsg,
|
|
||||||
type = MessageDisplayType.ACTIONABLE_ERROR,
|
|
||||||
action = MessageAction.NAVIGATE_TO_API_KEYS
|
|
||||||
))
|
|
||||||
callback.onFailure(errorMsg)
|
callback.onFailure(errorMsg)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
package eu.gaudian.translator.model.communication
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.core.content.edit
|
|
||||||
import eu.gaudian.translator.R
|
|
||||||
import eu.gaudian.translator.utils.Log
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import retrofit2.Retrofit
|
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
|
||||||
import retrofit2.create
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.security.MessageDigest
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages downloading files from the server, verifying checksums, and checking versions.
|
|
||||||
*/
|
|
||||||
class FileDownloadManager(private val context: Context) {
|
|
||||||
|
|
||||||
private val baseUrl = "http://23.88.48.47/"
|
|
||||||
|
|
||||||
private val retrofit = Retrofit.Builder()
|
|
||||||
.baseUrl(baseUrl)
|
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
|
||||||
.client(OkHttpClient.Builder().build())
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private val manifestApiService = retrofit.create<ManifestApiService>()
|
|
||||||
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the manifest from the server.
|
|
||||||
*/
|
|
||||||
suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val response = manifestApiService.getManifest().execute()
|
|
||||||
if (response.isSuccessful) {
|
|
||||||
response.body()
|
|
||||||
} else {
|
|
||||||
@Suppress("HardCodedStringLiteral") val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
|
||||||
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
|
|
||||||
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
Log.e("FileDownloadManager", "Error fetching manifest", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads all assets for a file and verifies their checksums.
|
|
||||||
*/
|
|
||||||
suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
val totalAssets = fileInfo.assets.size
|
|
||||||
|
|
||||||
for ((completedAssets, asset) in fileInfo.assets.withIndex()) {
|
|
||||||
val success = downloadAsset(asset) { assetProgress ->
|
|
||||||
// Calculate overall progress
|
|
||||||
val assetContribution = assetProgress / totalAssets
|
|
||||||
val previousAssetsProgress = completedAssets.toFloat() / totalAssets
|
|
||||||
onProgress(previousAssetsProgress + assetContribution)
|
|
||||||
}
|
|
||||||
if (!success) {
|
|
||||||
return@withContext false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save version after all assets are downloaded successfully
|
|
||||||
sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads a specific asset and verifies its checksum.
|
|
||||||
*/
|
|
||||||
private suspend fun downloadAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
|
|
||||||
val fileUrl = "${baseUrl}${asset.filename}"
|
|
||||||
val localFile = File(context.filesDir, asset.filename)
|
|
||||||
|
|
||||||
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
|
|
||||||
@Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
|
|
||||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
|
||||||
output.write(buffer, 0, bytesRead)
|
|
||||||
digest.update(buffer, 0, bytesRead)
|
|
||||||
totalBytesRead += bytesRead
|
|
||||||
onProgress((totalBytesRead.toFloat() / contentLength))
|
|
||||||
}
|
|
||||||
|
|
||||||
output.flush()
|
|
||||||
|
|
||||||
// Compute checksum
|
|
||||||
val computedChecksum = digest.digest().joinToString("") {
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
"%02X".format(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
Log.d("FileDownloadManager", "Download successful for ${asset.filename}")
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
Log.e("FileDownloadManager",
|
|
||||||
context.getString(
|
|
||||||
R.string.text_checksum_mismatch_for_expected_got,
|
|
||||||
asset.filename,
|
|
||||||
asset.checksumSha256,
|
|
||||||
computedChecksum
|
|
||||||
))
|
|
||||||
localFile.delete() // Delete corrupted file
|
|
||||||
throw Exception("Checksum verification failed for ${asset.filename}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
Log.e("FileDownloadManager", "Error downloading asset", e)
|
|
||||||
// Clean up partial download
|
|
||||||
if (localFile.exists()) {
|
|
||||||
localFile.delete()
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a newer version is available for a file.
|
|
||||||
*/
|
|
||||||
fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
|
|
||||||
val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0"
|
|
||||||
return compareVersions(fileInfo.version, localVersion) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compares two version strings (assuming semantic versioning).
|
|
||||||
*/
|
|
||||||
private fun compareVersions(version1: String, version2: String): Int {
|
|
||||||
val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
|
|
||||||
val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
|
|
||||||
|
|
||||||
for (i in 0 until maxOf(parts1.size, parts2.size)) {
|
|
||||||
val part1 = parts1.getOrElse(i) { 0 }
|
|
||||||
val part2 = parts2.getOrElse(i) { 0 }
|
|
||||||
if (part1 != part2) {
|
|
||||||
return part1.compareTo(part2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the local version of a file.
|
|
||||||
*/
|
|
||||||
fun getLocalVersion(fileId: String): String {
|
|
||||||
return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package eu.gaudian.translator.model.communication
|
|
||||||
|
|
||||||
import retrofit2.Call
|
|
||||||
import retrofit2.http.GET
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API service for fetching the manifest and downloading files.
|
|
||||||
*/
|
|
||||||
interface ManifestApiService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the manifest from the server.
|
|
||||||
*/
|
|
||||||
@GET("manifest.json")
|
|
||||||
fun getManifest(): Call<ManifestResponse>
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
@file:Suppress("HardCodedStringLiteral")
|
||||||
|
|
||||||
|
package eu.gaudian.translator.model.communication.files_download
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized configuration for all download-related URLs and paths.
|
||||||
|
* Update this file when server configuration changes.
|
||||||
|
*/
|
||||||
|
object DownloadConfig {
|
||||||
|
|
||||||
|
// ===== BASE URLS =====
|
||||||
|
|
||||||
|
/** Base URL for all polly content */
|
||||||
|
const val POLLY_BASE_URL = "http://pollyapp.b-cdn.net/"
|
||||||
|
|
||||||
|
// ===== DICTIONARIES CONFIG =====
|
||||||
|
|
||||||
|
/** Subdirectory for dictionary files on the server */
|
||||||
|
const val DICTIONARIES_SUBDIRECTORY = "dictionaries"
|
||||||
|
|
||||||
|
/** Full URL for dictionary files (baseUrl + subdirectory) */
|
||||||
|
const val DICTIONARIES_BASE_URL = "$POLLY_BASE_URL$DICTIONARIES_SUBDIRECTORY/"
|
||||||
|
|
||||||
|
/** Manifest file name for dictionaries */
|
||||||
|
const val DICTIONARIES_MANIFEST_FILE = "manifest.json"
|
||||||
|
|
||||||
|
/** Full URL for the dictionary manifest */
|
||||||
|
const val DICTIONARIES_MANIFEST_URL = "$DICTIONARIES_BASE_URL$DICTIONARIES_MANIFEST_FILE"
|
||||||
|
|
||||||
|
// ===== FLASHCARDS CONFIG =====
|
||||||
|
|
||||||
|
/** Subdirectory for flashcard/vocab files on the server */
|
||||||
|
const val FLASHCARDS_SUBDIRECTORY = "flashcards"
|
||||||
|
|
||||||
|
/** Full URL for flashcard files (baseUrl + subdirectory) */
|
||||||
|
const val FLASHCARDS_BASE_URL = "$POLLY_BASE_URL$FLASHCARDS_SUBDIRECTORY/"
|
||||||
|
|
||||||
|
/** Manifest file name for flashcards/vocab packs */
|
||||||
|
const val FLASHCARDS_MANIFEST_FILE = "vocab_manifest.json"
|
||||||
|
|
||||||
|
/** Full URL for the flashcard manifest */
|
||||||
|
const val FLASHCARDS_MANIFEST_URL = "$FLASHCARDS_BASE_URL$FLASHCARDS_MANIFEST_FILE"
|
||||||
|
|
||||||
|
// ===== LOCAL STORAGE PATHS =====
|
||||||
|
|
||||||
|
/** Local subdirectory for storing flashcard files (relative to filesDir) */
|
||||||
|
const val LOCAL_FLASHCARDS_PATH = FLASHCARDS_SUBDIRECTORY
|
||||||
|
|
||||||
|
// ===== HELPER METHODS =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the full remote URL for a dictionary asset.
|
||||||
|
* @param filename The asset filename (e.g., "dictionary_de.db")
|
||||||
|
*/
|
||||||
|
fun getDictionaryAssetUrl(filename: String): String = "$DICTIONARIES_BASE_URL$filename"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the full remote URL for a flashcard asset.
|
||||||
|
* @param filename The asset filename (e.g., "2026_02_20_verbs_beginners_zh_pl_A1.json")
|
||||||
|
*/
|
||||||
|
fun getFlashcardAssetUrl(filename: String): String = "$FLASHCARDS_BASE_URL$filename"
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
@file:Suppress("HardCodedStringLiteral")
|
||||||
|
|
||||||
|
package eu.gaudian.translator.model.communication.files_download
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import eu.gaudian.translator.R
|
||||||
|
import eu.gaudian.translator.model.communication.Asset
|
||||||
|
import eu.gaudian.translator.model.communication.FileInfo
|
||||||
|
import eu.gaudian.translator.model.communication.ManifestResponse
|
||||||
|
import eu.gaudian.translator.utils.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import retrofit2.create
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages downloading files from the server, verifying checksums, and checking versions.
|
||||||
|
* All URLs and paths are centralized in [DownloadConfig].
|
||||||
|
*/
|
||||||
|
class FileDownloadManager(private val context: Context) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
Log.d("FileDownloadManager", "=== FileDownloadManager initialized ===")
|
||||||
|
Log.d("FileDownloadManager", "Context filesDir: ${context.filesDir.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Polly base URL: ${DownloadConfig.POLLY_BASE_URL}")
|
||||||
|
Log.d("FileDownloadManager", "Dictionaries URL: ${DownloadConfig.DICTIONARIES_BASE_URL}")
|
||||||
|
Log.d("FileDownloadManager", "Flashcards URL: ${DownloadConfig.FLASHCARDS_BASE_URL}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Retrofit Services =====
|
||||||
|
|
||||||
|
private val dictionaryRetrofit = Retrofit.Builder()
|
||||||
|
.baseUrl(DownloadConfig.DICTIONARIES_BASE_URL)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.client(OkHttpClient.Builder().build())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val manifestApiService = dictionaryRetrofit.create<ManifestApiService>()
|
||||||
|
|
||||||
|
private val flashcardRetrofit = Retrofit.Builder()
|
||||||
|
.baseUrl(DownloadConfig.POLLY_BASE_URL)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.client(OkHttpClient.Builder().build())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val flashcardApiService = flashcardRetrofit.create<FlashcardApiService>()
|
||||||
|
|
||||||
|
private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// ===== Dictionary Manifest =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the manifest from the server.
|
||||||
|
*/
|
||||||
|
suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("FileDownloadManager", "=== fetchManifest() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Fetching manifest from: ${DownloadConfig.DICTIONARIES_MANIFEST_URL}")
|
||||||
|
try {
|
||||||
|
val response = manifestApiService.getManifest().execute()
|
||||||
|
Log.d("FileDownloadManager", "Manifest response received - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val manifest = response.body()
|
||||||
|
Log.d("FileDownloadManager", "Manifest parsed successfully, files count: ${manifest?.files?.size ?: 0}")
|
||||||
|
manifest?.files?.forEach { file ->
|
||||||
|
Log.d("FileDownloadManager", " - File: ${file.id}, name: ${file.name}, version: ${file.version}, assets: ${file.assets.size}")
|
||||||
|
}
|
||||||
|
manifest
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
||||||
|
Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
|
||||||
|
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FileDownloadManager", "Error fetching manifest", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Dictionary Downloads =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads all assets for a file and verifies their checksums.
|
||||||
|
*/
|
||||||
|
suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("FileDownloadManager", "=== downloadFile() called ===")
|
||||||
|
Log.d("FileDownloadManager", "File info - id: ${fileInfo.id}, name: ${fileInfo.name}, version: ${fileInfo.version}")
|
||||||
|
Log.d("FileDownloadManager", "Total assets to download: ${fileInfo.assets.size}")
|
||||||
|
val totalAssets = fileInfo.assets.size
|
||||||
|
|
||||||
|
for ((completedAssets, asset) in fileInfo.assets.withIndex()) {
|
||||||
|
Log.d("FileDownloadManager", "Processing asset ${completedAssets + 1}/$totalAssets: ${asset.filename}")
|
||||||
|
val success = downloadDictionaryAsset(asset) { assetProgress ->
|
||||||
|
// Calculate overall progress
|
||||||
|
val assetContribution = assetProgress / totalAssets
|
||||||
|
val previousAssetsProgress = completedAssets.toFloat() / totalAssets
|
||||||
|
onProgress(previousAssetsProgress + assetContribution)
|
||||||
|
}
|
||||||
|
if (!success) {
|
||||||
|
Log.e("FileDownloadManager", "Failed to download asset: ${asset.filename}")
|
||||||
|
return@withContext false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save version after all assets are downloaded successfully
|
||||||
|
sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
|
||||||
|
Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
|
||||||
|
Log.d("FileDownloadManager", "Saved version ${fileInfo.version} for id ${fileInfo.id}")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a specific dictionary asset and verifies its checksum.
|
||||||
|
*/
|
||||||
|
private suspend fun downloadDictionaryAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("FileDownloadManager", "=== downloadDictionaryAsset() called ===")
|
||||||
|
val fileUrl = DownloadConfig.getDictionaryAssetUrl(asset.filename)
|
||||||
|
val localFile = File(context.filesDir, asset.filename)
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
|
||||||
|
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log.d("FileDownloadManager", "Creating HTTP request...")
|
||||||
|
val client = OkHttpClient()
|
||||||
|
val request = Request.Builder().url(fileUrl).build()
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
|
||||||
|
|
||||||
|
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()
|
||||||
|
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
|
||||||
|
if (contentLength <= 0) {
|
||||||
|
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
|
||||||
|
throw Exception("Invalid file size: $contentLength")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Starting file download to: ${localFile.absolutePath}")
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Compute checksum
|
||||||
|
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
|
||||||
|
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
|
||||||
|
|
||||||
|
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
|
||||||
|
Log.d("FileDownloadManager", "Checksum VERIFIED for ${asset.filename}")
|
||||||
|
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
Log.e("FileDownloadManager", "Checksum MISMATCH for ${asset.filename}")
|
||||||
|
Log.e("FileDownloadManager",
|
||||||
|
context.getString(
|
||||||
|
R.string.text_checksum_mismatch_for_expected_got,
|
||||||
|
asset.filename,
|
||||||
|
asset.checksumSha256,
|
||||||
|
computedChecksum
|
||||||
|
))
|
||||||
|
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
|
||||||
|
localFile.delete() // Delete corrupted file
|
||||||
|
throw Exception("Checksum verification failed for ${asset.filename}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FileDownloadManager", "Error downloading asset from $fileUrl", e)
|
||||||
|
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
|
||||||
|
// Clean up partial download
|
||||||
|
if (localFile.exists()) {
|
||||||
|
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
|
||||||
|
localFile.delete()
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Version Management =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a newer version is available for a file.
|
||||||
|
*/
|
||||||
|
fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
|
||||||
|
Log.d("FileDownloadManager", "=== isNewerVersionAvailable() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Checking file: ${fileInfo.id} (${fileInfo.name})")
|
||||||
|
val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0"
|
||||||
|
Log.d("FileDownloadManager", "Local version: $localVersion, Server version: ${fileInfo.version}")
|
||||||
|
val result = compareVersions(fileInfo.version, localVersion) > 0
|
||||||
|
Log.d("FileDownloadManager", "Newer version available: $result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two version strings (assuming semantic versioning).
|
||||||
|
*/
|
||||||
|
private fun compareVersions(version1: String, version2: String): Int {
|
||||||
|
Log.d("FileDownloadManager", "Comparing versions: '$version1' vs '$version2'")
|
||||||
|
val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
|
||||||
|
for (i in 0 until maxOf(parts1.size, parts2.size)) {
|
||||||
|
val part1 = parts1.getOrElse(i) { 0 }
|
||||||
|
val part2 = parts2.getOrElse(i) { 0 }
|
||||||
|
if (part1 != part2) {
|
||||||
|
val result = part1.compareTo(part2)
|
||||||
|
Log.d("FileDownloadManager", "Version comparison result: $result (at part $i: $part1 vs $part2)")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.d("FileDownloadManager", "Versions are equal, returning 0")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the local version of a file.
|
||||||
|
*/
|
||||||
|
fun getLocalVersion(fileId: String): String {
|
||||||
|
val version = sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
|
||||||
|
Log.d("FileDownloadManager", "getLocalVersion($fileId) = $version")
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Flashcard Collections =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a flashcard collection file with checksum verification.
|
||||||
|
*/
|
||||||
|
suspend fun downloadFlashcardCollection(
|
||||||
|
flashcardInfo: FlashcardCollectionInfo,
|
||||||
|
onProgress: (Float) -> Unit = {}
|
||||||
|
): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("FileDownloadManager", "=== downloadFlashcardCollection() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Flashcard info - id: ${flashcardInfo.id}, version: ${flashcardInfo.version}")
|
||||||
|
|
||||||
|
val asset = flashcardInfo.asset
|
||||||
|
val fileUrl = DownloadConfig.getFlashcardAssetUrl(asset.filename)
|
||||||
|
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${asset.filename}")
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
|
||||||
|
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
|
||||||
|
|
||||||
|
// Create subdirectory if it doesn't exist
|
||||||
|
val parentDir = localFile.parentFile
|
||||||
|
if (parentDir != null && !parentDir.exists()) {
|
||||||
|
Log.d("FileDownloadManager", "Creating subdirectory: ${parentDir.absolutePath}")
|
||||||
|
val created = parentDir.mkdirs()
|
||||||
|
Log.d("FileDownloadManager", "Subdirectory created: $created")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log.d("FileDownloadManager", "Creating HTTP request for flashcard...")
|
||||||
|
val client = OkHttpClient()
|
||||||
|
val request = Request.Builder().url(fileUrl).build()
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
|
||||||
|
|
||||||
|
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()
|
||||||
|
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
|
||||||
|
if (contentLength <= 0) {
|
||||||
|
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
|
||||||
|
throw Exception("Invalid file size: $contentLength")
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Starting flashcard download to: ${localFile.absolutePath}")
|
||||||
|
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) }
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
|
||||||
|
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
|
||||||
|
|
||||||
|
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
|
||||||
|
Log.d("FileDownloadManager", "Checksum VERIFIED for flashcard ${asset.filename}")
|
||||||
|
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
|
||||||
|
// Save version
|
||||||
|
sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) }
|
||||||
|
Log.d("FileDownloadManager", "Saved version ${flashcardInfo.version} for id ${flashcardInfo.id}")
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
Log.e("FileDownloadManager", "Checksum MISMATCH for flashcard ${asset.filename}")
|
||||||
|
Log.e("FileDownloadManager", context.getString(
|
||||||
|
R.string.text_checksum_mismatch_for_expected_got,
|
||||||
|
asset.filename,
|
||||||
|
asset.checksumSha256,
|
||||||
|
computedChecksum
|
||||||
|
))
|
||||||
|
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
|
||||||
|
localFile.delete()
|
||||||
|
throw Exception("Checksum verification failed for ${asset.filename}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FileDownloadManager", "Error downloading flashcard collection from $fileUrl", e)
|
||||||
|
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
|
||||||
|
if (localFile.exists()) {
|
||||||
|
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
|
||||||
|
localFile.delete()
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a newer version is available for a flashcard collection.
|
||||||
|
*/
|
||||||
|
fun isNewerFlashcardVersionAvailable(flashcardInfo: FlashcardCollectionInfo): Boolean {
|
||||||
|
Log.d("FileDownloadManager", "=== isNewerFlashcardVersionAvailable() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Checking flashcard: ${flashcardInfo.id}")
|
||||||
|
val localVersion = sharedPreferences.getString(flashcardInfo.id, "0.0.0") ?: "0.0.0"
|
||||||
|
Log.d("FileDownloadManager", "Local version: $localVersion, Server version: ${flashcardInfo.version}")
|
||||||
|
val result = compareVersions(flashcardInfo.version, localVersion) > 0
|
||||||
|
Log.d("FileDownloadManager", "Newer version available: $result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the local version of a flashcard collection.
|
||||||
|
*/
|
||||||
|
fun getFlashcardLocalVersion(collectionId: String): String {
|
||||||
|
val version = sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
|
||||||
|
Log.d("FileDownloadManager", "getFlashcardLocalVersion($collectionId) = $version")
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Vocab Packs =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the vocabulary-pack manifest (vocab_manifest.json).
|
||||||
|
* Unwraps the top-level [VocabManifestResponse] and returns the `lists` array.
|
||||||
|
*/
|
||||||
|
suspend fun fetchVocabManifest(): List<VocabCollectionInfo>? = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("FileDownloadManager", "=== fetchVocabManifest() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Fetching vocab manifest from: ${DownloadConfig.FLASHCARDS_MANIFEST_URL}")
|
||||||
|
try {
|
||||||
|
val response = flashcardApiService.getVocabManifest().execute()
|
||||||
|
Log.d("FileDownloadManager", "Vocab manifest response - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val manifest = response.body()
|
||||||
|
val lists = manifest?.lists
|
||||||
|
Log.d("FileDownloadManager", "Vocab manifest parsed successfully, lists count: ${lists?.size ?: 0}")
|
||||||
|
lists?.forEach { list ->
|
||||||
|
Log.d("FileDownloadManager", " - Vocab list: ${list.id}, name: ${list.name}, version: ${list.version}, filename: ${list.filename}")
|
||||||
|
}
|
||||||
|
lists
|
||||||
|
} else {
|
||||||
|
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
||||||
|
Log.e("FileDownloadManager", "Failed to fetch vocab manifest: $errorMessage")
|
||||||
|
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FileDownloadManager", "Error fetching vocab manifest", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a single vocabulary pack file and verifies its SHA-256 checksum.
|
||||||
|
* The file is stored at [filesDir]/[DownloadConfig.LOCAL_FLASHCARDS_PATH]/[filename].
|
||||||
|
*
|
||||||
|
* @return true on success, false (or throws) on failure.
|
||||||
|
*/
|
||||||
|
suspend fun downloadVocabCollection(
|
||||||
|
info: VocabCollectionInfo,
|
||||||
|
onProgress: (Float) -> Unit = {}
|
||||||
|
): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("FileDownloadManager", "=== downloadVocabCollection() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Vocab info - id: ${info.id}, name: ${info.name}, version: ${info.version}")
|
||||||
|
Log.d("FileDownloadManager", "Vocab filename: ${info.filename}, size: ${info.sizeBytes} bytes")
|
||||||
|
|
||||||
|
val fileUrl = DownloadConfig.getFlashcardAssetUrl(info.filename)
|
||||||
|
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
|
||||||
|
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${info.checksumSha256}")
|
||||||
|
|
||||||
|
// Create subdirectory if it doesn't exist
|
||||||
|
val parentDir = localFile.parentFile
|
||||||
|
if (parentDir != null && !parentDir.exists()) {
|
||||||
|
Log.d("FileDownloadManager", "Creating subdirectory: ${parentDir.absolutePath}")
|
||||||
|
val created = parentDir.mkdirs()
|
||||||
|
Log.d("FileDownloadManager", "Subdirectory created: $created")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log.d("FileDownloadManager", "Creating HTTP request for vocab pack...")
|
||||||
|
val client = OkHttpClient()
|
||||||
|
val request = Request.Builder().url(fileUrl).build()
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
|
||||||
|
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val errorMessage = context.getString(
|
||||||
|
R.string.text_download_failed_http,
|
||||||
|
response.code,
|
||||||
|
response.message
|
||||||
|
)
|
||||||
|
Log.e("FileDownloadManager", errorMessage)
|
||||||
|
throw Exception(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body
|
||||||
|
val contentLength = body.contentLength().takeIf { it > 0 } ?: info.sizeBytes
|
||||||
|
Log.d("FileDownloadManager", "Content length from header: ${body.contentLength()}, using: $contentLength bytes")
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Starting vocab pack download to: ${localFile.absolutePath}")
|
||||||
|
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
|
||||||
|
if (contentLength > 0) onProgress(totalBytesRead.toFloat() / contentLength)
|
||||||
|
}
|
||||||
|
output.flush()
|
||||||
|
|
||||||
|
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
|
||||||
|
|
||||||
|
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
|
||||||
|
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
|
||||||
|
Log.d("FileDownloadManager", "Expected checksum: ${info.checksumSha256}")
|
||||||
|
|
||||||
|
if (computedChecksum.equals(info.checksumSha256, ignoreCase = true)) {
|
||||||
|
Log.d("FileDownloadManager", "Checksum VERIFIED for vocab pack ${info.filename}")
|
||||||
|
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
|
||||||
|
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
|
||||||
|
sharedPreferences.edit(commit = true) {
|
||||||
|
putString("vocab_${info.id}", info.version.toString())
|
||||||
|
}
|
||||||
|
Log.d("FileDownloadManager", "Saved version ${info.version} for vocab_${info.id}")
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
Log.e("FileDownloadManager", "Checksum MISMATCH for vocab pack ${info.filename}")
|
||||||
|
Log.e("FileDownloadManager",
|
||||||
|
context.getString(
|
||||||
|
R.string.text_checksum_mismatch_for_expected_got,
|
||||||
|
info.filename,
|
||||||
|
info.checksumSha256,
|
||||||
|
computedChecksum
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
|
||||||
|
localFile.delete()
|
||||||
|
throw Exception("Checksum verification failed for ${info.filename}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("FileDownloadManager", "Error downloading vocab pack from $fileUrl", e)
|
||||||
|
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
|
||||||
|
if (localFile.exists()) {
|
||||||
|
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
|
||||||
|
localFile.delete()
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the local file for this collection exists. */
|
||||||
|
fun isVocabCollectionDownloaded(info: VocabCollectionInfo): Boolean {
|
||||||
|
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
|
||||||
|
val exists = localFile.exists()
|
||||||
|
Log.d("FileDownloadManager", "isVocabCollectionDownloaded(${info.id}): $exists (path: ${localFile.absolutePath})")
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the server version is newer than the locally saved version. */
|
||||||
|
fun isNewerVocabVersionAvailable(info: VocabCollectionInfo): Boolean {
|
||||||
|
Log.d("FileDownloadManager", "=== isNewerVocabVersionAvailable() called ===")
|
||||||
|
Log.d("FileDownloadManager", "Checking vocab: ${info.id} (${info.name})")
|
||||||
|
val localVersion = sharedPreferences.getString("vocab_${info.id}", "0") ?: "0"
|
||||||
|
val serverVersion = info.version.toString().toIntOrNull() ?: 0
|
||||||
|
val localVersionInt = localVersion.toIntOrNull() ?: 0
|
||||||
|
Log.d("FileDownloadManager", "Local version: $localVersionInt, Server version: $serverVersion")
|
||||||
|
val result = serverVersion > localVersionInt
|
||||||
|
Log.d("FileDownloadManager", "Newer version available: $result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the locally saved version number string for a vocab pack (default "0"). */
|
||||||
|
fun getVocabLocalVersion(packId: String): String {
|
||||||
|
val version = sharedPreferences.getString("vocab_$packId", "0") ?: "0"
|
||||||
|
Log.d("FileDownloadManager", "getVocabLocalVersion($packId) = $version")
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package eu.gaudian.translator.model.communication.files_download
|
||||||
|
|
||||||
|
import retrofit2.Call
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Url
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API service for flashcard / vocabulary-pack downloads.
|
||||||
|
* Base URL should be set to DownloadConfig.POLLY_BASE_URL
|
||||||
|
*/
|
||||||
|
interface FlashcardApiService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the vocab manifest using the full URL from DownloadConfig.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
fun getVocabManifest(@Url url: String = DownloadConfig.FLASHCARDS_MANIFEST_URL): Call<VocabManifestResponse>
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package eu.gaudian.translator.model.communication.files_download
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// New: vocab_manifest.json schema
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level wrapper returned by vocab_manifest.json.
|
||||||
|
*
|
||||||
|
* {
|
||||||
|
* "manifest_version": "1.0",
|
||||||
|
* "updated_at": "…",
|
||||||
|
* "lists": [ … ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
data class VocabManifestResponse(
|
||||||
|
@SerializedName("manifest_version") val manifestVersion: String = "",
|
||||||
|
@SerializedName("updated_at") val updatedAt: String = "",
|
||||||
|
@SerializedName("lists") val lists: List<VocabCollectionInfo> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry inside the `lists` array of vocab_manifest.json.
|
||||||
|
*/
|
||||||
|
data class VocabCollectionInfo(
|
||||||
|
@SerializedName("id") val id: String,
|
||||||
|
@SerializedName("name") val name: String,
|
||||||
|
@SerializedName("description") val description: String,
|
||||||
|
@SerializedName("filename") val filename: String,
|
||||||
|
/** [lang_first_id, lang_second_id] matching Language IDs in the app */
|
||||||
|
@SerializedName("language_ids") val languageIds: List<Int>,
|
||||||
|
@SerializedName("category") val category: String,
|
||||||
|
@SerializedName("item_count") val itemCount: Int,
|
||||||
|
@SerializedName("emoji") val emoji: String,
|
||||||
|
@SerializedName("version") val version: Int,
|
||||||
|
/** CEFR difficulty level: A1, A2, B1, B2, C1, C2 (empty string if not set) */
|
||||||
|
@SerializedName("level") val level: String = "",
|
||||||
|
@SerializedName("size_bytes") val sizeBytes: Long,
|
||||||
|
@SerializedName("checksum_sha256") val checksumSha256: String,
|
||||||
|
@SerializedName("created_at") val createdAt: String,
|
||||||
|
@SerializedName("updated_at") val updatedAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy models (kept for backward compatibility with the old manifest.json
|
||||||
|
// dictionary download path)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
data class FlashcardManifestResponse(
|
||||||
|
@SerializedName("collections")
|
||||||
|
val collections: List<FlashcardCollectionInfo>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class 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 FlashcardAsset(
|
||||||
|
@SerializedName("filename") val filename: String,
|
||||||
|
@SerializedName("size_bytes") val sizeBytes: Long,
|
||||||
|
@SerializedName("checksum_sha256") val checksumSha256: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package eu.gaudian.translator.model.communication.files_download
|
||||||
|
|
||||||
|
import eu.gaudian.translator.model.communication.ManifestResponse
|
||||||
|
import retrofit2.Call
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Url
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API service for fetching the dictionary manifest.
|
||||||
|
* Base URL should be set to DownloadConfig.DICTIONARIES_BASE_URL
|
||||||
|
*/
|
||||||
|
interface ManifestApiService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the manifest from the server using the full URL.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
fun getManifest(@Url url: String = DownloadConfig.DICTIONARIES_MANIFEST_URL): Call<ManifestResponse>
|
||||||
|
|
||||||
|
}
|
||||||
@@ -6,9 +6,9 @@ import android.content.Context
|
|||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.model.communication.Asset
|
import eu.gaudian.translator.model.communication.Asset
|
||||||
import eu.gaudian.translator.model.communication.FileDownloadManager
|
|
||||||
import eu.gaudian.translator.model.communication.FileInfo
|
import eu.gaudian.translator.model.communication.FileInfo
|
||||||
import eu.gaudian.translator.model.communication.ManifestResponse
|
import eu.gaudian.translator.model.communication.ManifestResponse
|
||||||
|
import eu.gaudian.translator.model.communication.files_download.FileDownloadManager
|
||||||
import eu.gaudian.translator.utils.Log
|
import eu.gaudian.translator.utils.Log
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|||||||
@@ -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)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -37,6 +37,7 @@ enum class StatusMessageId(
|
|||||||
ERROR_VOCABULARY_IMPORT_FAILED(R.string.message_error_vocabulary_import_failed, MessageDisplayType.ERROR, 5),
|
ERROR_VOCABULARY_IMPORT_FAILED(R.string.message_error_vocabulary_import_failed, MessageDisplayType.ERROR, 5),
|
||||||
SUCCESS_ITEMS_MERGED(R.string.message_success_items_merged, MessageDisplayType.SUCCESS, 3),
|
SUCCESS_ITEMS_MERGED(R.string.message_success_items_merged, MessageDisplayType.SUCCESS, 3),
|
||||||
SUCCESS_ITEMS_ADDED(R.string.message_success_items_added, MessageDisplayType.SUCCESS, 3),
|
SUCCESS_ITEMS_ADDED(R.string.message_success_items_added, MessageDisplayType.SUCCESS, 3),
|
||||||
|
SUCCESS_All_ITEMS_IMPORTED(R.string.message_success_all_items_imported, MessageDisplayType.SUCCESS, 3),
|
||||||
ERROR_ITEMS_ADD_FAILED(R.string.message_error_items_add_failed, MessageDisplayType.ERROR, 5),
|
ERROR_ITEMS_ADD_FAILED(R.string.message_error_items_add_failed, MessageDisplayType.ERROR, 5),
|
||||||
SUCCESS_ITEMS_DELETED(R.string.message_success_items_deleted, MessageDisplayType.SUCCESS, 3),
|
SUCCESS_ITEMS_DELETED(R.string.message_success_items_deleted, MessageDisplayType.SUCCESS, 3),
|
||||||
ERROR_ITEMS_DELETE_FAILED(R.string.message_error_items_delete_failed, MessageDisplayType.ERROR, 5),
|
ERROR_ITEMS_DELETE_FAILED(R.string.message_error_items_delete_failed, MessageDisplayType.ERROR, 5),
|
||||||
@@ -66,6 +67,7 @@ enum class StatusMessageId(
|
|||||||
// API Key related
|
// API Key related
|
||||||
ERROR_API_KEY_MISSING(R.string.message_error_api_key_missing, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
|
ERROR_API_KEY_MISSING(R.string.message_error_api_key_missing, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
|
||||||
ERROR_API_KEY_INVALID(R.string.message_error_api_key_invalid, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
|
ERROR_API_KEY_INVALID(R.string.message_error_api_key_invalid, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
|
||||||
|
ERROR_NO_MODEL_CONFIGURED(R.string.message_error_no_model_configured, MessageDisplayType.ERROR, 5),
|
||||||
|
|
||||||
// Translation related
|
// Translation related
|
||||||
LOADING_TRANSLATING(R.string.message_loading_translating, MessageDisplayType.LOADING, 0),
|
LOADING_TRANSLATING(R.string.message_loading_translating, MessageDisplayType.LOADING, 0),
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ class TranslationService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) {
|
// Public method to directly use LibreTranslate (bypasses AI)
|
||||||
|
suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) {
|
||||||
|
Log.d("libreTranslate: $text, $source, $target")
|
||||||
try {
|
try {
|
||||||
val json = org.json.JSONObject().apply {
|
val json = org.json.JSONObject().apply {
|
||||||
put("q", text)
|
put("q", text)
|
||||||
|
|||||||
@@ -82,19 +82,14 @@ val LocalConnectionConfigured = compositionLocalOf { true }
|
|||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
|
||||||
private val settingsViewModel: SettingsViewModel by viewModels()
|
private val settingsViewModel: SettingsViewModel by viewModels()
|
||||||
private var isReady = false
|
private var isReady = false
|
||||||
private var isUiLoaded = false
|
private var isUiLoaded = false
|
||||||
private var isInitializing = true
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
installSplashScreen().apply {
|
installSplashScreen().apply {
|
||||||
// The splash screen will now correctly wait until isReady is true
|
|
||||||
setKeepOnScreenCondition { !isReady }
|
setKeepOnScreenCondition { !isReady }
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
@@ -104,28 +99,22 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
|
||||||
// Show UI immediately and load data in background
|
|
||||||
setContent {
|
setContent {
|
||||||
AppTheme(settingsViewModel = settingsViewModel) {
|
AppTheme(settingsViewModel = settingsViewModel) {
|
||||||
TranslatorApp(settingsViewModel = settingsViewModel)
|
TranslatorApp(settingsViewModel = settingsViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark UI as loaded immediately after setContent
|
|
||||||
isUiLoaded = true
|
isUiLoaded = true
|
||||||
|
|
||||||
// Start initialization in background without blocking UI
|
|
||||||
initializeData()
|
initializeData()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initializeData() {
|
private fun initializeData() {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
// Get repositories from the Application instance (lazy initialization)
|
|
||||||
val myApp = application as MyApplication
|
val myApp = application as MyApplication
|
||||||
val languageRepository = myApp.languageRepository
|
val languageRepository = myApp.languageRepository
|
||||||
val apiRepository = myApp.apiRepository
|
val apiRepository = myApp.apiRepository
|
||||||
|
|
||||||
// Perform initialization in parallel where possible
|
|
||||||
val languageJob = launch {
|
val languageJob = launch {
|
||||||
languageRepository.initializeDefaultLanguages()
|
languageRepository.initializeDefaultLanguages()
|
||||||
languageRepository.initializeAllLanguages()
|
languageRepository.initializeAllLanguages()
|
||||||
@@ -135,13 +124,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
apiRepository.initialInit()
|
apiRepository.initialInit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for both to complete
|
|
||||||
languageJob.join()
|
languageJob.join()
|
||||||
apiJob.join()
|
apiJob.join()
|
||||||
|
|
||||||
// Signal readiness after all work is done.
|
|
||||||
isReady = true
|
isReady = true
|
||||||
isInitializing = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,10 +135,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Suppress("AssignedValueIsNeverRead")
|
@Suppress("AssignedValueIsNeverRead")
|
||||||
@SuppressLint("LocalContextResourcesRead")
|
@SuppressLint("LocalContextResourcesRead")
|
||||||
@Composable
|
@Composable
|
||||||
fun TranslatorApp(
|
fun TranslatorApp(settingsViewModel: SettingsViewModel) {
|
||||||
settingsViewModel: SettingsViewModel
|
|
||||||
) {
|
|
||||||
|
|
||||||
val activity = LocalContext.current.findActivity()
|
val activity = LocalContext.current.findActivity()
|
||||||
val statusViewModel: StatusViewModel = hiltViewModel(activity)
|
val statusViewModel: StatusViewModel = hiltViewModel(activity)
|
||||||
val statusMessageService = StatusMessageService
|
val statusMessageService = StatusMessageService
|
||||||
@@ -179,7 +162,6 @@ fun TranslatorApp(
|
|||||||
showExitDialog = true
|
showExitDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (showExitDialog) {
|
if (showExitDialog) {
|
||||||
AppAlertDialog(
|
AppAlertDialog(
|
||||||
onDismissRequest = { showExitDialog = false },
|
onDismissRequest = { showExitDialog = false },
|
||||||
@@ -188,7 +170,6 @@ fun TranslatorApp(
|
|||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
showExitDialog = false
|
showExitDialog = false
|
||||||
// Minimize the app similar to default back at root behavior
|
|
||||||
activity.moveTaskToBack(true)
|
activity.moveTaskToBack(true)
|
||||||
}) {
|
}) {
|
||||||
Text(stringResource(R.string.quit))
|
Text(stringResource(R.string.quit))
|
||||||
@@ -202,7 +183,6 @@ fun TranslatorApp(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for app updates and show "What's New" dialog if needed
|
|
||||||
var showWhatsNewDialog by remember { mutableStateOf(false) }
|
var showWhatsNewDialog by remember { mutableStateOf(false) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val changelogEntries = context.resources.getStringArray(R.array.changelog_entries)
|
val changelogEntries = context.resources.getStringArray(R.array.changelog_entries)
|
||||||
@@ -210,7 +190,6 @@ fun TranslatorApp(
|
|||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
try {
|
try {
|
||||||
// Only check for updates if the intro is completed
|
|
||||||
if (introCompleted) {
|
if (introCompleted) {
|
||||||
val currentVersion = BuildConfig.VERSION_NAME
|
val currentVersion = BuildConfig.VERSION_NAME
|
||||||
val hasSeenCurrentVersion = settingsViewModel.hasSeenCurrentVersion(currentVersion)
|
val hasSeenCurrentVersion = settingsViewModel.hasSeenCurrentVersion(currentVersion)
|
||||||
@@ -253,19 +232,25 @@ fun TranslatorApp(
|
|||||||
val currentDestination = navBackStackEntry?.destination
|
val currentDestination = navBackStackEntry?.destination
|
||||||
val selectedScreen = Screen.fromDestination(currentDestination)
|
val selectedScreen = Screen.fromDestination(currentDestination)
|
||||||
|
|
||||||
@Suppress("HardCodedStringLiteral") val isBottomBarHidden = currentDestination?.hierarchy?.any { destination ->
|
@Suppress("HardCodedStringLiteral")
|
||||||
|
val currentRoute = currentDestination?.route
|
||||||
|
val isHiddenByHierarchy = currentDestination?.hierarchy?.any { destination ->
|
||||||
destination.route in setOf(
|
destination.route in setOf(
|
||||||
Screen.Translation.route,
|
Screen.Translation.route,
|
||||||
Screen.Dictionary.route,
|
Screen.Dictionary.route,
|
||||||
Screen.Exercises.route,
|
Screen.Exercises.route,
|
||||||
Screen.Settings.route
|
Screen.Settings.route,
|
||||||
|
Screen.Corrector.route
|
||||||
)
|
)
|
||||||
} == true || currentDestination?.route in setOf(
|
} == true
|
||||||
"start_exercise",
|
val isBottomBarHidden = isHiddenByHierarchy || currentRoute in setOf(
|
||||||
"new_word",
|
"new_word",
|
||||||
"new_word_review",
|
"new_word_review",
|
||||||
"vocabulary_detail/{itemId}"
|
"vocabulary_detail/{itemId}",
|
||||||
)
|
"daily_review",
|
||||||
|
"explore_packs"
|
||||||
|
) || currentRoute?.startsWith("start_exercise") == true
|
||||||
|
|| currentRoute?.startsWith("vocabulary_exercise") == true
|
||||||
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)
|
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
|
||||||
BottomNavigationBar(
|
BottomNavigationBar(
|
||||||
@@ -278,12 +263,11 @@ fun TranslatorApp(
|
|||||||
Screen.Translation,
|
Screen.Translation,
|
||||||
Screen.Dictionary,
|
Screen.Dictionary,
|
||||||
Screen.Settings,
|
Screen.Settings,
|
||||||
Screen.Exercises
|
Screen.Exercises,
|
||||||
|
Screen.Corrector
|
||||||
)
|
)
|
||||||
|
|
||||||
// Always reset the selected section to its root and clear back stack between sections
|
|
||||||
if (inSameSection) {
|
if (inSameSection) {
|
||||||
// If already within the same section, ensure we are at its graph root
|
|
||||||
navController.navigate(screen.route) {
|
navController.navigate(screen.route) {
|
||||||
popUpTo(screen.route) {
|
popUpTo(screen.route) {
|
||||||
inclusive = false
|
inclusive = false
|
||||||
@@ -298,9 +282,8 @@ fun TranslatorApp(
|
|||||||
restoreState = false
|
restoreState = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Switching sections: clear entire back stack to start to avoid back navigation results
|
|
||||||
navController.navigate(screen.route) {
|
navController.navigate(screen.route) {
|
||||||
popUpTo(0) { // Pop everything
|
popUpTo(0) {
|
||||||
inclusive = true
|
inclusive = true
|
||||||
saveState = false
|
saveState = false
|
||||||
}
|
}
|
||||||
@@ -335,8 +318,7 @@ fun TranslatorApp(
|
|||||||
statusState = statusState,
|
statusState = statusState,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
onDismiss = { statusMessageService.trigger(StatusAction.HideMessageBar) },
|
onDismiss = { statusMessageService.trigger(StatusAction.HideMessageBar) },
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
AppNavHost(
|
AppNavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -388,10 +370,8 @@ private fun AppTheme(
|
|||||||
val window = (view.context as Activity).window
|
val window = (view.context as Activity).window
|
||||||
val windowInsetsController = WindowInsetsControllerCompat(window, view)
|
val windowInsetsController = WindowInsetsControllerCompat(window, view)
|
||||||
|
|
||||||
// We must keep this for older Android version!!!
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
window.statusBarColor = colorScheme.surface.toArgb()
|
window.statusBarColor = colorScheme.surface.toArgb()
|
||||||
//Elevation must be the same as BottomNavigationBar
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
window.navigationBarColor = colorScheme.surfaceColorAtElevation(8.dp).toArgb()
|
window.navigationBarColor = colorScheme.surfaceColorAtElevation(8.dp).toArgb()
|
||||||
|
|
||||||
@@ -438,6 +418,4 @@ private fun AppTheme(
|
|||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -20,27 +20,29 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import androidx.navigation.navigation
|
import androidx.navigation.navigation
|
||||||
import eu.gaudian.translator.model.VocabularyStage
|
import eu.gaudian.translator.model.VocabularyStage
|
||||||
|
import eu.gaudian.translator.view.categories.CategoryDetailScreen
|
||||||
|
import eu.gaudian.translator.view.categories.CategoryListScreen
|
||||||
import eu.gaudian.translator.view.composable.Screen
|
import eu.gaudian.translator.view.composable.Screen
|
||||||
|
import eu.gaudian.translator.view.dictionary.CorrectionScreen
|
||||||
import eu.gaudian.translator.view.dictionary.DictionaryResultScreen
|
import eu.gaudian.translator.view.dictionary.DictionaryResultScreen
|
||||||
|
import eu.gaudian.translator.view.dictionary.DictionaryScreen
|
||||||
import eu.gaudian.translator.view.dictionary.EtymologyResultScreen
|
import eu.gaudian.translator.view.dictionary.EtymologyResultScreen
|
||||||
import eu.gaudian.translator.view.dictionary.MainDictionaryScreen
|
import eu.gaudian.translator.view.home.DailyReviewScreen
|
||||||
import eu.gaudian.translator.view.exercises.ExerciseSessionScreen
|
|
||||||
import eu.gaudian.translator.view.exercises.MainExerciseScreen
|
|
||||||
import eu.gaudian.translator.view.exercises.StartExerciseScreen
|
|
||||||
import eu.gaudian.translator.view.exercises.YouTubeBrowserScreen
|
|
||||||
import eu.gaudian.translator.view.exercises.YouTubeExerciseScreen
|
|
||||||
import eu.gaudian.translator.view.home.HomeScreen
|
import eu.gaudian.translator.view.home.HomeScreen
|
||||||
import eu.gaudian.translator.view.library.LibraryScreen
|
import eu.gaudian.translator.view.library.LibraryScreen
|
||||||
|
import eu.gaudian.translator.view.new_ecercises.ExerciseSessionScreen
|
||||||
|
import eu.gaudian.translator.view.new_ecercises.MainExerciseScreen
|
||||||
|
import eu.gaudian.translator.view.new_ecercises.StartExerciseScreen
|
||||||
|
import eu.gaudian.translator.view.new_ecercises.YouTubeBrowserScreen
|
||||||
|
import eu.gaudian.translator.view.new_ecercises.YouTubeExerciseScreen
|
||||||
import eu.gaudian.translator.view.settings.DictionaryOptionsScreen
|
import eu.gaudian.translator.view.settings.DictionaryOptionsScreen
|
||||||
import eu.gaudian.translator.view.settings.SettingsRoutes
|
|
||||||
import eu.gaudian.translator.view.settings.TranslationSettingsScreen
|
import eu.gaudian.translator.view.settings.TranslationSettingsScreen
|
||||||
import eu.gaudian.translator.view.settings.settingsGraph
|
import eu.gaudian.translator.view.settings.settingsGraph
|
||||||
import eu.gaudian.translator.view.stats.StatsScreen
|
import eu.gaudian.translator.view.stats.StatsScreen
|
||||||
import eu.gaudian.translator.view.translation.TranslationScreen
|
import eu.gaudian.translator.view.translation.TranslationScreen
|
||||||
import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
|
import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
|
||||||
import eu.gaudian.translator.view.vocabulary.CategoryDetailScreen
|
import eu.gaudian.translator.view.vocabulary.ExplorePacksScreen
|
||||||
import eu.gaudian.translator.view.vocabulary.CategoryListScreen
|
import eu.gaudian.translator.view.vocabulary.LanguageJourneyScreen
|
||||||
import eu.gaudian.translator.view.vocabulary.LanguageProgressScreen
|
|
||||||
import eu.gaudian.translator.view.vocabulary.NewWordReviewScreen
|
import eu.gaudian.translator.view.vocabulary.NewWordReviewScreen
|
||||||
import eu.gaudian.translator.view.vocabulary.NewWordScreen
|
import eu.gaudian.translator.view.vocabulary.NewWordScreen
|
||||||
import eu.gaudian.translator.view.vocabulary.NoGrammarItemsScreen
|
import eu.gaudian.translator.view.vocabulary.NoGrammarItemsScreen
|
||||||
@@ -53,10 +55,12 @@ import eu.gaudian.translator.view.vocabulary.VocabularySortingScreen
|
|||||||
private const val TRANSITION_DURATION = 300
|
private const val TRANSITION_DURATION = 300
|
||||||
|
|
||||||
object NavigationRoutes {
|
object NavigationRoutes {
|
||||||
|
const val DAILY_REVIEW = "daily_review"
|
||||||
const val NEW_WORD = "new_word"
|
const val NEW_WORD = "new_word"
|
||||||
const val NEW_WORD_REVIEW = "new_word_review"
|
const val NEW_WORD_REVIEW = "new_word_review"
|
||||||
const val VOCABULARY_DETAIL = "vocabulary_detail"
|
const val VOCABULARY_DETAIL = "vocabulary_detail"
|
||||||
const val START_EXERCISE = "start_exercise"
|
const val START_EXERCISE = "start_exercise"
|
||||||
|
const val START_EXERCISE_DAILY = "start_exercise_daily"
|
||||||
const val CATEGORY_DETAIL = "category_detail"
|
const val CATEGORY_DETAIL = "category_detail"
|
||||||
const val CATEGORY_LIST = "category_list_screen"
|
const val CATEGORY_LIST = "category_list_screen"
|
||||||
const val STATS_VOCABULARY_HEATMAP = "stats/vocabulary_heatmap"
|
const val STATS_VOCABULARY_HEATMAP = "stats/vocabulary_heatmap"
|
||||||
@@ -66,75 +70,65 @@ object NavigationRoutes {
|
|||||||
const val STATS_VOCABULARY_SORTING = "stats/vocabulary_sorting"
|
const val STATS_VOCABULARY_SORTING = "stats/vocabulary_sorting"
|
||||||
const val STATS_NO_GRAMMAR_ITEMS = "stats/no_grammar_items"
|
const val STATS_NO_GRAMMAR_ITEMS = "stats/no_grammar_items"
|
||||||
const val STATS_VOCABULARY_LIST = "stats/vocabulary_list"
|
const val STATS_VOCABULARY_LIST = "stats/vocabulary_list"
|
||||||
|
const val EXPLORE_PACKS = "explore_packs"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost(
|
fun AppNavHost(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 1. Define your Main Tab "Leaf" Routes (the actual start destinations of your graphs)
|
|
||||||
val mainTabRoutes = setOf(
|
val mainTabRoutes = setOf(
|
||||||
Screen.Home.route,
|
Screen.Home.route,
|
||||||
Screen.Library.route,
|
Screen.Library.route,
|
||||||
Screen.Stats.route,
|
Screen.Stats.route,
|
||||||
Screen.Translation.route,
|
|
||||||
Screen.Dictionary.route,
|
|
||||||
Screen.Exercises.route,
|
|
||||||
SettingsRoutes.LIST
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helper to check if a route is a top-level tab
|
|
||||||
fun isTabTransition(initial: String?, target: String?): Boolean {
|
fun isTabTransition(initial: String?, target: String?): Boolean {
|
||||||
return mainTabRoutes.contains(initial) && mainTabRoutes.contains(target)
|
if (initial == null || target == null) return false
|
||||||
|
val initialIsTab = mainTabRoutes.contains(initial) ||
|
||||||
|
mainTabRoutes.any { route ->
|
||||||
|
initial == "main_${route}" || initial.startsWith("${route}_")
|
||||||
|
}
|
||||||
|
val targetIsTab = mainTabRoutes.contains(target) ||
|
||||||
|
mainTabRoutes.any { route ->
|
||||||
|
target == "main_${route}" || target.startsWith("${route}_")
|
||||||
|
}
|
||||||
|
return initialIsTab && targetIsTab
|
||||||
}
|
}
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.Home.route,
|
startDestination = Screen.Home.route,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
||||||
// ENTER TRANSITION
|
|
||||||
enterTransition = {
|
enterTransition = {
|
||||||
if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
|
if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
|
||||||
// Tab Switch: Just Fade In (Subtle Scale for modern feel)
|
|
||||||
fadeIn(animationSpec = tween(TRANSITION_DURATION)) +
|
fadeIn(animationSpec = tween(TRANSITION_DURATION)) +
|
||||||
scaleIn(initialScale = 0.96f, animationSpec = tween(TRANSITION_DURATION))
|
scaleIn(initialScale = 0.96f, animationSpec = tween(TRANSITION_DURATION))
|
||||||
} else {
|
} else {
|
||||||
// Detail Screen: Slide in from Right
|
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
|
initialOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
|
||||||
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
||||||
) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
|
) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// EXIT TRANSITION
|
|
||||||
exitTransition = {
|
exitTransition = {
|
||||||
if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
|
if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
|
||||||
// Tab Switch: Just Fade Out
|
|
||||||
fadeOut(animationSpec = tween(TRANSITION_DURATION))
|
fadeOut(animationSpec = tween(TRANSITION_DURATION))
|
||||||
} else {
|
} else {
|
||||||
// Detail Screen: Slide out to Left
|
|
||||||
slideOutHorizontally(
|
slideOutHorizontally(
|
||||||
targetOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
|
targetOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
|
||||||
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
||||||
) + fadeOut(animationSpec = tween(TRANSITION_DURATION))
|
) + fadeOut(animationSpec = tween(TRANSITION_DURATION))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// POP ENTER (Pressing Back) -> Always Slide back from left
|
|
||||||
popEnterTransition = {
|
popEnterTransition = {
|
||||||
slideInHorizontally(
|
slideInHorizontally(
|
||||||
initialOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
|
initialOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
|
||||||
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
|
||||||
) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
|
) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
|
||||||
},
|
},
|
||||||
|
|
||||||
// POP EXIT (Pressing Back) -> Always Slide away to right
|
|
||||||
popExitTransition = {
|
popExitTransition = {
|
||||||
slideOutHorizontally(
|
slideOutHorizontally(
|
||||||
targetOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
|
targetOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
|
||||||
@@ -145,25 +139,49 @@ fun AppNavHost(
|
|||||||
composable(Screen.Home.route) {
|
composable(Screen.Home.route) {
|
||||||
HomeScreen(navController = navController)
|
HomeScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
composable(NavigationRoutes.DAILY_REVIEW) {
|
||||||
|
DailyReviewScreen(navController = navController)
|
||||||
|
}
|
||||||
composable(NavigationRoutes.NEW_WORD) {
|
composable(NavigationRoutes.NEW_WORD) {
|
||||||
NewWordScreen(navController = navController)
|
NewWordScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(NavigationRoutes.NEW_WORD_REVIEW) {
|
composable(NavigationRoutes.NEW_WORD_REVIEW) {
|
||||||
NewWordReviewScreen(navController = navController)
|
NewWordReviewScreen(navController = navController)
|
||||||
}
|
}
|
||||||
|
composable(NavigationRoutes.EXPLORE_PACKS) {
|
||||||
composable(NavigationRoutes.START_EXERCISE) {
|
ExplorePacksScreen(navController = navController)
|
||||||
StartExerciseScreen(navController = navController)
|
}
|
||||||
|
composable(
|
||||||
|
route = "${NavigationRoutes.START_EXERCISE}?categoryId={categoryId}",
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("categoryId") {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) { backStackEntry ->
|
||||||
|
val categoryIdString = backStackEntry.arguments?.getString("categoryId")
|
||||||
|
val categoryId = categoryIdString?.toIntOrNull()
|
||||||
|
StartExerciseScreen(
|
||||||
|
navController = navController,
|
||||||
|
preselectedCategoryId = categoryId,
|
||||||
|
dueTodayOnly = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(NavigationRoutes.START_EXERCISE_DAILY) {
|
||||||
|
StartExerciseScreen(
|
||||||
|
navController = navController,
|
||||||
|
preselectedCategoryId = null,
|
||||||
|
dueTodayOnly = true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define all other navigation graphs at the same top level.
|
|
||||||
homeGraph(navController)
|
homeGraph(navController)
|
||||||
libraryGraph(navController)
|
libraryGraph(navController)
|
||||||
statsGraph(navController)
|
statsGraph(navController)
|
||||||
translationGraph(navController)
|
translationGraph(navController)
|
||||||
dictionaryGraph(navController)
|
dictionaryGraph(navController)
|
||||||
|
correctorGraph(navController)
|
||||||
exerciseGraph(navController)
|
exerciseGraph(navController)
|
||||||
settingsGraph(navController)
|
settingsGraph(navController)
|
||||||
}
|
}
|
||||||
@@ -189,9 +207,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
LibraryScreen(navController = navController)
|
LibraryScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("vocabulary_sorting") {
|
composable("vocabulary_sorting") {
|
||||||
VocabularySortingScreen(
|
VocabularySortingScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("vocabulary_detail/{itemId}") { backStackEntry ->
|
composable("vocabulary_detail/{itemId}") { backStackEntry ->
|
||||||
val itemId = backStackEntry.arguments?.getString("itemId")?.toIntOrNull()
|
val itemId = backStackEntry.arguments?.getString("itemId")?.toIntOrNull()
|
||||||
@@ -208,10 +224,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
composable("dictionary_result/{entryId}") { backStackEntry ->
|
composable("dictionary_result/{entryId}") { backStackEntry ->
|
||||||
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
|
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
|
||||||
if (entryId != null) {
|
if (entryId != null) {
|
||||||
DictionaryResultScreen(
|
DictionaryResultScreen(entryId = entryId, navController = navController)
|
||||||
entryId = entryId,
|
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Text("Error: Invalid Entry ID")
|
Text("Error: Invalid Entry ID")
|
||||||
}
|
}
|
||||||
@@ -223,23 +236,16 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
showDueTodayOnly = showDueTodayOnly,
|
showDueTodayOnly = showDueTodayOnly,
|
||||||
categoryId = categoryId,
|
categoryId = categoryId,
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
enableNavigationButtons = true
|
enableNavigationButtons = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("language_progress") {
|
composable("language_progress") {
|
||||||
LanguageProgressScreen(
|
LanguageJourneyScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
|
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
|
||||||
VocabularyHeatmapScreen(
|
VocabularyHeatmapScreen(navController = navController)
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
|
composable("vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
|
||||||
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
||||||
@@ -247,14 +253,11 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
val stage = stageString?.let {
|
val stage = stageString?.let {
|
||||||
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
|
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
AllCardsListScreen(
|
AllCardsListScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showDueTodayOnly = showDueTodayOnly,
|
showDueTodayOnly = showDueTodayOnly,
|
||||||
stage = stage,
|
stage = stage,
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
categoryId = 0,
|
categoryId = 0,
|
||||||
enableNavigationButtons = true
|
enableNavigationButtons = true
|
||||||
@@ -267,22 +270,15 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
navArgument("categories") { type = NavType.StringType; nullable = true },
|
navArgument("categories") { type = NavType.StringType; nullable = true },
|
||||||
navArgument("stages") { type = NavType.StringType; nullable = true },
|
navArgument("stages") { type = NavType.StringType; nullable = true },
|
||||||
navArgument("languages") { type = NavType.StringType; nullable = true },
|
navArgument("languages") { type = NavType.StringType; nullable = true },
|
||||||
navArgument("dailyOnly") {
|
navArgument("dailyOnly") { type = NavType.BoolType; defaultValue = false }
|
||||||
type = NavType.BoolType
|
|
||||||
defaultValue = false
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
val arguments = backStackEntry.arguments
|
val arguments = backStackEntry.arguments
|
||||||
|
|
||||||
val dailyOnly = arguments?.getBoolean("dailyOnly") ?: false
|
val dailyOnly = arguments?.getBoolean("dailyOnly") ?: false
|
||||||
|
|
||||||
val categoryIds = arguments?.getString("categories")
|
val categoryIds = arguments?.getString("categories")
|
||||||
val stageNames = arguments?.getString("stages")
|
val stageNames = arguments?.getString("stages")
|
||||||
val languageIds = arguments?.getString("languages")
|
val languageIds = arguments?.getString("languages")
|
||||||
|
|
||||||
val dailyOnlyJson = "{\"dailyOnly\": $dailyOnly}"
|
val dailyOnlyJson = "{\"dailyOnly\": $dailyOnly}"
|
||||||
|
|
||||||
VocabularyExerciseHostScreen(
|
VocabularyExerciseHostScreen(
|
||||||
categoryIdsAsJson = categoryIds,
|
categoryIdsAsJson = categoryIds,
|
||||||
stageNamesAsJson = stageNames,
|
stageNamesAsJson = stageNames,
|
||||||
@@ -292,13 +288,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
navController = navController
|
navController = navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable("vocabulary_exercise/{dailyOnly}?", arguments = listOf(navArgument("dailyOnly") { type = NavType.BoolType })) { _ ->
|
||||||
route = "vocabulary_exercise/{dailyOnly}?",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("dailyOnly") { type = NavType.BoolType },
|
|
||||||
)
|
|
||||||
) { _ ->
|
|
||||||
|
|
||||||
VocabularyExerciseHostScreen(
|
VocabularyExerciseHostScreen(
|
||||||
categoryIdsAsJson = null,
|
categoryIdsAsJson = null,
|
||||||
stageNamesAsJson = null,
|
stageNamesAsJson = null,
|
||||||
@@ -308,34 +298,18 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
dailyOnlyAsJson = "{\"dailyOnly\": true}"
|
dailyOnlyAsJson = "{\"dailyOnly\": true}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable("stage_detail/{stage}", arguments = listOf(navArgument("stage") { type = NavType.EnumType(VocabularyStage::class.java) })) { backStackEntry ->
|
||||||
"stage_detail/{stage}",
|
@Suppress("DEPRECATION")
|
||||||
arguments = listOf(
|
val stage = backStackEntry.arguments?.getSerializable("stage") as VocabularyStage
|
||||||
navArgument("stage") {
|
StageDetailScreen(navController = navController, stage = stage)
|
||||||
type = NavType.EnumType(VocabularyStage::class.java)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
{ backStackEntry ->
|
|
||||||
@Suppress("DEPRECATION") val stage = backStackEntry.arguments?.getSerializable("stage") as VocabularyStage
|
|
||||||
//NOTE: can ignore warning for now, once moved away from min SDK 28, use:
|
|
||||||
// val stage = backStackEntry.arguments?.getSerializable("stage", VocabularyStage::class.java)
|
|
||||||
StageDetailScreen(
|
|
||||||
navController = navController,
|
|
||||||
stage = stage
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("category_detail/{categoryId}") { backStackEntry ->
|
composable("category_detail/{categoryId}") { backStackEntry ->
|
||||||
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
|
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
|
||||||
|
|
||||||
if (categoryId != null) {
|
if (categoryId != null) {
|
||||||
CategoryDetailScreen(
|
CategoryDetailScreen(
|
||||||
categoryId = categoryId,
|
categoryId = categoryId,
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
navController = navController
|
navController = navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -343,37 +317,22 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
|||||||
composable("category_list_screen") {
|
composable("category_list_screen") {
|
||||||
CategoryListScreen(
|
CategoryListScreen(
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onCategoryClicked = { categoryId ->
|
onCategoryClicked = { categoryId -> navController.navigate("category_detail/$categoryId") }
|
||||||
navController.navigate("category_detail/$categoryId")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable("vocabulary_sorting?mode={mode}", arguments = listOf(navArgument("mode") { type = NavType.StringType; nullable = true })) { backStackEntry ->
|
||||||
route = "vocabulary_sorting?mode={mode}", // Route now accepts an optional 'mode'
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("mode") { // Define the argument
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { backStackEntry ->
|
|
||||||
VocabularySortingScreen(
|
VocabularySortingScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
// Pass the argument to the screen
|
|
||||||
initialFilterMode = backStackEntry.arguments?.getString("mode")
|
initialFilterMode = backStackEntry.arguments?.getString("mode")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("no_grammar_items") {
|
composable("no_grammar_items") {
|
||||||
NoGrammarItemsScreen(
|
NoGrammarItemsScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun NavGraphBuilder.statsGraph(
|
fun NavGraphBuilder.statsGraph(navController: NavHostController) {
|
||||||
navController: NavHostController,
|
|
||||||
) {
|
|
||||||
navigation(
|
navigation(
|
||||||
startDestination = "main_stats",
|
startDestination = "main_stats",
|
||||||
route = Screen.Stats.route
|
route = Screen.Stats.route
|
||||||
@@ -382,9 +341,7 @@ fun NavGraphBuilder.statsGraph(
|
|||||||
StatsScreen(navController = navController)
|
StatsScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable("stats/vocabulary_sorting") {
|
composable("stats/vocabulary_sorting") {
|
||||||
VocabularySortingScreen(
|
VocabularySortingScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("stats/vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry ->
|
composable("stats/vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry ->
|
||||||
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
||||||
@@ -393,22 +350,16 @@ fun NavGraphBuilder.statsGraph(
|
|||||||
navController = navController,
|
navController = navController,
|
||||||
showDueTodayOnly = showDueTodayOnly,
|
showDueTodayOnly = showDueTodayOnly,
|
||||||
categoryId = categoryId,
|
categoryId = categoryId,
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
enableNavigationButtons = true
|
enableNavigationButtons = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(NavigationRoutes.STATS_LANGUAGE_PROGRESS) {
|
composable(NavigationRoutes.STATS_LANGUAGE_PROGRESS) {
|
||||||
LanguageProgressScreen(
|
LanguageJourneyScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
|
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
|
||||||
VocabularyHeatmapScreen(
|
VocabularyHeatmapScreen(navController = navController)
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("stats/vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
|
composable("stats/vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
|
||||||
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
||||||
@@ -416,14 +367,11 @@ fun NavGraphBuilder.statsGraph(
|
|||||||
val stage = stageString?.let {
|
val stage = stageString?.let {
|
||||||
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
|
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
AllCardsListScreen(
|
AllCardsListScreen(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
showDueTodayOnly = showDueTodayOnly,
|
showDueTodayOnly = showDueTodayOnly,
|
||||||
stage = stage,
|
stage = stage,
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
categoryId = 0,
|
categoryId = 0,
|
||||||
enableNavigationButtons = true
|
enableNavigationButtons = true
|
||||||
@@ -431,14 +379,11 @@ fun NavGraphBuilder.statsGraph(
|
|||||||
}
|
}
|
||||||
composable("stats/category_detail/{categoryId}") { backStackEntry ->
|
composable("stats/category_detail/{categoryId}") { backStackEntry ->
|
||||||
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
|
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
|
||||||
|
|
||||||
if (categoryId != null) {
|
if (categoryId != null) {
|
||||||
CategoryDetailScreen(
|
CategoryDetailScreen(
|
||||||
categoryId = categoryId,
|
categoryId = categoryId,
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
onNavigateToItem = { item ->
|
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
|
||||||
navController.navigate("vocabulary_detail/${item.id}")
|
|
||||||
},
|
|
||||||
navController = navController
|
navController = navController
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -446,29 +391,14 @@ fun NavGraphBuilder.statsGraph(
|
|||||||
composable("stats/category_list_screen") {
|
composable("stats/category_list_screen") {
|
||||||
CategoryListScreen(
|
CategoryListScreen(
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
onCategoryClicked = { categoryId ->
|
onCategoryClicked = { categoryId -> navController.navigate("stats/category_detail/$categoryId") }
|
||||||
navController.navigate("stats/category_detail/$categoryId")
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable("stats/vocabulary_sorting?mode={mode}", arguments = listOf(navArgument("mode") { type = NavType.StringType; nullable = true })) { backStackEntry ->
|
||||||
route = "stats/vocabulary_sorting?mode={mode}",
|
VocabularySortingScreen(navController = navController, initialFilterMode = backStackEntry.arguments?.getString("mode"))
|
||||||
arguments = listOf(
|
|
||||||
navArgument("mode") {
|
|
||||||
type = NavType.StringType
|
|
||||||
nullable = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) { backStackEntry ->
|
|
||||||
VocabularySortingScreen(
|
|
||||||
navController = navController,
|
|
||||||
initialFilterMode = backStackEntry.arguments?.getString("mode")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("stats/no_grammar_items") {
|
composable("stats/no_grammar_items") {
|
||||||
NoGrammarItemsScreen(
|
NoGrammarItemsScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -495,15 +425,16 @@ fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
|
|||||||
route = Screen.Dictionary.route
|
route = Screen.Dictionary.route
|
||||||
) {
|
) {
|
||||||
composable("main_dictionary") {
|
composable("main_dictionary") {
|
||||||
MainDictionaryScreen(navController = navController)
|
DictionaryScreen(
|
||||||
|
navController = navController,
|
||||||
|
onEntryClick = { entry -> navController.navigate("dictionary_result/${entry.id}") },
|
||||||
|
onNavigateToOptions = { navController.navigate("dictionary_options") }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable("dictionary_result/{entryId}") { backStackEntry ->
|
composable("dictionary_result/{entryId}") { backStackEntry ->
|
||||||
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
|
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
|
||||||
if (entryId != null) {
|
if (entryId != null) {
|
||||||
DictionaryResultScreen(
|
DictionaryResultScreen(entryId = entryId, navController = navController)
|
||||||
entryId = entryId,
|
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Text("Error: Invalid Entry ID")
|
Text("Error: Invalid Entry ID")
|
||||||
}
|
}
|
||||||
@@ -514,43 +445,39 @@ fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
|
|||||||
composable("etymology_result/{word}/{languageCode}") { backStackEntry ->
|
composable("etymology_result/{word}/{languageCode}") { backStackEntry ->
|
||||||
val word = backStackEntry.arguments?.getString("word") ?: ""
|
val word = backStackEntry.arguments?.getString("word") ?: ""
|
||||||
val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1
|
val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1
|
||||||
EtymologyResultScreen(
|
EtymologyResultScreen(navController = navController, word = word, languageCode = languageCode)
|
||||||
navController = navController,
|
}
|
||||||
word = word,
|
}
|
||||||
languageCode = languageCode
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun NavGraphBuilder.correctorGraph(navController: NavHostController) {
|
||||||
|
navigation(
|
||||||
|
startDestination = "main_corrector",
|
||||||
|
route = Screen.Corrector.route
|
||||||
|
) {
|
||||||
|
composable("main_corrector") {
|
||||||
|
CorrectionScreen(navController = navController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||||
fun NavGraphBuilder.exerciseGraph(
|
fun NavGraphBuilder.exerciseGraph(navController: NavHostController) {
|
||||||
navController: NavHostController,
|
|
||||||
) {
|
|
||||||
navigation(
|
navigation(
|
||||||
startDestination = "main_exercise",
|
startDestination = "main_exercise",
|
||||||
route = Screen.Exercises.route
|
route = Screen.Exercises.route
|
||||||
) {
|
) {
|
||||||
composable("main_exercise") {
|
composable("main_exercise") {
|
||||||
MainExerciseScreen(
|
MainExerciseScreen(navController = navController)
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("exercise_session") {
|
composable("exercise_session") {
|
||||||
ExerciseSessionScreen(
|
ExerciseSessionScreen(navController = navController)
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("youtube_exercise") {
|
composable("youtube_exercise") {
|
||||||
YouTubeExerciseScreen(
|
YouTubeExerciseScreen(navController = navController)
|
||||||
navController = navController
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable("youtube_browse") {
|
composable("youtube_browse") {
|
||||||
YouTubeBrowserScreen(
|
YouTubeBrowserScreen(navController = navController)
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,13 +16,18 @@ import androidx.compose.ui.res.painterResource
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavController
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.view.composable.AppButton
|
import eu.gaudian.translator.view.composable.AppButton
|
||||||
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NoConnectionScreen(onSettingsClick: () -> Unit) {
|
fun NoConnectionScreen(onSettingsClick: () -> Unit, navController: NavController) {
|
||||||
|
AppTopAppBar(
|
||||||
|
title = "No Connection",
|
||||||
|
onNavigateBack = {navController.popBackStack()},
|
||||||
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
@@ -41,7 +46,7 @@ fun NoConnectionScreen(onSettingsClick: () -> Unit) {
|
|||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
AppButton(onClick = onSettingsClick, modifier = Modifier.padding(top = 16.dp)) {
|
AppButton(onClick = onSettingsClick, modifier = Modifier.padding(top = 16.dp)) {
|
||||||
Text(text = stringResource(id = R.string.settings_title_connection))
|
Text(text = "Configure Connection")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
@file:Suppress("HardCodedStringLiteral")
|
@file:Suppress("HardCodedStringLiteral")
|
||||||
|
|
||||||
package eu.gaudian.translator.view.vocabulary
|
package eu.gaudian.translator.view.categories
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.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 +17,26 @@ 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.shape.RoundedCornerShape
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
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
|
||||||
@@ -42,20 +51,25 @@ import eu.gaudian.translator.model.TagCategory
|
|||||||
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.utils.findActivity
|
import eu.gaudian.translator.utils.findActivity
|
||||||
|
import eu.gaudian.translator.view.composable.AppCard
|
||||||
import eu.gaudian.translator.view.composable.AppIcons
|
import eu.gaudian.translator.view.composable.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.AllCardsListScreen
|
||||||
|
import eu.gaudian.translator.view.stats.widgets.CategoryProgressCircle
|
||||||
|
import eu.gaudian.translator.view.stats.widgets.ChartLegend
|
||||||
import eu.gaudian.translator.viewmodel.CategoryProgress
|
import eu.gaudian.translator.viewmodel.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 +85,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 +133,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 +197,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,14 +249,17 @@ fun CategoryDetailScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Category Header Card with Progress and Action Buttons
|
// Category Header Card with Progress and Action Buttons (animated)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isHeaderVisible,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
CategoryHeaderCard(
|
CategoryHeaderCard(
|
||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
categoryProgress = categoryProgress,
|
categoryProgress = categoryProgress,
|
||||||
onStartExerciseClick = {
|
onStartExerciseClick = {
|
||||||
val categories = listOf(category)
|
navController.navigate("start_exercise?categoryId=$categoryId")
|
||||||
val categoryIds = categories.joinToString(",") { it?.id.toString() }
|
|
||||||
navController.navigate("vocabulary_exercise/false?categories=$categoryIds")
|
|
||||||
},
|
},
|
||||||
onEditClick = {
|
onEditClick = {
|
||||||
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
|
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
|
||||||
@@ -177,6 +270,7 @@ fun CategoryDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(modifier = Modifier.padding(paddingValues)) {
|
Column(modifier = Modifier.padding(paddingValues)) {
|
||||||
AllCardsListScreen(
|
AllCardsListScreen(
|
||||||
@@ -186,7 +280,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
|
||||||
@@ -222,15 +317,10 @@ fun CategoryHeaderCard(
|
|||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Card(
|
AppCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
|
||||||
),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -249,22 +339,26 @@ 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(4.dp))
|
||||||
|
ChartLegend()
|
||||||
|
Spacer(modifier = Modifier.height(16.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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@file:Suppress("AssignedValueIsNeverRead")
|
@file:Suppress("AssignedValueIsNeverRead")
|
||||||
|
|
||||||
package eu.gaudian.translator.view.vocabulary
|
package eu.gaudian.translator.view.categories
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -44,8 +44,8 @@ import eu.gaudian.translator.view.composable.AppScaffold
|
|||||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
import eu.gaudian.translator.view.dialogs.AddCategoryDialog
|
import eu.gaudian.translator.view.dialogs.AddCategoryDialog
|
||||||
import eu.gaudian.translator.view.dialogs.DeleteMultipleCategoriesDialog
|
import eu.gaudian.translator.view.dialogs.DeleteMultipleCategoriesDialog
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.CategoryCircleType
|
import eu.gaudian.translator.view.stats.widgets.CategoryCircleType
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle
|
import eu.gaudian.translator.view.stats.widgets.CategoryProgressCircle
|
||||||
import eu.gaudian.translator.viewmodel.CategoryViewModel
|
import eu.gaudian.translator.viewmodel.CategoryViewModel
|
||||||
import eu.gaudian.translator.viewmodel.ProgressViewModel
|
import eu.gaudian.translator.viewmodel.ProgressViewModel
|
||||||
|
|
||||||
@@ -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 = {
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package eu.gaudian.translator.view.composable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A compact action card with an icon and label, designed for use in rows or grids.
|
||||||
|
* Used for quick action buttons like "Explore Packs", "Import CSV", etc.
|
||||||
|
*
|
||||||
|
* @param label The text label below the icon
|
||||||
|
* @param icon The icon to display
|
||||||
|
* @param onClick Callback when the card is clicked
|
||||||
|
* @param modifier Modifier for the card
|
||||||
|
* @param height The height of the card (default 120.dp)
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppActionCard(
|
||||||
|
label: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
height: Dp = 120.dp,
|
||||||
|
iconContainerSize: Dp = 48.dp,
|
||||||
|
iconSize: Dp = 24.dp
|
||||||
|
) {
|
||||||
|
AppCard(
|
||||||
|
modifier = modifier.height(height),
|
||||||
|
onClick = onClick
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
CircularIconContainer(
|
||||||
|
imageVector = icon,
|
||||||
|
size = iconContainerSize,
|
||||||
|
iconSize = iconSize
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A section header label with consistent styling.
|
||||||
|
* Used for section titles like "Recently Added", etc.
|
||||||
|
*
|
||||||
|
* @param text The section title text
|
||||||
|
* @param modifier Modifier for the text
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SectionLabel(
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A labeled section with an optional action button.
|
||||||
|
* Provides consistent header styling for sections with a title and optional action.
|
||||||
|
*
|
||||||
|
* @param title The section title
|
||||||
|
* @param modifier Modifier for the section header
|
||||||
|
* @param actionLabel Optional label for the action button
|
||||||
|
* @param onActionClick Optional callback for the action button
|
||||||
|
* @param content The content below the header
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LabeledSection(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
actionLabel: String? = null,
|
||||||
|
onActionClick: (() -> Unit)? = null,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
// Header row with title and optional action
|
||||||
|
if (actionLabel != null && onActionClick != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
SectionLabel(text = title)
|
||||||
|
androidx.compose.material3.TextButton(onClick = onActionClick) {
|
||||||
|
Text(actionLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SectionLabel(text = title)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package eu.gaudian.translator.view.composable
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.gaudian.translator.R
|
||||||
|
import eu.gaudian.translator.view.composable.ComponentDefaults.DefaultElevation
|
||||||
|
import eu.gaudian.translator.view.hints.Hint
|
||||||
|
import eu.gaudian.translator.view.hints.HintBottomSheet
|
||||||
|
import eu.gaudian.translator.view.hints.LocalShowHints
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A styled card container for displaying content with a consistent floating look.
|
||||||
|
*
|
||||||
|
* @param modifier The modifier to be applied to the card.
|
||||||
|
* @param content The content to be displayed inside the card.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppCard(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String? = null,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
text: String? = null,
|
||||||
|
expandable: Boolean = false,
|
||||||
|
initiallyExpanded: Boolean = false,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
hintContent : Hint? = null,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
|
||||||
|
val showHints = LocalShowHints.current
|
||||||
|
|
||||||
|
val rotationState by animateFloatAsState(
|
||||||
|
targetValue = if (isExpanded) 180f else 0f,
|
||||||
|
label = "Chevron Rotation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check if we need to render the header row
|
||||||
|
// Updated to include icon in the check
|
||||||
|
val hasHeader = title != null || text != null || expandable || icon != null
|
||||||
|
val canClickHeader = expandable || onClick != null
|
||||||
|
|
||||||
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showBottomSheet) {
|
||||||
|
hintContent?.let {
|
||||||
|
HintBottomSheet(
|
||||||
|
onDismissRequest = { showBottomSheet = false },
|
||||||
|
content = it,
|
||||||
|
sheetState = rememberModalBottomSheetState(
|
||||||
|
skipPartiallyExpanded = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.shadow(
|
||||||
|
DefaultElevation,
|
||||||
|
shape = ComponentDefaults.CardShape
|
||||||
|
)
|
||||||
|
.clip(ComponentDefaults.CardClipShape)
|
||||||
|
// Animate height changes when expanding/collapsing
|
||||||
|
.animateContentSize(),
|
||||||
|
shape = ComponentDefaults.CardShape,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
// --- Header Row ---
|
||||||
|
if (hasHeader) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = canClickHeader) {
|
||||||
|
if (expandable) {
|
||||||
|
isExpanded = !isExpanded
|
||||||
|
}
|
||||||
|
onClick?.invoke()
|
||||||
|
}
|
||||||
|
.padding(ComponentDefaults.CardPadding),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// 1. Optional Icon on the left
|
||||||
|
if (icon != null) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Title and Text Column
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
if (!title.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show spacer if both title and text exist
|
||||||
|
if (!title.isNullOrBlank() && !text.isNullOrBlank()) {
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showHints && hintContent != null) {
|
||||||
|
IconButton(onClick = { showBottomSheet = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = AppIcons.Help,
|
||||||
|
contentDescription = stringResource(R.string.show_hint),
|
||||||
|
tint = MaterialTheme.colorScheme.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Expand Chevron (Far right)
|
||||||
|
if (expandable) {
|
||||||
|
Icon(
|
||||||
|
imageVector = AppIcons.ArrowDropDown,
|
||||||
|
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||||
|
modifier = Modifier.rotate(rotationState),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Content Area ---
|
||||||
|
if (!expandable || isExpanded) {
|
||||||
|
val contentModifier = Modifier
|
||||||
|
.padding(
|
||||||
|
start = ComponentDefaults.CardPadding,
|
||||||
|
end = ComponentDefaults.CardPadding,
|
||||||
|
bottom = ComponentDefaults.CardPadding,
|
||||||
|
// If we have a header, remove the top padding so content sits closer to the title.
|
||||||
|
// If no header (legacy behavior), keep the top padding.
|
||||||
|
top = if (hasHeader) 0.dp else ComponentDefaults.CardPadding
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasHeader && onClick != null) {
|
||||||
|
Column(
|
||||||
|
modifier = contentModifier.clickable { onClick() },
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = contentModifier,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun AppCardPreview() {
|
||||||
|
AppCard {
|
||||||
|
Text(stringResource(R.string.this_is_the_content_inside_the_card))
|
||||||
|
PrimaryButton(onClick = { }, text = stringResource(R.string.label_continue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun AppCardPreview2() {
|
||||||
|
MaterialTheme {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// 1. Expandable Card (Initially Collapsed)
|
||||||
|
AppCard(
|
||||||
|
title = "Advanced Settings",
|
||||||
|
text = "Click to reveal more options",
|
||||||
|
expandable = true,
|
||||||
|
initiallyExpanded = false
|
||||||
|
) {
|
||||||
|
Text("Here are some hidden settings.")
|
||||||
|
Text("They are only visible when expanded.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Expandable Card (Initially Expanded)
|
||||||
|
AppCard(
|
||||||
|
title = "Translation History",
|
||||||
|
text = "Recent items",
|
||||||
|
expandable = true,
|
||||||
|
initiallyExpanded = true
|
||||||
|
) {
|
||||||
|
Text("• Hello -> Hallo")
|
||||||
|
Text("• World -> Welt")
|
||||||
|
Text("• Sun -> Sonne")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Static Card (No Title/Expand logic - Legacy behavior)
|
||||||
|
AppCard {
|
||||||
|
Text("This is a standard card without a header.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ data class FabMenuItem(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@Deprecated("We don't want to use floating butto menus anymore")
|
||||||
@Composable
|
@Composable
|
||||||
fun AppFabMenu(
|
fun AppFabMenu(
|
||||||
items: List<FabMenuItem>,
|
items: List<FabMenuItem>,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package eu.gaudian.translator.view.composable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable icon container that displays an icon inside a shaped background.
|
||||||
|
* Used throughout the app for consistent icon presentation in cards, buttons, and action items.
|
||||||
|
*
|
||||||
|
* @param imageVector The icon to display
|
||||||
|
* @param modifier Modifier to be applied to the container
|
||||||
|
* @param size The size of the container (default 40.dp)
|
||||||
|
* @param iconSize The size of the icon itself (default 24.dp)
|
||||||
|
* @param shape The shape of the container (default RoundedCornerShape(12.dp))
|
||||||
|
* @param backgroundColor Background color of the container
|
||||||
|
* @param iconTint Tint color for the icon
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppIconContainer(
|
||||||
|
imageVector: ImageVector,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 40.dp,
|
||||||
|
iconSize: Dp = 24.dp,
|
||||||
|
shape: androidx.compose.ui.graphics.Shape = RoundedCornerShape(12.dp),
|
||||||
|
backgroundColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary,
|
||||||
|
contentDescription: String? = null
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(size)
|
||||||
|
.clip(shape)
|
||||||
|
.background(backgroundColor),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = imageVector,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
tint = iconTint,
|
||||||
|
modifier = Modifier.size(iconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A circular variant of AppIconContainer.
|
||||||
|
* Convenience wrapper for circular icon containers.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CircularIconContainer(
|
||||||
|
imageVector: ImageVector,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 48.dp,
|
||||||
|
iconSize: Dp = 24.dp,
|
||||||
|
backgroundColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary,
|
||||||
|
contentDescription: String? = null
|
||||||
|
) {
|
||||||
|
AppIconContainer(
|
||||||
|
imageVector = imageVector,
|
||||||
|
modifier = modifier,
|
||||||
|
size = size,
|
||||||
|
iconSize = iconSize,
|
||||||
|
shape = CircleShape,
|
||||||
|
backgroundColor = backgroundColor,
|
||||||
|
iconTint = iconTint,
|
||||||
|
contentDescription = contentDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -115,7 +115,7 @@ fun AppOutlinedTextField(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier,
|
||||||
label = label,
|
label = label,
|
||||||
trailingIcon = finalTrailingIcon,
|
trailingIcon = finalTrailingIcon,
|
||||||
shape = ComponentDefaults.DefaultShape,
|
shape = ComponentDefaults.DefaultShape,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface TabItem {
|
|||||||
val title: String
|
val title: String
|
||||||
val icon: ImageVector
|
val icon: ImageVector
|
||||||
}
|
}
|
||||||
|
@Deprecated("Migrate to new (like used in LibraryScreen")
|
||||||
@SuppressLint("UnusedBoxWithConstraintsScope", "LocalContextResourcesRead", "DiscouragedApi",
|
@SuppressLint("UnusedBoxWithConstraintsScope", "LocalContextResourcesRead", "DiscouragedApi",
|
||||||
"SuspiciousIndentation"
|
"SuspiciousIndentation"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package eu.gaudian.translator.view.composable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A styled filled text input field.
|
||||||
|
* Different from AppOutlinedTextField - this uses a filled background style.
|
||||||
|
*
|
||||||
|
* @param value The input text to be shown in the text field.
|
||||||
|
* @param onValueChange The callback that is triggered when the input service updates the text.
|
||||||
|
* @param modifier The modifier to be applied to the text field.
|
||||||
|
* @param placeholder The placeholder text to display when the field is empty.
|
||||||
|
* @param enabled Whether the text field is enabled.
|
||||||
|
* @param readOnly Whether the text field is read-only.
|
||||||
|
* @param singleLine Whether the text field is single line.
|
||||||
|
* @param minLines Minimum number of lines.
|
||||||
|
* @param maxLines Maximum number of lines.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AppTextField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeholder: String? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
readOnly: Boolean = false,
|
||||||
|
singleLine: Boolean = true,
|
||||||
|
minLines: Int = 1,
|
||||||
|
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||||
|
leadingIcon: @Composable (() -> Unit)? = null,
|
||||||
|
trailingIcon: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val cornerRadius = 12.dp
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
placeholder = placeholder?.let {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(cornerRadius),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = MaterialTheme.colorScheme.primary,
|
||||||
|
focusedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
),
|
||||||
|
singleLine = singleLine,
|
||||||
|
minLines = minLines,
|
||||||
|
maxLines = maxLines,
|
||||||
|
enabled = enabled,
|
||||||
|
readOnly = readOnly,
|
||||||
|
leadingIcon = leadingIcon,
|
||||||
|
trailingIcon = trailingIcon
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -49,7 +49,7 @@ fun AppTopAppBar(
|
|||||||
navigationIcon: @Composable (() -> Unit)? = null,
|
navigationIcon: @Composable (() -> Unit)? = null,
|
||||||
actions: @Composable RowScope.() -> Unit = {},
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
|
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
|
||||||
hintContent: Hint? = null
|
hint: Hint? = null
|
||||||
) {
|
) {
|
||||||
val sheetState = rememberModalBottomSheetState()
|
val sheetState = rememberModalBottomSheetState()
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
@@ -61,7 +61,7 @@ fun AppTopAppBar(
|
|||||||
colors = colors,
|
colors = colors,
|
||||||
title = {
|
title = {
|
||||||
val showHints = LocalShowHints.current
|
val showHints = LocalShowHints.current
|
||||||
if (showHints && hintContent != null) {
|
if (showHints && hint != null) {
|
||||||
// Simplified row: keeps the title and hint icon neatly centered together
|
// Simplified row: keeps the title and hint icon neatly centered together
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -114,7 +114,7 @@ fun AppTopAppBar(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (showBottomSheet) {
|
if (showBottomSheet) {
|
||||||
hintContent?.let {
|
hint?.let {
|
||||||
HintBottomSheet(
|
HintBottomSheet(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
@Suppress("AssignedValueIsNeverRead")
|
@Suppress("AssignedValueIsNeverRead")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.animation.core.spring
|
|||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -77,6 +78,7 @@ sealed class Screen(
|
|||||||
object Settings : Screen("settings", R.string.title_settings, AppIcons.SettingsFilled, AppIcons.SettingsOutlined)
|
object Settings : Screen("settings", R.string.title_settings, AppIcons.SettingsFilled, AppIcons.SettingsOutlined)
|
||||||
object More : Screen("more", R.string.label_more, AppIcons.MoreVert, AppIcons.MoreHorizontal)
|
object More : Screen("more", R.string.label_more, AppIcons.MoreVert, AppIcons.MoreHorizontal)
|
||||||
object Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
|
object Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
|
||||||
|
object Corrector : Screen("corrector", R.string.title_corrector, AppIcons.SpellCheck, AppIcons.SpellCheck)
|
||||||
object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
|
object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -88,6 +90,7 @@ sealed class Screen(
|
|||||||
val items = mutableListOf<Screen>()
|
val items = mutableListOf<Screen>()
|
||||||
items.add(Translation)
|
items.add(Translation)
|
||||||
items.add(Dictionary)
|
items.add(Dictionary)
|
||||||
|
items.add(Corrector)
|
||||||
items.add(Settings)
|
items.add(Settings)
|
||||||
if (showExperimental) {
|
if (showExperimental) {
|
||||||
items.add(Exercises)
|
items.add(Exercises)
|
||||||
@@ -258,7 +261,7 @@ fun BottomNavigationBar(
|
|||||||
.background(
|
.background(
|
||||||
brush = Brush.radialGradient(
|
brush = Brush.radialGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -271,6 +274,12 @@ fun BottomNavigationBar(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(playButtonSize)
|
.size(playButtonSize)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
|
// CHANGED: Added a border to give the button definition
|
||||||
|
.border(
|
||||||
|
width = 4.dp, // Adjust this thickness to your liking
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant, // Creates a nice "cutout" separation
|
||||||
|
shape = CircleShape
|
||||||
|
)
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
.clickable {
|
.clickable {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
@@ -281,7 +290,7 @@ fun BottomNavigationBar(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.PlayArrow,
|
imageVector = Icons.Filled.PlayArrow,
|
||||||
contentDescription = "Play",
|
contentDescription = "Play",
|
||||||
tint = Color.White,
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,17 @@
|
|||||||
|
|
||||||
package eu.gaudian.translator.view.composable
|
package eu.gaudian.translator.view.composable
|
||||||
|
|
||||||
import androidx.compose.animation.animateContentSize
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonColors
|
import androidx.compose.material3.ButtonColors
|
||||||
@@ -28,26 +22,19 @@ import androidx.compose.material3.CardDefaults
|
|||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CheckboxDefaults
|
import androidx.compose.material3.CheckboxDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedCard
|
import androidx.compose.material3.OutlinedCard
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.SwitchDefaults
|
import androidx.compose.material3.SwitchDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
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.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
@@ -57,10 +44,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.ui.theme.ThemePreviews
|
import eu.gaudian.translator.ui.theme.ThemePreviews
|
||||||
import eu.gaudian.translator.ui.theme.semanticColors
|
import eu.gaudian.translator.ui.theme.semanticColors
|
||||||
import eu.gaudian.translator.view.composable.ComponentDefaults.DefaultElevation
|
|
||||||
import eu.gaudian.translator.view.hints.Hint
|
|
||||||
import eu.gaudian.translator.view.hints.HintBottomSheet
|
|
||||||
import eu.gaudian.translator.view.hints.LocalShowHints
|
|
||||||
|
|
||||||
|
|
||||||
object ComponentDefaults {
|
object ComponentDefaults {
|
||||||
@@ -90,218 +73,6 @@ object ComponentDefaults {
|
|||||||
const val ALPHA_LOW = 0.3f
|
const val ALPHA_LOW = 0.3f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A styled card container for displaying content with a consistent floating look.
|
|
||||||
*
|
|
||||||
* @param modifier The modifier to be applied to the card.
|
|
||||||
* @param content The content to be displayed inside the card.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun AppCard(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
title: String? = null,
|
|
||||||
icon: ImageVector? = null,
|
|
||||||
text: String? = null,
|
|
||||||
expandable: Boolean = false,
|
|
||||||
initiallyExpanded: Boolean = false,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
hintContent : Hint? = null,
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
|
|
||||||
val showHints = LocalShowHints.current
|
|
||||||
|
|
||||||
val rotationState by animateFloatAsState(
|
|
||||||
targetValue = if (isExpanded) 180f else 0f,
|
|
||||||
label = "Chevron Rotation"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check if we need to render the header row
|
|
||||||
// Updated to include icon in the check
|
|
||||||
val hasHeader = title != null || text != null || expandable || icon != null
|
|
||||||
val canClickHeader = expandable || onClick != null
|
|
||||||
|
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (showBottomSheet) {
|
|
||||||
hintContent?.let {
|
|
||||||
HintBottomSheet(
|
|
||||||
onDismissRequest = { showBottomSheet = false },
|
|
||||||
content = it,
|
|
||||||
sheetState = rememberModalBottomSheetState(
|
|
||||||
skipPartiallyExpanded = true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.shadow(
|
|
||||||
DefaultElevation,
|
|
||||||
shape = ComponentDefaults.CardShape
|
|
||||||
)
|
|
||||||
.clip(ComponentDefaults.CardClipShape)
|
|
||||||
// Animate height changes when expanding/collapsing
|
|
||||||
.animateContentSize(),
|
|
||||||
shape = ComponentDefaults.CardShape,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
// --- Header Row ---
|
|
||||||
if (hasHeader) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(enabled = canClickHeader) {
|
|
||||||
if (expandable) {
|
|
||||||
isExpanded = !isExpanded
|
|
||||||
}
|
|
||||||
onClick?.invoke()
|
|
||||||
}
|
|
||||||
.padding(ComponentDefaults.CardPadding),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// 1. Optional Icon on the left
|
|
||||||
if (icon != null) {
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Title and Text Column
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
if (!title.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show spacer if both title and text exist
|
|
||||||
if (!title.isNullOrBlank() && !text.isNullOrBlank()) {
|
|
||||||
Spacer(Modifier.size(4.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!text.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showHints && hintContent != null) {
|
|
||||||
IconButton(onClick = { showBottomSheet = true }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = AppIcons.Help,
|
|
||||||
contentDescription = stringResource(R.string.show_hint),
|
|
||||||
tint = MaterialTheme.colorScheme.secondary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Expand Chevron (Far right)
|
|
||||||
if (expandable) {
|
|
||||||
Icon(
|
|
||||||
imageVector = AppIcons.ArrowDropDown,
|
|
||||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
|
||||||
modifier = Modifier.rotate(rotationState),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Content Area ---
|
|
||||||
if (!expandable || isExpanded) {
|
|
||||||
val contentModifier = Modifier
|
|
||||||
.padding(
|
|
||||||
start = ComponentDefaults.CardPadding,
|
|
||||||
end = ComponentDefaults.CardPadding,
|
|
||||||
bottom = ComponentDefaults.CardPadding,
|
|
||||||
// If we have a header, remove the top padding so content sits closer to the title.
|
|
||||||
// If no header (legacy behavior), keep the top padding.
|
|
||||||
top = if (hasHeader) 0.dp else ComponentDefaults.CardPadding
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!hasHeader && onClick != null) {
|
|
||||||
Column(
|
|
||||||
modifier = contentModifier.clickable { onClick() },
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Column(
|
|
||||||
modifier = contentModifier,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun AppCardPreview() {
|
|
||||||
AppCard {
|
|
||||||
Text(stringResource(R.string.this_is_the_content_inside_the_card))
|
|
||||||
PrimaryButton(onClick = { }, text = stringResource(R.string.label_continue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun AppCardPreview2() {
|
|
||||||
MaterialTheme {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(16.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
// 1. Expandable Card (Initially Collapsed)
|
|
||||||
AppCard(
|
|
||||||
title = "Advanced Settings",
|
|
||||||
text = "Click to reveal more options",
|
|
||||||
expandable = true,
|
|
||||||
initiallyExpanded = false
|
|
||||||
) {
|
|
||||||
Text("Here are some hidden settings.")
|
|
||||||
Text("They are only visible when expanded.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Expandable Card (Initially Expanded)
|
|
||||||
AppCard(
|
|
||||||
title = "Translation History",
|
|
||||||
text = "Recent items",
|
|
||||||
expandable = true,
|
|
||||||
initiallyExpanded = true
|
|
||||||
) {
|
|
||||||
Text("• Hello -> Hallo")
|
|
||||||
Text("• World -> Welt")
|
|
||||||
Text("• Sun -> Sonne")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Static Card (No Title/Expand logic - Legacy behavior)
|
|
||||||
AppCard {
|
|
||||||
Text("This is a standard card without a header.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The primary button for the most important actions.
|
* The primary button for the most important actions.
|
||||||
*
|
*
|
||||||
@@ -636,6 +407,7 @@ fun WrongOutlinedButtonPreview(){
|
|||||||
WrongOutlinedButton(onClick = { }, text = stringResource(R.string.label_continue))
|
WrongOutlinedButton(onClick = { }, text = stringResource(R.string.label_continue))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//This is basically just a wrapper for screens to control width (tablet mode) etc.
|
||||||
@Composable
|
@Composable
|
||||||
fun AppOutlinedCard(
|
fun AppOutlinedCard(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
|
||||||
|
|
||||||
|
package eu.gaudian.translator.view.composable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import eu.gaudian.translator.R
|
||||||
|
import eu.gaudian.translator.model.Language
|
||||||
|
import eu.gaudian.translator.model.VocabularyItem
|
||||||
|
import eu.gaudian.translator.utils.StatusMessageService
|
||||||
|
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CsvImportDialog(
|
||||||
|
showDialog: Boolean,
|
||||||
|
parsedTable: List<List<String>>,
|
||||||
|
languageViewModel: LanguageViewModel,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onImport: (List<VocabularyItem>) -> Unit,
|
||||||
|
statusMessageService: StatusMessageService
|
||||||
|
) {
|
||||||
|
if (!showDialog) return
|
||||||
|
|
||||||
|
var selectedColFirst by remember { mutableIntStateOf(0) }
|
||||||
|
var selectedColSecond by remember { mutableIntStateOf(1.coerceAtMost((parsedTable.maxOfOrNull { it.size } ?: 1) - 1)) }
|
||||||
|
var skipHeader by remember { mutableStateOf(true) }
|
||||||
|
var selectedLangFirst by remember { mutableStateOf<Language?>(null) }
|
||||||
|
var selectedLangSecond by remember { mutableStateOf<Language?>(null) }
|
||||||
|
|
||||||
|
val errorSelectTwoColumns = stringResource(R.string.error_select_two_columns)
|
||||||
|
val errorSelectLanguages = stringResource(R.string.error_select_languages)
|
||||||
|
val errorNoRowsToImport = stringResource(R.string.error_no_rows_to_import)
|
||||||
|
|
||||||
|
AppDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.label_import_table_csv_excel)) }
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
|
||||||
|
|
||||||
|
// First Column Selection
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
|
||||||
|
var menu1Expanded by remember { mutableStateOf(false) }
|
||||||
|
AppOutlinedButton(onClick = { menu1Expanded = true }) {
|
||||||
|
Text(stringResource(R.string.label_column_n, selectedColFirst + 1))
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = menu1Expanded, onDismissRequest = { menu1Expanded = false }) {
|
||||||
|
(0 until columnCount).forEach { idx ->
|
||||||
|
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("#${idx + 1} • $header") },
|
||||||
|
onClick = { selectedColFirst = idx; menu1Expanded = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second Column Selection
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(stringResource(R.string.label_second_column), modifier = Modifier.weight(1f))
|
||||||
|
var menu2Expanded by remember { mutableStateOf(false) }
|
||||||
|
AppOutlinedButton(onClick = { menu2Expanded = true }) {
|
||||||
|
Text(stringResource(R.string.label_column_n, selectedColSecond + 1))
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = menu2Expanded, onDismissRequest = { menu2Expanded = false }) {
|
||||||
|
(0 until columnCount).forEach { idx ->
|
||||||
|
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("#${idx + 1} • $header") },
|
||||||
|
onClick = { selectedColSecond = idx; menu2Expanded = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language Selection
|
||||||
|
Text(stringResource(R.string.label_languages))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(stringResource(R.string.label_first_language))
|
||||||
|
SingleLanguageDropDown(
|
||||||
|
languageViewModel = languageViewModel,
|
||||||
|
selectedLanguage = selectedLangFirst,
|
||||||
|
onLanguageSelected = { selectedLangFirst = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(stringResource(R.string.label_second_language))
|
||||||
|
SingleLanguageDropDown(
|
||||||
|
languageViewModel = languageViewModel,
|
||||||
|
selectedLanguage = selectedLangSecond,
|
||||||
|
onLanguageSelected = { selectedLangSecond = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip Header Checkbox
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.label_header_row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
val startIdx = if (skipHeader) 1 else 0
|
||||||
|
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
|
||||||
|
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
|
||||||
|
Text(stringResource(R.string.label_preview_first, previewA))
|
||||||
|
Text(stringResource(R.string.label_preview_second, previewB))
|
||||||
|
|
||||||
|
// Row Count
|
||||||
|
val totalRows = parsedTable.drop(startIdx).count { row ->
|
||||||
|
val a = row.getOrNull(selectedColFirst).orEmpty().isNotBlank()
|
||||||
|
val b = row.getOrNull(selectedColSecond).orEmpty().isNotBlank()
|
||||||
|
a || b
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.text_rows_to_import_1d, totalRows))
|
||||||
|
|
||||||
|
// Action Buttons
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.label_cancel))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
PrimaryButton(
|
||||||
|
onClick = {
|
||||||
|
if (selectedColFirst == selectedColSecond) {
|
||||||
|
statusMessageService.showErrorMessage(errorSelectTwoColumns)
|
||||||
|
return@PrimaryButton
|
||||||
|
}
|
||||||
|
val langA = selectedLangFirst
|
||||||
|
val langB = selectedLangSecond
|
||||||
|
if (langA == null || langB == null) {
|
||||||
|
statusMessageService.showErrorMessage(errorSelectLanguages)
|
||||||
|
return@PrimaryButton
|
||||||
|
}
|
||||||
|
val items = parsedTable.drop(startIdx).mapNotNull { row ->
|
||||||
|
val a = row.getOrNull(selectedColFirst)?.trim().orEmpty()
|
||||||
|
val b = row.getOrNull(selectedColSecond)?.trim().orEmpty()
|
||||||
|
if (a.isBlank() && b.isBlank()) null else VocabularyItem(
|
||||||
|
id = 0,
|
||||||
|
languageFirstId = langA.nameResId,
|
||||||
|
languageSecondId = langB.nameResId,
|
||||||
|
wordFirst = a,
|
||||||
|
wordSecond = b
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
statusMessageService.showErrorMessage(errorNoRowsToImport)
|
||||||
|
return@PrimaryButton
|
||||||
|
}
|
||||||
|
onImport(items)
|
||||||
|
},
|
||||||
|
text = stringResource(R.string.label_import)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package eu.gaudian.translator.view.dialogs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import eu.gaudian.translator.R
|
||||||
|
import eu.gaudian.translator.ui.theme.ThemePreviews
|
||||||
|
import eu.gaudian.translator.view.composable.AppDialog
|
||||||
|
import eu.gaudian.translator.view.composable.AppOutlinedTextField
|
||||||
|
import eu.gaudian.translator.view.composable.AppSlider
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RequestMorePackDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var topic by remember { mutableStateOf("") }
|
||||||
|
var langFrom by remember { mutableStateOf("") }
|
||||||
|
var langTo by remember { mutableStateOf("") }
|
||||||
|
var amount by remember { mutableFloatStateOf(50f) }
|
||||||
|
|
||||||
|
AppDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Request a Pack", fontWeight = FontWeight.Bold) },
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.text_request_pack_desc),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.label_topic),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
AppOutlinedTextField(
|
||||||
|
value = topic,
|
||||||
|
onValueChange = { topic = it },
|
||||||
|
placeholder = { Text("e.g. Travel, Business, Cooking…") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.label_languages),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.label_optional),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
AppOutlinedTextField(
|
||||||
|
value = langFrom,
|
||||||
|
onValueChange = { langFrom = it },
|
||||||
|
placeholder = { Text(stringResource(R.string.label_from)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
AppOutlinedTextField(
|
||||||
|
value = langTo,
|
||||||
|
onValueChange = { langTo = it },
|
||||||
|
placeholder = { Text(stringResource(R.string.label_to)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Approx. word count: ~${amount.roundToInt()} words",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
AppSlider(
|
||||||
|
value = amount,
|
||||||
|
onValueChange = { amount = it },
|
||||||
|
valueRange = 10f..200f,
|
||||||
|
steps = 18,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.label_cancel)) }
|
||||||
|
TextButton(
|
||||||
|
enabled = topic.isNotBlank(),
|
||||||
|
onClick = {
|
||||||
|
val subject = "Polly Pack Request – $topic"
|
||||||
|
val langPart = buildString {
|
||||||
|
val from = langFrom.trim()
|
||||||
|
val to = langTo.trim()
|
||||||
|
if (from.isNotBlank() || to.isNotBlank()) {
|
||||||
|
append("Languages: ${from.ifBlank { "?" }} → ${to.ifBlank { "?" }}\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val body = buildString {
|
||||||
|
appendLine("Hey Jonas,")
|
||||||
|
appendLine()
|
||||||
|
appendLine("Please add the following vocabulary pack to Polly:")
|
||||||
|
appendLine()
|
||||||
|
appendLine("Topic: $topic")
|
||||||
|
if (langPart.isNotBlank()) append(langPart)
|
||||||
|
appendLine("Word count: ~${amount.roundToInt()} words")
|
||||||
|
appendLine()
|
||||||
|
appendLine("Thank you!")
|
||||||
|
}
|
||||||
|
val intent = android.content.Intent(android.content.Intent.ACTION_SENDTO).apply {
|
||||||
|
data = "mailto:play@gaudian.eu".toUri()
|
||||||
|
putExtra(android.content.Intent.EXTRA_EMAIL, arrayOf("play@gaudian.eu"))
|
||||||
|
putExtra(android.content.Intent.EXTRA_SUBJECT, subject)
|
||||||
|
putExtra(android.content.Intent.EXTRA_TEXT, body)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.label_send_request),
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ThemePreviews
|
||||||
|
@Composable
|
||||||
|
fun RequestMorePackDialogPreview() {
|
||||||
|
RequestMorePackDialog(
|
||||||
|
onDismiss = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ fun VocabularyReviewScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
AppTopAppBar(
|
AppTopAppBar(
|
||||||
title = stringResource(R.string.found_items),
|
title = stringResource(R.string.found_items),
|
||||||
hintContent = HintDefinition.REVIEW.hint()
|
hint = HintDefinition.REVIEW.hint()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
|
|||||||
@@ -60,12 +60,16 @@ import androidx.compose.ui.text.input.TextFieldValue
|
|||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavController
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.ui.theme.semanticColors
|
import eu.gaudian.translator.ui.theme.semanticColors
|
||||||
|
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.AppIcons
|
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.AppSwitch
|
import eu.gaudian.translator.view.composable.AppSwitch
|
||||||
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown
|
import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown
|
||||||
import eu.gaudian.translator.view.composable.DropdownDefaults
|
import eu.gaudian.translator.view.composable.DropdownDefaults
|
||||||
import eu.gaudian.translator.view.composable.LargeDropdownMenuItem
|
import eu.gaudian.translator.view.composable.LargeDropdownMenuItem
|
||||||
@@ -73,12 +77,15 @@ import eu.gaudian.translator.viewmodel.CorrectionViewModel
|
|||||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// 1. STATEFUL COMPONENT (Connects to ViewModels)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CorrectionScreen(
|
fun CorrectionScreen(
|
||||||
correctionViewModel: CorrectionViewModel,
|
navController: NavController
|
||||||
languageViewModel: LanguageViewModel
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
val activity = LocalContext.current.findActivity()
|
||||||
|
val correctionViewModel: CorrectionViewModel = hiltViewModel(activity)
|
||||||
|
val languageViewModel : LanguageViewModel = hiltViewModel(activity)
|
||||||
val textFieldValue by correctionViewModel.textFieldValue.collectAsState()
|
val textFieldValue by correctionViewModel.textFieldValue.collectAsState()
|
||||||
val explanation by correctionViewModel.explanation.collectAsState()
|
val explanation by correctionViewModel.explanation.collectAsState()
|
||||||
val isLoading by correctionViewModel.isLoading.collectAsState()
|
val isLoading by correctionViewModel.isLoading.collectAsState()
|
||||||
@@ -89,6 +96,15 @@ fun CorrectionScreen(
|
|||||||
|
|
||||||
val successColor = MaterialTheme.semanticColors.success
|
val successColor = MaterialTheme.semanticColors.success
|
||||||
|
|
||||||
|
Column(){
|
||||||
|
|
||||||
|
AppTopAppBar(
|
||||||
|
title = stringResource(R.string.label_correction),
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
CorrectionScreenContent(
|
CorrectionScreenContent(
|
||||||
textFieldValue = textFieldValue,
|
textFieldValue = textFieldValue,
|
||||||
explanation = explanation,
|
explanation = explanation,
|
||||||
@@ -114,6 +130,7 @@ fun CorrectionScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. STATELESS COMPONENT (Handles UI Layout)
|
// 2. STATELESS COMPONENT (Handles UI Layout)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -304,7 +321,6 @@ fun CorrectionScreenContent(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
|||||||
@@ -342,11 +342,13 @@ fun DictionarySimpleTopBar(
|
|||||||
languageName: String?,
|
languageName: String?,
|
||||||
onNavigateBack: () -> Unit
|
onNavigateBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
word?.let {
|
||||||
AppTopAppBar(
|
AppTopAppBar(
|
||||||
title = "TODO",
|
title = it,
|
||||||
onNavigateBack = onNavigateBack
|
onNavigateBack = onNavigateBack
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DefinitionBody(
|
fun DefinitionBody(
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package eu.gaudian.translator.view.dictionary
|
package eu.gaudian.translator.view.dictionary
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.utils.findActivity
|
import eu.gaudian.translator.utils.findActivity
|
||||||
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
import eu.gaudian.translator.viewmodel.DictionaryViewModel
|
import eu.gaudian.translator.viewmodel.DictionaryViewModel
|
||||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||||
|
|
||||||
@@ -20,6 +24,11 @@ fun DictionaryScreen(
|
|||||||
val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
AppTopAppBar(
|
||||||
|
title = stringResource(R.string.label_dictionary),
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
|
||||||
// Use the new refactored component
|
// Use the new refactored component
|
||||||
DictionaryScreenContent(
|
DictionaryScreenContent(
|
||||||
@@ -30,6 +39,7 @@ fun DictionaryScreen(
|
|||||||
onNavigateToOptions = onNavigateToOptions
|
onNavigateToOptions = onNavigateToOptions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ fun EtymologyResultScreen(
|
|||||||
AppScaffold(
|
AppScaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
AppTopAppBar(
|
AppTopAppBar(
|
||||||
title = "TODO",
|
title = "Result",
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
actions = {
|
actions = {
|
||||||
etymologyData?.let { data ->
|
etymologyData?.let { data ->
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
@file:Suppress("HardCodedStringLiteral")
|
|
||||||
|
|
||||||
package eu.gaudian.translator.view.dictionary
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import eu.gaudian.translator.R
|
|
||||||
import eu.gaudian.translator.ui.theme.ThemePreviews
|
|
||||||
import eu.gaudian.translator.utils.findActivity
|
|
||||||
import eu.gaudian.translator.view.LocalConnectionConfigured
|
|
||||||
import eu.gaudian.translator.view.NoConnectionScreen
|
|
||||||
import eu.gaudian.translator.view.composable.AppIcons
|
|
||||||
import eu.gaudian.translator.view.composable.AppTabLayout
|
|
||||||
import eu.gaudian.translator.view.composable.Screen
|
|
||||||
import eu.gaudian.translator.view.composable.TabItem
|
|
||||||
import eu.gaudian.translator.view.settings.SettingsRoutes
|
|
||||||
import eu.gaudian.translator.viewmodel.CorrectionViewModel
|
|
||||||
import eu.gaudian.translator.viewmodel.DictionaryViewModel
|
|
||||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun getDictionaryTabs(): List<TabItem> {
|
|
||||||
return listOf(
|
|
||||||
DictionaryTab(stringResource(R.string.label_dictionary), AppIcons.Dictionary),
|
|
||||||
DictionaryTab(stringResource(R.string.title_corrector), AppIcons.Check)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
private data class DictionaryTab(override val title: String, override val icon: ImageVector) :
|
|
||||||
TabItem
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun MainDictionaryScreen(
|
|
||||||
navController: NavController
|
|
||||||
) {
|
|
||||||
|
|
||||||
|
|
||||||
val activity = LocalContext.current.findActivity()
|
|
||||||
|
|
||||||
val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
|
||||||
val correctionViewModel: CorrectionViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
|
||||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
|
||||||
val dictionaryTabs = getDictionaryTabs()
|
|
||||||
|
|
||||||
val connectionConfigured = LocalConnectionConfigured.current
|
|
||||||
|
|
||||||
if (!connectionConfigured) {
|
|
||||||
NoConnectionScreen(onSettingsClick = {navController.navigate(SettingsRoutes.API_KEY)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var selectedTab by remember { mutableStateOf(dictionaryTabs[0]) }
|
|
||||||
Column {
|
|
||||||
AppTabLayout(
|
|
||||||
tabs = dictionaryTabs,
|
|
||||||
selectedTab = selectedTab,
|
|
||||||
onTabSelected = { selectedTab = it },
|
|
||||||
onNavigateBack = {
|
|
||||||
if (!navController.popBackStack()) {
|
|
||||||
navController.navigate(Screen.Home.route) {
|
|
||||||
launchSingleTop = true
|
|
||||||
restoreState = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
when (selectedTab) {
|
|
||||||
dictionaryTabs[0] -> DictionaryScreen(
|
|
||||||
navController = navController,
|
|
||||||
onEntryClick = { entry ->
|
|
||||||
// Set flag indicating navigation is from external source (not DictionaryResultScreen)
|
|
||||||
dictionaryViewModel.setNavigatingFromDictionaryResult(false)
|
|
||||||
navController.navigate("dictionary_result/${entry.id}")
|
|
||||||
},
|
|
||||||
onNavigateToOptions = {
|
|
||||||
navController.navigate("dictionary_options")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
dictionaryTabs[1] -> CorrectionScreen(
|
|
||||||
correctionViewModel = correctionViewModel,
|
|
||||||
languageViewModel = languageViewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ThemePreviews
|
|
||||||
@Composable
|
|
||||||
fun DictionaryHostScreenPreview() {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
|
|
||||||
MainDictionaryScreen(
|
|
||||||
navController = navController,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary
|
package eu.gaudian.translator.view.exercises
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.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
|
||||||
@@ -23,6 +23,8 @@ import eu.gaudian.translator.view.composable.AppOutlinedButton
|
|||||||
import eu.gaudian.translator.view.composable.AppOutlinedTextField
|
import eu.gaudian.translator.view.composable.AppOutlinedTextField
|
||||||
import eu.gaudian.translator.view.composable.CorrectButton
|
import eu.gaudian.translator.view.composable.CorrectButton
|
||||||
import eu.gaudian.translator.view.composable.WrongButton
|
import eu.gaudian.translator.view.composable.WrongButton
|
||||||
|
import eu.gaudian.translator.view.vocabulary.VocabularyExerciseAction
|
||||||
|
import eu.gaudian.translator.view.vocabulary.VocabularyExerciseState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ExerciseControls(
|
fun ExerciseControls(
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary
|
package eu.gaudian.translator.view.exercises
|
||||||
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
@@ -26,7 +26,8 @@ enum class HintDefinition(
|
|||||||
REVIEW("review_hint", R.string.review_intro),
|
REVIEW("review_hint", R.string.review_intro),
|
||||||
SORTING("sorting_hint", R.string.sorting_hint_title),
|
SORTING("sorting_hint", R.string.sorting_hint_title),
|
||||||
TRANSLATION("translation_hint", R.string.hint_translate_how_it_works),
|
TRANSLATION("translation_hint", R.string.hint_translate_how_it_works),
|
||||||
VOCABULARY_PROGRESS("vocabulary_progress_hint", R.string.hint_vocabulary_progress_hint_title);
|
VOCABULARY_PROGRESS("vocabulary_progress_hint", R.string.hint_vocabulary_progress_hint_title),
|
||||||
|
EXPLORE_PACKS("explore_packs_hint", R.string.hint_explore_packs_title);
|
||||||
|
|
||||||
/** Creates the Hint data class for this hint definition. */
|
/** Creates the Hint data class for this hint definition. */
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package eu.gaudian.translator.view.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import eu.gaudian.translator.R
|
||||||
|
import eu.gaudian.translator.utils.findActivity
|
||||||
|
import eu.gaudian.translator.view.NavigationRoutes
|
||||||
|
import eu.gaudian.translator.view.composable.AppButton
|
||||||
|
import eu.gaudian.translator.view.composable.AppScaffold
|
||||||
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
|
import eu.gaudian.translator.view.library.VocabularyCard
|
||||||
|
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||||
|
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DailyReviewScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = context.findActivity()
|
||||||
|
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
|
||||||
|
val dueTodayItems by vocabularyViewModel.dueTodayItems.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
AppScaffold(
|
||||||
|
topBar = {
|
||||||
|
AppTopAppBar(
|
||||||
|
title = stringResource(R.string.label_daily_review),
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.fillMaxSize()
|
||||||
|
) { paddingValues ->
|
||||||
|
if (dueTodayItems.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
modifier = Modifier.size(200.dp),
|
||||||
|
painter = painterResource(id = R.drawable.ic_nothing_found),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_items_due_for_review),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(16.dp)
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = dueTodayItems,
|
||||||
|
key = { it.id }
|
||||||
|
) { item ->
|
||||||
|
VocabularyCard(
|
||||||
|
item = item,
|
||||||
|
allLanguages = allLanguages,
|
||||||
|
isSelected = false,
|
||||||
|
onItemClick = {
|
||||||
|
vocabularyViewModel.setNavigationContext(dueTodayItems, item.id)
|
||||||
|
navController.navigate("${NavigationRoutes.VOCABULARY_DETAIL}/${item.id}")
|
||||||
|
},
|
||||||
|
onItemLongClick = { },
|
||||||
|
onDeleteClick = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Add spacing at the bottom for the button
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Exercise Button (fixed at bottom)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
AppButton(
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(NavigationRoutes.START_EXERCISE_DAILY)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp),
|
||||||
|
enabled = dueTodayItems.isNotEmpty(),
|
||||||
|
shape = RoundedCornerShape(28.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_start_exercise_2d, dueTodayItems.size),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayArrow,
|
||||||
|
contentDescription = stringResource(R.string.cd_play),
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
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.fillMaxHeight
|
||||||
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.height
|
||||||
@@ -27,7 +29,6 @@ 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.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -47,9 +48,10 @@ import eu.gaudian.translator.R
|
|||||||
import eu.gaudian.translator.utils.findActivity
|
import eu.gaudian.translator.utils.findActivity
|
||||||
import eu.gaudian.translator.view.NavigationRoutes
|
import eu.gaudian.translator.view.NavigationRoutes
|
||||||
import eu.gaudian.translator.view.composable.AppCard
|
import eu.gaudian.translator.view.composable.AppCard
|
||||||
|
import eu.gaudian.translator.view.composable.LabeledSection
|
||||||
import eu.gaudian.translator.view.composable.Screen
|
import eu.gaudian.translator.view.composable.Screen
|
||||||
import eu.gaudian.translator.view.settings.SettingsRoutes
|
import eu.gaudian.translator.view.settings.SettingsRoutes
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
|
import eu.gaudian.translator.view.stats.widgets.WeeklyActivityChartWidget
|
||||||
import eu.gaudian.translator.viewmodel.ProgressViewModel
|
import eu.gaudian.translator.viewmodel.ProgressViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -62,6 +64,7 @@ fun HomeScreen(
|
|||||||
val streak by viewModel.streak.collectAsState()
|
val streak by viewModel.streak.collectAsState()
|
||||||
val dailyGoal by viewModel.dailyGoal.collectAsState()
|
val dailyGoal by viewModel.dailyGoal.collectAsState()
|
||||||
val todayCompletedCount by viewModel.todayCompletedCount.collectAsState()
|
val todayCompletedCount by viewModel.todayCompletedCount.collectAsState()
|
||||||
|
val dueTodayCount by viewModel.dueTodayCount.collectAsState()
|
||||||
|
|
||||||
// Calculate daily goal progress
|
// Calculate daily goal progress
|
||||||
val progress = if (dailyGoal > 0) {
|
val progress = if (dailyGoal > 0) {
|
||||||
@@ -95,13 +98,12 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
//TODO replace with actual implementation
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
ActionCard(
|
ActionCard(
|
||||||
title = "Daily Review",
|
title = stringResource(R.string.label_daily_review),
|
||||||
subtitle = "42 words need attention",
|
subtitle = stringResource(R.string.desc_daily_review_due, dueTodayCount),
|
||||||
icon = Icons.Default.Psychology,
|
icon = Icons.Default.Psychology,
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
onClick = { navController.navigate(NavigationRoutes.DAILY_REVIEW) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -146,6 +148,9 @@ fun TopProfileSection(navController: NavHostController, context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { navController.navigate(Screen.Settings.route) },
|
onClick = { navController.navigate(Screen.Settings.route) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -158,6 +163,7 @@ fun TopProfileSection(navController: NavHostController, context: Context) {
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,20 +358,12 @@ fun WeeklyProgressSection(
|
|||||||
val viewModel: ProgressViewModel = hiltViewModel(activity)
|
val viewModel: ProgressViewModel = hiltViewModel(activity)
|
||||||
val weeklyActivityStats by viewModel.weeklyActivityStats.collectAsState()
|
val weeklyActivityStats by viewModel.weeklyActivityStats.collectAsState()
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
LabeledSection(
|
||||||
Row(
|
title = stringResource(R.string.label_weekly_progress),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
actionLabel = stringResource(R.string.label_see_history),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
onActionClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
|
||||||
) {
|
) {
|
||||||
Text(text = stringResource(R.string.label_weekly_progress), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
TextButton(onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }) {
|
|
||||||
Text(stringResource(R.string.label_see_history))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
AppCard(
|
AppCard(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
|
onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
|
||||||
@@ -401,12 +399,16 @@ fun BottomStatsSection(
|
|||||||
val learnedWords by viewModel.totalWordsCompleted.collectAsState()
|
val learnedWords by viewModel.totalWordsCompleted.collectAsState()
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(IntrinsicSize.Min),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Total Words
|
// Total Words
|
||||||
AppCard(
|
AppCard(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
onClick = { navController.navigate(Screen.Library.route) }
|
onClick = { navController.navigate(Screen.Library.route) }
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
Column(modifier = Modifier.padding(20.dp)) {
|
||||||
@@ -419,7 +421,9 @@ fun BottomStatsSection(
|
|||||||
|
|
||||||
// Learned
|
// Learned
|
||||||
AppCard(
|
AppCard(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
onClick = { navController.navigate(NavigationRoutes.STATS_LANGUAGE_PROGRESS) }
|
onClick = { navController.navigate(NavigationRoutes.STATS_LANGUAGE_PROGRESS) }
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(20.dp)) {
|
Column(modifier = Modifier.padding(20.dp)) {
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ import androidx.compose.material.icons.filled.Add
|
|||||||
import androidx.compose.material.icons.filled.AddCircleOutline
|
import androidx.compose.material.icons.filled.AddCircleOutline
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.LocalMall
|
import androidx.compose.material.icons.filled.LocalMall
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
|
import androidx.compose.material.icons.rounded.Check
|
||||||
|
import androidx.compose.material.icons.rounded.Star
|
||||||
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
|
||||||
@@ -58,6 +60,7 @@ import androidx.compose.ui.draw.drawBehind
|
|||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.PathEffect
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -70,6 +73,8 @@ import eu.gaudian.translator.R
|
|||||||
import eu.gaudian.translator.model.Language
|
import eu.gaudian.translator.model.Language
|
||||||
import eu.gaudian.translator.model.VocabularyCategory
|
import eu.gaudian.translator.model.VocabularyCategory
|
||||||
import eu.gaudian.translator.model.VocabularyItem
|
import eu.gaudian.translator.model.VocabularyItem
|
||||||
|
import eu.gaudian.translator.model.VocabularyStage
|
||||||
|
import eu.gaudian.translator.view.composable.AppCard
|
||||||
import eu.gaudian.translator.view.composable.AppIcons
|
import eu.gaudian.translator.view.composable.AppIcons
|
||||||
import eu.gaudian.translator.view.composable.insertBreakOpportunities
|
import eu.gaudian.translator.view.composable.insertBreakOpportunities
|
||||||
|
|
||||||
@@ -118,6 +123,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
|
||||||
@@ -126,22 +132,29 @@ fun SelectionTopBar(
|
|||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
// 1. Close Button
|
||||||
IconButton(onClick = onCloseClick) {
|
IconButton(onClick = onCloseClick) {
|
||||||
Icon(imageVector = AppIcons.Close, contentDescription = stringResource(R.string.label_close_selection_mode))
|
Icon(
|
||||||
}
|
imageVector = AppIcons.Close,
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
contentDescription = stringResource(R.string.label_close_selection_mode)
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.d_selected, selectionCount),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
|
||||||
|
// 2. Title Text (Gets weight to prevent pushing icons off-screen)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.d_selected, selectionCount),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Action Icons Group
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
IconButton(onClick = onSelectAllClick) {
|
IconButton(onClick = onSelectAllClick) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = AppIcons.SelectAll,
|
imageVector = AppIcons.SelectAll,
|
||||||
@@ -159,6 +172,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 = {
|
||||||
@@ -311,10 +332,12 @@ fun AllCardsView(
|
|||||||
vocabularyItems: List<VocabularyItem>,
|
vocabularyItems: List<VocabularyItem>,
|
||||||
allLanguages: List<Language>,
|
allLanguages: List<Language>,
|
||||||
selection: Set<Long>,
|
selection: Set<Long>,
|
||||||
|
stageMapping: Map<Int, VocabularyStage> = emptyMap(),
|
||||||
onItemClick: (VocabularyItem) -> Unit,
|
onItemClick: (VocabularyItem) -> Unit,
|
||||||
onItemLongClick: (VocabularyItem) -> Unit,
|
onItemLongClick: (VocabularyItem) -> Unit,
|
||||||
onDeleteClick: (VocabularyItem) -> Unit,
|
onDeleteClick: (VocabularyItem) -> Unit,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
|
onAddClick: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (vocabularyItems.isEmpty()) {
|
if (vocabularyItems.isEmpty()) {
|
||||||
@@ -337,11 +360,26 @@ fun AllCardsView(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
|
if (onAddClick != null) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
androidx.compose.material3.Button(
|
||||||
|
onClick = onAddClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.6f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(text = stringResource(R.string.label_add_vocabulary))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 100.dp)
|
contentPadding = PaddingValues(bottom = 100.dp)
|
||||||
) {
|
) {
|
||||||
@@ -350,10 +388,12 @@ fun AllCardsView(
|
|||||||
key = { it.id }
|
key = { it.id }
|
||||||
) { item ->
|
) { item ->
|
||||||
val isSelected = selection.contains(item.id.toLong())
|
val isSelected = selection.contains(item.id.toLong())
|
||||||
|
val stage = stageMapping[item.id] ?: VocabularyStage.NEW
|
||||||
VocabularyCard(
|
VocabularyCard(
|
||||||
item = item,
|
item = item,
|
||||||
allLanguages = allLanguages,
|
allLanguages = allLanguages,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
|
stage = stage,
|
||||||
onItemClick = { onItemClick(item) },
|
onItemClick = { onItemClick(item) },
|
||||||
onItemLongClick = { onItemLongClick(item) },
|
onItemLongClick = { onItemLongClick(item) },
|
||||||
onDeleteClick = { onDeleteClick(item) }
|
onDeleteClick = { onDeleteClick(item) }
|
||||||
@@ -363,14 +403,12 @@ fun AllCardsView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Individual vocabulary card component
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VocabularyCard(
|
fun VocabularyCard(
|
||||||
item: VocabularyItem,
|
item: VocabularyItem,
|
||||||
allLanguages: List<Language>,
|
allLanguages: List<Language>,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
|
stage: VocabularyStage = VocabularyStage.NEW,
|
||||||
onItemClick: () -> Unit,
|
onItemClick: () -> Unit,
|
||||||
onItemLongClick: () -> Unit,
|
onItemLongClick: () -> Unit,
|
||||||
onDeleteClick: () -> Unit,
|
onDeleteClick: () -> Unit,
|
||||||
@@ -383,14 +421,15 @@ fun VocabularyCard(
|
|||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(12.dp)) // Slightly rounder for a modern look
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onClick = onItemClick,
|
onClick = onItemClick,
|
||||||
onLongClick = onItemLongClick
|
onLongClick = onItemLongClick
|
||||||
),
|
),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.surfaceContainer
|
// Fixed the contentColor bug here:
|
||||||
|
contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
|
||||||
),
|
),
|
||||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
|
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
|
||||||
) {
|
) {
|
||||||
@@ -401,52 +440,48 @@ fun VocabularyCard(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(end = 12.dp) // Ensures text doesn't bleed into the trailing icon
|
||||||
|
) {
|
||||||
// Top row: First word + Language Pill
|
// Top row: First word + Language Pill
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = insertBreakOpportunities(item.wordFirst),
|
text = insertBreakOpportunities(item.wordFirst),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
// This modifier allows the text to wrap without squishing the pill
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Surface(
|
LanguagePill(
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
|
|
||||||
shape = RoundedCornerShape(4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = langFirst,
|
text = langFirst,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
backgroundColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
textColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(6.dp)) // Slightly more breathing room
|
||||||
|
|
||||||
// Bottom row: Second word + Language Pill
|
// Bottom row: Second word + Language Pill
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = insertBreakOpportunities(item.wordSecond),
|
text = insertBreakOpportunities(item.wordSecond),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
// Applied to the second text as well for consistency
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Surface(
|
LanguagePill(
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = langSecond,
|
text = langSecond,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
textColor = MaterialTheme.colorScheme.primary
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -455,16 +490,122 @@ fun VocabularyCard(
|
|||||||
tint = MaterialTheme.colorScheme.primary
|
tint = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
IconButton(onClick = { /* Options menu could go here */ }) {
|
// Stage indicator showing the vocabulary item's learning stage
|
||||||
|
StageIndicator(stage = stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StageIndicator(
|
||||||
|
stage: VocabularyStage,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
// Convert VocabularyStage to a step number (0-6)
|
||||||
|
val step = when (stage) {
|
||||||
|
VocabularyStage.NEW -> 0
|
||||||
|
VocabularyStage.STAGE_1 -> 1
|
||||||
|
VocabularyStage.STAGE_2 -> 2
|
||||||
|
VocabularyStage.STAGE_3 -> 3
|
||||||
|
VocabularyStage.STAGE_4 -> 4
|
||||||
|
VocabularyStage.STAGE_5 -> 5
|
||||||
|
VocabularyStage.LEARNED -> 6
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Calculate how full the ring should be (0.0 to 1.0)
|
||||||
|
val maxSteps = 6f
|
||||||
|
val progress = step / maxSteps
|
||||||
|
|
||||||
|
// 2. Determine the ring color based on the stage
|
||||||
|
val indicatorColor = when (stage) {
|
||||||
|
VocabularyStage.NEW -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
|
||||||
|
VocabularyStage.STAGE_1, VocabularyStage.STAGE_2 -> Color(0xFFE57373) // Soft Red
|
||||||
|
VocabularyStage.STAGE_3 -> Color(0xFFFFB74D) // Soft Orange
|
||||||
|
VocabularyStage.STAGE_4 -> Color(0xFFFFD54F) // Soft Yellow
|
||||||
|
VocabularyStage.STAGE_5 -> Color(0xFFAED581) // Light Green
|
||||||
|
VocabularyStage.LEARNED -> Color(0xFF81C784) // Solid Green
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = modifier.size(36.dp) // Keeps it neatly sized within the row
|
||||||
|
) {
|
||||||
|
// The background track (empty ring)
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { 1f },
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
// The colored progress ring
|
||||||
|
if (stage != VocabularyStage.NEW) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { progress },
|
||||||
|
color = indicatorColor,
|
||||||
|
strokeWidth = 3.dp,
|
||||||
|
strokeCap = StrokeCap.Round, // Gives the progress bar nice rounded ends
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The center content (Number or Icon)
|
||||||
|
when (stage) {
|
||||||
|
VocabularyStage.NEW -> {
|
||||||
|
// An empty dot or small icon to denote it's untouched
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.MoreVert,
|
imageVector = Icons.Rounded.Star, // Or any generic 'new' icon
|
||||||
contentDescription = stringResource(R.string.cd_options),
|
contentDescription = "New Word",
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
VocabularyStage.LEARNED -> {
|
||||||
|
// A checkmark for mastery
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Check,
|
||||||
|
contentDescription = "Learned",
|
||||||
|
tint = indicatorColor,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Display the actual level number (1 through 5)
|
||||||
|
Text(
|
||||||
|
text = step.toString(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Extracted for consistency and cleaner code
|
||||||
|
@Composable
|
||||||
|
private fun LanguagePill(
|
||||||
|
text: String,
|
||||||
|
backgroundColor: Color,
|
||||||
|
textColor: Color
|
||||||
|
) {
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
Surface(
|
||||||
|
color = backgroundColor,
|
||||||
|
shape = RoundedCornerShape(6.dp) // Consistent corner rounding for all pills
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = textColor,
|
||||||
|
// Guaranteed to never wrap awkwardly
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -506,13 +647,11 @@ fun CategoryCard(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Card(
|
AppCard(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(140.dp)
|
.height(140.dp)
|
||||||
.clickable(onClick = onClick),
|
.clickable(onClick = onClick),
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -651,6 +790,7 @@ fun SelectionTopBarPreview() {
|
|||||||
onDeleteClick = {},
|
onDeleteClick = {},
|
||||||
onMoveToCategoryClick = {},
|
onMoveToCategoryClick = {},
|
||||||
onMoveToStageClick = {},
|
onMoveToStageClick = {},
|
||||||
|
onExportClick = {},
|
||||||
isRemoveEnabled = true,
|
isRemoveEnabled = true,
|
||||||
onRemoveFromCategoryClick = {}
|
onRemoveFromCategoryClick = {}
|
||||||
)
|
)
|
||||||
@@ -699,6 +839,7 @@ fun VocabularyCardPreview() {
|
|||||||
),
|
),
|
||||||
allLanguages = emptyList(),
|
allLanguages = emptyList(),
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
|
stage = VocabularyStage.NEW,
|
||||||
onItemClick = {},
|
onItemClick = {},
|
||||||
onItemLongClick = {},
|
onItemLongClick = {},
|
||||||
onDeleteClick = {}
|
onDeleteClick = {}
|
||||||
@@ -706,6 +847,154 @@ fun VocabularyCardPreview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("HardCodedStringLiteral")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun VocabularyCardStage1Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
VocabularyCard(
|
||||||
|
item = VocabularyItem(
|
||||||
|
id = 2,
|
||||||
|
wordFirst = "Goodbye",
|
||||||
|
wordSecond = "Adiós",
|
||||||
|
languageFirstId = 1,
|
||||||
|
languageSecondId = 2,
|
||||||
|
createdAt = null,
|
||||||
|
features = null,
|
||||||
|
zipfFrequencyFirst = null,
|
||||||
|
zipfFrequencySecond = null
|
||||||
|
),
|
||||||
|
allLanguages = emptyList(),
|
||||||
|
isSelected = false,
|
||||||
|
stage = VocabularyStage.STAGE_1,
|
||||||
|
onItemClick = {},
|
||||||
|
onItemLongClick = {},
|
||||||
|
onDeleteClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("HardCodedStringLiteral")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun VocabularyCardStage3Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
VocabularyCard(
|
||||||
|
item = VocabularyItem(
|
||||||
|
id = 3,
|
||||||
|
wordFirst = "Thank you",
|
||||||
|
wordSecond = "Gracias",
|
||||||
|
languageFirstId = 1,
|
||||||
|
languageSecondId = 2,
|
||||||
|
createdAt = null,
|
||||||
|
features = null,
|
||||||
|
zipfFrequencyFirst = null,
|
||||||
|
zipfFrequencySecond = null
|
||||||
|
),
|
||||||
|
allLanguages = emptyList(),
|
||||||
|
isSelected = false,
|
||||||
|
stage = VocabularyStage.STAGE_3,
|
||||||
|
onItemClick = {},
|
||||||
|
onItemLongClick = {},
|
||||||
|
onDeleteClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("HardCodedStringLiteral")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun VocabularyCardStage5Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
VocabularyCard(
|
||||||
|
item = VocabularyItem(
|
||||||
|
id = 4,
|
||||||
|
wordFirst = "Please",
|
||||||
|
wordSecond = "Por favor",
|
||||||
|
languageFirstId = 1,
|
||||||
|
languageSecondId = 2,
|
||||||
|
createdAt = null,
|
||||||
|
features = null,
|
||||||
|
zipfFrequencyFirst = null,
|
||||||
|
zipfFrequencySecond = null
|
||||||
|
),
|
||||||
|
allLanguages = emptyList(),
|
||||||
|
isSelected = false,
|
||||||
|
stage = VocabularyStage.STAGE_5,
|
||||||
|
onItemClick = {},
|
||||||
|
onItemLongClick = {},
|
||||||
|
onDeleteClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("HardCodedStringLiteral")
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun VocabularyCardLearnedPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
VocabularyCard(
|
||||||
|
item = VocabularyItem(
|
||||||
|
id = 5,
|
||||||
|
wordFirst = "Yes",
|
||||||
|
wordSecond = "Sí",
|
||||||
|
languageFirstId = 1,
|
||||||
|
languageSecondId = 2,
|
||||||
|
createdAt = null,
|
||||||
|
features = null,
|
||||||
|
zipfFrequencyFirst = null,
|
||||||
|
zipfFrequencySecond = null
|
||||||
|
),
|
||||||
|
allLanguages = emptyList(),
|
||||||
|
isSelected = false,
|
||||||
|
stage = VocabularyStage.LEARNED,
|
||||||
|
onItemClick = {},
|
||||||
|
onItemLongClick = {},
|
||||||
|
onDeleteClick = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun StageIndicatorNewPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
StageIndicator(stage = VocabularyStage.NEW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun StageIndicatorStage1Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
StageIndicator(stage = VocabularyStage.STAGE_1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun StageIndicatorStage3Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
StageIndicator(stage = VocabularyStage.STAGE_3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun StageIndicatorStage5Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
StageIndicator(stage = VocabularyStage.STAGE_5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun StageIndicatorLearnedPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
StageIndicator(stage = VocabularyStage.LEARNED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("HardCodedStringLiteral")
|
@Suppress("HardCodedStringLiteral")
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -73,10 +73,12 @@ 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
|
||||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel.SortOrder
|
import eu.gaudian.translator.viewmodel.VocabularyViewModel.SortOrder
|
||||||
|
import eu.gaudian.translator.viewmodel.toStringResource
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@@ -97,11 +99,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 +124,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(
|
||||||
@@ -133,6 +139,17 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val vocabularyItems by vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
|
val vocabularyItems by vocabularyItemsFlow.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
|
val stageMapping by vocabularyViewModel.stageMapping.collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||||
|
|
||||||
|
// 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) }
|
||||||
@@ -195,6 +212,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 = {}
|
||||||
)
|
)
|
||||||
@@ -242,7 +264,9 @@ fun LibraryScreen(
|
|||||||
vocabularyItems = vocabularyItems,
|
vocabularyItems = vocabularyItems,
|
||||||
allLanguages = allLanguages,
|
allLanguages = allLanguages,
|
||||||
selection = selection,
|
selection = selection,
|
||||||
|
stageMapping = stageMapping,
|
||||||
listState = lazyListState,
|
listState = lazyListState,
|
||||||
|
onAddClick = { navController.navigate(NavigationRoutes.NEW_WORD) },
|
||||||
onItemClick = { item ->
|
onItemClick = { item ->
|
||||||
if (isInSelectionMode) {
|
if (isInSelectionMode) {
|
||||||
selection = if (selection.contains(item.id.toLong())) {
|
selection = if (selection.contains(item.id.toLong())) {
|
||||||
@@ -465,8 +489,7 @@ fun FilterBottomSheetContent(
|
|||||||
selected = sortOrder == order,
|
selected = sortOrder == order,
|
||||||
onClick = { sortOrder = order },
|
onClick = { sortOrder = order },
|
||||||
label = {
|
label = {
|
||||||
Text(order.name.replace('_', ' ').lowercase()
|
Text(stringResource(order.toStringResource()))
|
||||||
.replaceFirstChar { it.titlecase() })
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -57,7 +57,7 @@ import eu.gaudian.translator.utils.findActivity
|
|||||||
import eu.gaudian.translator.view.composable.AppButton
|
import eu.gaudian.translator.view.composable.AppButton
|
||||||
import eu.gaudian.translator.view.composable.AppIcons
|
import eu.gaudian.translator.view.composable.AppIcons
|
||||||
import eu.gaudian.translator.view.composable.ComponentDefaults
|
import eu.gaudian.translator.view.composable.ComponentDefaults
|
||||||
import eu.gaudian.translator.view.vocabulary.ExerciseProgressIndicator
|
import eu.gaudian.translator.view.exercises.ExerciseProgressIndicator
|
||||||
import eu.gaudian.translator.viewmodel.AnswerResult
|
import eu.gaudian.translator.viewmodel.AnswerResult
|
||||||
import eu.gaudian.translator.viewmodel.ExerciseSessionState
|
import eu.gaudian.translator.viewmodel.ExerciseSessionState
|
||||||
import eu.gaudian.translator.viewmodel.ExerciseViewModel
|
import eu.gaudian.translator.viewmodel.ExerciseViewModel
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@file:Suppress("AssignedValueIsNeverRead")
|
@file:Suppress("AssignedValueIsNeverRead")
|
||||||
|
|
||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@file:Suppress("HardCodedStringLiteral")
|
@file:Suppress("HardCodedStringLiteral")
|
||||||
|
|
||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -35,6 +35,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
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.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -56,7 +57,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.model.Language
|
import eu.gaudian.translator.model.Language
|
||||||
import eu.gaudian.translator.model.TagCategory
|
|
||||||
import eu.gaudian.translator.model.VocabularyCategory
|
import eu.gaudian.translator.model.VocabularyCategory
|
||||||
import eu.gaudian.translator.model.VocabularyStage
|
import eu.gaudian.translator.model.VocabularyStage
|
||||||
import eu.gaudian.translator.utils.findActivity
|
import eu.gaudian.translator.utils.findActivity
|
||||||
@@ -76,17 +76,40 @@ import kotlinx.coroutines.launch
|
|||||||
@Composable
|
@Composable
|
||||||
fun StartExerciseScreen(
|
fun StartExerciseScreen(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
|
preselectedCategoryId: Int? = null,
|
||||||
|
dueTodayOnly: Boolean = false,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val activity = androidx.compose.ui.platform.LocalContext.current.findActivity()
|
val activity = androidx.compose.ui.platform.LocalContext.current.findActivity()
|
||||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
val exerciseViewModel: VocabularyExerciseViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val exerciseViewModel: VocabularyExerciseViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
|
||||||
|
// Initialize exercise config with dueTodayOnly if specified
|
||||||
|
androidx.compose.runtime.LaunchedEffect(dueTodayOnly) {
|
||||||
|
if (dueTodayOnly) {
|
||||||
|
exerciseViewModel.updatePendingExerciseConfig(
|
||||||
|
exerciseViewModel.pendingExerciseConfig.value.copy(dueTodayOnly = true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val exerciseConfig by exerciseViewModel.pendingExerciseConfig.collectAsState()
|
val exerciseConfig by exerciseViewModel.pendingExerciseConfig.collectAsState()
|
||||||
|
val allCategories by categoryViewModel.categories.collectAsState(initial = emptyList())
|
||||||
var selectedLanguagePairs by remember { mutableStateOf<List<Pair<Language, Language>>>(emptyList()) }
|
var selectedLanguagePairs by remember { mutableStateOf<List<Pair<Language, Language>>>(emptyList()) }
|
||||||
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory>>(emptyList()) }
|
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory>>(emptyList()) }
|
||||||
var selectedStages by remember { mutableStateOf<List<VocabularyStage>>(emptyList()) }
|
var selectedStages by remember { mutableStateOf<List<VocabularyStage>>(emptyList()) }
|
||||||
|
|
||||||
|
// Initialize preselected category
|
||||||
|
LaunchedEffect(allCategories, preselectedCategoryId) {
|
||||||
|
if (preselectedCategoryId != null) {
|
||||||
|
val category = allCategories.find { it.id == preselectedCategoryId }
|
||||||
|
if (category != null && category !in selectedCategories) {
|
||||||
|
selectedCategories = listOf(category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var selectedOriginLanguage by remember { mutableStateOf<Language?>(null) }
|
var selectedOriginLanguage by remember { mutableStateOf<Language?>(null) }
|
||||||
var selectedTargetLanguage by remember { mutableStateOf<Language?>(null) }
|
var selectedTargetLanguage by remember { mutableStateOf<Language?>(null) }
|
||||||
val isDirectionPreferenceSet = selectedOriginLanguage != null || selectedTargetLanguage != null
|
val isDirectionPreferenceSet = selectedOriginLanguage != null || selectedTargetLanguage != null
|
||||||
@@ -143,6 +166,13 @@ fun StartExerciseScreen(
|
|||||||
.widthIn(max = 700.dp) // Keeps it from over-stretching on tablets
|
.widthIn(max = 700.dp) // Keeps it from over-stretching on tablets
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
|
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
val allLanguages by languageViewModel.allLanguages.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
|
val availableLanguages = remember(availableLanguagesFromItems, allLanguages) {
|
||||||
|
allLanguages.filter { it.nameResId in availableLanguagesFromItems }
|
||||||
|
}
|
||||||
|
|
||||||
TopBarSection(
|
TopBarSection(
|
||||||
onBackClick = { navController.popBackStack() },
|
onBackClick = { navController.popBackStack() },
|
||||||
shuffleCards = exerciseConfig.shuffleCards,
|
shuffleCards = exerciseConfig.shuffleCards,
|
||||||
@@ -152,30 +182,10 @@ fun StartExerciseScreen(
|
|||||||
shuffleLanguagesEnabled = !isDirectionPreferenceSet,
|
shuffleLanguagesEnabled = !isDirectionPreferenceSet,
|
||||||
trainingMode = exerciseConfig.trainingMode,
|
trainingMode = exerciseConfig.trainingMode,
|
||||||
onTrainingModeChanged = { updateConfig(exerciseConfig.copy(trainingMode = it)) },
|
onTrainingModeChanged = { updateConfig(exerciseConfig.copy(trainingMode = it)) },
|
||||||
)
|
selectedOriginLanguage = selectedOriginLanguage,
|
||||||
|
selectedTargetLanguage = selectedTargetLanguage,
|
||||||
LazyColumn(
|
languageSelectionEnabled = true,
|
||||||
modifier = Modifier
|
availableLanguages = availableLanguages,
|
||||||
.weight(1f)
|
|
||||||
.padding(horizontal = 24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(32.dp)
|
|
||||||
) {
|
|
||||||
item { Spacer(modifier = Modifier.height(8.dp)) }
|
|
||||||
item {
|
|
||||||
LanguagePairSection(
|
|
||||||
selectedPairs = selectedLanguagePairs,
|
|
||||||
availableLanguageIds = availableLanguagesFromItems,
|
|
||||||
onPairsChanged = { updatedPairs ->
|
|
||||||
val hadPairs = selectedLanguagePairs.isNotEmpty()
|
|
||||||
selectedLanguagePairs = updatedPairs
|
|
||||||
if (updatedPairs.isNotEmpty()) {
|
|
||||||
selectedOriginLanguage = null
|
|
||||||
selectedTargetLanguage = null
|
|
||||||
updateConfig(exerciseConfig.copy(originLanguageId = null, targetLanguageId = null))
|
|
||||||
} else if (hadPairs) {
|
|
||||||
updateConfig(exerciseConfig.copy(originLanguageId = null, targetLanguageId = null))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onOriginLanguageSelected = { language ->
|
onOriginLanguageSelected = { language ->
|
||||||
if (language?.nameResId == selectedOriginLanguage?.nameResId) {
|
if (language?.nameResId == selectedOriginLanguage?.nameResId) {
|
||||||
selectedOriginLanguage = null
|
selectedOriginLanguage = null
|
||||||
@@ -201,11 +211,32 @@ fun StartExerciseScreen(
|
|||||||
}
|
}
|
||||||
updateConfig(exerciseConfig.copy(targetLanguageId = language?.nameResId))
|
updateConfig(exerciseConfig.copy(targetLanguageId = language?.nameResId))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(32.dp)
|
||||||
|
) {
|
||||||
|
item { Spacer(modifier = Modifier.height(8.dp)) }
|
||||||
|
item {
|
||||||
|
LanguagePairSection(
|
||||||
|
selectedPairs = selectedLanguagePairs,
|
||||||
|
availableLanguageIds = availableLanguagesFromItems,
|
||||||
|
onPairsChanged = { updatedPairs ->
|
||||||
|
val hadPairs = selectedLanguagePairs.isNotEmpty()
|
||||||
|
selectedLanguagePairs = updatedPairs
|
||||||
|
if (updatedPairs.isNotEmpty()) {
|
||||||
|
selectedOriginLanguage = null
|
||||||
|
selectedTargetLanguage = null
|
||||||
|
updateConfig(exerciseConfig.copy(originLanguageId = null, targetLanguageId = null))
|
||||||
|
} else if (hadPairs) {
|
||||||
|
updateConfig(exerciseConfig.copy(originLanguageId = null, targetLanguageId = null))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
languageSelectionEnabled = true,
|
selectedPairsCount = selectedLanguagePairs.size
|
||||||
selectedPairsCount = selectedLanguagePairs.size,
|
|
||||||
selectedOriginLanguage = selectedOriginLanguage,
|
|
||||||
selectedTargetLanguage = selectedTargetLanguage
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -284,7 +315,13 @@ fun TopBarSection(
|
|||||||
onShuffleLanguagesChanged: (Boolean) -> Unit,
|
onShuffleLanguagesChanged: (Boolean) -> Unit,
|
||||||
shuffleLanguagesEnabled: Boolean,
|
shuffleLanguagesEnabled: Boolean,
|
||||||
trainingMode: Boolean,
|
trainingMode: Boolean,
|
||||||
onTrainingModeChanged: (Boolean) -> Unit
|
onTrainingModeChanged: (Boolean) -> Unit,
|
||||||
|
selectedOriginLanguage: Language?,
|
||||||
|
selectedTargetLanguage: Language?,
|
||||||
|
languageSelectionEnabled: Boolean,
|
||||||
|
availableLanguages: List<Language>,
|
||||||
|
onOriginLanguageSelected: (Language?) -> Unit,
|
||||||
|
onTargetLanguageSelected: (Language?) -> Unit
|
||||||
) {
|
) {
|
||||||
var showSettings by remember { mutableStateOf(false) }
|
var showSettings by remember { mutableStateOf(false) }
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
@@ -343,6 +380,12 @@ fun TopBarSection(
|
|||||||
shuffleLanguagesEnabled = shuffleLanguagesEnabled,
|
shuffleLanguagesEnabled = shuffleLanguagesEnabled,
|
||||||
trainingMode = trainingMode,
|
trainingMode = trainingMode,
|
||||||
onTrainingModeChanged = onTrainingModeChanged,
|
onTrainingModeChanged = onTrainingModeChanged,
|
||||||
|
selectedOriginLanguage = selectedOriginLanguage,
|
||||||
|
selectedTargetLanguage = selectedTargetLanguage,
|
||||||
|
languageSelectionEnabled = languageSelectionEnabled,
|
||||||
|
availableLanguages = availableLanguages,
|
||||||
|
onOriginLanguageSelected = onOriginLanguageSelected,
|
||||||
|
onTargetLanguageSelected = onTargetLanguageSelected,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
scope.launch { sheetState.hide() }.invokeOnCompletion {
|
||||||
if (!sheetState.isVisible) {
|
if (!sheetState.isVisible) {
|
||||||
@@ -388,12 +431,7 @@ fun LanguagePairSection(
|
|||||||
selectedPairs: List<Pair<Language, Language>>,
|
selectedPairs: List<Pair<Language, Language>>,
|
||||||
availableLanguageIds: Set<Int>,
|
availableLanguageIds: Set<Int>,
|
||||||
onPairsChanged: (List<Pair<Language, Language>>) -> Unit,
|
onPairsChanged: (List<Pair<Language, Language>>) -> Unit,
|
||||||
onOriginLanguageSelected: (Language?) -> Unit,
|
selectedPairsCount: Int
|
||||||
onTargetLanguageSelected: (Language?) -> Unit,
|
|
||||||
languageSelectionEnabled: Boolean,
|
|
||||||
selectedPairsCount: Int,
|
|
||||||
selectedOriginLanguage: Language?,
|
|
||||||
selectedTargetLanguage: Language?
|
|
||||||
) {
|
) {
|
||||||
val activity = androidx.compose.ui.platform.LocalContext.current.findActivity()
|
val activity = androidx.compose.ui.platform.LocalContext.current.findActivity()
|
||||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
@@ -428,8 +466,17 @@ fun LanguagePairSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isExpanded by remember { mutableStateOf(false) }
|
||||||
|
val displayedPairs = if (isExpanded) availablePairs else availablePairs.take(3)
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
SectionHeader(title = stringResource(R.string.language_pair))
|
SectionHeader(
|
||||||
|
title = stringResource(R.string.language_pair),
|
||||||
|
actionText = if (availablePairs.size > 3) {
|
||||||
|
if (isExpanded) stringResource(R.string.label_show_less) else stringResource(R.string.label_show_more)
|
||||||
|
} else null,
|
||||||
|
onActionClick = { isExpanded = !isExpanded }
|
||||||
|
)
|
||||||
|
|
||||||
if (availablePairs.isEmpty()) {
|
if (availablePairs.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
@@ -442,7 +489,7 @@ fun LanguagePairSection(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
availablePairs.forEach { pair ->
|
displayedPairs.forEach { pair ->
|
||||||
val isSelected = selectedPairs.contains(pair)
|
val isSelected = selectedPairs.contains(pair)
|
||||||
LanguageChip(
|
LanguageChip(
|
||||||
text = "${pair.first.name} ⇄ ${pair.second.name}",
|
text = "${pair.first.name} ⇄ ${pair.second.name}",
|
||||||
@@ -460,74 +507,6 @@ fun LanguagePairSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.label_language_direction),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.text_language_direction_explanation),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
if (!languageSelectionEnabled && selectedPairsCount > 0) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.text_language_direction_disabled_with_pairs),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.label_origin_language),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
selectedLanguage = selectedOriginLanguage,
|
|
||||||
onLanguageSelected = { language ->
|
|
||||||
if (selectedTargetLanguage?.nameResId == language.nameResId) return@SingleLanguageDropDown
|
|
||||||
onOriginLanguageSelected(language)
|
|
||||||
},
|
|
||||||
showNoneOption = true,
|
|
||||||
onNoneSelected = { onOriginLanguageSelected(null) },
|
|
||||||
alternateLanguages = availableLanguages,
|
|
||||||
restrictToAlternateLanguages = true,
|
|
||||||
enabled = languageSelectionEnabled
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.label_target_language),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
selectedLanguage = selectedTargetLanguage,
|
|
||||||
onLanguageSelected = { language ->
|
|
||||||
if (selectedOriginLanguage?.nameResId == language.nameResId) return@SingleLanguageDropDown
|
|
||||||
onTargetLanguageSelected(language)
|
|
||||||
},
|
|
||||||
showNoneOption = true,
|
|
||||||
onNoneSelected = { onTargetLanguageSelected(null) },
|
|
||||||
alternateLanguages = availableLanguages,
|
|
||||||
restrictToAlternateLanguages = true,
|
|
||||||
enabled = languageSelectionEnabled
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,10 +555,25 @@ fun CategoriesSection(
|
|||||||
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
|
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
SectionHeader(title = stringResource(R.string.label_categories))
|
val tagCategories = categories
|
||||||
|
var isExpanded by remember { mutableStateOf(false) }
|
||||||
|
val displayedCategories = if (isExpanded) tagCategories else tagCategories.take(3)
|
||||||
|
|
||||||
val tagCategories = categories.filterIsInstance<TagCategory>()
|
SectionHeader(
|
||||||
if (tagCategories.size > 15) {
|
title = stringResource(R.string.label_categories),
|
||||||
|
actionText = if (tagCategories.size > 3) {
|
||||||
|
if (isExpanded) stringResource(R.string.label_show_less) else stringResource(R.string.label_show_more)
|
||||||
|
} else null,
|
||||||
|
onActionClick = { isExpanded = !isExpanded }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (tagCategories.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.no_vocabulary_items_found_perhaps_try_changing_the_filters),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else if (tagCategories.size > 15) {
|
||||||
CategoryDropdown(
|
CategoryDropdown(
|
||||||
onCategorySelected = { selections ->
|
onCategorySelected = { selections ->
|
||||||
onCategoriesChanged(selections.filterNotNull())
|
onCategoriesChanged(selections.filterNotNull())
|
||||||
@@ -596,7 +590,7 @@ fun CategoriesSection(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
tagCategories.forEach { category ->
|
displayedCategories.forEach { category ->
|
||||||
val isSelected = selectedCategories.contains(category)
|
val isSelected = selectedCategories.contains(category)
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(20.dp),
|
shape = RoundedCornerShape(20.dp),
|
||||||
@@ -743,9 +737,9 @@ fun NumberOfCardsSection(
|
|||||||
availableQuickSelections.forEach { value ->
|
availableQuickSelections.forEach { value ->
|
||||||
AppOutlinedButton(
|
AppOutlinedButton(
|
||||||
onClick = { onAmountChanged(value) },
|
onClick = { onAmountChanged(value) },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f).padding(0.dp)
|
||||||
) {
|
) {
|
||||||
Text(value.toString())
|
Text(text = value.toString(), softWrap = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -779,7 +773,7 @@ fun QuestionTypesSection(
|
|||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
QuestionTypeCard(
|
QuestionTypeCard(
|
||||||
title = stringResource(R.string.label_multiple_choice_exercise),
|
title = stringResource(R.string.label_multiple_choice_exercise),
|
||||||
subtitle = stringResource(R.string.label_choose_exercise_types),
|
subtitle = stringResource(R.string.label_multiple_choice_desc),
|
||||||
icon = AppIcons.CheckList,
|
icon = AppIcons.CheckList,
|
||||||
isSelected = selectedTypes.contains(VocabularyExerciseType.MULTIPLE_CHOICE),
|
isSelected = selectedTypes.contains(VocabularyExerciseType.MULTIPLE_CHOICE),
|
||||||
onClick = { onTypeSelected(VocabularyExerciseType.MULTIPLE_CHOICE) }
|
onClick = { onTypeSelected(VocabularyExerciseType.MULTIPLE_CHOICE) }
|
||||||
@@ -879,8 +873,17 @@ private fun StartExerciseSettingsBottomSheet(
|
|||||||
shuffleLanguagesEnabled: Boolean,
|
shuffleLanguagesEnabled: Boolean,
|
||||||
trainingMode: Boolean,
|
trainingMode: Boolean,
|
||||||
onTrainingModeChanged: (Boolean) -> Unit,
|
onTrainingModeChanged: (Boolean) -> Unit,
|
||||||
|
selectedOriginLanguage: Language?,
|
||||||
|
selectedTargetLanguage: Language?,
|
||||||
|
languageSelectionEnabled: Boolean,
|
||||||
|
availableLanguages: List<Language>,
|
||||||
|
onOriginLanguageSelected: (Language?) -> Unit,
|
||||||
|
onTargetLanguageSelected: (Language?) -> Unit,
|
||||||
onDismiss: () -> Unit
|
onDismiss: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val activity = androidx.compose.ui.platform.LocalContext.current.findActivity()
|
||||||
|
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||||
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
sheetState = sheetState
|
sheetState = sheetState
|
||||||
@@ -889,7 +892,7 @@ private fun StartExerciseSettingsBottomSheet(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.options),
|
text = stringResource(R.string.options),
|
||||||
@@ -927,6 +930,73 @@ private fun StartExerciseSettingsBottomSheet(
|
|||||||
checked = trainingMode,
|
checked = trainingMode,
|
||||||
onCheckedChange = onTrainingModeChanged
|
onCheckedChange = onTrainingModeChanged
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Language Direction Section
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_language_direction),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.text_language_direction_explanation),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (!languageSelectionEnabled) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.text_language_direction_disabled_with_pairs),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_origin_language),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
languageViewModel = languageViewModel,
|
||||||
|
selectedLanguage = selectedOriginLanguage,
|
||||||
|
onLanguageSelected = { language ->
|
||||||
|
if (selectedTargetLanguage?.nameResId == language.nameResId) return@SingleLanguageDropDown
|
||||||
|
onOriginLanguageSelected(language)
|
||||||
|
},
|
||||||
|
showNoneOption = true,
|
||||||
|
onNoneSelected = { onOriginLanguageSelected(null) },
|
||||||
|
alternateLanguages = availableLanguages,
|
||||||
|
restrictToAlternateLanguages = true,
|
||||||
|
enabled = languageSelectionEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_target_language),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
languageViewModel = languageViewModel,
|
||||||
|
selectedLanguage = selectedTargetLanguage,
|
||||||
|
onLanguageSelected = { language ->
|
||||||
|
if (selectedOriginLanguage?.nameResId == language.nameResId) return@SingleLanguageDropDown
|
||||||
|
onTargetLanguageSelected(language)
|
||||||
|
},
|
||||||
|
showNoneOption = true,
|
||||||
|
onNoneSelected = { onTargetLanguageSelected(null) },
|
||||||
|
alternateLanguages = availableLanguages,
|
||||||
|
restrictToAlternateLanguages = true,
|
||||||
|
enabled = languageSelectionEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@file:Suppress("AssignedValueIsNeverRead")
|
@file:Suppress("AssignedValueIsNeverRead")
|
||||||
|
|
||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@file:Suppress("AssignedValueIsNeverRead")
|
@file:Suppress("AssignedValueIsNeverRead")
|
||||||
|
|
||||||
package eu.gaudian.translator.view.exercises
|
package eu.gaudian.translator.view.new_ecercises
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -136,7 +136,7 @@ fun AddModelScreen(navController: NavController, providerKey: String) {
|
|||||||
AppTopAppBar(
|
AppTopAppBar(
|
||||||
title = providerName,
|
title = providerName,
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
hintContent = HintDefinition.ADD_MODEL_SCAN.hint()
|
hint = HintDefinition.ADD_MODEL_SCAN.hint()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ fun ApiKeyScreen(navController: NavController) {
|
|||||||
AppTopAppBar(
|
AppTopAppBar(
|
||||||
title = stringResource(R.string.label_ai_configuration),
|
title = stringResource(R.string.label_ai_configuration),
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
hintContent = HintDefinition.API_KEY.hint()
|
hint = HintDefinition.API_KEY.hint()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ fun CustomVocabularyPromptScreen(
|
|||||||
AppTopAppBar(
|
AppTopAppBar(
|
||||||
title = stringResource(R.string.text_vocabulary_prompt),
|
title = stringResource(R.string.text_vocabulary_prompt),
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
hintContent = null //TODO: Add hint
|
hint = null //TODO: Add hint
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ fun DictionaryOptionsScreen(
|
|||||||
AppTopAppBar(
|
AppTopAppBar(
|
||||||
title = stringResource(R.string.label_dictionary_options),
|
title = stringResource(R.string.label_dictionary_options),
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
hintContent = HintDefinition.DICTIONARY_OPTIONS.hint()
|
hint = HintDefinition.DICTIONARY_OPTIONS.hint()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ fun TranslationSettingsScreen(
|
|||||||
AppTopAppBar(
|
AppTopAppBar(
|
||||||
title = stringResource(R.string.label_translation_settings),
|
title = stringResource(R.string.label_translation_settings),
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
hintContent = null //TODO add hint
|
hint = null //TODO add hint
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ fun VocabularyProgressOptionsScreen(
|
|||||||
AppTopAppBar(
|
AppTopAppBar(
|
||||||
title = stringResource(R.string.label_vocabulary_settings),
|
title = stringResource(R.string.label_vocabulary_settings),
|
||||||
onNavigateBack = { navController.popBackStack() },
|
onNavigateBack = { navController.popBackStack() },
|
||||||
hintContent = HintDefinition.VOCABULARY_PROGRESS.hint()
|
hint = HintDefinition.VOCABULARY_PROGRESS.hint()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
|
|||||||
@@ -1,84 +1,163 @@
|
|||||||
@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.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.DropdownMenu
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.Divider
|
||||||
|
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.AppOutlinedButton
|
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.CsvImportDialog
|
||||||
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.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 +169,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 +184,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 +210,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 +268,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 +284,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 +385,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 +434,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,124 +475,250 @@ fun VocabularyRepositoryOptionsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Export Dialog
|
||||||
if (showTableImportDialog.value) {
|
if (showExportDialog) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showTableImportDialog.value = false },
|
onDismissRequest = { showExportDialog = false },
|
||||||
title = { Text(stringResource(R.string.label_import_table_csv_excel)) },
|
title = { Text("Export Categories") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
LazyColumn {
|
||||||
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
|
item {
|
||||||
// Column selectors
|
Text(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
"Select categories to export:",
|
||||||
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
var menu1Expanded by remember { mutableStateOf(false) }
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
AppOutlinedButton(onClick = { menu1Expanded = true }) { Text(stringResource(R.string.label_column_n, selectedColFirst + 1)) }
|
)
|
||||||
DropdownMenu(expanded = menu1Expanded, onDismissRequest = { menu1Expanded = false }) {
|
}
|
||||||
(0 until columnCount).forEach { idx ->
|
items(categories) { category ->
|
||||||
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
|
Row(
|
||||||
DropdownMenuItem(
|
modifier = Modifier
|
||||||
text = { Text("#${idx + 1} • $header") },
|
.fillMaxWidth()
|
||||||
onClick = { selectedColFirst = idx; menu1Expanded = false }
|
.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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(stringResource(R.string.label_second_column), modifier = Modifier.weight(1f))
|
|
||||||
var menu2Expanded by remember { mutableStateOf(false) }
|
|
||||||
AppOutlinedButton(onClick = { menu2Expanded = true }) { Text(stringResource(R.string.label_column_n, selectedColSecond + 1)) }
|
|
||||||
DropdownMenu(expanded = menu2Expanded, onDismissRequest = { menu2Expanded = false }) {
|
|
||||||
(0 until columnCount).forEach { idx ->
|
|
||||||
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("#${idx + 1} • $header") },
|
|
||||||
onClick = { selectedColSecond = idx; menu2Expanded = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Language selectors
|
|
||||||
Text(stringResource(R.string.label_languages))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(stringResource(R.string.label_first_language))
|
|
||||||
SingleLanguageDropDown(
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
selectedLanguage = selectedLangFirst,
|
|
||||||
onLanguageSelected = { selectedLangFirst = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(stringResource(R.string.label_second_language))
|
|
||||||
SingleLanguageDropDown(
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
selectedLanguage = selectedLangSecond,
|
|
||||||
onLanguageSelected = { selectedLangSecond = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Header toggle
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(stringResource(R.string.label_header_row))
|
|
||||||
}
|
|
||||||
// Previews
|
|
||||||
val startIdx = if (skipHeader) 1 else 0
|
|
||||||
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
|
|
||||||
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
|
|
||||||
Text(stringResource(R.string.label_preview_first, previewA))
|
|
||||||
Text(stringResource(R.string.label_preview_second, previewB))
|
|
||||||
val totalRows = parsedTable.drop(startIdx).count { row ->
|
|
||||||
val a = row.getOrNull(selectedColFirst).orEmpty().isNotBlank()
|
|
||||||
val b = row.getOrNull(selectedColSecond).orEmpty().isNotBlank()
|
|
||||||
a || b
|
|
||||||
}
|
|
||||||
Text(stringResource(R.string.text_rows_to_import_1d, totalRows))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
val errorSelectTwoColumns = stringResource(R.string.error_select_two_columns)
|
TextButton(
|
||||||
val errorSelectLanguages = stringResource(R.string.error_select_languages)
|
onClick = {
|
||||||
val errorNoRowsToImport = stringResource(R.string.error_no_rows_to_import)
|
if (selectedCategories.size == 1) {
|
||||||
val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from)
|
exportImportViewModel.exportCategory(selectedCategories.first())
|
||||||
TextButton(onClick = {
|
} else if (selectedCategories.isNotEmpty()) {
|
||||||
if (selectedColFirst == selectedColSecond) {
|
// Simplified: export first selected category
|
||||||
statusMessageService.showErrorMessage(errorSelectTwoColumns)
|
exportImportViewModel.exportCategory(selectedCategories.first())
|
||||||
return@TextButton
|
|
||||||
}
|
}
|
||||||
val langA = selectedLangFirst
|
showExportDialog = false
|
||||||
val langB = selectedLangSecond
|
selectedCategories.clear()
|
||||||
if (langA == null || langB == null) {
|
},
|
||||||
statusMessageService.showErrorMessage(errorSelectLanguages)
|
enabled = selectedCategories.isNotEmpty()
|
||||||
return@TextButton
|
) {
|
||||||
|
Text("Export")
|
||||||
}
|
}
|
||||||
val startIdx = if (skipHeader) 1 else 0
|
|
||||||
val items = parsedTable.drop(startIdx).mapNotNull { row ->
|
|
||||||
val a = row.getOrNull(selectedColFirst)?.trim().orEmpty()
|
|
||||||
val b = row.getOrNull(selectedColSecond)?.trim().orEmpty()
|
|
||||||
if (a.isBlank() && b.isBlank()) null else eu.gaudian.translator.model.VocabularyItem(
|
|
||||||
id = 0,
|
|
||||||
languageFirstId = langA.nameResId,
|
|
||||||
languageSecondId = langB.nameResId,
|
|
||||||
wordFirst = a,
|
|
||||||
wordSecond = b
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (items.isEmpty()) {
|
|
||||||
statusMessageService.showErrorMessage(errorNoRowsToImport)
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
vocabularyViewModel.addVocabularyItems(items)
|
|
||||||
statusMessageService.showSuccessMessage(infoImportedItemsFrom + " " +items.size)
|
|
||||||
showTableImportDialog.value = false
|
|
||||||
}) { Text(stringResource(R.string.label_import)) }
|
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showTableImportDialog.value = false }) { Text(stringResource(R.string.label_cancel)) }
|
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) {
|
||||||
|
val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from)
|
||||||
|
CsvImportDialog(
|
||||||
|
showDialog = showTableImportDialog.value,
|
||||||
|
parsedTable = parsedTable,
|
||||||
|
languageViewModel = languageViewModel,
|
||||||
|
onDismiss = { showTableImportDialog.value = false },
|
||||||
|
onImport = { items ->
|
||||||
|
vocabularyViewModel.addVocabularyItems(items)
|
||||||
|
statusMessageService.showSuccessMessage("$infoImportedItemsFrom ${items.size}")
|
||||||
|
showTableImportDialog.value = false
|
||||||
|
},
|
||||||
|
statusMessageService = statusMessageService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import androidx.compose.animation.core.spring
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.gestures.scrollBy
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -45,6 +47,7 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
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.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -62,13 +65,13 @@ import eu.gaudian.translator.view.composable.AppCard
|
|||||||
import eu.gaudian.translator.view.composable.AppIcons
|
import eu.gaudian.translator.view.composable.AppIcons
|
||||||
import eu.gaudian.translator.view.composable.AppOutlinedCard
|
import eu.gaudian.translator.view.composable.AppOutlinedCard
|
||||||
import eu.gaudian.translator.view.dialogs.MissingLanguageDialog
|
import eu.gaudian.translator.view.dialogs.MissingLanguageDialog
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.AllVocabularyWidget
|
import eu.gaudian.translator.view.stats.widgets.AllVocabularyWidget
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressWidget
|
import eu.gaudian.translator.view.stats.widgets.CategoryProgressWidget
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.DueTodayWidget
|
import eu.gaudian.translator.view.stats.widgets.DueTodayWidget
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.LevelWidget
|
import eu.gaudian.translator.view.stats.widgets.LevelWidget
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.StatusWidget
|
import eu.gaudian.translator.view.stats.widgets.StatusWidget
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.StreakWidget
|
import eu.gaudian.translator.view.stats.widgets.StreakWidget
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
|
import eu.gaudian.translator.view.stats.widgets.WeeklyActivityChartWidget
|
||||||
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.SettingsViewModel
|
import eu.gaudian.translator.viewmodel.SettingsViewModel
|
||||||
@@ -203,12 +206,27 @@ fun StatsScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.padding(16.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_stats),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = lazyListState,
|
state = lazyListState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.dragContainer(dragDropState),
|
.dragContainer(dragDropState),
|
||||||
contentPadding = PaddingValues(bottom = 160.dp)
|
contentPadding = PaddingValues( 8.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = orderedWidgets,
|
items = orderedWidgets,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary.widgets
|
package eu.gaudian.translator.view.stats.widgets
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary.widgets
|
package eu.gaudian.translator.view.stats.widgets
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -290,7 +290,7 @@ fun CategoryProgressCircle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChartLegend() {
|
fun ChartLegend() {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary.widgets
|
package eu.gaudian.translator.view.stats.widgets
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary.widgets
|
package eu.gaudian.translator.view.stats.widgets
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary.widgets
|
package eu.gaudian.translator.view.stats.widgets
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary.widgets
|
package eu.gaudian.translator.view.stats.widgets
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary.widgets
|
package eu.gaudian.translator.view.stats.widgets
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -90,7 +90,7 @@ fun StatusWidget(
|
|||||||
if (itemsWithoutGrammarCount > 0) {
|
if (itemsWithoutGrammarCount > 0) {
|
||||||
StatusItem(
|
StatusItem(
|
||||||
icon = AppIcons.Error,
|
icon = AppIcons.Error,
|
||||||
text = stringResource(R.string.items_without_grammar_infos),
|
text = stringResource(R.string.label_items_without_grammar),
|
||||||
count = itemsWithoutGrammarCount,
|
count = itemsWithoutGrammarCount,
|
||||||
onClick = onNavigateToNoGrammar,
|
onClick = onNavigateToNoGrammar,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary.widgets
|
package eu.gaudian.translator.view.stats.widgets
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
@file:Suppress("HardCodedStringLiteral")
|
||||||
|
|
||||||
|
package eu.gaudian.translator.view.stats.widgets
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.drawText
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import eu.gaudian.translator.R
|
||||||
|
import eu.gaudian.translator.ui.theme.ThemePreviews
|
||||||
|
import eu.gaudian.translator.viewmodel.WeeklyActivityStat
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A widget that displays weekly activity statistics in a visually appealing smooth line chart.
|
||||||
|
* It's designed to be consistent with the app's modern UI style using the theme's colors.
|
||||||
|
*
|
||||||
|
* @param weeklyStats A list of [WeeklyActivityStat] for the last 7 days.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun WeeklyActivityChartWidget(
|
||||||
|
weeklyStats: List<WeeklyActivityStat>
|
||||||
|
) {
|
||||||
|
val hasNoData = remember(weeklyStats) {
|
||||||
|
weeklyStats.all { it.completed == 0 && it.answeredRight == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasNoData) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.text_no_data_available),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// Reduced horizontal padding to give the chart more space
|
||||||
|
.padding(vertical = 24.dp, horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
WeeklyChartLegend()
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
InteractiveLineChart(weeklyStats = weeklyStats)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
ChartFooter(weeklyStats = weeklyStats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InteractiveLineChart(weeklyStats: List<WeeklyActivityStat>) {
|
||||||
|
var selectedIndex by remember { mutableStateOf<Int?>(3) } // Default selection
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
|
||||||
|
val colorCompleted = MaterialTheme.colorScheme.primary
|
||||||
|
val colorCorrect = MaterialTheme.colorScheme.tertiary
|
||||||
|
|
||||||
|
val gridColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
|
||||||
|
val tooltipLineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
|
||||||
|
val dotCenterColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
val tooltipBgColor = MaterialTheme.colorScheme.inverseSurface
|
||||||
|
val tooltipTextColor = MaterialTheme.colorScheme.inverseOnSurface
|
||||||
|
|
||||||
|
var startAnimation by remember { mutableStateOf(false) }
|
||||||
|
val animationProgress by animateFloatAsState(
|
||||||
|
targetValue = if (startAnimation) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = 1000),
|
||||||
|
label = "chartAnimation"
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(100)
|
||||||
|
startAnimation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val yAxisMax = remember(weeklyStats) {
|
||||||
|
val max = weeklyStats.flatMap { listOf(it.completed, it.answeredRight) }.maxOrNull() ?: 0
|
||||||
|
if (max < 10) 10 else ((max / 10) + 1) * 10
|
||||||
|
}
|
||||||
|
val yMax = yAxisMax.toFloat()
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
// Left Side: Y-Axis Amounts
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(180.dp)
|
||||||
|
// Reduced end padding to save space
|
||||||
|
.padding(end = 8.dp, top = 2.dp, bottom = 2.dp),
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
horizontalAlignment = Alignment.End
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = yAxisMax.toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = (yAxisMax / 2).toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "0",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right Side: Chart Area
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(180.dp)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures { offset ->
|
||||||
|
val itemWidth = size.width / (weeklyStats.size - 1).coerceAtLeast(1)
|
||||||
|
selectedIndex = (offset.x / itemWidth).roundToInt().coerceIn(0, weeklyStats.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectHorizontalDragGestures { change, _ ->
|
||||||
|
val itemWidth = size.width / (weeklyStats.size - 1).coerceAtLeast(1)
|
||||||
|
selectedIndex = (change.position.x / itemWidth).roundToInt().coerceIn(0, weeklyStats.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val width = size.width
|
||||||
|
val height = size.height
|
||||||
|
val xSpacing = width / (weeklyStats.size - 1).coerceAtLeast(1)
|
||||||
|
|
||||||
|
drawLine(gridColor, Offset(0f, 0f), Offset(width, 0f), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
|
||||||
|
drawLine(gridColor, Offset(0f, height / 2f), Offset(width, height / 2f), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
|
||||||
|
drawLine(gridColor, Offset(0f, height), Offset(width, height), strokeWidth = 2f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f)))
|
||||||
|
|
||||||
|
if (animationProgress == 0f) return@Canvas
|
||||||
|
|
||||||
|
val pointsCompleted = weeklyStats.mapIndexed { i, stat ->
|
||||||
|
Offset(i * xSpacing, height - ((stat.completed * animationProgress) / yMax) * height)
|
||||||
|
}
|
||||||
|
val pointsCorrect = weeklyStats.mapIndexed { i, stat ->
|
||||||
|
Offset(i * xSpacing, height - ((stat.answeredRight * animationProgress) / yMax) * height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define Paths
|
||||||
|
val pathCorrect = Path().apply { smoothCurve(pointsCorrect) }
|
||||||
|
val fillPathCorrect = Path().apply {
|
||||||
|
smoothCurve(pointsCorrect)
|
||||||
|
lineTo(width, height)
|
||||||
|
lineTo(0f, height)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val pathCompleted = Path().apply { smoothCurve(pointsCompleted) }
|
||||||
|
val fillPathCompleted = Path().apply {
|
||||||
|
smoothCurve(pointsCompleted)
|
||||||
|
lineTo(width, height)
|
||||||
|
lineTo(0f, height)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw semi-transparent fills first
|
||||||
|
drawPath(
|
||||||
|
path = fillPathCompleted,
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(colorCompleted.copy(alpha = 0.25f), Color.Transparent),
|
||||||
|
startY = 0f,
|
||||||
|
endY = height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
drawPath(
|
||||||
|
path = fillPathCorrect,
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(colorCorrect.copy(alpha = 0.25f), Color.Transparent),
|
||||||
|
startY = 0f,
|
||||||
|
endY = height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw solid strokes on top of the fills
|
||||||
|
drawPath(
|
||||||
|
path = pathCorrect,
|
||||||
|
color = colorCorrect,
|
||||||
|
style = Stroke(width = 6f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(12f, 12f), 0f))
|
||||||
|
)
|
||||||
|
|
||||||
|
drawPath(
|
||||||
|
path = pathCompleted,
|
||||||
|
color = colorCompleted,
|
||||||
|
style = Stroke(width = 8f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Interactive Highlights & Dual Separated Tooltips
|
||||||
|
selectedIndex?.let { index ->
|
||||||
|
val stat = weeklyStats[index]
|
||||||
|
val x = index * xSpacing
|
||||||
|
val yCompleted = height - ((stat.completed * animationProgress) / yMax) * height
|
||||||
|
val yCorrect = height - ((stat.answeredRight * animationProgress) / yMax) * height
|
||||||
|
|
||||||
|
// Vertical line marker
|
||||||
|
drawLine(
|
||||||
|
color = tooltipLineColor,
|
||||||
|
start = Offset(x, 0f),
|
||||||
|
end = Offset(x, height),
|
||||||
|
strokeWidth = 3f
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dots on lines
|
||||||
|
drawCircle(color = dotCenterColor, radius = 12f, center = Offset(x, yCompleted))
|
||||||
|
drawCircle(color = colorCompleted, radius = 7f, center = Offset(x, yCompleted))
|
||||||
|
|
||||||
|
drawCircle(color = dotCenterColor, radius = 12f, center = Offset(x, yCorrect))
|
||||||
|
drawCircle(color = colorCorrect, radius = 7f, center = Offset(x, yCorrect))
|
||||||
|
|
||||||
|
// Measure text
|
||||||
|
val textStyle = TextStyle(color = tooltipTextColor, fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||||
|
val textResCompleted = textMeasurer.measure(stat.completed.toString(), textStyle)
|
||||||
|
val textResCorrect = textMeasurer.measure(stat.answeredRight.toString(), textStyle)
|
||||||
|
|
||||||
|
val dotRadius = 5f
|
||||||
|
val gap = 6f
|
||||||
|
val padX = 12f
|
||||||
|
val padY = 8f
|
||||||
|
|
||||||
|
val w1 = padX * 2 + dotRadius * 2 + gap + textResCompleted.size.width
|
||||||
|
val h1 = padY * 2 + textResCompleted.size.height
|
||||||
|
|
||||||
|
val w2 = padX * 2 + dotRadius * 2 + gap + textResCorrect.size.width
|
||||||
|
val h2 = padY * 2 + textResCorrect.size.height
|
||||||
|
|
||||||
|
// Tooltip Overlap Prevention Logic
|
||||||
|
val completedIsHigher = yCompleted <= yCorrect
|
||||||
|
var yPosCompleted = if (completedIsHigher) yCompleted - h1 - 12f else yCompleted + 12f
|
||||||
|
var yPosCorrect = if (completedIsHigher) yCorrect + 12f else yCorrect - h2 - 12f
|
||||||
|
|
||||||
|
// Prevent clipping out of canvas bounds natively first
|
||||||
|
if (yPosCompleted < 0f && completedIsHigher) yPosCompleted = 0f
|
||||||
|
if (yPosCorrect < 0f && !completedIsHigher) yPosCorrect = 0f
|
||||||
|
if (yPosCompleted + h1 > height && !completedIsHigher) yPosCompleted = height - h1
|
||||||
|
if (yPosCorrect + h2 > height && completedIsHigher) yPosCorrect = height - h2
|
||||||
|
|
||||||
|
// Overlap resolution
|
||||||
|
val topRectY = minOf(yPosCompleted, yPosCorrect)
|
||||||
|
val topRectH = if (topRectY == yPosCompleted) h1 else h2
|
||||||
|
val bottomRectY = maxOf(yPosCompleted, yPosCorrect)
|
||||||
|
|
||||||
|
val gapBetweenTooltips = 8f
|
||||||
|
if (topRectY + topRectH + gapBetweenTooltips > bottomRectY) {
|
||||||
|
val midPointY = (yCompleted + yCorrect) / 2f
|
||||||
|
val adjustedTopY = midPointY - (topRectH + gapBetweenTooltips / 2f)
|
||||||
|
val adjustedBottomY = midPointY + (gapBetweenTooltips / 2f)
|
||||||
|
|
||||||
|
if (topRectY == yPosCompleted) {
|
||||||
|
yPosCompleted = adjustedTopY
|
||||||
|
yPosCorrect = adjustedBottomY
|
||||||
|
} else {
|
||||||
|
yPosCorrect = adjustedTopY
|
||||||
|
yPosCompleted = adjustedBottomY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final Canvas Bounds Check post-resolution
|
||||||
|
val finalMinY = minOf(yPosCompleted, yPosCorrect)
|
||||||
|
if (finalMinY < 0f) {
|
||||||
|
yPosCompleted -= finalMinY
|
||||||
|
yPosCorrect -= finalMinY
|
||||||
|
}
|
||||||
|
val finalMaxY = maxOf(yPosCompleted + h1, yPosCorrect + h2)
|
||||||
|
if (finalMaxY > height) {
|
||||||
|
val shift = finalMaxY - height
|
||||||
|
yPosCompleted -= shift
|
||||||
|
yPosCorrect -= shift
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Completed Tooltip
|
||||||
|
val t1X = (x - w1 / 2f).coerceIn(0f, width - w1)
|
||||||
|
drawRoundRect(color = tooltipBgColor, topLeft = Offset(t1X, yPosCompleted), size = Size(w1, h1), cornerRadius = CornerRadius(16f, 16f))
|
||||||
|
drawCircle(color = colorCompleted, radius = dotRadius, center = Offset(t1X + padX + dotRadius, yPosCompleted + h1 / 2f))
|
||||||
|
drawText(textLayoutResult = textResCompleted, topLeft = Offset(t1X + padX + dotRadius * 2 + gap, yPosCompleted + padY))
|
||||||
|
|
||||||
|
// Draw Correct Tooltip
|
||||||
|
val t2X = (x - w2 / 2f).coerceIn(0f, width - w2)
|
||||||
|
drawRoundRect(color = tooltipBgColor, topLeft = Offset(t2X, yPosCorrect), size = Size(w2, h2), cornerRadius = CornerRadius(16f, 16f))
|
||||||
|
drawCircle(color = colorCorrect, radius = dotRadius, center = Offset(t2X + padX + dotRadius, yPosCorrect + h2 / 2f))
|
||||||
|
drawText(textLayoutResult = textResCorrect, topLeft = Offset(t2X + padX + dotRadius * 2 + gap, yPosCorrect + padY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// X-Axis Labels (Freed from fixed widths, prevented from wrapping)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
weeklyStats.forEachIndexed { index, stat ->
|
||||||
|
val isSelected = index == selectedIndex
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stat.day.uppercase().take(3) + ".",
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 11.sp, // Slightly smaller to ensure fit across all devices
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false // Prevents the text from splitting into multiple lines
|
||||||
|
)
|
||||||
|
if (isSelected) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(2.dp)
|
||||||
|
.width(20.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Invisible spacer to prevent layout jumping when line appears
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Path.smoothCurve(points: List<Offset>) {
|
||||||
|
if (points.isEmpty()) return
|
||||||
|
moveTo(points.first().x, points.first().y)
|
||||||
|
for (i in 1 until points.size) {
|
||||||
|
val prev = points[i - 1]
|
||||||
|
val curr = points[i]
|
||||||
|
val controlX = (prev.x + curr.x) / 2f
|
||||||
|
cubicTo(
|
||||||
|
controlX, prev.y,
|
||||||
|
controlX, curr.y,
|
||||||
|
curr.x, curr.y
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeeklyChartLegend() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
LegendItem(color = MaterialTheme.colorScheme.primary, label = stringResource(R.string.label_completed).uppercase())
|
||||||
|
LegendItem(color = MaterialTheme.colorScheme.tertiary, label = stringResource(R.string.label_correct).uppercase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LegendItem(color: Color, label: String) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.background(color, shape = CircleShape)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
letterSpacing = 0.5.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChartFooter(weeklyStats: List<WeeklyActivityStat>) {
|
||||||
|
val bestDay = remember(weeklyStats) {
|
||||||
|
weeklyStats.maxByOrNull { it.completed + it.answeredRight }?.day?.uppercase() ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Melhor Dia:",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = bestDay,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ThemePreviews
|
||||||
|
@Composable
|
||||||
|
fun WeeklyActivityChartWidgetPreview() {
|
||||||
|
val sampleStats = listOf(
|
||||||
|
WeeklyActivityStat("Seg", 30, 15, 10),
|
||||||
|
WeeklyActivityStat("Ter", 45, 20, 12),
|
||||||
|
WeeklyActivityStat("Qua", 80, 25, 15),
|
||||||
|
WeeklyActivityStat("Qui", 84, 35, 18),
|
||||||
|
WeeklyActivityStat("Sex", 50, 40, 22),
|
||||||
|
WeeklyActivityStat("Sáb", 70, 30, 20),
|
||||||
|
WeeklyActivityStat("Dom", 60, 25, 18)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
WeeklyActivityChartWidget(weeklyStats = sampleStats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@file:Suppress("HardCodedStringLiteral")
|
@file:Suppress("HardCodedStringLiteral")
|
||||||
|
|
||||||
package eu.gaudian.translator.view.vocabulary.widgets
|
package eu.gaudian.translator.view.stats.widgets
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -6,6 +6,7 @@ import android.content.ClipData
|
|||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
@@ -58,6 +59,7 @@ import eu.gaudian.translator.view.LocalConnectionConfigured
|
|||||||
import eu.gaudian.translator.view.NoConnectionScreen
|
import eu.gaudian.translator.view.NoConnectionScreen
|
||||||
import eu.gaudian.translator.view.composable.AppCard
|
import eu.gaudian.translator.view.composable.AppCard
|
||||||
import eu.gaudian.translator.view.composable.AppOutlinedCard
|
import eu.gaudian.translator.view.composable.AppOutlinedCard
|
||||||
|
import eu.gaudian.translator.view.composable.Screen
|
||||||
import eu.gaudian.translator.view.dialogs.AddVocabularyDialog
|
import eu.gaudian.translator.view.dialogs.AddVocabularyDialog
|
||||||
import eu.gaudian.translator.view.hints.HintDefinition
|
import eu.gaudian.translator.view.hints.HintDefinition
|
||||||
import eu.gaudian.translator.view.settings.SettingsRoutes
|
import eu.gaudian.translator.view.settings.SettingsRoutes
|
||||||
@@ -88,7 +90,10 @@ fun TranslationScreen(
|
|||||||
|
|
||||||
|
|
||||||
if (isInitializationComplete && !connectionConfigured) {
|
if (isInitializationComplete && !connectionConfigured) {
|
||||||
NoConnectionScreen(onSettingsClick = { navController.navigate(SettingsRoutes.API_KEY) })
|
NoConnectionScreen(
|
||||||
|
onSettingsClick = { navController.navigate(SettingsRoutes.API_KEY) },
|
||||||
|
navController = navController
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +113,7 @@ fun TranslationScreen(
|
|||||||
onSettingsClick = onSettingsClick,
|
onSettingsClick = onSettingsClick,
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
if (!navController.popBackStack()) {
|
if (!navController.popBackStack()) {
|
||||||
navController.navigate(eu.gaudian.translator.view.composable.Screen.Home.route) {
|
navController.navigate(Screen.Home.route) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
restoreState = false
|
restoreState = false
|
||||||
}
|
}
|
||||||
@@ -182,7 +187,7 @@ private fun LoadedTranslationContent(
|
|||||||
|
|
||||||
AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) {
|
AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) {
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
if (isLandscape) {
|
if (isLandscape) {
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|||||||
@@ -1,671 +0,0 @@
|
|||||||
@file:Suppress("HardCodedStringLiteral", "AssignedValueIsNeverRead", "UnusedMaterial3ScaffoldPaddingParameter")
|
|
||||||
|
|
||||||
package eu.gaudian.translator.view.vocabulary
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.compose.animation.animateContentSize
|
|
||||||
import androidx.compose.animation.core.Animatable
|
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
||||||
import androidx.compose.animation.core.Spring
|
|
||||||
import androidx.compose.animation.core.VisibilityThreshold
|
|
||||||
import androidx.compose.animation.core.spring
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.gestures.detectDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.scrollBy
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.zIndex
|
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import eu.gaudian.translator.R
|
|
||||||
import eu.gaudian.translator.model.VocabularyStage
|
|
||||||
import eu.gaudian.translator.model.WidgetType
|
|
||||||
import eu.gaudian.translator.utils.findActivity
|
|
||||||
import eu.gaudian.translator.view.composable.AppCard
|
|
||||||
import eu.gaudian.translator.view.composable.AppIcons
|
|
||||||
import eu.gaudian.translator.view.composable.AppOutlinedCard
|
|
||||||
import eu.gaudian.translator.view.dialogs.MissingLanguageDialog
|
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.AllVocabularyWidget
|
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressWidget
|
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.DueTodayWidget
|
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.LevelWidget
|
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.StatusWidget
|
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.StreakWidget
|
|
||||||
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
|
|
||||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
|
||||||
import eu.gaudian.translator.viewmodel.ProgressViewModel
|
|
||||||
import eu.gaudian.translator.viewmodel.SettingsViewModel
|
|
||||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@SuppressLint("FrequentlyChangingValue")
|
|
||||||
@Composable
|
|
||||||
fun DashboardContent(
|
|
||||||
navController: NavController,
|
|
||||||
onShowCustomExerciseDialog: () -> Unit,
|
|
||||||
startDailyExercise: (Boolean) -> Unit,
|
|
||||||
onNavigateToCategoryDetail: (Int) -> Unit,
|
|
||||||
onNavigateToCategoryList: () -> Unit,
|
|
||||||
onShowWordPairExerciseDialog: () -> Unit,
|
|
||||||
onScroll: (Boolean) -> Unit = {},
|
|
||||||
) {
|
|
||||||
val activity = LocalContext.current.findActivity()
|
|
||||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
|
||||||
val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
|
||||||
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
|
||||||
|
|
||||||
var showMissingLanguageDialog by remember { mutableStateOf(false) }
|
|
||||||
var selectedMissingLanguageId by remember { mutableStateOf<Int?>(null) }
|
|
||||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
|
||||||
|
|
||||||
val affectedItems by remember(selectedMissingLanguageId) {
|
|
||||||
selectedMissingLanguageId?.let {
|
|
||||||
vocabularyViewModel.getItemsForLanguage(it)
|
|
||||||
} ?: flowOf(emptyList())
|
|
||||||
}.collectAsState(initial = emptyList())
|
|
||||||
|
|
||||||
if (showMissingLanguageDialog && selectedMissingLanguageId != null) {
|
|
||||||
MissingLanguageDialog(
|
|
||||||
showDialog = true,
|
|
||||||
missingLanguageId = selectedMissingLanguageId!!,
|
|
||||||
affectedItems = affectedItems,
|
|
||||||
onDismiss = { showMissingLanguageDialog = false },
|
|
||||||
onDelete = { items ->
|
|
||||||
vocabularyViewModel.deleteVocabularyItemsById(items.map { it.id })
|
|
||||||
showMissingLanguageDialog = false
|
|
||||||
},
|
|
||||||
onReplace = { oldId, newId ->
|
|
||||||
vocabularyViewModel.replaceLanguageId(oldId, newId)
|
|
||||||
showMissingLanguageDialog = false
|
|
||||||
},
|
|
||||||
onCreate = { newLanguage ->
|
|
||||||
languageViewModel.addCustomLanguage(newLanguage)
|
|
||||||
},
|
|
||||||
languageViewModel = languageViewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AppOutlinedCard {
|
|
||||||
// We collect the order from DB initially
|
|
||||||
val initialWidgetOrder by settingsViewModel.widgetOrder.collectAsState(initial = null)
|
|
||||||
val collapsedWidgetIds by settingsViewModel.collapsedWidgetIds.collectAsState(initial = emptySet())
|
|
||||||
val dashboardScrollState by settingsViewModel.dashboardScrollState.collectAsState()
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
if (initialWidgetOrder == null) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(vertical = 64.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// BEST PRACTICE: Use a SnapshotStateList for immediate UI updates.
|
|
||||||
// We only initialize this once, so DB updates don't reset the list while dragging.
|
|
||||||
val orderedWidgets = remember { mutableStateListOf<WidgetType>() }
|
|
||||||
|
|
||||||
// Sync with DB only on first load
|
|
||||||
LaunchedEffect(initialWidgetOrder) {
|
|
||||||
if (orderedWidgets.isEmpty() && !initialWidgetOrder.isNullOrEmpty()) {
|
|
||||||
orderedWidgets.addAll(initialWidgetOrder!!)
|
|
||||||
} else if (orderedWidgets.isEmpty()) {
|
|
||||||
orderedWidgets.addAll(WidgetType.DEFAULT_ORDER)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val lazyListState = rememberLazyListState(
|
|
||||||
initialFirstVisibleItemIndex = dashboardScrollState.first,
|
|
||||||
initialFirstVisibleItemScrollOffset = dashboardScrollState.second
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save scroll state
|
|
||||||
LaunchedEffect(lazyListState.firstVisibleItemIndex, lazyListState.firstVisibleItemScrollOffset) {
|
|
||||||
settingsViewModel.saveDashboardScrollState(
|
|
||||||
lazyListState.firstVisibleItemIndex,
|
|
||||||
lazyListState.firstVisibleItemScrollOffset
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect scroll and notify parent
|
|
||||||
LaunchedEffect(lazyListState.isScrollInProgress) {
|
|
||||||
onScroll(lazyListState.isScrollInProgress)
|
|
||||||
}
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
settingsViewModel.saveDashboardScrollState(
|
|
||||||
lazyListState.firstVisibleItemIndex,
|
|
||||||
lazyListState.firstVisibleItemScrollOffset
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Robust Drag and Drop State ---
|
|
||||||
val dragDropState = rememberDragDropState(
|
|
||||||
lazyListState = lazyListState,
|
|
||||||
onSwap = { fromIndex, toIndex ->
|
|
||||||
// Swap data immediately for responsiveness
|
|
||||||
orderedWidgets.apply {
|
|
||||||
add(toIndex, removeAt(fromIndex))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDragEnd = {
|
|
||||||
// Persist to DB only when user drops
|
|
||||||
settingsViewModel.saveWidgetOrder(orderedWidgets.toList())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
state = lazyListState,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.dragContainer(dragDropState),
|
|
||||||
contentPadding = PaddingValues(bottom = 160.dp)
|
|
||||||
) {
|
|
||||||
itemsIndexed(
|
|
||||||
items = orderedWidgets,
|
|
||||||
key = { _, widget -> widget.id }
|
|
||||||
) { index, widgetType ->
|
|
||||||
|
|
||||||
val isDragging = index == dragDropState.draggingItemIndex
|
|
||||||
|
|
||||||
// Calculate translation: distinct logic for dragged vs. stationary items
|
|
||||||
val translationY = if (isDragging) {
|
|
||||||
dragDropState.draggingItemOffset
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.zIndex(if (isDragging) 1f else 0f)
|
|
||||||
.graphicsLayer {
|
|
||||||
this.translationY = translationY
|
|
||||||
this.shadowElevation = if (isDragging) 8.dp.toPx() else 0f
|
|
||||||
this.scaleX = if (isDragging) 1.02f else 1f
|
|
||||||
this.scaleY = if (isDragging) 1.02f else 1f
|
|
||||||
}
|
|
||||||
// CRITICAL FIX: Only apply animation to items NOT being dragged.
|
|
||||||
// This prevents the "flicker" by stopping the layout animation
|
|
||||||
// from fighting your manual drag offset.
|
|
||||||
.then(
|
|
||||||
if (!isDragging) {
|
|
||||||
Modifier.animateItem(
|
|
||||||
placementSpec = spring(
|
|
||||||
stiffness = Spring.StiffnessLow,
|
|
||||||
visibilityThreshold = IntOffset.VisibilityThreshold
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
WidgetContainer(
|
|
||||||
widgetType = widgetType,
|
|
||||||
isExpanded = widgetType.id !in collapsedWidgetIds,
|
|
||||||
onExpandedChange = { newExpandedState ->
|
|
||||||
scope.launch {
|
|
||||||
settingsViewModel.setWidgetExpandedState(widgetType.id, newExpandedState)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDragStart = { dragDropState.onDragStart(index) },
|
|
||||||
onDrag = { dragAmount -> dragDropState.onDrag(dragAmount) },
|
|
||||||
onDragEnd = { dragDropState.onDragEnd() },
|
|
||||||
onDragCancel = { dragDropState.onDragInterrupted() },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
LazyWidget(
|
|
||||||
widgetType = widgetType,
|
|
||||||
navController = navController,
|
|
||||||
vocabularyViewModel = vocabularyViewModel,
|
|
||||||
progressViewModel = progressViewModel,
|
|
||||||
onShowCustomExerciseDialog = onShowCustomExerciseDialog,
|
|
||||||
startDailyExercise = startDailyExercise,
|
|
||||||
onNavigateToCategoryDetail = onNavigateToCategoryDetail,
|
|
||||||
onNavigateToCategoryList = onNavigateToCategoryList,
|
|
||||||
onShowWordPairExerciseDialog = onShowWordPairExerciseDialog,
|
|
||||||
onMissingLanguage = { missingId ->
|
|
||||||
selectedMissingLanguageId = missingId
|
|
||||||
showMissingLanguageDialog = true
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WidgetContainer(
|
|
||||||
widgetType: WidgetType,
|
|
||||||
isExpanded: Boolean,
|
|
||||||
onExpandedChange: (Boolean) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onDragStart: () -> Unit,
|
|
||||||
onDrag: (Float) -> Unit,
|
|
||||||
onDragEnd: () -> Unit,
|
|
||||||
onDragCancel: () -> Unit,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
AppCard(
|
|
||||||
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.animateContentSize(animationSpec = tween(300, easing = FastOutSlowInEasing))
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(widgetType.titleRes),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(onClick = { onExpandedChange(!isExpanded) }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isExpanded) AppIcons.ArrowDropUp
|
|
||||||
else AppIcons.ArrowDropDown,
|
|
||||||
contentDescription = if (isExpanded) stringResource(R.string.text_collapse_widget)
|
|
||||||
else stringResource(R.string.text_expand_widget)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drag Handle with specific pointer input
|
|
||||||
Icon(
|
|
||||||
imageVector = AppIcons.DragHandle,
|
|
||||||
contentDescription = stringResource(R.string.text_drag_to_reorder),
|
|
||||||
tint = if (isExpanded) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(end = 8.dp, start = 8.dp)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectDragGestures(
|
|
||||||
onDragStart = { _ -> onDragStart() },
|
|
||||||
onDrag = { change, dragAmount ->
|
|
||||||
change.consume()
|
|
||||||
onDrag(dragAmount.y)
|
|
||||||
},
|
|
||||||
onDragEnd = { onDragEnd() },
|
|
||||||
onDragCancel = { onDragCancel() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (isExpanded) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------------
|
|
||||||
// Fixed Drag and Drop Logic
|
|
||||||
// --------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberDragDropState(
|
|
||||||
lazyListState: LazyListState,
|
|
||||||
onSwap: (Int, Int) -> Unit,
|
|
||||||
onDragEnd: () -> Unit
|
|
||||||
): DragDropState {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
return remember(lazyListState, scope) {
|
|
||||||
DragDropState(
|
|
||||||
state = lazyListState,
|
|
||||||
onSwap = onSwap,
|
|
||||||
onDragFinished = onDragEnd,
|
|
||||||
scope = scope
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
|
|
||||||
return this.pointerInput(dragDropState) {
|
|
||||||
// Just allows the modifier to exist in the chain, logic is in the handle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DragDropState(
|
|
||||||
private val state: LazyListState,
|
|
||||||
private val onSwap: (Int, Int) -> Unit,
|
|
||||||
private val onDragFinished: () -> Unit,
|
|
||||||
private val scope: CoroutineScope
|
|
||||||
) {
|
|
||||||
var draggingItemIndex by mutableIntStateOf(-1)
|
|
||||||
private set
|
|
||||||
|
|
||||||
private val _draggingItemOffset = Animatable(0f)
|
|
||||||
val draggingItemOffset: Float
|
|
||||||
get() = _draggingItemOffset.value
|
|
||||||
|
|
||||||
private val scrollChannel = Channel<Float>(Channel.CONFLATED)
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
for (scrollAmount in scrollChannel) {
|
|
||||||
if (scrollAmount != 0f) {
|
|
||||||
state.scrollBy(scrollAmount)
|
|
||||||
checkSwap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDragStart(index: Int) {
|
|
||||||
draggingItemIndex = index
|
|
||||||
scope.launch { _draggingItemOffset.snapTo(0f) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDrag(dragAmount: Float) {
|
|
||||||
if (draggingItemIndex == -1) return
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
_draggingItemOffset.snapTo(_draggingItemOffset.value + dragAmount)
|
|
||||||
checkSwap()
|
|
||||||
checkOverscroll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkSwap() {
|
|
||||||
val draggedIndex = draggingItemIndex
|
|
||||||
if (draggedIndex == -1) return
|
|
||||||
|
|
||||||
val visibleItems = state.layoutInfo.visibleItemsInfo
|
|
||||||
val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return
|
|
||||||
|
|
||||||
// Calculate the visual center of the dragged item
|
|
||||||
val draggedCenter = draggedItemInfo.offset + (draggedItemInfo.size / 2) + _draggingItemOffset.value
|
|
||||||
|
|
||||||
// Find a target to swap with
|
|
||||||
// FIX: We strictly check if we have crossed the CENTER of the target item.
|
|
||||||
// This acts as a hysteresis buffer to prevent flickering at the edges.
|
|
||||||
val targetItem = visibleItems.find { item ->
|
|
||||||
item.index != draggedIndex &&
|
|
||||||
draggedCenter > item.offset &&
|
|
||||||
draggedCenter < (item.offset + item.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetItem != null) {
|
|
||||||
// Extra Check: Ensure we have actually crossed the midpoint of the target
|
|
||||||
val targetCenter = itemCenter(targetItem.offset, targetItem.size)
|
|
||||||
val isAboveAndMovingDown = draggedIndex < targetItem.index && draggedCenter > targetCenter
|
|
||||||
val isBelowAndMovingUp = draggedIndex > targetItem.index && draggedCenter < targetCenter
|
|
||||||
|
|
||||||
if (isAboveAndMovingDown || isBelowAndMovingUp) {
|
|
||||||
val targetIndex = targetItem.index
|
|
||||||
|
|
||||||
// 1. Swap Data
|
|
||||||
onSwap(draggedIndex, targetIndex)
|
|
||||||
|
|
||||||
// 2. Adjust Offset
|
|
||||||
// We calculate the physical distance the item moved in the layout (e.g. 150px).
|
|
||||||
// We subtract this from the current drag offset to keep the item visually stationary under the finger.
|
|
||||||
val layoutJumpDistance = (targetItem.offset - draggedItemInfo.offset).toFloat()
|
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
_draggingItemOffset.snapTo(_draggingItemOffset.value - layoutJumpDistance)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Update Index
|
|
||||||
draggingItemIndex = targetIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun itemCenter(offset: Int, size: Int): Float {
|
|
||||||
return offset + (size / 2f)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkOverscroll() {
|
|
||||||
val draggedIndex = draggingItemIndex
|
|
||||||
if (draggedIndex == -1) {
|
|
||||||
scrollChannel.trySend(0f)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val layoutInfo = state.layoutInfo
|
|
||||||
val visibleItems = layoutInfo.visibleItemsInfo
|
|
||||||
val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return
|
|
||||||
|
|
||||||
val viewportStart = layoutInfo.viewportStartOffset
|
|
||||||
val viewportEnd = layoutInfo.viewportEndOffset
|
|
||||||
// Increased threshold slightly for smoother top-edge scrolling
|
|
||||||
val boundsStart = viewportStart + (viewportEnd * 0.15f)
|
|
||||||
val boundsEnd = viewportEnd - (viewportEnd * 0.15f)
|
|
||||||
|
|
||||||
val itemTop = draggedItemInfo.offset + _draggingItemOffset.value
|
|
||||||
val itemBottom = itemTop + draggedItemInfo.size
|
|
||||||
|
|
||||||
val scrollAmount = when {
|
|
||||||
itemTop < boundsStart -> -10f // Slower, more controlled scroll speed
|
|
||||||
itemBottom > boundsEnd -> 10f
|
|
||||||
else -> 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollChannel.trySend(scrollAmount)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDragEnd() {
|
|
||||||
resetDrag()
|
|
||||||
onDragFinished()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDragInterrupted() {
|
|
||||||
resetDrag()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resetDrag() {
|
|
||||||
draggingItemIndex = -1
|
|
||||||
scrollChannel.trySend(0f)
|
|
||||||
scope.launch { _draggingItemOffset.snapTo(0f) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------------
|
|
||||||
// Remainder of your existing components
|
|
||||||
// --------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LazyWidget(
|
|
||||||
widgetType: WidgetType,
|
|
||||||
navController: NavController,
|
|
||||||
vocabularyViewModel: VocabularyViewModel,
|
|
||||||
progressViewModel: ProgressViewModel,
|
|
||||||
onShowCustomExerciseDialog: () -> Unit,
|
|
||||||
startDailyExercise: (Boolean) -> Unit,
|
|
||||||
onNavigateToCategoryDetail: (Int) -> Unit,
|
|
||||||
onNavigateToCategoryList: () -> Unit,
|
|
||||||
onShowWordPairExerciseDialog: () -> Unit,
|
|
||||||
onMissingLanguage: (Int) -> Unit
|
|
||||||
) {
|
|
||||||
when (widgetType) {
|
|
||||||
|
|
||||||
|
|
||||||
WidgetType.Status -> LazyStatusWidget(
|
|
||||||
vocabularyViewModel = vocabularyViewModel,
|
|
||||||
onNavigateToNew = { navController.navigate("vocabulary_sorting?mode=NEW") },
|
|
||||||
onNavigateToDuplicates = { navController.navigate("vocabulary_sorting?mode=DUPLICATES") },
|
|
||||||
onNavigateToFaulty = { navController.navigate("vocabulary_sorting?mode=FAULTY") },
|
|
||||||
onNavigateToNoGrammar = { navController.navigate("no_grammar_items") },
|
|
||||||
onNavigateToMissingLanguage = onMissingLanguage
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
// Regular widgets that load immediately
|
|
||||||
when (widgetType) {
|
|
||||||
WidgetType.Streak -> StreakWidget(
|
|
||||||
streak = progressViewModel.streak.collectAsState(initial = 0).value,
|
|
||||||
lastSevenDays = progressViewModel.lastSevenDays.collectAsState().value,
|
|
||||||
dueTodayCount = progressViewModel.dueTodayCount.collectAsState().value,
|
|
||||||
wordsCompleted = progressViewModel.totalWordsCompleted.collectAsState().value,
|
|
||||||
onStatisticsClicked = { navController.navigate("vocabulary_heatmap") }
|
|
||||||
)
|
|
||||||
|
|
||||||
WidgetType.WeeklyActivityChart -> WeeklyActivityChartWidget(
|
|
||||||
weeklyStats = progressViewModel.weeklyActivityStats.collectAsState().value
|
|
||||||
)
|
|
||||||
|
|
||||||
WidgetType.AllVocabulary -> AllVocabularyWidget(
|
|
||||||
vocabularyViewModel = vocabularyViewModel,
|
|
||||||
onOpenAllVocabulary = { navController.navigate("vocabulary_list/false/null") },
|
|
||||||
onStageClicked = { stage -> navController.navigate("vocabulary_list/false/$stage") }
|
|
||||||
)
|
|
||||||
|
|
||||||
WidgetType.DueToday -> DueTodayWidget(
|
|
||||||
vocabularyViewModel = vocabularyViewModel,
|
|
||||||
onStageClicked = { stage -> navController.navigate("vocabulary_list/false/$stage") }
|
|
||||||
)
|
|
||||||
|
|
||||||
WidgetType.CategoryProgress -> CategoryProgressWidget(
|
|
||||||
onCategoryClicked = { category ->
|
|
||||||
category?.let { onNavigateToCategoryDetail(it.id) }
|
|
||||||
},
|
|
||||||
onViewAllClicked = onNavigateToCategoryList
|
|
||||||
)
|
|
||||||
|
|
||||||
WidgetType.Levels -> LevelWidget(
|
|
||||||
totalWords = vocabularyViewModel.vocabularyItems.collectAsState().value.size,
|
|
||||||
learnedWords = vocabularyViewModel.stageStats.collectAsState().value
|
|
||||||
.firstOrNull { it.stage == VocabularyStage.LEARNED }?.itemCount ?: 0,
|
|
||||||
onNavigateToProgress = { navController.navigate("language_progress") }
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LazyStatusWidget(
|
|
||||||
vocabularyViewModel: VocabularyViewModel,
|
|
||||||
onNavigateToNew: () -> Unit,
|
|
||||||
onNavigateToDuplicates: () -> Unit,
|
|
||||||
onNavigateToFaulty: () -> Unit,
|
|
||||||
onNavigateToNoGrammar: () -> Unit,
|
|
||||||
onNavigateToMissingLanguage: (Int) -> Unit
|
|
||||||
) {
|
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
// Collect all flows asynchronously
|
|
||||||
val newItemsCount by vocabularyViewModel.newItemsCount.collectAsState()
|
|
||||||
val duplicateCount by vocabularyViewModel.duplicateItemsCount.collectAsState()
|
|
||||||
val faultyItemsCount by vocabularyViewModel.faultyItemsCount.collectAsState()
|
|
||||||
val itemsWithoutGrammarCount by vocabularyViewModel.itemsWithoutGrammarCount.collectAsState()
|
|
||||||
val missingLanguageInfo by vocabularyViewModel.missingLanguageInfo.collectAsState()
|
|
||||||
|
|
||||||
LaunchedEffect(
|
|
||||||
newItemsCount,
|
|
||||||
duplicateCount,
|
|
||||||
faultyItemsCount,
|
|
||||||
itemsWithoutGrammarCount,
|
|
||||||
missingLanguageInfo
|
|
||||||
) {
|
|
||||||
delay(100)
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 32.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
StatusWidget(
|
|
||||||
onNavigateToNew = onNavigateToNew,
|
|
||||||
onNavigateToDuplicates = onNavigateToDuplicates,
|
|
||||||
onNavigateToFaulty = onNavigateToFaulty,
|
|
||||||
onNavigateToNoGrammar = onNavigateToNoGrammar,
|
|
||||||
onNavigateToMissingLanguage = onNavigateToMissingLanguage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun DashboardContentPreview() {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
DashboardContent(
|
|
||||||
navController = navController,
|
|
||||||
onShowCustomExerciseDialog = {},
|
|
||||||
onNavigateToCategoryDetail = {},
|
|
||||||
startDailyExercise = {},
|
|
||||||
onNavigateToCategoryList = {},
|
|
||||||
onShowWordPairExerciseDialog = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun WidgetContainerPreview() {
|
|
||||||
WidgetContainer(
|
|
||||||
widgetType = WidgetType.Streak,
|
|
||||||
isExpanded = true,
|
|
||||||
onExpandedChange = {},
|
|
||||||
onDragStart = { } ,
|
|
||||||
onDrag = { },
|
|
||||||
onDragEnd = { },
|
|
||||||
onDragCancel = { }
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text("Preview Content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -67,7 +67,7 @@ import eu.gaudian.translator.view.composable.AppTopAppBar
|
|||||||
import eu.gaudian.translator.viewmodel.ProgressViewModel
|
import eu.gaudian.translator.viewmodel.ProgressViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LanguageProgressScreen(navController: NavController) {
|
fun LanguageJourneyScreen(navController: NavController) {
|
||||||
|
|
||||||
val activity = LocalContext.current.findActivity()
|
val activity = LocalContext.current.findActivity()
|
||||||
val progressViewModel : ProgressViewModel = hiltViewModel(activity)
|
val progressViewModel : ProgressViewModel = hiltViewModel(activity)
|
||||||
@@ -379,6 +379,6 @@ private fun LevelDetailDialog(level: MyAppLanguageLevel, onDismiss: () -> Unit)
|
|||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun LanguageProgressScreenPreview() {
|
fun LanguageJourneyScreenPreview() {
|
||||||
LanguageProgressScreen(navController = NavController(LocalContext.current))
|
LanguageJourneyScreen(navController = NavController(LocalContext.current))
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
package eu.gaudian.translator.view.vocabulary
|
package eu.gaudian.translator.view.vocabulary
|
||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
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.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -16,38 +14,30 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.AutoAwesome
|
import androidx.compose.material.icons.filled.AutoAwesome
|
||||||
import androidx.compose.material.icons.filled.DriveFolderUpload
|
import androidx.compose.material.icons.filled.DriveFolderUpload
|
||||||
import androidx.compose.material.icons.filled.EditNote
|
import androidx.compose.material.icons.filled.EditNote
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
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.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
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.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.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringArrayResource
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -56,16 +46,15 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import eu.gaudian.translator.R
|
import eu.gaudian.translator.R
|
||||||
import eu.gaudian.translator.model.Language
|
|
||||||
import eu.gaudian.translator.model.VocabularyItem
|
import eu.gaudian.translator.model.VocabularyItem
|
||||||
import eu.gaudian.translator.utils.StatusMessageId
|
|
||||||
import eu.gaudian.translator.utils.StatusMessageService
|
|
||||||
import eu.gaudian.translator.utils.findActivity
|
import eu.gaudian.translator.utils.findActivity
|
||||||
|
import eu.gaudian.translator.view.LocalConnectionConfigured
|
||||||
import eu.gaudian.translator.view.NavigationRoutes
|
import eu.gaudian.translator.view.NavigationRoutes
|
||||||
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.AppIconContainer
|
||||||
import eu.gaudian.translator.view.composable.AppIcons
|
import eu.gaudian.translator.view.composable.AppIcons
|
||||||
import eu.gaudian.translator.view.composable.AppOutlinedButton
|
import eu.gaudian.translator.view.composable.AppOutlinedTextField
|
||||||
import eu.gaudian.translator.view.composable.AppSlider
|
import eu.gaudian.translator.view.composable.AppSlider
|
||||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||||
import eu.gaudian.translator.view.composable.InspiringSearchField
|
import eu.gaudian.translator.view.composable.InspiringSearchField
|
||||||
@@ -74,6 +63,7 @@ import eu.gaudian.translator.view.composable.SourceLanguageDropdown
|
|||||||
import eu.gaudian.translator.view.composable.TargetLanguageDropdown
|
import eu.gaudian.translator.view.composable.TargetLanguageDropdown
|
||||||
import eu.gaudian.translator.view.hints.HintDefinition
|
import eu.gaudian.translator.view.hints.HintDefinition
|
||||||
import eu.gaudian.translator.view.library.VocabularyCard
|
import eu.gaudian.translator.view.library.VocabularyCard
|
||||||
|
import eu.gaudian.translator.view.settings.SettingsRoutes
|
||||||
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
|
import kotlinx.coroutines.launch
|
||||||
@@ -104,109 +94,12 @@ fun NewWordScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val statusMessageService = StatusMessageService
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val showTableImportDialog = remember { mutableStateOf(false) }
|
|
||||||
var parsedTable by remember { mutableStateOf<List<List<String>>>(emptyList()) }
|
|
||||||
var selectedColFirst by remember { mutableIntStateOf(0) }
|
|
||||||
var selectedColSecond by remember { mutableIntStateOf(1) }
|
|
||||||
var skipHeader by remember { mutableStateOf(true) }
|
|
||||||
var selectedLangFirst by remember { mutableStateOf<Language?>(null) }
|
|
||||||
var selectedLangSecond by remember { mutableStateOf<Language?>(null) }
|
|
||||||
|
|
||||||
val recentlyAdded = remember(recentItems) {
|
val recentlyAdded = remember(recentItems) {
|
||||||
recentItems.sortedByDescending { it.id }.take(4)
|
recentItems.sortedByDescending { it.id }.take(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseCsv(text: String): List<List<String>> {
|
|
||||||
if (text.isBlank()) return emptyList()
|
|
||||||
val candidates = listOf(',', ';', '\t')
|
|
||||||
val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList()
|
|
||||||
val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } }
|
|
||||||
|
|
||||||
val rows = mutableListOf<List<String>>()
|
|
||||||
var current = StringBuilder()
|
|
||||||
var inQuotes = false
|
|
||||||
val currentRow = mutableListOf<String>()
|
|
||||||
|
|
||||||
var i = 0
|
|
||||||
while (i < text.length) {
|
|
||||||
when (val ch = text[i]) {
|
|
||||||
'"' -> {
|
|
||||||
if (inQuotes && i + 1 < text.length && text[i + 1] == '"') {
|
|
||||||
current.append('"')
|
|
||||||
i++
|
|
||||||
} else {
|
|
||||||
inQuotes = !inQuotes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'\r' -> {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
'\n' -> {
|
|
||||||
val field = current.toString()
|
|
||||||
current = StringBuilder()
|
|
||||||
currentRow.add(field)
|
|
||||||
rows.add(currentRow.toList())
|
|
||||||
currentRow.clear()
|
|
||||||
inQuotes = false
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
if (ch == delimiter && !inQuotes) {
|
|
||||||
val field = current.toString()
|
|
||||||
currentRow.add(field)
|
|
||||||
current = StringBuilder()
|
|
||||||
} else {
|
|
||||||
current.append(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
if (current.isNotEmpty() || currentRow.isNotEmpty()) {
|
|
||||||
currentRow.add(current.toString())
|
|
||||||
rows.add(currentRow.toList())
|
|
||||||
}
|
|
||||||
return rows.map { row ->
|
|
||||||
row.map { it.trim().trim('"') }
|
|
||||||
}.filter { r -> r.any { it.isNotBlank() } }
|
|
||||||
}
|
|
||||||
|
|
||||||
val importTableLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
|
||||||
onResult = { uri ->
|
|
||||||
uri?.let { u ->
|
|
||||||
try {
|
|
||||||
context.contentResolver.takePersistableUriPermission(u, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
try {
|
|
||||||
val mime = context.contentResolver.getType(u)
|
|
||||||
val isExcel = mime == "application/vnd.ms-excel" ||
|
|
||||||
mime == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
||||||
if (isExcel) {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_EXCEL_NOT_SUPPORTED)
|
|
||||||
return@let
|
|
||||||
}
|
|
||||||
context.contentResolver.openInputStream(u)?.use { inputStream ->
|
|
||||||
val text = inputStream.bufferedReader().use { it.readText() }
|
|
||||||
val rows = parseCsv(text)
|
|
||||||
if (rows.isNotEmpty() && rows.maxOf { it.size } >= 2) {
|
|
||||||
parsedTable = rows
|
|
||||||
selectedColFirst = 0
|
|
||||||
selectedColSecond = 1.coerceAtMost(rows.first().size - 1)
|
|
||||||
showTableImportDialog.value = true
|
|
||||||
} else {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_PARSING_TABLE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_PARSING_TABLE_WITH_REASON)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -227,6 +120,14 @@ fun NewWordScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Explore Packs - Prominent full-width card at top
|
||||||
|
ExplorePacksProminentCard(
|
||||||
|
onClick = { navController.navigate(NavigationRoutes.EXPLORE_PACKS) }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// AI Generator Card
|
||||||
AIGeneratorCard(
|
AIGeneratorCard(
|
||||||
category = category,
|
category = category,
|
||||||
onCategoryChange = { category = it },
|
onCategoryChange = { category = it },
|
||||||
@@ -242,10 +143,12 @@ fun NewWordScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
navController = navController,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Add Manually Card
|
||||||
AddManuallyCard(
|
AddManuallyCard(
|
||||||
languageViewModel = languageViewModel,
|
languageViewModel = languageViewModel,
|
||||||
vocabularyViewModel = vocabularyViewModel,
|
vocabularyViewModel = vocabularyViewModel,
|
||||||
@@ -253,19 +156,11 @@ fun NewWordScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
BottomActionCardsRow(
|
// Import CSV - Full width card at bottom
|
||||||
onImportCsvClick = {
|
ImportCsvCard(
|
||||||
|
onClick = {
|
||||||
@Suppress("HardCodedStringLiteral")
|
@Suppress("HardCodedStringLiteral")
|
||||||
importTableLauncher.launch(
|
navController.navigate("settings_vocabulary_repository_options")
|
||||||
arrayOf(
|
|
||||||
"text/csv",
|
|
||||||
"text/comma-separated-values",
|
|
||||||
"text/tab-separated-values",
|
|
||||||
"text/plain",
|
|
||||||
"application/vnd.ms-excel",
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -310,123 +205,6 @@ fun NewWordScreen(
|
|||||||
Spacer(modifier = Modifier.height(100.dp))
|
Spacer(modifier = Modifier.height(100.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTableImportDialog.value) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showTableImportDialog.value = false },
|
|
||||||
title = { Text(stringResource(R.string.label_import_table_csv_excel)) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
|
|
||||||
var menu1Expanded by remember { mutableStateOf(false) }
|
|
||||||
AppOutlinedButton(onClick = { menu1Expanded = true }) {
|
|
||||||
Text(stringResource(R.string.label_column_n, selectedColFirst + 1))
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = menu1Expanded, onDismissRequest = { menu1Expanded = false }) {
|
|
||||||
(0 until columnCount).forEach { idx ->
|
|
||||||
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("#${idx + 1} • $header") },
|
|
||||||
onClick = { selectedColFirst = idx; menu1Expanded = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(stringResource(R.string.label_second_column), modifier = Modifier.weight(1f))
|
|
||||||
var menu2Expanded by remember { mutableStateOf(false) }
|
|
||||||
AppOutlinedButton(onClick = { menu2Expanded = true }) {
|
|
||||||
Text(stringResource(R.string.label_column_n, selectedColSecond + 1))
|
|
||||||
}
|
|
||||||
DropdownMenu(expanded = menu2Expanded, onDismissRequest = { menu2Expanded = false }) {
|
|
||||||
(0 until columnCount).forEach { idx ->
|
|
||||||
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("#${idx + 1} • $header") },
|
|
||||||
onClick = { selectedColSecond = idx; menu2Expanded = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(stringResource(R.string.label_languages))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(stringResource(R.string.label_first_language))
|
|
||||||
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
selectedLanguage = selectedLangFirst,
|
|
||||||
onLanguageSelected = { selectedLangFirst = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(stringResource(R.string.label_second_language))
|
|
||||||
eu.gaudian.translator.view.composable.SingleLanguageDropDown(
|
|
||||||
languageViewModel = languageViewModel,
|
|
||||||
selectedLanguage = selectedLangSecond,
|
|
||||||
onLanguageSelected = { selectedLangSecond = it }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
androidx.compose.material3.Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(stringResource(R.string.label_header_row))
|
|
||||||
}
|
|
||||||
val startIdx = if (skipHeader) 1 else 0
|
|
||||||
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
|
|
||||||
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
|
|
||||||
Text(stringResource(R.string.label_preview_first, previewA))
|
|
||||||
Text(stringResource(R.string.label_preview_second, previewB))
|
|
||||||
val totalRows = parsedTable.drop(startIdx).count { row ->
|
|
||||||
val a = row.getOrNull(selectedColFirst).orEmpty().isNotBlank()
|
|
||||||
val b = row.getOrNull(selectedColSecond).orEmpty().isNotBlank()
|
|
||||||
a || b
|
|
||||||
}
|
|
||||||
Text(stringResource(R.string.text_rows_to_import_1d, totalRows))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
if (selectedColFirst == selectedColSecond) {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_SELECT_TWO_COLUMNS)
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
val langA = selectedLangFirst
|
|
||||||
val langB = selectedLangSecond
|
|
||||||
if (langA == null || langB == null) {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_SELECT_LANGUAGES)
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
val startIdx = if (skipHeader) 1 else 0
|
|
||||||
val items = parsedTable.drop(startIdx).mapNotNull { row ->
|
|
||||||
val a = row.getOrNull(selectedColFirst)?.trim().orEmpty()
|
|
||||||
val b = row.getOrNull(selectedColSecond)?.trim().orEmpty()
|
|
||||||
if (a.isBlank() && b.isBlank()) null else VocabularyItem(
|
|
||||||
id = 0,
|
|
||||||
languageFirstId = langA.nameResId,
|
|
||||||
languageSecondId = langB.nameResId,
|
|
||||||
wordFirst = a,
|
|
||||||
wordSecond = b
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (items.isEmpty()) {
|
|
||||||
statusMessageService.showErrorById(StatusMessageId.ERROR_NO_ROWS_TO_IMPORT)
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
vocabularyViewModel.addVocabularyItems(items)
|
|
||||||
statusMessageService.showSuccessById(StatusMessageId.SUCCESS_ITEMS_IMPORTED)
|
|
||||||
showTableImportDialog.value = false
|
|
||||||
}) { Text(stringResource(R.string.label_import)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showTableImportDialog.value = false }) {
|
|
||||||
Text(stringResource(R.string.label_cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- AI GENERATOR CARD (From previous implementation) ---
|
// --- AI GENERATOR CARD (From previous implementation) ---
|
||||||
@@ -440,9 +218,14 @@ fun AIGeneratorCard(
|
|||||||
languageViewModel: LanguageViewModel,
|
languageViewModel: LanguageViewModel,
|
||||||
isGenerating: Boolean,
|
isGenerating: Boolean,
|
||||||
onGenerate: () -> Unit,
|
onGenerate: () -> Unit,
|
||||||
|
navController: NavHostController,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
val connectionConfigured = LocalConnectionConfigured.current
|
||||||
|
|
||||||
|
if (connectionConfigured) {
|
||||||
|
// Show the normal AI generator card
|
||||||
val icon = Icons.Default.AutoAwesome
|
val icon = Icons.Default.AutoAwesome
|
||||||
val hints = stringArrayResource(R.array.vocabulary_hints)
|
val hints = stringArrayResource(R.array.vocabulary_hints)
|
||||||
AppCard(
|
AppCard(
|
||||||
@@ -472,7 +255,11 @@ fun AIGeneratorCard(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
|
||||||
|
.padding(12.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
SourceLanguageDropdown(
|
SourceLanguageDropdown(
|
||||||
@@ -542,6 +329,49 @@ fun AIGeneratorCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Show the "configure connection" card when not configured
|
||||||
|
AppCard(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
title = stringResource(R.string.label_ai_generator),
|
||||||
|
icon = Icons.Default.AutoAwesome,
|
||||||
|
hintContent = HintDefinition.VOCABULARY_GENERATE_AI.hint(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.text_ai_generator_requires_configuration),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
AppButton(
|
||||||
|
onClick = { navController.navigate(SettingsRoutes.API_KEY) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(56.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.label_configure),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NEW COMPONENTS START HERE ---
|
// --- NEW COMPONENTS START HERE ---
|
||||||
@@ -564,26 +394,16 @@ fun AddManuallyCard(
|
|||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(24.dp)) {
|
Column(modifier = Modifier.padding(24.dp)) {
|
||||||
// Header Row
|
// Header Row - Using reusable AppIconContainer
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(
|
AppIconContainer(
|
||||||
modifier = Modifier
|
imageVector = Icons.Default.EditNote
|
||||||
.size(40.dp)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.EditNote,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.label_add_vocabulary),
|
text = stringResource(R.string.label_add_vocabulary),
|
||||||
@@ -596,37 +416,19 @@ fun AddManuallyCard(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Input Fields
|
// Input Fields - Using AppOutlinedTextField
|
||||||
TextField(
|
AppOutlinedTextField(
|
||||||
value = wordText,
|
value = wordText,
|
||||||
onValueChange = { wordText = it },
|
onValueChange = { wordText = it },
|
||||||
placeholder = { Text(stringResource(R.string.text_label_word), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) },
|
placeholder = { Text(stringResource(R.string.text_label_word)) }
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = MaterialTheme.colorScheme.surface, // Very dark background
|
|
||||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent
|
|
||||||
),
|
|
||||||
singleLine = true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
TextField(
|
AppOutlinedTextField(
|
||||||
value = translationText,
|
value = translationText,
|
||||||
onValueChange = { translationText = it },
|
onValueChange = { translationText = it },
|
||||||
placeholder = { Text(stringResource(R.string.text_translation), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) },
|
placeholder = { Text(stringResource(R.string.text_translation)) }
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent
|
|
||||||
),
|
|
||||||
singleLine = true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -661,7 +463,7 @@ fun AddManuallyCard(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Add to List Button (Darker variant)
|
// Add to List Button
|
||||||
AppButton(
|
AppButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val newItem = VocabularyItem(
|
val newItem = VocabularyItem(
|
||||||
@@ -690,90 +492,79 @@ fun AddManuallyCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Explore Packs Prominent Card (Full width at top) ---
|
||||||
@Composable
|
@Composable
|
||||||
fun BottomActionCardsRow(
|
fun ExplorePacksProminentCard(
|
||||||
modifier: Modifier = Modifier,
|
onClick: () -> Unit,
|
||||||
onImportCsvClick: () -> Unit
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
AppCard(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
onClick = onClick
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
//TODO Explore Packs Card
|
|
||||||
AppCard(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.height(120.dp),
|
.padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(
|
AppIconContainer(
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.alpha(0.6f),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(48.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = AppIcons.Vocabulary,
|
imageVector = AppIcons.Vocabulary,
|
||||||
contentDescription = null,
|
size = 56.dp,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
iconSize = 28.dp
|
||||||
)
|
)
|
||||||
}
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
@Suppress("HardCodedStringLiteral")
|
|
||||||
Text(
|
Text(
|
||||||
text = "Explore Packs",
|
text = stringResource(R.string.title_explore_packs),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.desc_explore_packs),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
}
|
||||||
@Suppress("HardCodedStringLiteral")
|
}
|
||||||
Text(
|
|
||||||
text = "Coming soon",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import CSV Card
|
// --- Import CSV Card (Full width at bottom) ---
|
||||||
|
@Composable
|
||||||
|
fun ImportCsvCard(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
AppCard(
|
AppCard(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
onClick = onClick
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.height(120.dp),
|
.padding(20.dp),
|
||||||
onClick = onImportCsvClick
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(
|
AppIconContainer(
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(48.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.DriveFolderUpload,
|
imageVector = Icons.Default.DriveFolderUpload,
|
||||||
contentDescription = null,
|
size = 56.dp,
|
||||||
tint = MaterialTheme.colorScheme.primary
|
iconSize = 28.dp
|
||||||
)
|
)
|
||||||
}
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.label_import_csv),
|
text = stringResource(R.string.label_import_csv_or_lists),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.desc_import_csv),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user