Compare commits
1 Commits
95dfd3c7eb
...
glassmorph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d0bf4cb1c |
4
.idea/deploymentTargetSelector.xml
generated
4
.idea/deploymentTargetSelector.xml
generated
@@ -4,10 +4,10 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-02-16T10:13:39.492968600Z">
|
||||
<DropdownSelection timestamp="2026-02-15T19:51:37.987601800Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCW11ML1WV" />
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\jonas\.android\avd\Medium_Phone_28.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
||||
@@ -6,6 +6,7 @@ import java.util.Locale
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.hilt.android)
|
||||
id("kotlin-parcelize")
|
||||
@@ -61,8 +62,11 @@ android {
|
||||
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
viewBinding = false
|
||||
@@ -126,8 +130,8 @@ dependencies {
|
||||
implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime
|
||||
implementation(libs.androidx.room.ktx)
|
||||
implementation(libs.core.ktx)
|
||||
implementation(libs.androidx.compose.runtime)
|
||||
ksp(libs.room.compiler)
|
||||
implementation(libs.androidx.compose.foundation.layout)
|
||||
ksp(libs.room.compiler) // CHANGED: Use ksp instead of implementation
|
||||
|
||||
// Networking
|
||||
implementation(libs.retrofit)
|
||||
|
||||
@@ -6,6 +6,9 @@ object TestConfig {
|
||||
// REPLACE with your actual API Key for the test
|
||||
const val API_KEY = "YOUR_REAL_API_KEY_HERE"
|
||||
|
||||
// Set to true if you want to see full log output in Logcat
|
||||
const val ENABLE_LOGGING = true
|
||||
|
||||
// Optional: If your ApiManager requires a specific provider (e.g., "Mistral", "OpenAI")
|
||||
const val PROVIDER_NAME = "Mistral"
|
||||
|
||||
|
||||
@@ -32,16 +32,17 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".CorrectActivity"
|
||||
android:exported="true">
|
||||
|
||||
<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>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,40 +0,0 @@
|
||||
## 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.
|
||||
@@ -1,24 +0,0 @@
|
||||
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!
|
||||
45
app/src/main/java/eu/gaudian/translator/CorrectActivity.kt
Normal file
45
app/src/main/java/eu/gaudian/translator/CorrectActivity.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import eu.gaudian.translator.utils.Log
|
||||
|
||||
class CorrectActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val intent = intent
|
||||
val action = intent.action
|
||||
val type = intent.type
|
||||
|
||||
if (Intent.ACTION_SEND == action && type == "text/plain") {
|
||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (sharedText != null) {
|
||||
Log.d("EditActivity", "Received text: $sharedText")
|
||||
setContent {
|
||||
Text(stringResource(R.string.editing_text, sharedText))
|
||||
|
||||
}
|
||||
} else {
|
||||
Log.e("EditActivity", getString(R.string.no_text_received))
|
||||
setContent {
|
||||
Text(stringResource(R.string.error_no_text_to_edit))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d("EditActivity", "Not launched with ACTION_SEND")
|
||||
setContent {
|
||||
Text(stringResource(R.string.not_launched_with_text_to_edit))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("unused", "HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.di
|
||||
|
||||
import android.app.Application
|
||||
|
||||
@@ -57,10 +57,6 @@ data class VocabularyItem(
|
||||
features = switchedFeaturesJson
|
||||
)
|
||||
}
|
||||
|
||||
fun hasFeatures(): Boolean {
|
||||
return !features.isNullOrBlank() && features != "{}"
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
@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
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import eu.gaudian.translator.R
|
||||
sealed class WidgetType(val id: String, @param:StringRes val titleRes: Int) {
|
||||
data object Status : WidgetType("status", R.string.label_status)
|
||||
data object Streak : WidgetType("streak", R.string.title_widget_streak)
|
||||
data object StartButtons : WidgetType("start_buttons", R.string.label_start_exercise)
|
||||
data object AllVocabulary : WidgetType("all_vocabulary", R.string.label_all_vocabulary)
|
||||
data object DueToday : WidgetType("due_today", R.string.title_widget_due_today)
|
||||
data object CategoryProgress : WidgetType("category_progress", R.string.label_categories)
|
||||
@@ -22,6 +23,7 @@ sealed class WidgetType(val id: String, @param:StringRes val titleRes: Int) {
|
||||
val DEFAULT_ORDER = listOf(
|
||||
Status,
|
||||
Streak,
|
||||
StartButtons,
|
||||
AllVocabulary,
|
||||
DueToday,
|
||||
CategoryProgress ,
|
||||
|
||||
@@ -228,7 +228,10 @@ class ApiManager(private val context: Context) {
|
||||
val allowNoKey = provider.isCustom || isLocalHost
|
||||
val effectiveKey = if (key.isNotEmpty()) key else if (allowNoKey) "" else key
|
||||
|
||||
|
||||
// Perplexity does not support listing models via /v1/models; fail fast with a clear message
|
||||
if (provider.key.equals("perplexity", ignoreCase = true)) {
|
||||
return Pair(emptyList(), "Perplexity does not support fetching modeles.") //TODO this must be transalted!
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
@@ -115,6 +115,17 @@ data class ApiProvider(
|
||||
LanguageModel("llama-3.1-8b-instant", "Llama 3.1 8B", "groq", "Powerful Llama 3 model running at extreme speed."),
|
||||
)
|
||||
),
|
||||
ApiProvider(
|
||||
key = "perplexity",
|
||||
displayName = "Perplexity",
|
||||
baseUrl = "https://api.perplexity.ai/",
|
||||
endpoint = "chat/completions",
|
||||
websiteUrl = "https://www.perplexity.ai/",
|
||||
models = listOf(
|
||||
LanguageModel("sonar", "Sonar Small Online", "perplexity", "A faster online model for quick, up-to-date answers."), // default
|
||||
LanguageModel("sonar-pro", "Sonar Pro", "perplexity", "Advanced search-focused model for richer context and longer answers."),
|
||||
)
|
||||
),
|
||||
ApiProvider(
|
||||
key = "xai",
|
||||
displayName = "xAI Grok",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
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,443 +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.model.communication.files_download.FlashcardCollectionInfo
|
||||
import eu.gaudian.translator.model.communication.files_download.FlashcardManifestResponse
|
||||
import eu.gaudian.translator.model.communication.files_download.VocabCollectionInfo
|
||||
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
|
||||
|
||||
/**
|
||||
* Enum representing different download sources.
|
||||
*/
|
||||
enum class DownloadSource(val baseUrl: String, val subdirectory: String) {
|
||||
DICTIONARIES("http://23.88.48.47/", "dictionaries"),
|
||||
FLASHCARDS("http://23.88.48.47/", "flashcard-collections")
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages downloading files from the server, verifying checksums, and checking versions.
|
||||
*/
|
||||
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"
|
||||
}
|
||||
|
||||
// ===== Flashcard Collections Support =====
|
||||
|
||||
private val flashcardRetrofit = Retrofit.Builder()
|
||||
.baseUrl(DownloadSource.FLASHCARDS.baseUrl)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.client(OkHttpClient.Builder().build())
|
||||
.build()
|
||||
|
||||
private val flashcardApiService = flashcardRetrofit.create<FlashcardApiService>()
|
||||
|
||||
/**
|
||||
* Fetches the flashcard collection manifest from the server.
|
||||
*/
|
||||
suspend fun fetchFlashcardManifest(): FlashcardManifestResponse? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = flashcardApiService.getFlashcardManifest().execute()
|
||||
if (response.isSuccessful) {
|
||||
response.body()
|
||||
} else {
|
||||
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
|
||||
Log.e("FileDownloadManager", "Failed to fetch flashcard manifest: $errorMessage")
|
||||
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FileDownloadManager", "Error fetching flashcard manifest", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a flashcard collection file with checksum verification.
|
||||
*/
|
||||
suspend fun downloadFlashcardCollection(
|
||||
flashcardInfo: FlashcardCollectionInfo,
|
||||
onProgress: (Float) -> Unit = {}
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
val asset = flashcardInfo.asset
|
||||
val source = DownloadSource.FLASHCARDS
|
||||
val fileUrl = "${source.baseUrl}${source.subdirectory}/${asset.filename}"
|
||||
val localFile = File(context.filesDir, "${source.subdirectory}/${asset.filename}")
|
||||
|
||||
// Create subdirectory if it doesn't exist
|
||||
localFile.parentFile?.mkdirs()
|
||||
|
||||
try {
|
||||
val client = OkHttpClient()
|
||||
val request = Request.Builder().url(fileUrl).build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
val errorMessage = context.getString(
|
||||
R.string.text_download_failed_http,
|
||||
response.code,
|
||||
response.message
|
||||
)
|
||||
Log.e("FileDownloadManager", errorMessage)
|
||||
throw Exception(errorMessage)
|
||||
}
|
||||
|
||||
val body = response.body
|
||||
val contentLength = body.contentLength()
|
||||
if (contentLength <= 0) {
|
||||
throw Exception("Invalid file size: $contentLength")
|
||||
}
|
||||
|
||||
FileOutputStream(localFile).use { output ->
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
var totalBytesRead: Long = 0
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
output.write(buffer, 0, bytesRead)
|
||||
digest.update(buffer, 0, bytesRead)
|
||||
totalBytesRead += bytesRead
|
||||
onProgress(totalBytesRead.toFloat() / contentLength)
|
||||
}
|
||||
|
||||
output.flush()
|
||||
|
||||
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
|
||||
|
||||
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
|
||||
Log.d("FileDownloadManager", "Flashcard download successful for ${asset.filename}")
|
||||
// Save version
|
||||
sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) }
|
||||
true
|
||||
} else {
|
||||
Log.e("FileDownloadManager", context.getString(
|
||||
R.string.text_checksum_mismatch_for_expected_got,
|
||||
asset.filename,
|
||||
asset.checksumSha256,
|
||||
computedChecksum
|
||||
))
|
||||
localFile.delete()
|
||||
throw Exception("Checksum verification failed for ${asset.filename}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FileDownloadManager", "Error downloading flashcard collection", e)
|
||||
if (localFile.exists()) {
|
||||
localFile.delete()
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a newer version is available for a flashcard collection.
|
||||
*/
|
||||
fun isNewerFlashcardVersionAvailable(flashcardInfo: FlashcardCollectionInfo): Boolean {
|
||||
val localVersion = sharedPreferences.getString(flashcardInfo.id, "0.0.0") ?: "0.0.0"
|
||||
return compareVersions(flashcardInfo.version, localVersion) > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the local version of a flashcard collection.
|
||||
*/
|
||||
fun getFlashcardLocalVersion(collectionId: String): String {
|
||||
return sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
|
||||
}
|
||||
|
||||
// ===== Vocab Packs (vocab_manifest.json) =====
|
||||
|
||||
/**
|
||||
* Fetches the vocabulary-pack manifest (vocab_manifest.json).
|
||||
* Unwraps the top-level [eu.gaudian.translator.model.communication.files_download.VocabManifestResponse] and returns the `lists` array.
|
||||
*/
|
||||
suspend fun fetchVocabManifest(): List<VocabCollectionInfo>? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val response = flashcardApiService.getVocabManifest().execute()
|
||||
if (response.isSuccessful) {
|
||||
response.body()?.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]/flashcard-collections/[filename].
|
||||
*
|
||||
* @return true on success, false (or throws) on failure.
|
||||
*/
|
||||
suspend fun downloadVocabCollection(
|
||||
info: VocabCollectionInfo,
|
||||
onProgress: (Float) -> Unit = {}
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
val subdirectory = DownloadSource.FLASHCARDS.subdirectory
|
||||
val fileUrl = "${DownloadSource.FLASHCARDS.baseUrl}$subdirectory/${info.filename}"
|
||||
val localFile = File(context.filesDir, "$subdirectory/${info.filename}")
|
||||
localFile.parentFile?.mkdirs()
|
||||
|
||||
try {
|
||||
val client = OkHttpClient()
|
||||
val request = Request.Builder().url(fileUrl).build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
val errorMessage = context.getString(
|
||||
R.string.text_download_failed_http,
|
||||
response.code,
|
||||
response.message
|
||||
)
|
||||
Log.e("FileDownloadManager", errorMessage)
|
||||
throw Exception(errorMessage)
|
||||
}
|
||||
|
||||
val body = response.body
|
||||
val contentLength = body.contentLength().takeIf { it > 0 } ?: info.sizeBytes
|
||||
|
||||
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
|
||||
if (contentLength > 0) onProgress(totalBytesRead.toFloat() / contentLength)
|
||||
}
|
||||
output.flush()
|
||||
|
||||
val computedChecksum = digest.digest().joinToString("") {
|
||||
@Suppress("HardCodedStringLiteral") "%02X".format(it)
|
||||
}
|
||||
|
||||
if (computedChecksum.equals(info.checksumSha256, ignoreCase = true)) {
|
||||
Log.d("FileDownloadManager", "Vocab pack downloaded: ${info.filename}")
|
||||
sharedPreferences.edit(commit = true) {
|
||||
putString("vocab_${info.id}", info.version.toString())
|
||||
}
|
||||
true
|
||||
} else {
|
||||
Log.e("FileDownloadManager",
|
||||
context.getString(
|
||||
R.string.text_checksum_mismatch_for_expected_got,
|
||||
info.filename,
|
||||
info.checksumSha256,
|
||||
computedChecksum
|
||||
)
|
||||
)
|
||||
localFile.delete()
|
||||
throw Exception("Checksum verification failed for ${info.filename}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("FileDownloadManager", "Error downloading vocab pack", e)
|
||||
if (localFile.exists()) localFile.delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the local file for this collection exists. */
|
||||
fun isVocabCollectionDownloaded(info: VocabCollectionInfo): Boolean =
|
||||
File(context.filesDir, "${DownloadSource.FLASHCARDS.subdirectory}/${info.filename}").exists()
|
||||
|
||||
/** Returns true if the server version is newer than the locally saved version. */
|
||||
fun isNewerVocabVersionAvailable(info: VocabCollectionInfo): Boolean {
|
||||
val localVersion = sharedPreferences.getString("vocab_${info.id}", "0") ?: "0"
|
||||
return (info.version.toString().toIntOrNull() ?: 0) > (localVersion.toIntOrNull() ?: 0)
|
||||
}
|
||||
|
||||
/** Returns the locally saved version number string for a vocab pack (default "0"). */
|
||||
fun getVocabLocalVersion(packId: String): String =
|
||||
sharedPreferences.getString("vocab_$packId", "0") ?: "0"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package eu.gaudian.translator.model.communication
|
||||
|
||||
import eu.gaudian.translator.model.communication.files_download.FlashcardManifestResponse
|
||||
import eu.gaudian.translator.model.communication.files_download.VocabManifestResponse
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.GET
|
||||
|
||||
/**
|
||||
* API service for flashcard / vocabulary-pack downloads.
|
||||
*/
|
||||
interface FlashcardApiService {
|
||||
|
||||
// ── Legacy endpoint (old manifest schema) ────────────────────────────────
|
||||
@GET("flashcard-collections/manifest.json")
|
||||
fun getFlashcardManifest(): Call<FlashcardManifestResponse>
|
||||
|
||||
// ── New vocab packs endpoint ──────────────────────────────────────────────
|
||||
/**
|
||||
* Fetches the vocabulary-pack manifest.
|
||||
* Returns a JSON object { manifest_version, updated_at, lists: [...] }.
|
||||
* URL: http://23.88.48.47/flashcard-collections/vocab_manifest.json
|
||||
*/
|
||||
@GET("flashcard-collections/vocab_manifest.json")
|
||||
fun getVocabManifest(): Call<VocabManifestResponse>
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -56,7 +56,6 @@ object LocalDictionaryMorphologyMapper {
|
||||
/**
|
||||
* Overload that uses parsed [DictionaryEntryData] instead of raw JSON.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
fun parseMorphology(
|
||||
langCode: String,
|
||||
pos: String?,
|
||||
|
||||
@@ -144,6 +144,19 @@ class ApiRepository(private val context: Context) {
|
||||
|
||||
var configurationValid = true
|
||||
|
||||
// (Helper function to reduce repetition)
|
||||
fun checkAndFallback(current: LanguageModel?, setter: suspend (LanguageModel) -> Unit) {
|
||||
val isValid = current != null && availableModels.any { it.modelId == current.modelId && it.providerKey == current.providerKey }
|
||||
if (!isValid) {
|
||||
val fallback = findFallbackModel(availableModels)
|
||||
if (fallback != null) {
|
||||
// We must use a blocking call or scope here because we can't easily pass a suspend function to a lambda
|
||||
// But since we are inside a suspend function, we can just call the setter directly if we unroll the loop.
|
||||
// For simplicity, I'll keep the unrolled logic below.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback checks
|
||||
if (currentTrans == null || !availableModels.any { it.modelId == currentTrans.modelId && it.providerKey == currentTrans.providerKey }) {
|
||||
findFallbackModel(availableModels)?.let { setTranslationModel(it) } ?: run { configurationValid = false }
|
||||
|
||||
@@ -76,7 +76,6 @@ class LanguageRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
suspend fun wipeHistoryAndFavorites() {
|
||||
clearLanguages(LanguageListType.HISTORY)
|
||||
clearLanguages(LanguageListType.FAVORITE)
|
||||
|
||||
@@ -5,19 +5,9 @@ package eu.gaudian.translator.model.repository
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.withTransaction
|
||||
import eu.gaudian.translator.model.CategoryExport
|
||||
import eu.gaudian.translator.model.CategoryMappingData
|
||||
import eu.gaudian.translator.model.ConflictStrategy
|
||||
import eu.gaudian.translator.model.ExportMetadata
|
||||
import eu.gaudian.translator.model.FullRepositoryExport
|
||||
import eu.gaudian.translator.model.ImportResult
|
||||
import eu.gaudian.translator.model.ItemListExport
|
||||
import eu.gaudian.translator.model.Language
|
||||
import eu.gaudian.translator.model.SingleItemExport
|
||||
import eu.gaudian.translator.model.StageMappingData
|
||||
import eu.gaudian.translator.model.TagCategory
|
||||
import eu.gaudian.translator.model.VocabularyCategory
|
||||
import eu.gaudian.translator.model.VocabularyExportData
|
||||
import eu.gaudian.translator.model.VocabularyFilter
|
||||
import eu.gaudian.translator.model.VocabularyItem
|
||||
import eu.gaudian.translator.model.VocabularyItemState
|
||||
@@ -55,7 +45,6 @@ import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
@@ -636,7 +625,7 @@ class VocabularyRepository private constructor(context: Context) {
|
||||
|
||||
suspend fun isTargetMetForDate(date: LocalDate): Boolean {
|
||||
val dailyCorrectCount = getDailyCorrectCount(date)
|
||||
val target = settingsRepository.dailyGoal.flow.first()
|
||||
val target = 10 // TODO: Replace with settingsRepository.dailyGoal.flow.first()
|
||||
return dailyCorrectCount >= target
|
||||
}
|
||||
|
||||
@@ -807,594 +796,6 @@ class VocabularyRepository private constructor(context: Context) {
|
||||
}
|
||||
Log.d(TAG, "--- END REPOSITORY STATE ---")
|
||||
}
|
||||
|
||||
// ==================== EXPORT/IMPORT FUNCTIONS ====================
|
||||
|
||||
/**
|
||||
* Exports the complete repository state including all vocabulary items, categories,
|
||||
* learning states, and mappings.
|
||||
*
|
||||
* This creates a full backup that can be used to restore the complete state on another
|
||||
* device or after data loss.
|
||||
*
|
||||
* @return [FullRepositoryExport] containing all repository data
|
||||
*
|
||||
* @see importVocabularyData for importing the exported data
|
||||
* @see exportToJson for converting to JSON string
|
||||
*/
|
||||
suspend fun exportFullRepository(): FullRepositoryExport {
|
||||
Log.i(TAG, "exportFullRepository: Creating full repository export")
|
||||
val items = getAllVocabularyItems()
|
||||
val categories = getAllCategories()
|
||||
val states = getAllVocabularyItemStates()
|
||||
val categoryMappings = getCategoryMappings()
|
||||
val stageMapping = loadStageMapping().first()
|
||||
|
||||
return FullRepositoryExport(
|
||||
formatVersion = 1,
|
||||
exportDate = Clock.System.now(),
|
||||
metadata = ExportMetadata(
|
||||
itemCount = items.size,
|
||||
categoryCount = categories.size,
|
||||
exportScope = "Full Repository"
|
||||
),
|
||||
items = items,
|
||||
categories = categories,
|
||||
states = states,
|
||||
categoryMappings = categoryMappings.map { CategoryMappingData(it.vocabularyItemId, it.categoryId) },
|
||||
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) }
|
||||
).also {
|
||||
Log.i(TAG, "exportFullRepository: Export complete. Items: ${items.size}, Categories: ${categories.size}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a single category with all its vocabulary items and associated data.
|
||||
*
|
||||
* @param categoryId The ID of the category to export
|
||||
* @return [CategoryExport] containing the category and its items, or null if category not found
|
||||
*
|
||||
* @see importVocabularyData for importing the exported data
|
||||
*/
|
||||
suspend fun exportCategory(categoryId: Int): CategoryExport? {
|
||||
Log.i(TAG, "exportCategory: Exporting category id=$categoryId")
|
||||
val category = getCategoryById(categoryId) ?: run {
|
||||
Log.w(TAG, "exportCategory: Category id=$categoryId not found")
|
||||
return null
|
||||
}
|
||||
|
||||
val items = getVocabularyItemsByCategory(categoryId)
|
||||
val itemIds = items.map { it.id }
|
||||
val states = getAllVocabularyItemStates().filter { it.vocabularyItemId in itemIds }
|
||||
val stageMapping = loadStageMapping().first().filterKeys { it in itemIds }
|
||||
|
||||
return CategoryExport(
|
||||
formatVersion = 1,
|
||||
exportDate = Clock.System.now(),
|
||||
metadata = ExportMetadata(
|
||||
itemCount = items.size,
|
||||
categoryCount = 1,
|
||||
exportScope = "Category: ${category.name}"
|
||||
),
|
||||
category = category,
|
||||
items = items,
|
||||
states = states,
|
||||
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) }
|
||||
).also {
|
||||
Log.i(TAG, "exportCategory: Export complete. Category: ${category.name}, Items: ${items.size}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a list of vocabulary items by their IDs.
|
||||
*
|
||||
* @param itemIds List of vocabulary item IDs to export
|
||||
* @param includeCategories Whether to include category information for these items
|
||||
* @return [ItemListExport] containing the items and their data
|
||||
*
|
||||
* @see importVocabularyData for importing the exported data
|
||||
*/
|
||||
suspend fun exportItemList(itemIds: List<Int>, includeCategories: Boolean = true): ItemListExport {
|
||||
Log.i(TAG, "exportItemList: Exporting ${itemIds.size} items")
|
||||
val items = itemDao.getItemsByIds(itemIds)
|
||||
val states = getAllVocabularyItemStates().filter { it.vocabularyItemId in itemIds }
|
||||
val stageMapping = loadStageMapping().first().filterKeys { it in itemIds }
|
||||
|
||||
val associatedCategories = if (includeCategories) {
|
||||
val mappings = getCategoryMappings().filter { it.vocabularyItemId in itemIds }
|
||||
val categoryIds = mappings.map { it.categoryId }.distinct()
|
||||
getAllCategories().filter { it.id in categoryIds }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val categoryMappings = if (includeCategories) {
|
||||
getCategoryMappings().filter { it.vocabularyItemId in itemIds }
|
||||
.map { CategoryMappingData(it.vocabularyItemId, it.categoryId) }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return ItemListExport(
|
||||
formatVersion = 1,
|
||||
exportDate = Clock.System.now(),
|
||||
metadata = ExportMetadata(
|
||||
itemCount = items.size,
|
||||
categoryCount = associatedCategories.size,
|
||||
exportScope = "Item List (${items.size} items)"
|
||||
),
|
||||
items = items,
|
||||
states = states,
|
||||
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) },
|
||||
associatedCategories = associatedCategories,
|
||||
categoryMappings = categoryMappings
|
||||
).also {
|
||||
Log.i(TAG, "exportItemList: Export complete. Items: ${items.size}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a single vocabulary item with all its details.
|
||||
*
|
||||
* @param itemId The ID of the vocabulary item to export
|
||||
* @return [SingleItemExport] containing the item and its data, or null if item not found
|
||||
*
|
||||
* @see importVocabularyData for importing the exported data
|
||||
*/
|
||||
suspend fun exportSingleItem(itemId: Int): SingleItemExport? {
|
||||
Log.i(TAG, "exportSingleItem: Exporting item id=$itemId")
|
||||
val item = getVocabularyItemById(itemId) ?: run {
|
||||
Log.w(TAG, "exportSingleItem: Item id=$itemId not found")
|
||||
return null
|
||||
}
|
||||
|
||||
val state = getVocabularyItemStateById(itemId)
|
||||
val stage = loadStageMapping().first()[itemId] ?: VocabularyStage.NEW
|
||||
val mappings = getCategoryMappings().filter { it.vocabularyItemId == itemId }
|
||||
val categoryIds = mappings.map { it.categoryId }
|
||||
val categories = getAllCategories().filter { it.id in categoryIds }
|
||||
|
||||
return SingleItemExport(
|
||||
formatVersion = 1,
|
||||
exportDate = Clock.System.now(),
|
||||
metadata = ExportMetadata(
|
||||
itemCount = 1,
|
||||
categoryCount = categories.size,
|
||||
exportScope = "Single Item: ${item.wordFirst}"
|
||||
),
|
||||
item = item,
|
||||
state = state,
|
||||
stage = stage,
|
||||
categories = categories
|
||||
).also {
|
||||
Log.i(TAG, "exportSingleItem: Export complete. Item: ${item.wordFirst}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts any [VocabularyExportData] to a JSON string.
|
||||
*
|
||||
* The resulting JSON can be:
|
||||
* - Saved to a file
|
||||
* - Sent via REST API
|
||||
* - Shared through messaging apps (WhatsApp, Telegram, etc.)
|
||||
* - Stored in cloud storage (Google Drive, Dropbox, etc.)
|
||||
* - Transmitted via any text-based protocol
|
||||
*
|
||||
* @param exportData The export data to convert
|
||||
* @param prettyPrint Whether to format the JSON for human readability (default: false)
|
||||
* @return JSON string representation of the export data
|
||||
*
|
||||
* @see importFromJson for parsing JSON back into export data
|
||||
*/
|
||||
fun exportToJson(exportData: VocabularyExportData, prettyPrint: Boolean = false): String {
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
this.prettyPrint = prettyPrint
|
||||
}
|
||||
return json.encodeToString(VocabularyExportData.serializer(), exportData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a JSON string into [VocabularyExportData].
|
||||
*
|
||||
* @param jsonString The JSON string to parse
|
||||
* @return Parsed export data
|
||||
* @throws kotlinx.serialization.SerializationException if JSON is invalid
|
||||
*
|
||||
* @see exportToJson for converting export data to JSON
|
||||
* @see importVocabularyData for importing the parsed data
|
||||
*/
|
||||
fun importFromJson(jsonString: String): VocabularyExportData {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
return json.decodeFromString(VocabularyExportData.serializer(), jsonString)
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports vocabulary data from an export.
|
||||
*
|
||||
* This function handles different export types (full repository, category, item list, single item)
|
||||
* and applies the specified conflict resolution strategy.
|
||||
*
|
||||
* @param exportData The export data to import
|
||||
* @param strategy The conflict resolution strategy to use (default: MERGE)
|
||||
* @return [ImportResult] with statistics about the import operation
|
||||
*
|
||||
* @see ConflictStrategy for available strategies
|
||||
* @see exportFullRepository, exportCategory, exportItemList, exportSingleItem for creating exports
|
||||
*/
|
||||
suspend fun importVocabularyData(
|
||||
exportData: VocabularyExportData,
|
||||
strategy: ConflictStrategy = ConflictStrategy.MERGE
|
||||
): ImportResult {
|
||||
Log.i(TAG, "importVocabularyData: Starting import with strategy=$strategy, scope=${exportData.metadata.exportScope}")
|
||||
|
||||
return when (exportData) {
|
||||
is FullRepositoryExport -> importFullRepository(exportData, strategy)
|
||||
is CategoryExport -> importCategory(exportData, strategy)
|
||||
is ItemListExport -> importItemList(exportData, strategy)
|
||||
is SingleItemExport -> importSingleItem(exportData, strategy)
|
||||
}.also { result ->
|
||||
Log.i(TAG, "importVocabularyData: Import complete. Imported: ${result.itemsImported}, " +
|
||||
"Skipped: ${result.itemsSkipped}, Updated: ${result.itemsUpdated}, " +
|
||||
"Categories: ${result.categoriesImported}, Errors: ${result.errors.size}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to import a full repository export.
|
||||
*/
|
||||
private suspend fun importFullRepository(
|
||||
export: FullRepositoryExport,
|
||||
strategy: ConflictStrategy
|
||||
): ImportResult {
|
||||
val errors = mutableListOf<String>()
|
||||
var itemsImported = 0
|
||||
var itemsSkipped = 0
|
||||
var itemsUpdated = 0
|
||||
var categoriesImported = 0
|
||||
|
||||
try {
|
||||
db.withTransaction {
|
||||
// Import categories first (they're referenced by items)
|
||||
val categoryIdMap = importCategories(export.categories, strategy)
|
||||
categoriesImported = categoryIdMap.size
|
||||
|
||||
// Import items
|
||||
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
|
||||
itemsImported = itemIdMap.count { it.value >= 0 }
|
||||
itemsSkipped = itemIdMap.count { it.value == -1 }
|
||||
|
||||
// Import category mappings with remapped IDs
|
||||
importCategoryMappings(export.categoryMappings, itemIdMap, categoryIdMap)
|
||||
}
|
||||
|
||||
requestUpdateMappings()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "importFullRepository: Error during import", e)
|
||||
errors.add("Import failed: ${e.message}")
|
||||
}
|
||||
|
||||
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to import a category export.
|
||||
*/
|
||||
private suspend fun importCategory(
|
||||
export: CategoryExport,
|
||||
strategy: ConflictStrategy
|
||||
): ImportResult {
|
||||
val errors = mutableListOf<String>()
|
||||
var itemsImported = 0
|
||||
var itemsSkipped = 0
|
||||
var itemsUpdated = 0
|
||||
var categoriesImported = 0
|
||||
|
||||
try {
|
||||
db.withTransaction {
|
||||
// Import the category
|
||||
val categoryIdMap = importCategories(listOf(export.category), strategy)
|
||||
categoriesImported = categoryIdMap.size
|
||||
val newCategoryId = categoryIdMap[export.category.id] ?: export.category.id
|
||||
|
||||
// Import items
|
||||
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
|
||||
itemsImported = itemIdMap.count { it.value >= 0 }
|
||||
itemsSkipped = itemIdMap.count { it.value == -1 }
|
||||
|
||||
// Create category mappings for all imported items
|
||||
val mappings = itemIdMap.filter { it.value >= 0 }.map { (oldId, newId) ->
|
||||
CategoryMappingData(newId, newCategoryId)
|
||||
}
|
||||
importCategoryMappings(mappings, mapOf(), mapOf())
|
||||
}
|
||||
|
||||
requestUpdateMappings()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "importCategory: Error during import", e)
|
||||
errors.add("Import failed: ${e.message}")
|
||||
}
|
||||
|
||||
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to import an item list export.
|
||||
*/
|
||||
private suspend fun importItemList(
|
||||
export: ItemListExport,
|
||||
strategy: ConflictStrategy
|
||||
): ImportResult {
|
||||
val errors = mutableListOf<String>()
|
||||
var itemsImported = 0
|
||||
var itemsSkipped = 0
|
||||
var itemsUpdated = 0
|
||||
var categoriesImported = 0
|
||||
|
||||
try {
|
||||
db.withTransaction {
|
||||
// Import associated categories if present
|
||||
val categoryIdMap = if (export.associatedCategories.isNotEmpty()) {
|
||||
importCategories(export.associatedCategories, strategy).also {
|
||||
categoriesImported = it.size
|
||||
}
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
// Import items
|
||||
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
|
||||
itemsImported = itemIdMap.count { it.value >= 0 }
|
||||
itemsSkipped = itemIdMap.count { it.value == -1 }
|
||||
|
||||
// Import category mappings if present
|
||||
if (export.categoryMappings.isNotEmpty()) {
|
||||
importCategoryMappings(export.categoryMappings, itemIdMap, categoryIdMap)
|
||||
}
|
||||
}
|
||||
|
||||
requestUpdateMappings()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "importItemList: Error during import", e)
|
||||
errors.add("Import failed: ${e.message}")
|
||||
}
|
||||
|
||||
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal function to import a single item export.
|
||||
*/
|
||||
private suspend fun importSingleItem(
|
||||
export: SingleItemExport,
|
||||
strategy: ConflictStrategy
|
||||
): ImportResult {
|
||||
val errors = mutableListOf<String>()
|
||||
var itemsImported = 0
|
||||
var itemsSkipped = 0
|
||||
var itemsUpdated = 0
|
||||
var categoriesImported = 0
|
||||
|
||||
try {
|
||||
db.withTransaction {
|
||||
// Import categories if present
|
||||
val categoryIdMap = if (export.categories.isNotEmpty()) {
|
||||
importCategories(export.categories, strategy).also {
|
||||
categoriesImported = it.size
|
||||
}
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
// Import the single item
|
||||
val states = if (export.state != null) listOf(export.state) else emptyList()
|
||||
val stageMappings = listOf(StageMappingData(export.item.id, export.stage))
|
||||
val itemIdMap = importItems(listOf(export.item), states, stageMappings, strategy)
|
||||
itemsImported = itemIdMap.count { it.value >= 0 }
|
||||
itemsSkipped = itemIdMap.count { it.value == -1 }
|
||||
|
||||
// Create category mappings
|
||||
val newItemId = itemIdMap[export.item.id] ?: export.item.id
|
||||
if (newItemId >= 0) {
|
||||
val mappings = export.categories.map { category ->
|
||||
val newCategoryId = categoryIdMap[category.id] ?: category.id
|
||||
CategoryMappingData(newItemId, newCategoryId)
|
||||
}
|
||||
importCategoryMappings(mappings, mapOf(), mapOf())
|
||||
}
|
||||
}
|
||||
|
||||
requestUpdateMappings()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "importSingleItem: Error during import", e)
|
||||
errors.add("Import failed: ${e.message}")
|
||||
}
|
||||
|
||||
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to import categories with conflict resolution.
|
||||
* Returns a map of old category IDs to new category IDs.
|
||||
*/
|
||||
private suspend fun importCategories(
|
||||
categories: List<VocabularyCategory>,
|
||||
strategy: ConflictStrategy
|
||||
): Map<Int, Int> {
|
||||
val idMap = mutableMapOf<Int, Int>()
|
||||
val existingCategories = getAllCategories()
|
||||
|
||||
for (category in categories) {
|
||||
val existing = existingCategories.find { it.name == category.name && it::class == category::class }
|
||||
|
||||
when {
|
||||
existing != null && strategy == ConflictStrategy.SKIP -> {
|
||||
// Skip, but map old ID to existing ID
|
||||
idMap[category.id] = existing.id
|
||||
Log.d(TAG, "importCategories: Skipping existing category '${category.name}'")
|
||||
}
|
||||
existing != null && strategy == ConflictStrategy.REPLACE -> {
|
||||
// Replace existing category
|
||||
val updated = when (category) {
|
||||
is TagCategory -> category.copy(id = existing.id)
|
||||
is VocabularyFilter -> category.copy(id = existing.id)
|
||||
}
|
||||
saveCategory(updated)
|
||||
idMap[category.id] = existing.id
|
||||
Log.d(TAG, "importCategories: Replaced category '${category.name}'")
|
||||
}
|
||||
existing != null && strategy == ConflictStrategy.MERGE -> {
|
||||
// Keep existing, map old ID to existing ID
|
||||
idMap[category.id] = existing.id
|
||||
Log.d(TAG, "importCategories: Merged with existing category '${category.name}'")
|
||||
}
|
||||
strategy == ConflictStrategy.RENAME || existing == null -> {
|
||||
// Assign new ID
|
||||
val maxId = categoryDao.getAllCategories().maxOfOrNull { it.id } ?: 0
|
||||
val newId = maxId + 1
|
||||
val newCategory = when (category) {
|
||||
is TagCategory -> category.copy(id = newId)
|
||||
is VocabularyFilter -> category.copy(id = newId)
|
||||
}
|
||||
saveCategory(newCategory)
|
||||
idMap[category.id] = newId
|
||||
Log.d(TAG, "importCategories: Created new category '${category.name}' with id=$newId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return idMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to import vocabulary items with their states and stage mappings.
|
||||
* Returns a map of old item IDs to new item IDs (-1 means skipped).
|
||||
*/
|
||||
private suspend fun importItems(
|
||||
items: List<VocabularyItem>,
|
||||
states: List<VocabularyItemState>,
|
||||
stageMappings: List<StageMappingData>,
|
||||
strategy: ConflictStrategy
|
||||
): Map<Int, Int> {
|
||||
val idMap = mutableMapOf<Int, Int>()
|
||||
val existingItems = getAllVocabularyItems()
|
||||
val stateMap = states.associateBy { it.vocabularyItemId }
|
||||
val stageMap = stageMappings.associate { it.vocabularyItemId to it.stage }
|
||||
|
||||
for (item in items) {
|
||||
val duplicate = existingItems.find { it.isDuplicate(item) }
|
||||
|
||||
when {
|
||||
duplicate != null && strategy == ConflictStrategy.SKIP -> {
|
||||
// Skip this item
|
||||
idMap[item.id] = -1
|
||||
Log.d(TAG, "importItems: Skipping duplicate item '${item.wordFirst}'")
|
||||
}
|
||||
duplicate != null && strategy == ConflictStrategy.REPLACE -> {
|
||||
// Replace with imported version
|
||||
val updated = item.copy(id = duplicate.id)
|
||||
itemDao.upsertItem(updated)
|
||||
idMap[item.id] = duplicate.id
|
||||
|
||||
// Update state and stage
|
||||
stateMap[item.id]?.let { state ->
|
||||
stateDao.upsertState(state.copy(vocabularyItemId = duplicate.id))
|
||||
}
|
||||
stageMap[item.id]?.let { stage ->
|
||||
mappingDao.upsertStageMapping(StageMappingEntity(duplicate.id, stage))
|
||||
}
|
||||
Log.d(TAG, "importItems: Replaced item '${item.wordFirst}'")
|
||||
}
|
||||
duplicate != null && strategy == ConflictStrategy.MERGE -> {
|
||||
// Merge: keep item, merge states (keep better progress)
|
||||
idMap[item.id] = duplicate.id
|
||||
|
||||
stateMap[item.id]?.let { importedState ->
|
||||
val existingState = getVocabularyItemStateById(duplicate.id)
|
||||
val mergedState = mergeStates(existingState, importedState, duplicate.id)
|
||||
stateDao.upsertState(mergedState)
|
||||
}
|
||||
|
||||
stageMap[item.id]?.let { importedStage ->
|
||||
val existingStage = loadStageMapping().first()[duplicate.id] ?: VocabularyStage.NEW
|
||||
val mergedStage = maxOf(importedStage, existingStage)
|
||||
mappingDao.upsertStageMapping(StageMappingEntity(duplicate.id, mergedStage))
|
||||
}
|
||||
Log.d(TAG, "importItems: Merged item '${item.wordFirst}'")
|
||||
}
|
||||
strategy == ConflictStrategy.RENAME || duplicate == null -> {
|
||||
// Assign new ID
|
||||
val maxId = itemDao.getMaxItemId() ?: 0
|
||||
val newId = maxId + idMap.size + 1
|
||||
val newItem = item.copy(id = newId)
|
||||
itemDao.upsertItem(newItem)
|
||||
idMap[item.id] = newId
|
||||
|
||||
// Import state and stage with new ID
|
||||
stateMap[item.id]?.let { state ->
|
||||
stateDao.upsertState(state.copy(vocabularyItemId = newId))
|
||||
}
|
||||
stageMap[item.id]?.let { stage ->
|
||||
mappingDao.upsertStageMapping(StageMappingEntity(newId, stage))
|
||||
}
|
||||
Log.d(TAG, "importItems: Created new item '${item.wordFirst}' with id=$newId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return idMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to import category mappings with remapped IDs.
|
||||
*/
|
||||
private suspend fun importCategoryMappings(
|
||||
mappings: List<CategoryMappingData>,
|
||||
itemIdMap: Map<Int, Int>,
|
||||
categoryIdMap: Map<Int, Int>
|
||||
) {
|
||||
for (mapping in mappings) {
|
||||
val newItemId = itemIdMap[mapping.vocabularyItemId] ?: mapping.vocabularyItemId
|
||||
val newCategoryId = categoryIdMap[mapping.categoryId] ?: mapping.categoryId
|
||||
|
||||
// Skip if item was skipped during import
|
||||
if (newItemId < 0) continue
|
||||
|
||||
mappingDao.addCategoryMapping(CategoryMappingEntity(newItemId, newCategoryId))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to merge two vocabulary item states.
|
||||
* Keeps the more advanced learning progress.
|
||||
*/
|
||||
private fun mergeStates(
|
||||
existing: VocabularyItemState?,
|
||||
imported: VocabularyItemState,
|
||||
itemId: Int
|
||||
): VocabularyItemState {
|
||||
if (existing == null) return imported.copy(vocabularyItemId = itemId)
|
||||
|
||||
return VocabularyItemState(
|
||||
vocabularyItemId = itemId,
|
||||
lastCorrectAnswer = maxOfNullable(existing.lastCorrectAnswer, imported.lastCorrectAnswer),
|
||||
lastIncorrectAnswer = maxOfNullable(existing.lastIncorrectAnswer, imported.lastIncorrectAnswer),
|
||||
correctAnswerCount = max(existing.correctAnswerCount, imported.correctAnswerCount),
|
||||
incorrectAnswerCount = max(existing.incorrectAnswerCount, imported.incorrectAnswerCount)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the maximum of two nullable Instants.
|
||||
*/
|
||||
private fun maxOfNullable(a: Instant?, b: Instant?): Instant? {
|
||||
return when {
|
||||
a == null -> b
|
||||
b == null -> a
|
||||
else -> if (a > b) a else b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -5,22 +5,22 @@ import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import eu.gaudian.translator.ui.theme.themes.AutumnSpiceTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.CitrusSplashTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.CoffeeTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.CrimsonTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.CyberpunkTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.DebugTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.DefaultTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.ElectricVioletTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.ForestTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.LavenderDreamTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.MossStoneTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.NeonPulseTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.NordTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.OceanicCalmTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.PixelTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.SageGardenTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.SakuraTheme
|
||||
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.TerracottaEarthTheme
|
||||
import eu.gaudian.translator.ui.theme.themes.TwilightSerenityTheme
|
||||
|
||||
/**
|
||||
* A data class to hold the core colors for a theme variation (light or dark).
|
||||
@@ -97,23 +97,26 @@ data class AppTheme(
|
||||
|
||||
|
||||
val AllThemes = listOf(
|
||||
|
||||
DefaultTheme,
|
||||
PixelTheme,
|
||||
CrimsonTheme,
|
||||
SakuraTheme,
|
||||
AutumnSpiceTheme,
|
||||
TealTheme,
|
||||
ForestTheme,
|
||||
CoffeeTheme,
|
||||
CitrusSplashTheme,
|
||||
OceanicCalmTheme,
|
||||
SlateAndStoneTheme,
|
||||
NordTheme,
|
||||
TwilightSerenityTheme,
|
||||
SpaceTheme,
|
||||
CyberpunkTheme,
|
||||
SynthwaveTheme,
|
||||
DebugTheme,
|
||||
LavenderDreamTheme,
|
||||
SageGardenTheme,
|
||||
MossStoneTheme,
|
||||
ElectricVioletTheme,
|
||||
NeonPulseTheme,
|
||||
TerracottaEarthTheme,
|
||||
|
||||
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 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),
|
||||
)
|
||||
)
|
||||
@@ -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 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),
|
||||
)
|
||||
)
|
||||
@@ -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 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)
|
||||
)
|
||||
)
|
||||
@@ -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 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)
|
||||
)
|
||||
)
|
||||
@@ -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 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)
|
||||
)
|
||||
)
|
||||
@@ -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 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)
|
||||
)
|
||||
)
|
||||
@@ -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 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)
|
||||
)
|
||||
)
|
||||
@@ -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 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)
|
||||
)
|
||||
)
|
||||
@@ -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 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)
|
||||
)
|
||||
)
|
||||
@@ -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 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)
|
||||
)
|
||||
)
|
||||
@@ -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 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)
|
||||
)
|
||||
)
|
||||
@@ -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 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)
|
||||
)
|
||||
)
|
||||
@@ -129,6 +129,25 @@ class JsonHelper {
|
||||
*/
|
||||
class JsonParsingException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
|
||||
/**
|
||||
* Legacy JsonHelper class for backward compatibility.
|
||||
* @deprecated Use the enhanced JsonHelper class instead
|
||||
*/
|
||||
@Deprecated("Use the enhanced JsonHelper class instead")
|
||||
class LegacyJsonHelper {
|
||||
|
||||
fun cleanJson(json: String): String {
|
||||
val startIndex = json.indexOf('{')
|
||||
val endIndex = json.lastIndexOf('}')
|
||||
|
||||
if (startIndex == -1 || endIndex == -1 || startIndex >= endIndex) {
|
||||
throw IllegalArgumentException("Invalid JSON format")
|
||||
}
|
||||
|
||||
return json.substring(startIndex, endIndex + 1).trim()
|
||||
}
|
||||
}
|
||||
|
||||
object JsonCleanUtil {
|
||||
private val jsonParser = Json { isLenient = true; ignoreUnknownKeys = true }
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import timber.log.Timber
|
||||
* "HardcodedText" lint warning for log messages, which are for
|
||||
* development purposes only.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
object Log {
|
||||
|
||||
@SuppressLint("HardcodedText")
|
||||
|
||||
@@ -55,12 +55,6 @@ enum class StatusMessageId(
|
||||
ERROR_FILE_PICKER_NOT_INITIALIZED(R.string.message_error_file_picker_not_initialized, MessageDisplayType.ERROR, 5),
|
||||
SUCCESS_CATEGORY_SAVED(R.string.message_success_category_saved, MessageDisplayType.SUCCESS, 3),
|
||||
ERROR_EXCEL_NOT_SUPPORTED(R.string.message_error_excel_not_supported, MessageDisplayType.ERROR, 5),
|
||||
ERROR_PARSING_TABLE(R.string.error_parsing_table, MessageDisplayType.ERROR, 5),
|
||||
ERROR_PARSING_TABLE_WITH_REASON(R.string.error_parsing_table_with_reason, MessageDisplayType.ERROR, 5),
|
||||
ERROR_SELECT_TWO_COLUMNS(R.string.error_select_two_columns, MessageDisplayType.ERROR, 5),
|
||||
ERROR_SELECT_LANGUAGES(R.string.error_select_languages, MessageDisplayType.ERROR, 5),
|
||||
ERROR_NO_ROWS_TO_IMPORT(R.string.error_no_rows_to_import, MessageDisplayType.ERROR, 5),
|
||||
SUCCESS_ITEMS_IMPORTED(R.string.info_imported_items_from, MessageDisplayType.SUCCESS, 3),
|
||||
|
||||
|
||||
// API Key related
|
||||
|
||||
@@ -75,6 +75,7 @@ object StatusMessageService {
|
||||
* @deprecated Use showMessageById() instead for internationalization support.
|
||||
*/
|
||||
@Deprecated("Use showMessageById() for internationalization support", ReplaceWith("showMessageById(messageId)"))
|
||||
@Suppress("unused")
|
||||
fun showSimpleMessage(text: String, type: MessageDisplayType = MessageDisplayType.INFO) {
|
||||
scope.launch {
|
||||
_actions.emit(StatusAction.ShowMessage(text, type, 5))
|
||||
|
||||
@@ -55,9 +55,7 @@ class TranslationService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
private suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val json = org.json.JSONObject().apply {
|
||||
put("q", text)
|
||||
@@ -119,6 +117,7 @@ class TranslationService(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun translateSentence(sentence: String): Result<TranslationHistoryItem> = withContext(Dispatchers.IO) {
|
||||
val statusMessageService = StatusMessageService
|
||||
val additionalInstructions = settingsRepository.customPromptTranslation.flow.first()
|
||||
val selectedSource = languageRepository.loadSelectedSourceLanguage().first()
|
||||
val sourceLangName = selectedSource?.englishName ?: "Auto"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package eu.gaudian.translator.utils.dictionary
|
||||
|
||||
|
||||
import eu.gaudian.translator.model.grammar.Inflection
|
||||
|
||||
/**
|
||||
* Interface for a language-specific inflection parser.
|
||||
*/
|
||||
interface InflectionParser {
|
||||
fun parse(inflections: List<Inflection>): DisplayInflectionData
|
||||
}
|
||||
@@ -5,6 +5,11 @@ package eu.gaudian.translator.utils.dictionary
|
||||
* Either a simple list or a complex, grouped verb conjugation table.
|
||||
*/
|
||||
sealed class DisplayInflectionData {
|
||||
data class VerbConjugation(
|
||||
val gerund: String? = null,
|
||||
val participle: String? = null,
|
||||
val moods: List<DisplayMood>
|
||||
) : DisplayInflectionData()
|
||||
}
|
||||
|
||||
data class DisplayMood(
|
||||
|
||||
@@ -253,24 +253,7 @@ fun TranslatorApp(
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
val selectedScreen = Screen.fromDestination(currentDestination)
|
||||
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
val currentRoute = currentDestination?.route
|
||||
val isHiddenByHierarchy = currentDestination?.hierarchy?.any { destination ->
|
||||
destination.route in setOf(
|
||||
Screen.Translation.route,
|
||||
Screen.Dictionary.route,
|
||||
Screen.Exercises.route,
|
||||
Screen.Settings.route
|
||||
)
|
||||
} == true
|
||||
val isBottomBarHidden = isHiddenByHierarchy || currentRoute in setOf(
|
||||
"new_word",
|
||||
"new_word_review",
|
||||
"vocabulary_detail/{itemId}",
|
||||
"daily_review",
|
||||
"explore_packs"
|
||||
) || currentRoute?.startsWith("start_exercise") == true
|
||||
|| currentRoute?.startsWith("vocabulary_exercise") == true
|
||||
@Suppress("HardCodedStringLiteral") val isBottomBarHidden = currentDestination?.route?.startsWith("vocabulary_exercise") == true
|
||||
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)
|
||||
|
||||
BottomNavigationBar(
|
||||
@@ -279,12 +262,6 @@ fun TranslatorApp(
|
||||
showLabels = showBottomNavLabels,
|
||||
onItemSelected = { screen ->
|
||||
val inSameSection = currentDestination?.hierarchy?.any { it.route == screen.route } == true
|
||||
val isMoreSection = screen in setOf(
|
||||
Screen.Translation,
|
||||
Screen.Dictionary,
|
||||
Screen.Settings,
|
||||
Screen.Exercises
|
||||
)
|
||||
|
||||
// Always reset the selected section to its root and clear back stack between sections
|
||||
if (inSameSection) {
|
||||
@@ -297,11 +274,6 @@ fun TranslatorApp(
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
} else if (isMoreSection) {
|
||||
navController.navigate(screen.route) {
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
} else {
|
||||
// Switching sections: clear entire back stack to start to avoid back navigation results
|
||||
navController.navigate(screen.route) {
|
||||
@@ -313,10 +285,6 @@ fun TranslatorApp(
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
},
|
||||
onPlayClicked = {
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
navController.navigate("start_exercise")
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
@@ -20,59 +20,33 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.navigation
|
||||
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.dictionary.DictionaryResultScreen
|
||||
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.home.HomeScreen
|
||||
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.exercises.ExerciseSessionScreen
|
||||
import eu.gaudian.translator.view.exercises.MainExerciseScreen
|
||||
import eu.gaudian.translator.view.exercises.YouTubeBrowserScreen
|
||||
import eu.gaudian.translator.view.exercises.YouTubeExerciseScreen
|
||||
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.settingsGraph
|
||||
import eu.gaudian.translator.view.stats.StatsScreen
|
||||
import eu.gaudian.translator.view.translation.TranslationScreen
|
||||
import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
|
||||
import eu.gaudian.translator.view.vocabulary.ExplorePacksScreen
|
||||
import eu.gaudian.translator.view.vocabulary.LanguageJourneyScreen
|
||||
import eu.gaudian.translator.view.vocabulary.NewWordReviewScreen
|
||||
import eu.gaudian.translator.view.vocabulary.NewWordScreen
|
||||
import eu.gaudian.translator.view.vocabulary.CategoryDetailScreen
|
||||
import eu.gaudian.translator.view.vocabulary.CategoryListScreen
|
||||
import eu.gaudian.translator.view.vocabulary.LanguageProgressScreen
|
||||
import eu.gaudian.translator.view.vocabulary.MainVocabularyScreen
|
||||
import eu.gaudian.translator.view.vocabulary.NoGrammarItemsScreen
|
||||
import eu.gaudian.translator.view.vocabulary.StageDetailScreen
|
||||
import eu.gaudian.translator.view.vocabulary.VocabularyCardHost
|
||||
import eu.gaudian.translator.view.vocabulary.VocabularyExerciseHostScreen
|
||||
import eu.gaudian.translator.view.vocabulary.VocabularyHeatmapScreen
|
||||
import eu.gaudian.translator.view.vocabulary.VocabularyListScreen
|
||||
import eu.gaudian.translator.view.vocabulary.VocabularySortingScreen
|
||||
|
||||
private const val TRANSITION_DURATION = 300
|
||||
|
||||
object NavigationRoutes {
|
||||
const val DAILY_REVIEW = "daily_review"
|
||||
const val NEW_WORD = "new_word"
|
||||
const val NEW_WORD_REVIEW = "new_word_review"
|
||||
const val VOCABULARY_DETAIL = "vocabulary_detail"
|
||||
const val START_EXERCISE = "start_exercise"
|
||||
const val START_EXERCISE_DAILY = "start_exercise_daily"
|
||||
const val CATEGORY_DETAIL = "category_detail"
|
||||
const val CATEGORY_LIST = "category_list_screen"
|
||||
const val STATS_VOCABULARY_HEATMAP = "stats/vocabulary_heatmap"
|
||||
const val STATS_LANGUAGE_PROGRESS = "stats/language_progress"
|
||||
const val STATS_CATEGORY_DETAIL = "stats/category_detail"
|
||||
const val STATS_CATEGORY_LIST = "stats/category_list_screen"
|
||||
const val STATS_VOCABULARY_SORTING = "stats/vocabulary_sorting"
|
||||
const val STATS_NO_GRAMMAR_ITEMS = "stats/no_grammar_items"
|
||||
const val STATS_VOCABULARY_LIST = "stats/vocabulary_list"
|
||||
const val EXPLORE_PACKS = "explore_packs"
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun AppNavHost(
|
||||
navController: NavHostController,
|
||||
@@ -83,26 +57,17 @@ fun AppNavHost(
|
||||
|
||||
// 1. Define your Main Tab "Leaf" Routes (the actual start destinations of your graphs)
|
||||
val mainTabRoutes = setOf(
|
||||
Screen.Home.route,
|
||||
Screen.Library.route,
|
||||
Screen.Stats.route,
|
||||
|
||||
Screen.Home.route, // "home" or "main_translation" depending on which one you actually navigate to
|
||||
"main_translation",
|
||||
"main_dictionary",
|
||||
"main_vocabulary",
|
||||
"main_exercise",
|
||||
SettingsRoutes.LIST
|
||||
)
|
||||
|
||||
// Helper to check if a route is a top-level tab
|
||||
// Note: Routes can be "main_home", "main_library" etc. but mainTabRoutes contains parent routes
|
||||
fun isTabTransition(initial: String?, target: String?): Boolean {
|
||||
if (initial == null || target == null) return false
|
||||
// Check if either the direct route OR a "main_*" variant is in mainTabRoutes
|
||||
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
|
||||
return mainTabRoutes.contains(initial) && mainTabRoutes.contains(target)
|
||||
}
|
||||
|
||||
NavHost(
|
||||
@@ -156,81 +121,77 @@ fun AppNavHost(
|
||||
}
|
||||
) {
|
||||
composable(Screen.Home.route) {
|
||||
HomeScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable(NavigationRoutes.DAILY_REVIEW) {
|
||||
DailyReviewScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable(NavigationRoutes.NEW_WORD) {
|
||||
NewWordScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable(NavigationRoutes.NEW_WORD_REVIEW) {
|
||||
NewWordReviewScreen(navController = navController)
|
||||
}
|
||||
|
||||
composable(NavigationRoutes.EXPLORE_PACKS) {
|
||||
ExplorePacksScreen(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
|
||||
)
|
||||
TranslationScreen(navController = navController)
|
||||
}
|
||||
|
||||
// Define all other navigation graphs at the same top level.
|
||||
homeGraph(navController)
|
||||
libraryGraph(navController)
|
||||
statsGraph(navController)
|
||||
translationGraph(navController)
|
||||
dictionaryGraph(navController)
|
||||
vocabularyGraph(navController)
|
||||
exerciseGraph(navController)
|
||||
settingsGraph(navController)
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.homeGraph(navController: NavHostController) {
|
||||
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
fun NavGraphBuilder.translationGraph(navController: NavHostController) {
|
||||
navigation(
|
||||
startDestination = "main_home",
|
||||
startDestination = "main_translation",
|
||||
route = Screen.Home.route
|
||||
) {
|
||||
composable("main_home") {
|
||||
HomeScreen(navController = navController)
|
||||
composable("main_translation") {
|
||||
TranslationScreen(navController = navController)
|
||||
}
|
||||
composable("custom_translation_prompt") {
|
||||
TranslationSettingsScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
||||
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
|
||||
navigation(
|
||||
startDestination = "main_library",
|
||||
route = Screen.Library.route
|
||||
startDestination = "main_dictionary",
|
||||
route = Screen.Dictionary.route
|
||||
) {
|
||||
composable("main_library") {
|
||||
LibraryScreen(navController = navController)
|
||||
composable("main_dictionary") {
|
||||
MainDictionaryScreen(navController = navController)
|
||||
}
|
||||
composable("dictionary_result/{entryId}") { backStackEntry ->
|
||||
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
|
||||
if (entryId != null) {
|
||||
DictionaryResultScreen(
|
||||
entryId = entryId,
|
||||
navController = navController,
|
||||
)
|
||||
} else {
|
||||
Text("Error: Invalid Entry ID")
|
||||
}
|
||||
}
|
||||
composable("dictionary_options") {
|
||||
DictionaryOptionsScreen(navController = navController)
|
||||
}
|
||||
composable("etymology_result/{word}/{languageCode}") { backStackEntry ->
|
||||
val word = backStackEntry.arguments?.getString("word") ?: ""
|
||||
val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1
|
||||
EtymologyResultScreen(
|
||||
navController = navController,
|
||||
word = word,
|
||||
languageCode = languageCode
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.vocabularyGraph(
|
||||
navController: NavHostController,
|
||||
) {
|
||||
navigation(
|
||||
startDestination = "main_vocabulary",
|
||||
route = Screen.Vocabulary.route
|
||||
) {
|
||||
composable("main_vocabulary") {
|
||||
MainVocabularyScreen(navController = navController)
|
||||
}
|
||||
composable("vocabulary_sorting") {
|
||||
VocabularySortingScreen(
|
||||
@@ -263,7 +224,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
||||
composable("vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry ->
|
||||
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
||||
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
|
||||
AllCardsListScreen(
|
||||
VocabularyListScreen(
|
||||
navController = navController,
|
||||
showDueTodayOnly = showDueTodayOnly,
|
||||
categoryId = categoryId,
|
||||
@@ -275,12 +236,12 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
||||
)
|
||||
}
|
||||
composable("language_progress") {
|
||||
LanguageJourneyScreen(
|
||||
LanguageProgressScreen(
|
||||
navController = navController
|
||||
)
|
||||
|
||||
}
|
||||
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
|
||||
composable("vocabulary_heatmap") {
|
||||
VocabularyHeatmapScreen(
|
||||
navController = navController,
|
||||
)
|
||||
@@ -292,7 +253,7 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
||||
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
|
||||
}
|
||||
|
||||
AllCardsListScreen(
|
||||
VocabularyListScreen(
|
||||
navController = navController,
|
||||
showDueTodayOnly = showDueTodayOnly,
|
||||
stage = stage,
|
||||
@@ -415,159 +376,6 @@ fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
|
||||
}
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.statsGraph(
|
||||
navController: NavHostController,
|
||||
) {
|
||||
navigation(
|
||||
startDestination = "main_stats",
|
||||
route = Screen.Stats.route
|
||||
) {
|
||||
composable("main_stats") {
|
||||
StatsScreen(navController = navController)
|
||||
}
|
||||
composable("stats/vocabulary_sorting") {
|
||||
VocabularySortingScreen(
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
composable("stats/vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry ->
|
||||
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
||||
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
|
||||
AllCardsListScreen(
|
||||
navController = navController,
|
||||
showDueTodayOnly = showDueTodayOnly,
|
||||
categoryId = categoryId,
|
||||
onNavigateToItem = { item ->
|
||||
navController.navigate("vocabulary_detail/${item.id}")
|
||||
},
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
enableNavigationButtons = true
|
||||
)
|
||||
}
|
||||
composable(NavigationRoutes.STATS_LANGUAGE_PROGRESS) {
|
||||
LanguageJourneyScreen(
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
|
||||
VocabularyHeatmapScreen(
|
||||
navController = navController,
|
||||
)
|
||||
}
|
||||
composable("stats/vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
|
||||
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
|
||||
val stageString = backStackEntry.arguments?.getString("stage")
|
||||
val stage = stageString?.let {
|
||||
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
|
||||
}
|
||||
|
||||
AllCardsListScreen(
|
||||
navController = navController,
|
||||
showDueTodayOnly = showDueTodayOnly,
|
||||
stage = stage,
|
||||
onNavigateToItem = { item ->
|
||||
navController.navigate("vocabulary_detail/${item.id}")
|
||||
},
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
categoryId = 0,
|
||||
enableNavigationButtons = true
|
||||
)
|
||||
}
|
||||
composable("stats/category_detail/{categoryId}") { backStackEntry ->
|
||||
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
|
||||
|
||||
if (categoryId != null) {
|
||||
CategoryDetailScreen(
|
||||
categoryId = categoryId,
|
||||
onBackClick = { navController.popBackStack() },
|
||||
onNavigateToItem = { item ->
|
||||
navController.navigate("vocabulary_detail/${item.id}")
|
||||
},
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
composable("stats/category_list_screen") {
|
||||
CategoryListScreen(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onCategoryClicked = { categoryId ->
|
||||
navController.navigate("stats/category_detail/$categoryId")
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = "stats/vocabulary_sorting?mode={mode}",
|
||||
arguments = listOf(
|
||||
navArgument("mode") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
VocabularySortingScreen(
|
||||
navController = navController,
|
||||
initialFilterMode = backStackEntry.arguments?.getString("mode")
|
||||
)
|
||||
}
|
||||
composable("stats/no_grammar_items") {
|
||||
NoGrammarItemsScreen(
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
fun NavGraphBuilder.translationGraph(navController: NavHostController) {
|
||||
navigation(
|
||||
startDestination = "main_translation",
|
||||
route = Screen.Translation.route
|
||||
) {
|
||||
composable("main_translation") {
|
||||
TranslationScreen(navController = navController)
|
||||
}
|
||||
composable("custom_translation_prompt") {
|
||||
TranslationSettingsScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
|
||||
navigation(
|
||||
startDestination = "main_dictionary",
|
||||
route = Screen.Dictionary.route
|
||||
) {
|
||||
composable("main_dictionary") {
|
||||
MainDictionaryScreen(navController = navController)
|
||||
}
|
||||
composable("dictionary_result/{entryId}") { backStackEntry ->
|
||||
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
|
||||
if (entryId != null) {
|
||||
DictionaryResultScreen(
|
||||
entryId = entryId,
|
||||
navController = navController,
|
||||
)
|
||||
} else {
|
||||
Text("Error: Invalid Entry ID")
|
||||
}
|
||||
}
|
||||
composable("dictionary_options") {
|
||||
DictionaryOptionsScreen(navController = navController)
|
||||
}
|
||||
composable("etymology_result/{word}/{languageCode}") { backStackEntry ->
|
||||
val word = backStackEntry.arguments?.getString("word") ?: ""
|
||||
val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1
|
||||
EtymologyResultScreen(
|
||||
navController = navController,
|
||||
word = word,
|
||||
languageCode = languageCode
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
|
||||
fun NavGraphBuilder.exerciseGraph(
|
||||
navController: NavHostController,
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.view.categories
|
||||
|
||||
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.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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.model.TagCategory
|
||||
import eu.gaudian.translator.model.VocabularyFilter
|
||||
import eu.gaudian.translator.model.VocabularyItem
|
||||
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.AppScaffold
|
||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||
import eu.gaudian.translator.view.composable.PrimaryButton
|
||||
import eu.gaudian.translator.view.dialogs.DeleteCategoryDialog
|
||||
import eu.gaudian.translator.view.dialogs.DeleteItemsDialog
|
||||
import eu.gaudian.translator.view.dialogs.EditCategoryDialog
|
||||
import eu.gaudian.translator.view.vocabulary.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.CategoryViewModel
|
||||
import eu.gaudian.translator.viewmodel.ExportImportViewModel
|
||||
import eu.gaudian.translator.viewmodel.ExportState
|
||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||
import eu.gaudian.translator.viewmodel.ProgressViewModel
|
||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@SuppressLint("ContextCastToActivity")
|
||||
@Composable
|
||||
fun CategoryDetailScreen(
|
||||
categoryId: Int,
|
||||
onBackClick: () -> Unit,
|
||||
onNavigateToItem: (VocabularyItem) -> Unit,
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
|
||||
val category by categoryViewModel.getCategoryById(categoryId).collectAsState(initial = null)
|
||||
val categoryProgressList by progressViewModel.categoryProgressList.collectAsState()
|
||||
val categoryProgress = categoryProgressList.find { it.vocabularyCategory.id == categoryId }
|
||||
val exportState by exportImportViewModel.exportState.collectAsState()
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val title = when (val cat = category) {
|
||||
is TagCategory -> cat.name
|
||||
is VocabularyFilter -> cat.name
|
||||
else -> stringResource(R.string.text_loading_3d)
|
||||
}
|
||||
|
||||
val languages = languageViewModel.allLanguages.collectAsState(initial = emptyList())
|
||||
|
||||
val subtitle = when (val cat = category) {
|
||||
is TagCategory -> stringResource(R.string.text_manual_vocabulary_list)
|
||||
is VocabularyFilter -> buildString {
|
||||
val hasLangList = !cat.languages.isNullOrEmpty()
|
||||
val hasPair = cat.languagePairs != null
|
||||
val hasStages = !cat.stages.isNullOrEmpty()
|
||||
if (!hasLangList && !hasPair && !hasStages) {
|
||||
append(stringResource(R.string.text_filter_all_items))
|
||||
} else {
|
||||
append(" ")
|
||||
if (hasPair) {
|
||||
val (a, b) = cat.languagePairs
|
||||
append("[${languages.value.find { it.nameResId == a }?.name} - ${languages.value.find { it.nameResId == b }?.name}]")
|
||||
} else if (hasLangList) {
|
||||
append(cat.languages.joinToString(", ") { langId -> languages.value.find { it.nameResId == langId }?.name.toString() })
|
||||
} else {
|
||||
append(stringResource(R.string.text_all_languages))
|
||||
}
|
||||
append(" | ")
|
||||
if (hasStages) append(cat.stages.joinToString(", ") { it.toString(context) }) else append(stringResource(R.string.label_all_stages))
|
||||
}
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val showDeleteCategoryDialog by categoryViewModel.showDeleteCategoryDialog.collectAsState(initial = false)
|
||||
val showDeleteItemsDialog by categoryViewModel.showDeleteItemsDialog.collectAsState(initial = false)
|
||||
val showEditCategoryDialog by categoryViewModel.showEditCategoryDialog.collectAsState(initial = false)
|
||||
|
||||
// Handle export state changes
|
||||
LaunchedEffect(exportState) {
|
||||
when (exportState) {
|
||||
is ExportState.Success -> {
|
||||
// Create and launch share intent
|
||||
val shareIntent = exportImportViewModel.createShareIntent()
|
||||
if (shareIntent != null) {
|
||||
context.startActivity(shareIntent)
|
||||
}
|
||||
exportImportViewModel.resetExportState()
|
||||
}
|
||||
is ExportState.Error -> {
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = (exportState as ExportState.Error).message
|
||||
)
|
||||
}
|
||||
exportImportViewModel.resetExportState()
|
||||
}
|
||||
else -> { /* Idle or Loading */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll state for animation
|
||||
val listState = rememberLazyListState()
|
||||
var isHeaderVisible by remember { mutableStateOf(true) }
|
||||
var previousIndex by remember { mutableStateOf(0) }
|
||||
var previousScrollOffset by remember { mutableStateOf(0) }
|
||||
|
||||
// Detect scroll direction to show/hide header (same as LibraryScreen)
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.collect { (index, offset) ->
|
||||
val isScrollingDown = index > previousIndex || (index == previousIndex && offset > previousScrollOffset)
|
||||
val isAtTop = index == 0 && offset <= 4
|
||||
isHeaderVisible = if (isAtTop) true else !isScrollingDown
|
||||
previousIndex = index
|
||||
previousScrollOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
modifier = modifier,
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
topBar = {
|
||||
Column(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
AppTopAppBar(
|
||||
title = title,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
actions = {
|
||||
IconButton(onClick = { showMenu = !showMenu }) {
|
||||
Icon(
|
||||
imageVector = AppIcons.MoreVert,
|
||||
contentDescription = stringResource(R.string.text_more_options)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
modifier = Modifier.width(220.dp)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
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 = {
|
||||
exportImportViewModel.exportCategory(categoryId)
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) },
|
||||
enabled = exportState !is ExportState.Loading
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Delete Items") },
|
||||
onClick = {
|
||||
categoryViewModel.setShowDeleteItemsDialog(true, categoryId)
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = { Icon(AppIcons.Delete, contentDescription = null) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.label_edit)) },
|
||||
onClick = {
|
||||
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = { Icon(AppIcons.Edit, contentDescription = null) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.label_delete)) },
|
||||
onClick = {
|
||||
categoryViewModel.setShowDeleteCategoryDialog(true, categoryId)
|
||||
showMenu = false
|
||||
},
|
||||
leadingIcon = { Icon(AppIcons.Delete, contentDescription = null) }
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
|
||||
// Category Header Card with Progress and Action Buttons (animated)
|
||||
AnimatedVisibility(
|
||||
visible = isHeaderVisible,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
CategoryHeaderCard(
|
||||
subtitle = subtitle,
|
||||
categoryProgress = categoryProgress,
|
||||
onStartExerciseClick = {
|
||||
navController.navigate("start_exercise?categoryId=$categoryId")
|
||||
},
|
||||
onEditClick = {
|
||||
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
|
||||
},
|
||||
onDeleteClick = {
|
||||
categoryViewModel.setShowDeleteCategoryDialog(true, categoryId)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(modifier = Modifier.padding(paddingValues)) {
|
||||
AllCardsListScreen(
|
||||
categoryId = categoryId,
|
||||
showDueTodayOnly = false,
|
||||
onNavigateToItem = onNavigateToItem,
|
||||
navController = navController,
|
||||
isRemoveFromCategoryEnabled = category is TagCategory,
|
||||
showTopBar = false,
|
||||
enableNavigationButtons = true,
|
||||
listState = listState
|
||||
)
|
||||
|
||||
// Dialogs
|
||||
if (showDeleteCategoryDialog) {
|
||||
DeleteCategoryDialog(
|
||||
onDismiss = { categoryViewModel.setShowDeleteCategoryDialog(false, categoryId) },
|
||||
viewModel = categoryViewModel,
|
||||
)
|
||||
}
|
||||
if (showDeleteItemsDialog) {
|
||||
DeleteItemsDialog(
|
||||
onDismiss = { categoryViewModel.setShowDeleteItemsDialog(false, categoryId) },
|
||||
categoryId = categoryId
|
||||
)
|
||||
}
|
||||
if (showEditCategoryDialog) {
|
||||
EditCategoryDialog(
|
||||
onDismiss = { categoryViewModel.setShowEditCategoryDialog(false, categoryId) },
|
||||
languageViewModel = languageViewModel,
|
||||
categoryViewModel = categoryViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CategoryHeaderCard(
|
||||
subtitle: String,
|
||||
categoryProgress: CategoryProgress?,
|
||||
onStartExerciseClick: () -> Unit,
|
||||
onEditClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AppCard(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Subtitle
|
||||
if (subtitle.isNotBlank()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Progress Circle - smaller size
|
||||
if (categoryProgress != null) {
|
||||
|
||||
|
||||
CategoryProgressCircle(
|
||||
totalItems = categoryProgress.totalItems,
|
||||
itemsCompleted = categoryProgress.itemsCompleted,
|
||||
itemsInStages = categoryProgress.itemsInStages,
|
||||
newItems = categoryProgress.newItems,
|
||||
circleSize = 100.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
ChartLegend()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Action Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Start Exercise Button (Primary)
|
||||
PrimaryButton(
|
||||
text = stringResource(R.string.label_start_exercise),
|
||||
icon = AppIcons.Play,
|
||||
onClick = onStartExerciseClick,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PREVIEWS ====================
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun CategoryHeaderCardPreview() {
|
||||
MaterialTheme {
|
||||
CategoryHeaderCard(
|
||||
subtitle = "German - English | All Stages",
|
||||
categoryProgress = null,
|
||||
onStartExerciseClick = {},
|
||||
onEditClick = {},
|
||||
onDeleteClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
fun CategoryHeaderCardWithProgressPreview() {
|
||||
MaterialTheme {
|
||||
CategoryHeaderCard(
|
||||
subtitle = "Travel Vocabulary",
|
||||
categoryProgress = CategoryProgress(
|
||||
vocabularyCategory = TagCategory(
|
||||
1,
|
||||
"Travel"
|
||||
),
|
||||
totalItems = 50,
|
||||
newItems = 15,
|
||||
itemsInStages = 25,
|
||||
itemsCompleted = 10
|
||||
),
|
||||
onStartExerciseClick = {},
|
||||
onEditClick = {},
|
||||
onDeleteClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ fun AppAlertDialog(
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
text: @Composable (() -> Unit)? = null,
|
||||
properties: DialogProperties = DialogProperties(),
|
||||
hintContent:Hint? = null,
|
||||
hintContent: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
@@ -142,13 +142,11 @@ fun AppAlertDialog(
|
||||
)
|
||||
|
||||
if (showBottomSheet) {
|
||||
hintContent?.let {
|
||||
HintBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
content = it
|
||||
)
|
||||
}
|
||||
HintBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
content = hintContent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +212,7 @@ private fun DialogHeader(
|
||||
@Composable
|
||||
private fun DialogTitleWithHint(
|
||||
title: @Composable () -> Unit,
|
||||
hintContent: Hint? = null,
|
||||
hintContent: @Composable (() -> Unit)?,
|
||||
onHintClick: () -> Unit
|
||||
) {
|
||||
val showHints = LocalShowHints.current
|
||||
@@ -426,6 +424,7 @@ fun AppAlertDialogPreview() {
|
||||
},
|
||||
title = { Text("Alert Dialog Title") },
|
||||
text = { Text("This is the alert dialog text.") },
|
||||
hintContent = { Text("This is a hint for the alert dialog.") }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -493,6 +492,7 @@ fun AppAlertDialogLongTextPreview() {
|
||||
Text("Third paragraph with additional information that users need to be aware of.")
|
||||
}
|
||||
},
|
||||
hintContent = { Text("This hint explains the terms in more detail.") }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
@@ -551,55 +550,7 @@ fun AppDropdownMenu(
|
||||
// =========================================
|
||||
// LEGACY COMPONENTS (UPDATED TO USE UNIFIED STYLES)
|
||||
// =========================================
|
||||
@Composable
|
||||
fun BottomSheetMenuItem(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Circular Icon Background
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// Title and Subtitle Column
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun LargeDropdownMenuItem(
|
||||
text: String,
|
||||
|
||||
@@ -35,7 +35,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -52,7 +52,6 @@ data class FabMenuItem(
|
||||
)
|
||||
|
||||
|
||||
@Deprecated("We don't want to use floating butto menus anymore")
|
||||
@Composable
|
||||
fun AppFabMenu(
|
||||
items: List<FabMenuItem>,
|
||||
@@ -160,14 +159,14 @@ private fun MenuItem(
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.shadow(elevation = 8.dp, shape = ComponentDefaults.DefaultShape)
|
||||
.glassmorphic(shape = RoundedCornerShape(16.dp), alpha = 0.4f)
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
color = Color.Transparent // Allow glassmorphic modifier to handle color
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
@@ -198,15 +197,3 @@ private fun MenuItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun MenuItemPreview() {
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
MenuItem(
|
||||
text = "Menu Item",
|
||||
imageVector = AppIcons.Add,
|
||||
painter = null,
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.Icons.Default
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBackIos
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.automirrored.filled.DriveFileMove
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
@@ -81,7 +81,6 @@ import androidx.compose.material.icons.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.Merge
|
||||
import androidx.compose.material.icons.filled.ModelTraining
|
||||
import androidx.compose.material.icons.filled.MonitorHeart
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.NoteAdd
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
@@ -136,7 +135,7 @@ object AppIcons {
|
||||
val AI = Default.AutoAwesome
|
||||
val Appearance = Icons.Filled.ColorLens
|
||||
val ApiKey = Default.Key
|
||||
val ArrowBack = Icons.AutoMirrored.Filled.ArrowBackIos
|
||||
val ArrowBack = Icons.AutoMirrored.Filled.ArrowBack
|
||||
val ArrowCircleUp = Icons.Filled.ArrowCircleUp
|
||||
val ArrowDropDown = Icons.Filled.KeyboardArrowDown
|
||||
val ArrowDropUp = Icons.Filled.KeyboardArrowUp
|
||||
@@ -203,7 +202,6 @@ object AppIcons {
|
||||
val Merge = Icons.Filled.Merge
|
||||
val ModelTraining = Icons.Filled.ModelTraining
|
||||
val More = Default.MoreVert
|
||||
val MoreHorizontal = Icons.Filled.MoreHoriz
|
||||
val MoreVert = Default.MoreVert
|
||||
val MoveTo = Icons.AutoMirrored.Filled.DriveFileMove
|
||||
val Paste = Default.ContentPaste
|
||||
|
||||
@@ -115,7 +115,7 @@ fun AppOutlinedTextField(
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
label = label,
|
||||
trailingIcon = finalTrailingIcon,
|
||||
shape = ComponentDefaults.DefaultShape,
|
||||
|
||||
@@ -2,15 +2,26 @@ package eu.gaudian.translator.view.composable
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.gaudian.translator.R
|
||||
|
||||
@Composable
|
||||
fun AppScaffold(
|
||||
@@ -47,3 +58,37 @@ fun AppScaffold(
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun ParrotTopBar() {
|
||||
val navyBlue = Color(0xFF1A237E) // The color from your mockup
|
||||
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "ParrotPal",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color.White
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
// Your new parrot logo icon
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_level_parrot),
|
||||
contentDescription = "Logo",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = Color.Unspecified // Keeps the logo's original colors
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { /* Search */ }) {
|
||||
Icon(Icons.Default.Search, contentDescription = "Search", tint = Color.White)
|
||||
}
|
||||
IconButton(onClick = { /* Profile */ }) {
|
||||
Icon(Icons.Default.AccountCircle, contentDescription = "Profile", tint = Color.White)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||
containerColor = navyBlue
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.view.composable
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
@@ -18,13 +20,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -38,7 +36,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.ui.theme.ThemePreviews
|
||||
|
||||
/**
|
||||
@@ -49,58 +46,33 @@ interface TabItem {
|
||||
val title: String
|
||||
val icon: ImageVector
|
||||
}
|
||||
@Deprecated("Migrate to new (like used in LibraryScreen")
|
||||
@SuppressLint("UnusedBoxWithConstraintsScope", "LocalContextResourcesRead", "DiscouragedApi",
|
||||
"SuspiciousIndentation"
|
||||
)
|
||||
|
||||
/**
|
||||
* A generic, reusable tab layout composable.
|
||||
* @param T The type of the tab item, which must implement the TabItem interface.
|
||||
* @param tabs A list of all tab items to display.
|
||||
* @param selectedTab The currently selected tab item.
|
||||
* @param onTabSelected A lambda function to be invoked when a tab is clicked.
|
||||
*/
|
||||
@SuppressLint("UnusedBoxWithConstraintsScope", "LocalContextResourcesRead", "DiscouragedApi")
|
||||
@Composable
|
||||
fun <T : TabItem> AppTabLayout(
|
||||
tabs: List<T>,
|
||||
selectedTab: T,
|
||||
onTabSelected: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onNavigateBack: (() -> Unit)? = null
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val selectedIndex = tabs.indexOf(selectedTab)
|
||||
|
||||
Row(
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||
.height(56.dp)
|
||||
// Replace background with glassmorphic extension
|
||||
.glassmorphic(shape = ComponentDefaults.CardShape, alpha = 0.3f)
|
||||
) {
|
||||
if (onNavigateBack != null) {
|
||||
IconButton(
|
||||
onClick = onNavigateBack,
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = CircleShape
|
||||
),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = AppIcons.ArrowBack,
|
||||
contentDescription = stringResource(R.string.cd_navigate_back)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(56.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = ComponentDefaults.CardShape
|
||||
)
|
||||
) {
|
||||
val tabWidth = maxWidth / tabs.size
|
||||
val tabWidth = maxWidth / tabs.size
|
||||
|
||||
val indicatorOffset by animateDpAsState(
|
||||
targetValue = tabWidth * selectedIndex,
|
||||
@@ -108,59 +80,58 @@ fun <T : TabItem> AppTabLayout(
|
||||
label = "IndicatorOffset"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = indicatorOffset)
|
||||
.width(tabWidth)
|
||||
.fillMaxHeight()
|
||||
.padding(4.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = indicatorOffset)
|
||||
.width(tabWidth)
|
||||
.fillMaxHeight()
|
||||
.padding(4.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), // Glassy indicator
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
tabs.forEach { tab ->
|
||||
val isSelected = tab == selectedTab
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessLow)
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
tabs.forEach { tab ->
|
||||
val isSelected = tab == selectedTab
|
||||
val contentColor by animateColorAsState(
|
||||
targetValue = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessLow)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clickable(
|
||||
onClick = { onTabSelected(tab) },
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clickable(
|
||||
onClick = { onTabSelected(tab) },
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resolvedTitle = run {
|
||||
val resId = context.resources.getIdentifier(tab.title, "string", context.packageName)
|
||||
if (resId != 0) stringResource(resId) else tab.title
|
||||
}
|
||||
Icon(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
imageVector = tab.icon,
|
||||
contentDescription = resolvedTitle,
|
||||
tint = contentColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = resolvedTitle,
|
||||
color = contentColor,
|
||||
)
|
||||
val context = LocalContext.current
|
||||
val resolvedTitle = run {
|
||||
val resId = context.resources.getIdentifier(tab.title, "string", context.packageName)
|
||||
if (resId != 0) stringResource(resId) else tab.title
|
||||
}
|
||||
Icon(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
imageVector = tab.icon,
|
||||
contentDescription = resolvedTitle,
|
||||
tint = contentColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = resolvedTitle,
|
||||
color = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,7 +139,6 @@ fun <T : TabItem> AppTabLayout(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
@ThemePreviews
|
||||
@Composable
|
||||
fun ModernTabLayoutPreview() {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,20 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.view.composable
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBackIosNew
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarColors
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
@@ -30,10 +25,10 @@ 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.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.ui.theme.ThemePreviews
|
||||
@@ -43,94 +38,108 @@ import eu.gaudian.translator.view.hints.LocalShowHints
|
||||
|
||||
@Composable
|
||||
fun AppTopAppBar(
|
||||
title: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
onNavigateBack: (() -> Unit)? = null,
|
||||
navigationIcon: @Composable (() -> Unit)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
|
||||
hint: Hint? = null
|
||||
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
scrolledContainerColor = Color.Transparent
|
||||
),
|
||||
hintContent: Hint? = null
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
// Changed to CenterAlignedTopAppBar to perfectly match the design requirements
|
||||
CenterAlignedTopAppBar(
|
||||
modifier = modifier.height(56.dp),
|
||||
windowInsets = WindowInsets(0.dp),
|
||||
colors = colors,
|
||||
Surface(
|
||||
modifier = modifier.glassmorphic(shape = RectangleShape, alpha = 0.2f),
|
||||
color = Color.Transparent
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = Modifier.height(56.dp),
|
||||
windowInsets = WindowInsets(0.dp),
|
||||
colors = colors,
|
||||
title = {
|
||||
val showHints = LocalShowHints.current
|
||||
if (showHints && hint != null) {
|
||||
// Simplified row: keeps the title and hint icon neatly centered together
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
IconButton(onClick = { showBottomSheet = true }) {
|
||||
Icon(
|
||||
imageVector = AppIcons.Help,
|
||||
contentDescription = stringResource(R.string.show_hint),
|
||||
tint = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val showHints = LocalShowHints.current
|
||||
if (showHints && hintContent != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
title()
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showBottomSheet = true }) {
|
||||
Icon(
|
||||
imageVector = AppIcons.Help,
|
||||
contentDescription = stringResource(R.string.show_hint),
|
||||
tint = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
title()
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
if (onNavigateBack != null) {
|
||||
IconButton(
|
||||
onClick = onNavigateBack,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
Box(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBackIosNew,
|
||||
contentDescription = "Back",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
AppIcons.ArrowBack,
|
||||
contentDescription = stringResource(R.string.cd_navigate_back),
|
||||
tint = LocalContentColor.current
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (navigationIcon != null) {
|
||||
navigationIcon()
|
||||
Box(modifier = Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) {
|
||||
navigationIcon()
|
||||
}
|
||||
} else {
|
||||
// No navigation icon
|
||||
}
|
||||
},
|
||||
actions = actions
|
||||
)
|
||||
actions = actions
|
||||
)
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
hint?.let {
|
||||
HintBottomSheet(
|
||||
onDismissRequest = {
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
showBottomSheet = false
|
||||
},
|
||||
sheetState = sheetState,
|
||||
content = it
|
||||
|
||||
)
|
||||
}
|
||||
HintBottomSheet(
|
||||
onDismissRequest = {
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
showBottomSheet = false
|
||||
},
|
||||
sheetState = sheetState,
|
||||
content = {
|
||||
hintContent?.Render()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A composable that acts as a TopAppBar, containing a back navigation icon
|
||||
* and an [AppTabLayout].
|
||||
*
|
||||
* @param T The type of the tab item, must implement [TabItem].
|
||||
* @param tabs The list of tab items to display.
|
||||
* @param selectedTab The currently selected tab item.
|
||||
* @param onTabSelected Callback function when a tab is selected.
|
||||
* @param onNavigateBack Callback function when the back arrow is clicked.
|
||||
* @param modifier The modifier to be applied to the layout.
|
||||
*/
|
||||
@Composable
|
||||
fun <T : TabItem> TabbedTopAppBar(
|
||||
@@ -140,6 +149,7 @@ fun <T : TabItem> TabbedTopAppBar(
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Use a Surface to provide background color and context for the app bar
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
@@ -148,21 +158,20 @@ fun <T : TabItem> TabbedTopAppBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Updated back icon here as well to keep your entire app consistent!
|
||||
// Back navigation icon, similar to its usage in AppTopAppBar
|
||||
IconButton(
|
||||
onClick = onNavigateBack,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp, end = 4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = AppIcons.ArrowBack,
|
||||
contentDescription = stringResource(R.string.cd_navigate_back),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
// The AppTabLayout, taking up the remaining space.
|
||||
// Its appearance matches the provided image.
|
||||
AppTabLayout(
|
||||
tabs = tabs,
|
||||
selectedTab = selectedTab,
|
||||
@@ -173,12 +182,11 @@ fun <T : TabItem> TabbedTopAppBar(
|
||||
}
|
||||
}
|
||||
|
||||
// ... [Previews remain exactly the same below]
|
||||
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
@ThemePreviews
|
||||
@Composable
|
||||
fun TabbedTopAppBarPreview() {
|
||||
// Sample data for preview, similar to ModernTabLayoutPreview
|
||||
data class SampleTabItem(override val title: String, override val icon: ImageVector) : TabItem
|
||||
|
||||
val tabs = listOf(
|
||||
@@ -204,7 +212,7 @@ fun TabbedTopAppBarPreview() {
|
||||
@Composable
|
||||
fun AppTopAppBarPreview() {
|
||||
AppTopAppBar(
|
||||
title = "Previwe Title"
|
||||
title = { Text("Preview Title") }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,7 +220,7 @@ fun AppTopAppBarPreview() {
|
||||
@Composable
|
||||
fun AppTopAppBarWithNavigationIconPreview() {
|
||||
AppTopAppBar(
|
||||
title = "Preview Title",
|
||||
title = { Text(stringResource(R.string.title_title_preview_title)) },
|
||||
onNavigateBack = {}
|
||||
)
|
||||
}
|
||||
@@ -221,13 +229,13 @@ fun AppTopAppBarWithNavigationIconPreview() {
|
||||
@Composable
|
||||
fun AppTopAppBarWithActionsPreview() {
|
||||
AppTopAppBar(
|
||||
title = "Preview Title",
|
||||
title = { Text(stringResource(R.string.title_title_preview_title)) },
|
||||
actions = {
|
||||
IconButton(onClick = {}) {
|
||||
Icon(AppIcons.Settings, contentDescription = stringResource(R.string.title_settings))
|
||||
}
|
||||
IconButton(onClick = {}) {
|
||||
Icon(AppIcons.ArrowBack, contentDescription = null)
|
||||
AppIcons.ArrowBack
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@file:Suppress("HardCodedStringLiteral", "AssignedValueIsNeverRead")
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.view.composable
|
||||
|
||||
@@ -11,43 +11,24 @@ import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.offset
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
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.rememberCoroutineScope
|
||||
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.scale
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
@@ -62,7 +43,6 @@ import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.ui.theme.ThemePreviews
|
||||
import eu.gaudian.translator.view.LocalShowExperimentalFeatures
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed class Screen(
|
||||
val route: String,
|
||||
@@ -70,42 +50,34 @@ sealed class Screen(
|
||||
val selectedIcon: ImageVector,
|
||||
val unselectedIcon: ImageVector
|
||||
) {
|
||||
object Home : Screen("home", R.string.label_home, AppIcons.Home, AppIcons.Home)
|
||||
object Stats : Screen("stats", R.string.label_stats, AppIcons.Statistics, AppIcons.Statistics)
|
||||
object Translation : Screen("translation", R.string.label_translation, AppIcons.TranslateFilled, AppIcons.TranslateOutlined)
|
||||
object Library : Screen("library", R.string.label_library, AppIcons.VocabularyFilled, AppIcons.VocabularyOutlined)
|
||||
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 Home : Screen("home", R.string.label_translation, AppIcons.TranslateFilled, AppIcons.TranslateOutlined)
|
||||
object Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
|
||||
object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
|
||||
object Vocabulary : Screen("vocabulary", R.string.label_vocabulary, AppIcons.VocabularyFilled, AppIcons.VocabularyOutlined)
|
||||
object Settings : Screen("settings", R.string.title_settings, AppIcons.SettingsFilled, AppIcons.SettingsOutlined)
|
||||
|
||||
companion object {
|
||||
fun getAllScreens(showExperimental: Boolean = false): List<Screen> {
|
||||
return listOf(Home, Library, Stats)
|
||||
}
|
||||
|
||||
fun getMoreMenuItems(showExperimental: Boolean = false): List<Screen> {
|
||||
val items = mutableListOf<Screen>()
|
||||
items.add(Translation)
|
||||
items.add(Dictionary)
|
||||
items.add(Settings)
|
||||
val screens = mutableListOf(Home, Dictionary, Vocabulary, Settings)
|
||||
if (showExperimental) {
|
||||
items.add(Exercises)
|
||||
screens.add(2, Exercises)
|
||||
}
|
||||
return items
|
||||
return screens
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun fromDestination(destination: NavDestination?): Screen {
|
||||
val showExperimental = LocalShowExperimentalFeatures.current
|
||||
val allScreens = getAllScreens(showExperimental) + getMoreMenuItems(showExperimental) + More
|
||||
return allScreens.find { screen ->
|
||||
return getAllScreens(showExperimental).find { screen ->
|
||||
destination?.hierarchy?.any { it.route == screen.route } == true
|
||||
} ?: Home
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A modernized Material 3 bottom navigation bar with spring animations and haptic feedback.
|
||||
*/
|
||||
@SuppressLint("UnusedBoxWithConstraintsScope")
|
||||
@Composable
|
||||
fun BottomNavigationBar(
|
||||
@@ -114,32 +86,19 @@ fun BottomNavigationBar(
|
||||
showLabels: Boolean,
|
||||
onItemSelected: (Screen) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onPlayClicked: () -> Unit = {}
|
||||
) {
|
||||
val showExperimental = LocalShowExperimentalFeatures.current
|
||||
val screens = remember(showExperimental) { Screen.getAllScreens(showExperimental) }
|
||||
val moreScreen = remember { Screen.More }
|
||||
val haptic = LocalHapticFeedback.current
|
||||
var showMoreMenu by remember { mutableStateOf(false) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Configuration for the play button
|
||||
val playButtonSize = 56.dp
|
||||
val glowPadding = 12.dp // Total extra space for the glow (16dp on each side)
|
||||
|
||||
// This dictates how far up the button shifts.
|
||||
// Setting it to around half the button size centers it on the top border.
|
||||
val upwardOffset = 16.dp
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = slideInVertically(
|
||||
animationSpec = spring(stiffness = Spring.StiffnessHigh),
|
||||
animationSpec = androidx.compose.animation.core.tween(durationMillis = 220),
|
||||
initialOffsetY = { it }
|
||||
),
|
||||
exit = slideOutVertically(
|
||||
animationSpec = spring(stiffness = Spring.StiffnessHigh),
|
||||
animationSpec = androidx.compose.animation.core.tween(durationMillis = 220),
|
||||
targetOffsetY = { it }
|
||||
)
|
||||
) {
|
||||
@@ -148,240 +107,68 @@ fun BottomNavigationBar(
|
||||
val navBarDp = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
|
||||
val height = baseHeight + navBarDp
|
||||
|
||||
// Outer Box height is purely determined by the NavigationBar now
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
NavigationBar(
|
||||
modifier = modifier
|
||||
.height(height)
|
||||
// Apply glassmorphism on the top corners
|
||||
.glassmorphic(shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), alpha = 0.35f),
|
||||
containerColor = Color.Transparent, // Let the glass shine through
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
screens.forEach { screen ->
|
||||
val isSelected = screen == selectedItem
|
||||
val title = stringResource(id = screen.title)
|
||||
|
||||
// The actual Navigation Bar
|
||||
NavigationBar(
|
||||
modifier = Modifier.height(height),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 8.dp,
|
||||
) {
|
||||
// Create a list of 5 items (2 left, 1 empty spacer, 2 right)
|
||||
val allNavItems = buildList {
|
||||
addAll(screens.take(2))
|
||||
add(null) // Empty spacer for Play Button gap
|
||||
if (screens.size > 2) {
|
||||
addAll(screens.drop(2))
|
||||
}
|
||||
add(moreScreen)
|
||||
}
|
||||
|
||||
allNavItems.forEach { screen ->
|
||||
if (screen == null) {
|
||||
// Dummy item to create the gap
|
||||
NavigationBarItem(
|
||||
selected = false,
|
||||
onClick = {},
|
||||
enabled = false, // Disables ripples and clicks
|
||||
icon = { Spacer(modifier = Modifier.size(24.dp)) },
|
||||
label = if (showLabels) { { Spacer(modifier = Modifier.size(10.dp)) } } else null,
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
disabledIconColor = Color.Transparent,
|
||||
disabledTextColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// Regular or More items
|
||||
val isSelected = if (screen == Screen.More) {
|
||||
selectedItem is Screen.More || Screen.getMoreMenuItems(showExperimental).contains(selectedItem)
|
||||
} else {
|
||||
screen == selectedItem
|
||||
}
|
||||
val title = stringResource(id = screen.title)
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isSelected) 1.2f else 1.0f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
),
|
||||
label = "iconScale"
|
||||
)
|
||||
|
||||
NavigationBarItem(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (!isSelected) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
if (screen == Screen.More) showMoreMenu = true else onItemSelected(screen)
|
||||
}
|
||||
},
|
||||
label = if (showLabels) {
|
||||
{
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if(isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
icon = {
|
||||
Crossfade(targetState = isSelected, label = "iconFade") { selected ->
|
||||
Icon(
|
||||
imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.scale(scale)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Glowing Play Button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// This negative offset pulls the button UP out of the bounding box
|
||||
// without increasing the layout height of the parent Box.
|
||||
.offset(y = -upwardOffset)
|
||||
.size(playButtonSize + glowPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Background radial glow
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
Color.Transparent
|
||||
)
|
||||
),
|
||||
shape = CircleShape
|
||||
)
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (isSelected) 1.2f else 1.0f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
),
|
||||
label = "iconScale"
|
||||
)
|
||||
|
||||
// Actual clickable button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(playButtonSize)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.clickable {
|
||||
NavigationBarItem(
|
||||
selected = isSelected,
|
||||
onClick = {
|
||||
if (!isSelected) {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onPlayClicked()
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PlayArrow,
|
||||
contentDescription = "Play",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
onItemSelected(screen)
|
||||
}
|
||||
},
|
||||
label = if (showLabels) {
|
||||
{
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if(isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
icon = {
|
||||
Crossfade(targetState = isSelected, label = "iconFade") { selected ->
|
||||
Icon(
|
||||
imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.scale(scale)
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), // Glassy indicator
|
||||
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Modal Bottom Sheet for More menu (Remains exactly the same)
|
||||
if (showMoreMenu) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showMoreMenu = false },
|
||||
sheetState = sheetState
|
||||
) {
|
||||
MoreBottomSheetContent(
|
||||
showExperimental = showExperimental,
|
||||
onItemSelected = { screen ->
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
showMoreMenu = false
|
||||
onItemSelected(screen)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MoreBottomSheetContent(
|
||||
showExperimental: Boolean,
|
||||
onItemSelected: (Screen) -> Unit
|
||||
) {
|
||||
val moreItems = remember(showExperimental) { Screen.getMoreMenuItems(showExperimental) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.label_more),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold, // Added bold to match the new style
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
)
|
||||
|
||||
// Removed HorizontalDivider() for a cleaner look
|
||||
|
||||
moreItems.forEach { screen ->
|
||||
MoreMenuItem(
|
||||
screen = screen,
|
||||
onClick = { onItemSelected(screen) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
fun MoreMenuItem(
|
||||
screen: Screen,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Circular Icon Background
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
// Adjust this depending on whether your Screen uses ImageVector or Drawable Res
|
||||
Icon(
|
||||
imageVector = screen.selectedIcon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
// Title
|
||||
Text(
|
||||
text = stringResource(id = screen.title), // Adjust to your actual string property
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ThemePreviews
|
||||
@Composable
|
||||
fun BottomNavigationBarPreview() {
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
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.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonColors
|
||||
@@ -25,6 +33,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -34,7 +43,12 @@ 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.composed
|
||||
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.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
@@ -47,30 +61,183 @@ import eu.gaudian.translator.ui.theme.semanticColors
|
||||
|
||||
|
||||
object ComponentDefaults {
|
||||
// Sizing
|
||||
val DefaultButtonHeight = 48.dp
|
||||
val CardPadding = 8.dp
|
||||
|
||||
// Elevation
|
||||
val DefaultElevation = 0.dp
|
||||
val NoElevation = 0.dp
|
||||
|
||||
// Borders
|
||||
val DefaultBorderWidth = 1.dp
|
||||
|
||||
// Shapes
|
||||
val DefaultCornerRadius = 16.dp
|
||||
val CardClipRadius = 8.dp
|
||||
val CardClipRadius = 16.dp // Increased slightly for softer glass look
|
||||
val NoRounding = 0.dp
|
||||
val DefaultShape = RoundedCornerShape(DefaultCornerRadius)
|
||||
val CardClipShape = RoundedCornerShape(CardClipRadius)
|
||||
val CardShape = RoundedCornerShape(DefaultCornerRadius)
|
||||
val NoShape = RoundedCornerShape(NoRounding)
|
||||
|
||||
// Opacity Levels
|
||||
const val ALPHA_HIGH = 0.6f
|
||||
const val ALPHA_MEDIUM = 0.5f
|
||||
const val ALPHA_LOW = 0.3f
|
||||
const val ALPHA_MEDIUM = 0.4f
|
||||
const val ALPHA_LOW = 0.2f // Adjusted for glass
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Glassmorphism Modifier
|
||||
*/
|
||||
fun Modifier.glassmorphic(
|
||||
shape: Shape = ComponentDefaults.DefaultShape,
|
||||
alpha: Float = ComponentDefaults.ALPHA_LOW,
|
||||
borderAlpha: Float = 0.15f
|
||||
): Modifier = composed {
|
||||
this
|
||||
.shadow(elevation = 8.dp, shape = shape, spotColor = Color.Black.copy(alpha = 0.05f))
|
||||
.clip(shape)
|
||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = alpha))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = borderAlpha),
|
||||
shape = shape
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppCard(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
text: String? = null,
|
||||
expandable: Boolean = false,
|
||||
initiallyExpanded: Boolean = false,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
|
||||
|
||||
val rotationState by animateFloatAsState(
|
||||
targetValue = if (isExpanded) 180f else 0f,
|
||||
label = "Chevron Rotation"
|
||||
)
|
||||
|
||||
val hasHeader = title != null || text != null || expandable || icon != null
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.glassmorphic(shape = ComponentDefaults.CardShape, alpha = 0.25f)
|
||||
.animateContentSize(),
|
||||
shape = ComponentDefaults.CardShape,
|
||||
color = Color.Transparent // Let glassmorphic handle the background
|
||||
) {
|
||||
Column {
|
||||
if (hasHeader) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = expandable) { isExpanded = !isExpanded }
|
||||
.padding(ComponentDefaults.CardPadding),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (!title.isNullOrBlank()) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
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 (expandable) {
|
||||
Icon(
|
||||
imageVector = AppIcons.ArrowDropDown,
|
||||
contentDescription = if (isExpanded) "Collapse" else "Expand",
|
||||
modifier = Modifier.rotate(rotationState),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!expandable || isExpanded) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
start = ComponentDefaults.CardPadding,
|
||||
end = ComponentDefaults.CardPadding,
|
||||
bottom = ComponentDefaults.CardPadding,
|
||||
top = if (hasHeader) 0.dp else ComponentDefaults.CardPadding
|
||||
),
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,31 +292,27 @@ fun AppButton(
|
||||
modifier: Modifier? = Modifier,
|
||||
enabled: Boolean = true,
|
||||
shape: Shape? = null,
|
||||
colors: ButtonColors = ButtonDefaults.buttonColors(),
|
||||
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(),
|
||||
colors: ButtonColors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) // Glassy primary
|
||||
),
|
||||
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(defaultElevation = 0.dp),
|
||||
border: BorderStroke? = null,
|
||||
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
|
||||
val m = modifier ?: Modifier.height(ComponentDefaults.DefaultButtonHeight)
|
||||
val s = shape ?: ComponentDefaults.DefaultShape
|
||||
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = m,
|
||||
modifier = m.border(1.dp, MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.2f), s),
|
||||
enabled = enabled,
|
||||
shape = s,
|
||||
colors = colors,
|
||||
elevation = elevation,
|
||||
border = border,
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp, // More horizontal padding
|
||||
end = 8.dp,
|
||||
top = 8.dp, // Default vertical padding
|
||||
bottom = 8.dp
|
||||
),
|
||||
contentPadding = PaddingValues(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
interactionSource = interactionSource
|
||||
) {
|
||||
content()
|
||||
@@ -189,11 +352,7 @@ fun AppOutlinedButton(
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PrimaryButtonWithIconPreview() {
|
||||
PrimaryButton(onClick = { }, text = stringResource(R.string.primary_with_icon), icon = AppIcons.Add)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The secondary button for less prominent actions.
|
||||
@@ -407,7 +566,6 @@ fun WrongOutlinedButtonPreview(){
|
||||
WrongOutlinedButton(onClick = { }, text = stringResource(R.string.label_continue))
|
||||
}
|
||||
|
||||
//This is basically just a wrapper for screens to control width (tablet mode) etc.
|
||||
@Composable
|
||||
fun AppOutlinedCard(
|
||||
modifier: Modifier = Modifier,
|
||||
|
||||
@@ -56,8 +56,6 @@ fun BaseLanguageDropDown(
|
||||
enableMultipleSelection: Boolean = false,
|
||||
onLanguagesSelected: (List<Language>) -> Unit = {},
|
||||
alternateLanguages: List<Language> = emptyList(),
|
||||
restrictToAlternateLanguages: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
iconEnabled: Boolean = true,
|
||||
noBorder: Boolean = false,
|
||||
) {
|
||||
@@ -70,12 +68,8 @@ fun BaseLanguageDropDown(
|
||||
var tempSelection by remember { mutableStateOf<List<Language>>(emptyList()) }
|
||||
var selectedLanguagesCount by remember { mutableIntStateOf(if (enableMultipleSelection) tempSelection.size else 0) }
|
||||
|
||||
val languages = remember(alternateLanguages, defaultLanguages, restrictToAlternateLanguages) {
|
||||
if (restrictToAlternateLanguages) {
|
||||
alternateLanguages
|
||||
} else {
|
||||
alternateLanguages.ifEmpty { defaultLanguages }
|
||||
}
|
||||
val languages = remember(alternateLanguages, defaultLanguages) {
|
||||
alternateLanguages.ifEmpty { defaultLanguages }
|
||||
}
|
||||
|
||||
val buttonText = when {
|
||||
@@ -96,7 +90,6 @@ fun BaseLanguageDropDown(
|
||||
AppOutlinedButton(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
onClick = { expanded = true },
|
||||
enabled = enabled,
|
||||
contentPadding = if (!iconEnabled) PaddingValues(0.dp) else PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
borderColor = if (noBorder) Color.Unspecified else null
|
||||
) {
|
||||
@@ -228,13 +221,8 @@ fun BaseLanguageDropDown(
|
||||
) {
|
||||
val isSearching = searchText.isNotBlank()
|
||||
|
||||
if (isSearching) {
|
||||
val searchBase = if (restrictToAlternateLanguages) {
|
||||
alternateLanguages
|
||||
} else {
|
||||
favoriteLanguages + languageHistory + languages
|
||||
}
|
||||
val searchResults = searchBase
|
||||
if (isSearching) {
|
||||
val searchResults = (favoriteLanguages + languageHistory + languages)
|
||||
.distinctBy { it.nameResId }
|
||||
.filter { language ->
|
||||
val matchesName = language.name.contains(searchText, ignoreCase = true)
|
||||
@@ -249,18 +237,8 @@ fun BaseLanguageDropDown(
|
||||
searchResults.forEach { language -> SingleSelectItem(language) }
|
||||
}
|
||||
|
||||
} else if (restrictToAlternateLanguages) {
|
||||
val sortedAlternate = alternateLanguages.sortedBy { it.name }
|
||||
if (enableMultipleSelection) {
|
||||
DropdownHeader(text = stringResource(R.string.text_all_languages))
|
||||
sortedAlternate.forEach { language -> MultiSelectItem(language) }
|
||||
} else {
|
||||
DropdownHeader(text = stringResource(R.string.text_all_languages))
|
||||
sortedAlternate.forEach { language -> SingleSelectItem(language) }
|
||||
}
|
||||
|
||||
} else if (alternateLanguages.isNotEmpty()) {
|
||||
val sortedAlternate = alternateLanguages.sortedBy { it.name }
|
||||
} else if (alternateLanguages.isNotEmpty()) {
|
||||
val sortedAlternate = alternateLanguages.sortedBy { it.name }
|
||||
if (enableMultipleSelection) {
|
||||
DropdownHeader(text = stringResource(R.string.text_all_languages))
|
||||
sortedAlternate.forEach { language -> MultiSelectItem(language) }
|
||||
@@ -480,9 +458,7 @@ fun SingleLanguageDropDown(
|
||||
onAutoSelected: () -> Unit = {},
|
||||
showNoneOption: Boolean = false,
|
||||
onNoneSelected: () -> Unit = {},
|
||||
alternateLanguages: List<Language> = emptyList(),
|
||||
restrictToAlternateLanguages: Boolean = false,
|
||||
enabled: Boolean = true
|
||||
alternateLanguages: List<Language> = emptyList()
|
||||
) {
|
||||
val languageHistory by languageViewModel.languageHistory.collectAsState()
|
||||
|
||||
@@ -501,10 +477,6 @@ fun SingleLanguageDropDown(
|
||||
showNoneOption = showNoneOption,
|
||||
onNoneSelected = onNoneSelected,
|
||||
enableMultipleSelection = false,
|
||||
alternateLanguages = alternateLanguages,
|
||||
restrictToAlternateLanguages = restrictToAlternateLanguages,
|
||||
enabled = enabled,
|
||||
iconEnabled = enabled,
|
||||
noBorder = !enabled
|
||||
alternateLanguages = alternateLanguages
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -27,6 +29,8 @@ fun CategorySelectionDialog(
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
|
||||
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
|
||||
|
||||
AppDialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
title = {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package eu.gaudian.translator.view.dialogs
|
||||
|
||||
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.width
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.utils.findActivity
|
||||
import eu.gaudian.translator.view.composable.AppDialog
|
||||
import eu.gaudian.translator.view.composable.AppSlider
|
||||
import eu.gaudian.translator.view.composable.DialogButton
|
||||
import eu.gaudian.translator.view.composable.InspiringSearchField
|
||||
import eu.gaudian.translator.view.composable.SourceLanguageDropdown
|
||||
import eu.gaudian.translator.view.composable.TargetLanguageDropdown
|
||||
import eu.gaudian.translator.view.hints.HintDefinition
|
||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ImportVocabularyDialog(
|
||||
onDismiss: () -> Unit,
|
||||
languageViewModel: LanguageViewModel,
|
||||
vocabularyViewModel : VocabularyViewModel,
|
||||
optionalDescription: String? = null,
|
||||
optionalSearchTerm: String? = null
|
||||
) {
|
||||
|
||||
val navController = rememberNavController()
|
||||
NavHost(navController = navController, startDestination = "import") {
|
||||
composable("import") {
|
||||
ImportDialogContent(
|
||||
navController = navController,
|
||||
onDismiss = onDismiss,
|
||||
languageViewModel = languageViewModel,
|
||||
optionalDescription = optionalDescription,
|
||||
optionalSearchTerm = optionalSearchTerm
|
||||
)
|
||||
}
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
composable("review") {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
// Full-screen surface to ensure the dialog covers content and stays above the main FAB/menu
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
VocabularyReviewScreen(
|
||||
onConfirm = { selectedItems, categoryIds ->
|
||||
vocabularyViewModel.addVocabularyItems(selectedItems, categoryIds)
|
||||
onDismiss()
|
||||
},
|
||||
onCancel = onDismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImportDialogContent(
|
||||
navController: NavController,
|
||||
onDismiss: () -> Unit,
|
||||
languageViewModel: LanguageViewModel,
|
||||
optionalDescription: String? = null,
|
||||
optionalSearchTerm: String? = null
|
||||
) {
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
var category by remember { mutableStateOf(optionalSearchTerm ?: "") }
|
||||
var amount by remember { mutableFloatStateOf(1f) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val descriptionText = optionalDescription ?: stringResource(R.string.text_let_ai_find_vocabulary_for_you)
|
||||
val isGenerating by vocabularyViewModel.isGenerating.collectAsState()
|
||||
|
||||
AppDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(descriptionText) },
|
||||
hintContent = HintDefinition.IMPORT.hint(),
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp)
|
||||
) {
|
||||
if (isGenerating) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.text_search_term),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
// Modern rotating field using XML resource array
|
||||
InspiringSearchField(
|
||||
value = category,
|
||||
hints = stringArrayResource(R.array.vocabulary_hints),
|
||||
onValueChange = { category = it }
|
||||
)
|
||||
|
||||
// The "Dica" string has been removed to keep the interface clean
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.text_select_languages),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(1.dp)
|
||||
) {
|
||||
SourceLanguageDropdown(languageViewModel = languageViewModel)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(1.dp)
|
||||
) {
|
||||
TargetLanguageDropdown(languageViewModel = languageViewModel)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.text_select_amount),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
AppSlider(
|
||||
value = amount,
|
||||
onValueChange = { amount = it },
|
||||
valueRange = 1f..25f,
|
||||
steps = 24,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.text_amount_2d, amount.toInt()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
DialogButton(
|
||||
onClick = onDismiss,
|
||||
content = { Text(stringResource(R.string.label_cancel)) }
|
||||
)
|
||||
if (category.isNotBlank() && !isGenerating) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
DialogButton(onClick = {
|
||||
coroutineScope.launch {
|
||||
vocabularyViewModel.generateVocabularyItems(category, amount.toInt())
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
navController.navigate("review")
|
||||
}
|
||||
}) { Text(stringResource(R.string.text_generate)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
@Preview
|
||||
@Composable
|
||||
fun ImportDialogContentPreview() {
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
|
||||
ImportDialogContent(
|
||||
navController = rememberNavController(),
|
||||
onDismiss = {},
|
||||
languageViewModel = languageViewModel,
|
||||
optionalDescription = "Let AI find vocabulary for you",
|
||||
optionalSearchTerm = "Travel"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
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 = {}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.model.Language
|
||||
import eu.gaudian.translator.model.VocabularyCategory
|
||||
import eu.gaudian.translator.model.VocabularyStage
|
||||
import eu.gaudian.translator.utils.findActivity
|
||||
import eu.gaudian.translator.view.composable.AppDialog
|
||||
import eu.gaudian.translator.view.composable.MultipleLanguageDropdown
|
||||
import eu.gaudian.translator.viewmodel.CategoryViewModel
|
||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun StartExerciseDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (
|
||||
categories: List<VocabularyCategory>,
|
||||
stages: List<VocabularyStage>,
|
||||
languageIds: List<Int>
|
||||
) -> Unit
|
||||
) {
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
|
||||
|
||||
var lids by remember { mutableStateOf<List<Int>>(emptyList()) }
|
||||
var languages by remember { mutableStateOf<List<Language>>(emptyList()) }
|
||||
// Map displayed Language to its DB id (lid) using position mapping from load
|
||||
var languageIdMap by remember { mutableStateOf<Map<Language, Int>>(emptyMap()) }
|
||||
var selectedLanguages by remember { mutableStateOf<List<Language>>(emptyList()) }
|
||||
var selectedStages by remember { mutableStateOf<List<VocabularyStage>>(emptyList()) }
|
||||
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
coroutineScope.launch {
|
||||
lids = vocabularyViewModel.getAllLanguagesIdsPresent().filterNotNull().toList()
|
||||
languages = lids.map { lid ->
|
||||
languageViewModel.getLanguageById(lid)
|
||||
}
|
||||
// build reverse map
|
||||
languageIdMap = languages.mapIndexed { index, lang -> lang to lids.getOrNull(index) }.filter { it.second != null }.associate { it.first to it.second!! }
|
||||
}
|
||||
}
|
||||
|
||||
AppDialog(onDismissRequest = onDismiss, title = { Text(stringResource(R.string.label_start_exercise)) }) {
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
MultipleLanguageDropdown(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
languageViewModel = languageViewModel,
|
||||
onLanguagesSelected = { langs ->
|
||||
selectedLanguages = langs
|
||||
},
|
||||
languages
|
||||
)
|
||||
CategoryDropdown(
|
||||
onCategorySelected = { cats ->
|
||||
selectedCategories = cats.filterIsInstance<VocabularyCategory>()
|
||||
},
|
||||
multipleSelectable = true,
|
||||
onlyLists = false, // Show both filters and lists
|
||||
addCategory = false,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
VocabularyStageDropDown(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
preselectedStages = selectedStages,
|
||||
onStageSelected = { stages ->
|
||||
@Suppress("FilterIsInstanceResultIsAlwaysEmpty")
|
||||
selectedStages = stages.filterIsInstance<VocabularyStage>()
|
||||
},
|
||||
multipleSelectable = true
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
) {
|
||||
Text(stringResource(R.string.label_cancel))
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
run {
|
||||
val ids = selectedLanguages.mapNotNull { languageIdMap[it] }
|
||||
onConfirm(selectedCategories, selectedStages, ids)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.label_start_exercise))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@file:Suppress("AssignedValueIsNeverRead")
|
||||
|
||||
package eu.gaudian.translator.view.dialogs
|
||||
|
||||
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.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.utils.findActivity
|
||||
import eu.gaudian.translator.view.composable.AppFabMenu
|
||||
import eu.gaudian.translator.view.composable.AppIcons
|
||||
import eu.gaudian.translator.view.composable.FabMenuItem
|
||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||
|
||||
@Composable
|
||||
fun VocabularyMenu(
|
||||
modifier: Modifier = Modifier,
|
||||
showFabText : Boolean = true
|
||||
) {
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
var showAddVocabularyDialog by remember { mutableStateOf(false) }
|
||||
var showImportVocabularyDialog by remember { mutableStateOf(false) }
|
||||
var showAddCategoryDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val menuItems = listOf(
|
||||
FabMenuItem(
|
||||
text = stringResource(R.string.label_add_vocabulary),
|
||||
imageVector = AppIcons.Add,
|
||||
onClick = { showAddVocabularyDialog = true }
|
||||
),
|
||||
FabMenuItem(
|
||||
text = stringResource(R.string.menu_import_vocabulary),
|
||||
imageVector = AppIcons.AI,
|
||||
onClick = { showImportVocabularyDialog = true }
|
||||
),
|
||||
FabMenuItem(
|
||||
text = stringResource(R.string.label_add_category),
|
||||
imageVector = AppIcons.Add,
|
||||
onClick = { showAddCategoryDialog = true }
|
||||
)
|
||||
)
|
||||
|
||||
AppFabMenu(items = menuItems, modifier = modifier, title = stringResource(R.string.label_add_vocabulary), showFabText = showFabText)
|
||||
|
||||
if (showAddVocabularyDialog) {
|
||||
AddVocabularyDialog(
|
||||
onDismissRequest = { showAddVocabularyDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showImportVocabularyDialog) {
|
||||
ImportVocabularyDialog(
|
||||
languageViewModel = languageViewModel,
|
||||
vocabularyViewModel = vocabularyViewModel,
|
||||
onDismiss = { showImportVocabularyDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showAddCategoryDialog) {
|
||||
AddCategoryDialog(
|
||||
onDismiss = { showAddCategoryDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import eu.gaudian.translator.view.composable.AppCheckbox
|
||||
import eu.gaudian.translator.view.composable.AppScaffold
|
||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||
import eu.gaudian.translator.view.hints.HintDefinition
|
||||
import eu.gaudian.translator.viewmodel.CategoryViewModel
|
||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||
|
||||
@Composable
|
||||
@@ -44,8 +45,10 @@ fun VocabularyReviewScreen(
|
||||
) {
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(activity)
|
||||
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
|
||||
val generatedItems: List<VocabularyItem> by vocabularyViewModel.generatedVocabularyItems.collectAsState()
|
||||
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
|
||||
|
||||
val selectedItems = remember { mutableStateListOf<VocabularyItem>() }
|
||||
val duplicates = remember { mutableStateListOf<Boolean>() }
|
||||
@@ -62,8 +65,8 @@ fun VocabularyReviewScreen(
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTopAppBar(
|
||||
title = stringResource(R.string.found_items),
|
||||
hint = HintDefinition.REVIEW.hint()
|
||||
title = { Text(stringResource(R.string.found_items)) },
|
||||
hintContent = HintDefinition.REVIEW.hint()
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
|
||||
@@ -381,7 +381,7 @@ fun DefinitionPart(part: EntryPart) {
|
||||
// Fallback for JsonObject or other top-level types
|
||||
else -> contentElement.toString()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
// Ultimate fallback if something else goes wrong during parsing
|
||||
part.content.toString()
|
||||
}
|
||||
@@ -466,6 +466,12 @@ fun DefinitionPartPreview() {
|
||||
DefinitionPart(part = mockPart)
|
||||
}
|
||||
|
||||
// Data classes for the refactored components
|
||||
data class EntryData(
|
||||
val entry: DictionaryEntry,
|
||||
val language: Language?
|
||||
)
|
||||
|
||||
data class BreadcrumbItem(
|
||||
val word: String,
|
||||
val entryId: Int
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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
|
||||
@@ -26,6 +28,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
@@ -342,12 +345,30 @@ fun DictionarySimpleTopBar(
|
||||
languageName: String?,
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
word?.let {
|
||||
AppTopAppBar(
|
||||
title = it,
|
||||
onNavigateBack = onNavigateBack
|
||||
)
|
||||
}
|
||||
AppTopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = word ?: stringResource(R.string.text_loading_3d),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
languageName?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontStyle = FontStyle.Italic
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
|
||||
}
|
||||
},
|
||||
actions = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@file:Suppress("SameParameterValue")
|
||||
|
||||
package eu.gaudian.translator.view.dictionary
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
|
||||
@@ -30,6 +30,7 @@ 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.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
@@ -93,8 +94,27 @@ fun EtymologyResultScreen(
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTopAppBar(
|
||||
title = "Result",
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = word,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
language?.name?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
etymologyData?.let { data ->
|
||||
if (isTtsAvailable) {
|
||||
|
||||
@@ -21,7 +21,6 @@ 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
|
||||
@@ -64,15 +63,7 @@ fun MainDictionaryScreen(
|
||||
AppTabLayout(
|
||||
tabs = dictionaryTabs,
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { selectedTab = it },
|
||||
onNavigateBack = {
|
||||
if (!navController.popBackStack()) {
|
||||
navController.navigate(Screen.Home.route) {
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
}
|
||||
onTabSelected = { selectedTab = it }
|
||||
)
|
||||
|
||||
when (selectedTab) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package eu.gaudian.translator.view.new_ecercises
|
||||
package eu.gaudian.translator.view.exercises
|
||||
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
@@ -1,4 +1,4 @@
|
||||
package eu.gaudian.translator.view.new_ecercises
|
||||
package eu.gaudian.translator.view.exercises
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -1,4 +1,4 @@
|
||||
package eu.gaudian.translator.view.new_ecercises
|
||||
package eu.gaudian.translator.view.exercises
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -1,4 +1,4 @@
|
||||
package eu.gaudian.translator.view.new_ecercises
|
||||
package eu.gaudian.translator.view.exercises
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.AppIcons
|
||||
import eu.gaudian.translator.view.composable.ComponentDefaults
|
||||
import eu.gaudian.translator.view.exercises.ExerciseProgressIndicator
|
||||
import eu.gaudian.translator.view.vocabulary.ExerciseProgressIndicator
|
||||
import eu.gaudian.translator.viewmodel.AnswerResult
|
||||
import eu.gaudian.translator.viewmodel.ExerciseSessionState
|
||||
import eu.gaudian.translator.viewmodel.ExerciseViewModel
|
||||
@@ -1,4 +1,4 @@
|
||||
package eu.gaudian.translator.view.new_ecercises
|
||||
package eu.gaudian.translator.view.exercises
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -15,7 +15,7 @@ import androidx.navigation.NavHostController
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.view.composable.AppButton
|
||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||
import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
|
||||
import eu.gaudian.translator.view.vocabulary.VocabularyListScreen
|
||||
|
||||
@Composable
|
||||
fun ExerciseVocabularyScreen(
|
||||
@@ -24,7 +24,7 @@ fun ExerciseVocabularyScreen(
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
AppTopAppBar(title =stringResource(R.string.text_new_vocabulary_for_this_exercise))
|
||||
AppTopAppBar(title = { Text(stringResource(R.string.text_new_vocabulary_for_this_exercise)) })
|
||||
},
|
||||
bottomBar = {
|
||||
Surface(shadowElevation = 8.dp) {
|
||||
@@ -41,7 +41,7 @@ fun ExerciseVocabularyScreen(
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.padding(paddingValues)) {
|
||||
|
||||
AllCardsListScreen(
|
||||
VocabularyListScreen(
|
||||
navController = navController as NavHostController?,
|
||||
onNavigateToItem = { item ->
|
||||
// Navigate to the detail screen for a specific vocabulary item
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:Suppress("AssignedValueIsNeverRead")
|
||||
|
||||
package eu.gaudian.translator.view.new_ecercises
|
||||
package eu.gaudian.translator.view.exercises
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.background
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.view.new_ecercises
|
||||
package eu.gaudian.translator.view.exercises
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -38,7 +38,6 @@ import eu.gaudian.translator.view.composable.AppIcons
|
||||
import eu.gaudian.translator.view.composable.AppOutlinedCard
|
||||
import eu.gaudian.translator.view.composable.AppTabLayout
|
||||
import eu.gaudian.translator.view.composable.DialogButton
|
||||
import eu.gaudian.translator.view.composable.Screen
|
||||
import eu.gaudian.translator.view.composable.TabItem
|
||||
import eu.gaudian.translator.viewmodel.AiGenerationState
|
||||
import eu.gaudian.translator.viewmodel.ExerciseViewModel
|
||||
@@ -77,15 +76,7 @@ fun MainExerciseScreen(
|
||||
AppTabLayout(
|
||||
tabs = ExerciseTab.entries,
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { selectedTab = it },
|
||||
onNavigateBack = {
|
||||
if (!navController.popBackStack()) {
|
||||
navController.navigate(Screen.Home.route) {
|
||||
launchSingleTop = true
|
||||
restoreState = false
|
||||
}
|
||||
}
|
||||
}
|
||||
onTabSelected = { selectedTab = it }
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package eu.gaudian.translator.view.new_ecercises
|
||||
package eu.gaudian.translator.view.exercises
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:Suppress("AssignedValueIsNeverRead")
|
||||
|
||||
package eu.gaudian.translator.view.new_ecercises
|
||||
package eu.gaudian.translator.view.exercises
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -24,10 +26,12 @@ 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.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.utils.findActivity
|
||||
import eu.gaudian.translator.view.composable.AppIcons
|
||||
import eu.gaudian.translator.view.composable.AppScaffold
|
||||
@@ -57,8 +61,12 @@ fun YouTubeBrowserScreen(
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTopAppBar(
|
||||
title = "YouTube" ,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
title = { Text("YouTube") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(AppIcons.ArrowBack, stringResource(R.string.cd_back))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
@@ -1,4 +1,4 @@
|
||||
package eu.gaudian.translator.view.new_ecercises
|
||||
package eu.gaudian.translator.view.exercises
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -1,6 +1,6 @@
|
||||
@file:Suppress("AssignedValueIsNeverRead")
|
||||
|
||||
package eu.gaudian.translator.view.new_ecercises
|
||||
package eu.gaudian.translator.view.exercises
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.widget.Toast
|
||||
@@ -183,8 +183,14 @@ fun YouTubeExerciseScreen(
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTopAppBar(
|
||||
title = title,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
title = { Text(title, maxLines = 1) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(AppIcons.ArrowBack, contentDescription = stringResource(
|
||||
R.string.cd_back
|
||||
))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { onFinishVideo() },
|
||||
@@ -21,13 +21,12 @@ enum class HintDefinition(
|
||||
CATEGORY("category_hint", R.string.category_hint_intro),
|
||||
DICTIONARY_OPTIONS("dictionary_hint", R.string.label_dictionary_options),
|
||||
EXERCISE("exercise_hint", R.string.label_exercise),
|
||||
VOCABULARY_GENERATE_AI("import_hint", R.string.hint_how_to_generate_vocabulary_with_ai),
|
||||
IMPORT("import_hint", R.string.hint_how_to_generate_vocabulary_with_ai),
|
||||
LEARNING_STAGES("learning_stages_hint", R.string.learning_stages_title),
|
||||
REVIEW("review_hint", R.string.review_intro),
|
||||
SORTING("sorting_hint", R.string.sorting_hint_title),
|
||||
TRANSLATION("translation_hint", R.string.hint_translate_how_it_works),
|
||||
VOCABULARY_PROGRESS("vocabulary_progress_hint", R.string.hint_vocabulary_progress_hint_title),
|
||||
EXPLORE_PACKS("explore_packs_hint", R.string.hint_explore_packs_title);
|
||||
VOCABULARY_PROGRESS("vocabulary_progress_hint", R.string.hint_vocabulary_progress_hint_title);
|
||||
|
||||
/** Creates the Hint data class for this hint definition. */
|
||||
@Composable
|
||||
@@ -41,6 +40,7 @@ enum class HintDefinition(
|
||||
@Composable
|
||||
fun hint(definition: HintDefinition): Hint = definition.hint()
|
||||
|
||||
@Composable fun HintContent(definition: HintDefinition) = definition.Render()
|
||||
@Composable fun HintScreen(navController: NavController, definition: HintDefinition) = HintScreen(
|
||||
navController = navController,
|
||||
title = stringResource(definition.titleRes),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("HardCodedStringLiteral")
|
||||
|
||||
package eu.gaudian.translator.view.hints
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
||||
@@ -32,7 +32,7 @@ import kotlinx.coroutines.launch
|
||||
fun HintBottomSheet(
|
||||
onDismissRequest: () -> Unit,
|
||||
sheetState: SheetState,
|
||||
content: Hint,
|
||||
content: @Composable (() -> Unit)?
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
ModalBottomSheet(
|
||||
@@ -50,7 +50,7 @@ fun HintBottomSheet(
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
content.Render()
|
||||
content?.invoke()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
@@ -15,6 +16,7 @@ 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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.view.composable.AppIcons
|
||||
@@ -37,8 +39,8 @@ val LocalShowHints = compositionLocalOf { false }
|
||||
*/
|
||||
@Composable
|
||||
fun WithHint(
|
||||
hintContent: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
hintContent: Hint? = null,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val showHints = LocalShowHints.current
|
||||
@@ -67,16 +69,27 @@ fun WithHint(
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
hintContent?.let {
|
||||
HintBottomSheet(
|
||||
onDismissRequest = {
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
showBottomSheet = false
|
||||
},
|
||||
sheetState = sheetState,
|
||||
content = it,
|
||||
)
|
||||
}
|
||||
HintBottomSheet(
|
||||
onDismissRequest = {
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
showBottomSheet = false
|
||||
},
|
||||
sheetState = sheetState,
|
||||
hintContent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun WithHintPreview() {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalShowHints provides true) {
|
||||
WithHint(
|
||||
hintContent = {
|
||||
Text(stringResource(R.string.this_is_a_hint))
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.this_is_the_main_content))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.navigation.NavController
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.view.composable.AppIcons
|
||||
import eu.gaudian.translator.view.composable.AppScaffold
|
||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||
|
||||
@@ -24,8 +30,12 @@ fun HintScreen(
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTopAppBar(
|
||||
title = title,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
|
||||
@@ -40,7 +40,7 @@ fun HintsOverviewScreen(
|
||||
val showExperimental = LocalShowExperimentalFeatures.current
|
||||
|
||||
// Get hints using the new function-based approach
|
||||
val importHint = HintDefinition.VOCABULARY_GENERATE_AI.hint()
|
||||
val importHint = HintDefinition.IMPORT.hint()
|
||||
val addModelScanHint = HintDefinition.ADD_MODEL_SCAN.hint()
|
||||
val dictionaryOptionsHint = HintDefinition.DICTIONARY_OPTIONS.hint()
|
||||
val translationScreenHint = HintDefinition.TRANSLATION.hint()
|
||||
@@ -77,7 +77,7 @@ fun HintsOverviewScreen(
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTopAppBar(
|
||||
title = stringResource(R.string.hint_title_hints_overview)
|
||||
title = { Text(stringResource(R.string.hint_title_hints_overview), style = MaterialTheme.typography.titleLarge) }
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
|
||||
@@ -47,7 +47,6 @@ object MarkdownHintLoader {
|
||||
append(language.lowercase())
|
||||
}
|
||||
if (country.isNotEmpty()) {
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
append("-r")
|
||||
append(country.uppercase())
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,437 +0,0 @@
|
||||
package eu.gaudian.translator.view.home
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddCircle
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.LocalFireDepartment
|
||||
import androidx.compose.material.icons.filled.Psychology
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
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.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.utils.findActivity
|
||||
import eu.gaudian.translator.view.NavigationRoutes
|
||||
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.settings.SettingsRoutes
|
||||
import eu.gaudian.translator.view.stats.widgets.WeeklyActivityChartWidget
|
||||
import eu.gaudian.translator.viewmodel.ProgressViewModel
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val viewModel: ProgressViewModel = hiltViewModel(activity)
|
||||
val streak by viewModel.streak.collectAsState()
|
||||
val dailyGoal by viewModel.dailyGoal.collectAsState()
|
||||
val todayCompletedCount by viewModel.todayCompletedCount.collectAsState()
|
||||
val dueTodayCount by viewModel.dueTodayCount.collectAsState()
|
||||
|
||||
// Calculate daily goal progress
|
||||
val progress = if (dailyGoal > 0) {
|
||||
(todayCompletedCount.toFloat() / dailyGoal).coerceIn(0f, 1f)
|
||||
} else 0f
|
||||
|
||||
// A Box with TopCenter alignment keeps the UI centered on wide screens (tablets/foldables)
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 700.dp) // Prevents extreme stretching on tablets
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 0.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(15.dp)
|
||||
) {
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item { TopProfileSection(
|
||||
navController = navController,
|
||||
context = LocalContext.current
|
||||
) }
|
||||
item {
|
||||
StreakAndGoalSection(
|
||||
streak = streak,
|
||||
progress = progress,
|
||||
progressTitle = "$todayCompletedCount / $dailyGoal",
|
||||
onGoalClick = { navController.navigate(SettingsRoutes.VOCABULARY_OPTIONS) },
|
||||
onStreakClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
|
||||
)
|
||||
}
|
||||
item {
|
||||
ActionCard(
|
||||
title = stringResource(R.string.label_daily_review),
|
||||
subtitle = stringResource(R.string.desc_daily_review_due, dueTodayCount),
|
||||
icon = Icons.Default.Psychology,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onClick = { navController.navigate(NavigationRoutes.DAILY_REVIEW) }
|
||||
)
|
||||
}
|
||||
item {
|
||||
ActionCard(
|
||||
title = stringResource(R.string.label_new_words),
|
||||
subtitle = stringResource(R.string.desc_expand_your_vocabulary),
|
||||
icon = Icons.Default.AddCircle,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onClick = { navController.navigate(NavigationRoutes.NEW_WORD) }
|
||||
)
|
||||
}
|
||||
item { WeeklyProgressSection(navController = navController) }
|
||||
item { BottomStatsSection(navController = navController) }
|
||||
|
||||
// Bottom padding for edge-to-edge screens
|
||||
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopProfileSection(navController: NavHostController, context: Context) {
|
||||
|
||||
val motivationalPhrases = remember {
|
||||
context.resources.getStringArray(R.array.motivational_phrases)
|
||||
}
|
||||
val randomPhrase = remember { motivationalPhrases.random() }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = randomPhrase,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
|
||||
IconButton(
|
||||
onClick = { navController.navigate(Screen.Settings.route) },
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = stringResource(R.string.label_settings),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StreakAndGoalSection(
|
||||
streak: Int,
|
||||
progress: Float,
|
||||
progressTitle: String,
|
||||
onGoalClick: () -> Unit,
|
||||
onStreakClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Streak Card
|
||||
StatCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = Icons.Default.LocalFireDepartment,
|
||||
title = stringResource(R.string.label_2d_days, streak),
|
||||
subtitle = stringResource(R.string.label_current_streak).uppercase(),
|
||||
onClick = onStreakClick
|
||||
)
|
||||
// Goal Card
|
||||
GoalCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
progress = progress,
|
||||
title = progressTitle,
|
||||
subtitle = stringResource(R.string.label_daily_goal).uppercase(),
|
||||
onClick = onGoalClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatCard(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
if (onClick != null) {
|
||||
AppCard(
|
||||
modifier = modifier,
|
||||
onClick = onClick
|
||||
) {
|
||||
StatCardContent(icon = icon, title = title, subtitle = subtitle)
|
||||
}
|
||||
} else {
|
||||
AppCard(
|
||||
modifier = modifier,
|
||||
) {
|
||||
StatCardContent(icon = icon, title = title, subtitle = subtitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatCardContent(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(20.dp)
|
||||
.height(120.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(text = subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GoalCard(
|
||||
modifier: Modifier = Modifier,
|
||||
progress: Float,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
if (onClick != null) {
|
||||
AppCard(
|
||||
modifier = modifier,
|
||||
onClick = onClick
|
||||
) {
|
||||
GoalCardContent(progress = progress, title = title, subtitle = subtitle)
|
||||
}
|
||||
} else {
|
||||
AppCard(
|
||||
modifier = modifier,
|
||||
) {
|
||||
GoalCardContent(progress = progress, title = title, subtitle = subtitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GoalCardContent(
|
||||
progress: Float,
|
||||
title: String,
|
||||
subtitle: String
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(20.dp)
|
||||
.height(120.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(48.dp),
|
||||
strokeWidth = 4.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
|
||||
)
|
||||
Text(text = "${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(text = subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionCard(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: ImageVector,
|
||||
contentColor: Color,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
val cardContent: @Composable () -> Unit = {
|
||||
Row(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(text = subtitle, style = MaterialTheme.typography.bodyMedium, color = contentColor.copy(alpha = 0.8f))
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = stringResource(R.string.cd_go),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onClick != null) {
|
||||
AppCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onClick
|
||||
) {
|
||||
cardContent()
|
||||
}
|
||||
} else {
|
||||
AppCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
cardContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WeeklyProgressSection(
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val viewModel: ProgressViewModel = hiltViewModel(activity)
|
||||
val weeklyActivityStats by viewModel.weeklyActivityStats.collectAsState()
|
||||
|
||||
LabeledSection(
|
||||
title = stringResource(R.string.label_weekly_progress),
|
||||
modifier = modifier,
|
||||
actionLabel = stringResource(R.string.label_see_history),
|
||||
onActionClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
|
||||
) {
|
||||
AppCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
|
||||
) {
|
||||
if (weeklyActivityStats.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.text_desc_no_activity_data_available),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
WeeklyActivityChartWidget(weeklyStats = weeklyActivityStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomStatsSection(
|
||||
navController: NavHostController
|
||||
) {
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val viewModel: ProgressViewModel = hiltViewModel(activity)
|
||||
val totalWords by viewModel.totalWords.collectAsState()
|
||||
val learnedWords by viewModel.totalWordsCompleted.collectAsState()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Total Words
|
||||
AppCard(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
onClick = { navController.navigate(Screen.Library.route) }
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(text = stringResource(R.string.label_total_words).uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = totalWords.toString(), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// Learned
|
||||
AppCard(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
onClick = { navController.navigate(NavigationRoutes.STATS_LANGUAGE_PROGRESS) }
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(text = stringResource(R.string.label_learned).uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = learnedWords.toString(), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,622 +0,0 @@
|
||||
@file:Suppress("AssignedValueIsNeverRead")
|
||||
|
||||
package eu.gaudian.translator.view.library
|
||||
|
||||
import android.os.Parcelable
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
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.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Folder
|
||||
import androidx.compose.material3.BottomSheetDefaults
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.R.string.text_add_new_word_to_list
|
||||
import eu.gaudian.translator.model.VocabularyStage
|
||||
import eu.gaudian.translator.utils.findActivity
|
||||
import eu.gaudian.translator.view.NavigationRoutes
|
||||
import eu.gaudian.translator.view.composable.AppIcons
|
||||
import eu.gaudian.translator.view.composable.AppSwitch
|
||||
import eu.gaudian.translator.view.composable.BottomSheetMenuItem
|
||||
import eu.gaudian.translator.view.composable.MultipleLanguageDropdown
|
||||
import eu.gaudian.translator.view.dialogs.AddCategoryDialog
|
||||
import eu.gaudian.translator.view.dialogs.CategorySelectionDialog
|
||||
import eu.gaudian.translator.view.dialogs.StageSelectionDialog
|
||||
import eu.gaudian.translator.viewmodel.ExportImportViewModel
|
||||
import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
|
||||
import eu.gaudian.translator.viewmodel.LanguageViewModel
|
||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel
|
||||
import eu.gaudian.translator.viewmodel.VocabularyViewModel.SortOrder
|
||||
import eu.gaudian.translator.viewmodel.toStringResource
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class LibraryFilterState(
|
||||
val searchQuery: String = "",
|
||||
val selectedStage: VocabularyStage? = null,
|
||||
val sortOrder: SortOrder = SortOrder.NEWEST_FIRST,
|
||||
val categoryIds: List<Int> = emptyList(),
|
||||
val dueTodayOnly: Boolean = false,
|
||||
val selectedLanguageIds: List<Int> = emptyList(),
|
||||
val selectedWordClass: String? = null
|
||||
) : Parcelable
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen(
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val lazyListState = rememberLazyListState()
|
||||
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
|
||||
|
||||
|
||||
var filterState by rememberSaveable { mutableStateOf(LibraryFilterState()) }
|
||||
var showFilterSheet by remember { mutableStateOf(false) }
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
var selection by remember { mutableStateOf<Set<Long>>(emptySet()) }
|
||||
val isInSelectionMode = selection.isNotEmpty()
|
||||
|
||||
var showCategoryDialog by remember { mutableStateOf(false) }
|
||||
var showStageDialog by remember { mutableStateOf(false) }
|
||||
var showAddMenu by remember { mutableStateOf(false) }
|
||||
var showAddCategoryDialog by remember { mutableStateOf(false) }
|
||||
|
||||
var isCategoriesView by remember { mutableStateOf(false) }
|
||||
|
||||
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val languagesPresent by vocabularyViewModel.languagesPresent.collectAsStateWithLifecycle(initialValue = emptySet())
|
||||
val categories by vocabularyViewModel.categories.collectAsStateWithLifecycle()
|
||||
val exportState by exportImportViewModel.exportState.collectAsStateWithLifecycle()
|
||||
|
||||
val vocabularyItemsFlow = remember(filterState) {
|
||||
vocabularyViewModel.filterVocabularyItems(
|
||||
languages = filterState.selectedLanguageIds,
|
||||
query = filterState.searchQuery.takeIf { it.isNotBlank() },
|
||||
categoryIds = filterState.categoryIds,
|
||||
stage = filterState.selectedStage,
|
||||
wordClass = filterState.selectedWordClass,
|
||||
dueTodayOnly = filterState.dueTodayOnly,
|
||||
sortOrder = filterState.sortOrder
|
||||
)
|
||||
}
|
||||
|
||||
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 previousIndex by remember { mutableIntStateOf(0) }
|
||||
var previousScrollOffset by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Set navigation context when vocabulary items are loaded
|
||||
LaunchedEffect(vocabularyItems) {
|
||||
if (vocabularyItems.isNotEmpty()) {
|
||||
vocabularyViewModel.setNavigationContext(vocabularyItems, vocabularyItems.first().id)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isCategoriesView, isInSelectionMode) {
|
||||
if (isCategoriesView || isInSelectionMode) {
|
||||
isHeaderVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(lazyListState, isCategoriesView, isInSelectionMode) {
|
||||
if (isCategoriesView || isInSelectionMode) return@LaunchedEffect
|
||||
snapshotFlow { lazyListState.firstVisibleItemIndex to lazyListState.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
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 700.dp)
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isHeaderVisible,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (isInSelectionMode) {
|
||||
SelectionTopBar(
|
||||
selectionCount = selection.size,
|
||||
onCloseClick = { selection = emptySet() },
|
||||
onSelectAllClick = {
|
||||
selection = if (selection.size == vocabularyItems.size) emptySet()
|
||||
else vocabularyItems.map { it.id.toLong() }.toSet()
|
||||
},
|
||||
onDeleteClick = {
|
||||
vocabularyViewModel.deleteVocabularyItemsById(selection.map { it.toInt() })
|
||||
selection = emptySet()
|
||||
},
|
||||
onMoveToCategoryClick = { showCategoryDialog = true },
|
||||
onMoveToStageClick = { showStageDialog = true },
|
||||
onExportClick = {
|
||||
val selectedIds = selection.map { it.toInt() }
|
||||
exportImportViewModel.exportItemList(selectedIds)
|
||||
selection = emptySet()
|
||||
},
|
||||
isRemoveEnabled = false,
|
||||
onRemoveFromCategoryClick = {}
|
||||
)
|
||||
} else {
|
||||
LibraryTopBar(
|
||||
onAddClick = { showAddMenu = true }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
SearchBar(
|
||||
searchQuery = filterState.searchQuery,
|
||||
onQueryChanged = { filterState = filterState.copy(searchQuery = it) },
|
||||
onFilterClick = { showFilterSheet = true }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
SegmentedControl(
|
||||
isCategoriesView = isCategoriesView,
|
||||
onTabSelected = { isCategoriesView = it }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
LibraryViewContainer(
|
||||
isCategoriesView = isCategoriesView,
|
||||
categoriesContent = {
|
||||
CategoriesView(
|
||||
categories = categories,
|
||||
onCategoryClick = { category ->
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
navController.navigate("${NavigationRoutes.CATEGORY_DETAIL}/${category.id}")
|
||||
},
|
||||
onExploreMoreClick = {
|
||||
navController.navigate(NavigationRoutes.CATEGORY_LIST)
|
||||
}
|
||||
)
|
||||
},
|
||||
allCardsContent = {
|
||||
AllCardsView(
|
||||
vocabularyItems = vocabularyItems,
|
||||
allLanguages = allLanguages,
|
||||
selection = selection,
|
||||
stageMapping = stageMapping,
|
||||
listState = lazyListState,
|
||||
onItemClick = { item ->
|
||||
if (isInSelectionMode) {
|
||||
selection = if (selection.contains(item.id.toLong())) {
|
||||
selection - item.id.toLong()
|
||||
} else {
|
||||
selection + item.id.toLong()
|
||||
}
|
||||
} else {
|
||||
vocabularyViewModel.setNavigationContext(vocabularyItems, item.id)
|
||||
@Suppress("HardCodedStringLiteral")
|
||||
navController.navigate("${NavigationRoutes.VOCABULARY_DETAIL}/${item.id}")
|
||||
}
|
||||
},
|
||||
onItemLongClick = { item ->
|
||||
if (!isInSelectionMode) {
|
||||
selection = setOf(item.id.toLong())
|
||||
}
|
||||
},
|
||||
onDeleteClick = { item ->
|
||||
vocabularyViewModel.deleteData(
|
||||
VocabularyViewModel.DeleteType.VOCABULARY_ITEM,
|
||||
item = item
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Floating Action Button for scrolling to top
|
||||
val showFab by remember {
|
||||
derivedStateOf { lazyListState.firstVisibleItemIndex > 5 && !isInSelectionMode }
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = showFab,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = { scope.launch { lazyListState.animateScrollToItem(0) } },
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(50.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Icon(AppIcons.ArrowCircleUp, contentDescription = stringResource(R.string.cd_scroll_to_top))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showFilterSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showFilterSheet = false },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
dragHandle = { BottomSheetDefaults.DragHandle() }
|
||||
) {
|
||||
FilterBottomSheetContent(
|
||||
currentFilterState = filterState,
|
||||
languageViewModel = languageViewModel,
|
||||
languagesPresent = allLanguages.filter { it.nameResId in languagesPresent },
|
||||
onApplyFilters = { newState ->
|
||||
filterState = newState
|
||||
showFilterSheet = false
|
||||
scope.launch { lazyListState.scrollToItem(0) }
|
||||
},
|
||||
onResetClick = {
|
||||
filterState = LibraryFilterState()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showCategoryDialog) {
|
||||
val selectedItems = vocabularyItems.filter { selection.contains(it.id.toLong()) }
|
||||
CategorySelectionDialog(
|
||||
onCategorySelected = {
|
||||
vocabularyViewModel.addVocabularyItemToCategories(
|
||||
selectedItems,
|
||||
it.mapNotNull { category -> category?.id }
|
||||
)
|
||||
showCategoryDialog = false
|
||||
selection = emptySet()
|
||||
},
|
||||
onDismissRequest = { showCategoryDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showAddMenu) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showAddMenu = false },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 32.dp) // Extra padding for system navigation bar
|
||||
) {
|
||||
BottomSheetMenuItem(
|
||||
icon = Icons.Rounded.Add, // Or any custom vector icon you prefer
|
||||
title = stringResource(R.string.label_add_vocabulary),
|
||||
subtitle = stringResource(text_add_new_word_to_list), // Suggest adding this to strings.xml
|
||||
onClick = {
|
||||
showAddMenu = false
|
||||
navController.navigate(NavigationRoutes.NEW_WORD)
|
||||
}
|
||||
)
|
||||
|
||||
BottomSheetMenuItem(
|
||||
icon = Icons.Rounded.Folder,
|
||||
title = stringResource(R.string.label_add_category),
|
||||
subtitle = stringResource(R.string.text_desc_organize_vocabulary_groups), // Suggest adding this to strings.xml
|
||||
onClick = {
|
||||
showAddMenu = false
|
||||
showAddCategoryDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddCategoryDialog) {
|
||||
AddCategoryDialog(onDismiss = { showAddCategoryDialog = false })
|
||||
}
|
||||
|
||||
if (showStageDialog) {
|
||||
val selectedItems = vocabularyItems.filter { selection.contains(it.id.toLong()) }
|
||||
StageSelectionDialog(
|
||||
onStageSelected = { selectedStage ->
|
||||
selectedStage?.let {
|
||||
vocabularyViewModel.addVocabularyItemToStage(selectedItems, it)
|
||||
}
|
||||
showStageDialog = false
|
||||
selection = emptySet()
|
||||
},
|
||||
onDismissRequest = { showStageDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FilterBottomSheetContent(
|
||||
currentFilterState: LibraryFilterState,
|
||||
languageViewModel: LanguageViewModel,
|
||||
languagesPresent: List<eu.gaudian.translator.model.Language>,
|
||||
onApplyFilters: (LibraryFilterState) -> Unit,
|
||||
onResetClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var selectedStage by rememberSaveable { mutableStateOf(currentFilterState.selectedStage) }
|
||||
var dueTodayOnly by rememberSaveable { mutableStateOf(currentFilterState.dueTodayOnly) }
|
||||
var selectedLanguageIds by rememberSaveable { mutableStateOf(currentFilterState.selectedLanguageIds) }
|
||||
var selectedWordClass by rememberSaveable { mutableStateOf(currentFilterState.selectedWordClass) }
|
||||
var sortOrder by rememberSaveable { mutableStateOf(currentFilterState.sortOrder) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = LocalContext.current.findActivity()
|
||||
val languageConfigViewModel: LanguageConfigViewModel = hiltViewModel(activity)
|
||||
val allWordClasses by languageConfigViewModel.allWordClasses.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.label_filter_cards),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
TextButton(onClick = {
|
||||
selectedStage = null
|
||||
dueTodayOnly = false
|
||||
selectedLanguageIds = emptyList()
|
||||
selectedWordClass = null
|
||||
sortOrder = SortOrder.NEWEST_FIRST
|
||||
onResetClick()
|
||||
}) {
|
||||
Text(stringResource(R.string.label_reset))
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
// Sort Order
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.label_sort_by).uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
SortOrder.entries.forEach { order ->
|
||||
FilterChip(
|
||||
selected = sortOrder == order,
|
||||
onClick = { sortOrder = order },
|
||||
label = {
|
||||
Text(stringResource(order.toStringResource()))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Due Today
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.text_due_today_only).uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
AppSwitch(checked = dueTodayOnly, onCheckedChange = { dueTodayOnly = it })
|
||||
}
|
||||
|
||||
// Stages
|
||||
Column {
|
||||
Text(
|
||||
text = "STAGES",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selectedStage == null,
|
||||
onClick = { selectedStage = null },
|
||||
label = { Text(stringResource(R.string.label_all_stages)) }
|
||||
)
|
||||
VocabularyStage.entries.forEach { stage ->
|
||||
FilterChip(
|
||||
selected = selectedStage == stage,
|
||||
onClick = { selectedStage = stage },
|
||||
label = { Text(stage.toString(context)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Languages
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.language).uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
MultipleLanguageDropdown(
|
||||
languageViewModel = languageViewModel,
|
||||
onLanguagesSelected = { languages ->
|
||||
selectedLanguageIds = languages.map { it.nameResId }
|
||||
},
|
||||
alternateLanguages = languagesPresent
|
||||
)
|
||||
}
|
||||
|
||||
// Word Class
|
||||
if (allWordClasses.isNotEmpty()) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.filter_by_word_type).uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selectedWordClass == null,
|
||||
onClick = { selectedWordClass = null },
|
||||
label = { Text(stringResource(R.string.label_all_types)) }
|
||||
)
|
||||
allWordClasses.forEach { wordClass ->
|
||||
FilterChip(
|
||||
selected = selectedWordClass == wordClass,
|
||||
onClick = { selectedWordClass = wordClass },
|
||||
label = { Text(wordClass.replaceFirstChar { it.titlecase() }) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
onApplyFilters(
|
||||
currentFilterState.copy(
|
||||
selectedStage = selectedStage,
|
||||
dueTodayOnly = dueTodayOnly,
|
||||
selectedLanguageIds = selectedLanguageIds,
|
||||
selectedWordClass = selectedWordClass,
|
||||
sortOrder = sortOrder
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
shape = RoundedCornerShape(28.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.label_apply_filters),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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
|
||||
@@ -72,8 +73,12 @@ fun AboutScreen(
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTopAppBar(
|
||||
title = stringResource(R.string.label_about),
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
title = { Text(stringResource(R.string.label_about)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
|
||||
@@ -134,9 +134,13 @@ fun AddModelScreen(navController: NavController, providerKey: String) {
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTopAppBar(
|
||||
title = providerName,
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
hint = HintDefinition.ADD_MODEL_SCAN.hint()
|
||||
title = { Text(providerName) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
|
||||
}
|
||||
},
|
||||
hintContent = HintDefinition.ADD_MODEL_SCAN.hint()
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
|
||||
@@ -115,9 +115,13 @@ fun ApiKeyScreen(navController: NavController) {
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTopAppBar(
|
||||
title = stringResource(R.string.label_ai_configuration),
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
hint = HintDefinition.API_KEY.hint()
|
||||
title = { Text(stringResource(R.string.label_ai_configuration)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
|
||||
}
|
||||
},
|
||||
hintContent = HintDefinition.API_KEY.hint()
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
@@ -133,7 +137,7 @@ fun ApiKeyScreen(navController: NavController) {
|
||||
AppTabLayout(
|
||||
tabs = apiTabs,
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { selectedTab = it },
|
||||
onTabSelected = { selectedTab = it }
|
||||
)
|
||||
|
||||
// Tab Content
|
||||
|
||||
@@ -5,6 +5,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -19,6 +22,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import eu.gaudian.translator.R
|
||||
import eu.gaudian.translator.utils.findActivity
|
||||
import eu.gaudian.translator.view.composable.AppIcons
|
||||
import eu.gaudian.translator.view.composable.AppScaffold
|
||||
import eu.gaudian.translator.view.composable.AppTopAppBar
|
||||
import eu.gaudian.translator.viewmodel.ApiViewModel
|
||||
@@ -51,9 +55,13 @@ fun CustomVocabularyPromptScreen(
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
AppTopAppBar(
|
||||
title = stringResource(R.string.text_vocabulary_prompt),
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
hint = null //TODO: Add hint
|
||||
title = { Text(stringResource(R.string.text_vocabulary_prompt)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
|
||||
}
|
||||
},
|
||||
hintContent = null //TODO: Add hint
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user