Compare commits

...

48 Commits

Author SHA1 Message Date
jonasgaudian
9600ef84ae update DictionaryResultScreen and EtymologyResultScreen top bars, refactor CategoryDetailScreen to use AppCard, and rename chart legend components 2026-02-18 01:10:25 +01:00
jonasgaudian
c81e0886b8 implement DailyReviewScreen and add support for "due today only" exercise configuration 2026-02-18 01:01:39 +01:00
jonasgaudian
9db538bf0a update HomeScreen UI by adjusting DailyReviewCard content color and adding spacers in the top bar 2026-02-18 00:35:37 +01:00
jonasgaudian
4cd014957f Refactor BottomNavBar visibility and add Daily Review feature 2026-02-18 00:32:22 +01:00
jonasgaudian
4b572f8773 Layout issues in the Start Exercise Screen 2026-02-17 23:53:37 +01:00
jonasgaudian
c4fbfdf0ed implement category preselection in StartExerciseScreen and update navigation logic from CategoryDetailScreen 2026-02-17 23:31:28 +01:00
jonasgaudian
ebfd097bf8 refine CategoryDetailScreen UI and add scroll-to-hide header animation 2026-02-17 23:13:39 +01:00
jonasgaudian
f2a6a58c05 update application themes, remove Perplexity API provider, and implement dynamic daily goal check 2026-02-17 22:36:12 +01:00
jonasgaudian
3966901da2 Implement intelligent merging for duplicate vocabulary items 2026-02-17 22:23:12 +01:00
jonasgaudian
3c1e71d805 implement a comprehensive vocabulary export/import system with JSON support and conflict resolution 2026-02-17 22:06:14 +01:00
jonasgaudian
ff77086ab1 localize UI strings in LibraryComponents and expand German and Portuguese translations with motivational phrases and dictionary content options 2026-02-17 18:44:33 +01:00
jonasgaudian
dc4c62ef0b localize UI strings in LibraryComponents and expand German and Portuguese translations with motivational phrases and dictionary content options 2026-02-17 18:44:30 +01:00
jonasgaudian
64dcc5d0d5 localize UI strings in LibraryComponents and expand German and Portuguese translations with motivational phrases and dictionary content options 2026-02-17 17:57:25 +01:00
jonasgaudian
f39375e9df Refactor navigation and cleanup resources across the application 2026-02-17 17:09:25 +01:00
jonasgaudian
db959dab20 Refactor VocabularyListScreen to AllCardsListScreen, introduce NavigationRoutes for centralized route management, and externalize hardcoded strings. 2026-02-17 16:26:30 +01:00
jonasgaudian
02530dafbf Remove the legacy MainVocabularyScreen and its associated components, consolidating vocabulary management into the new LibraryScreen and StatsScreen architectures. 2026-02-17 15:46:56 +01:00
jonasgaudian
85c407481d Refactor hint management by replacing @Composable lambda hint content with a structured Hint type and updating UI components to support it. 2026-02-17 14:57:56 +01:00
jonasgaudian
d14940ed11 implement language direction and shuffling logic in StartExerciseScreen 2026-02-17 13:55:15 +01:00
jonasgaudian
a0b6509367 update LanguageChip icon, enable default shuffling in ExerciseConfig, and refine onClose navigation in VocabularyExerciseHostScreen 2026-02-17 13:30:03 +01:00
jonasgaudian
d249da5f52 add comprehensive logging for exercise setup and state transitions across screens and ViewModels 2026-02-17 13:22:56 +01:00
jonasgaudian
c061e41cc6 Implement the StartExerciseScreen with comprehensive filtering and configuration options. 2026-02-17 13:07:07 +01:00
jonasgaudian
2db2b47c38 add TODO comments for upcoming implementation 2026-02-17 12:26:55 +01:00
jonasgaudian
f779da470f Refactor VocabularyCard into specialized VocabularyDisplayCard and VocabularyExerciseCard components. 2026-02-17 12:12:57 +01:00
jonasgaudian
4855a347b9 Update motivational phrases and deprecate VocabularyCard composable 2026-02-17 11:40:44 +01:00
jonasgaudian
4dd9fe86aa refactor More menu and replace AppDropDownMenu with ModalBottomSheet in `LibraryScreen 2026-02-17 11:27:23 +01:00
jonasgaudian
35080c208b update VocabularyProgressOptionsScreen layout and expand motivational phrases 2026-02-17 11:13:00 +01:00
jonasgaudian
142eb5a31d implement daily goal tracking and integrate dynamic streak data into HomeScreen 2026-02-17 10:57:59 +01:00
jonasgaudian
f50c0c08a5 remove onNavigateBack from ApiKeyScreen and clean up unused imports 2026-02-16 23:44:18 +01:00
jonasgaudian
dc629a54ef update BottomNavigationBar styling, animations, and icons 2026-02-16 23:38:40 +01:00
jonasgaudian
0c54d6f9c5 add motivational phrases and update HomeScreen profile section with a random phrase and app icon 2026-02-16 23:15:49 +01:00
jonasgaudian
059e5d9d3f implement AddCategoryDialog and add a dropdown menu for adding vocabulary or categories in LibraryScreen 2026-02-16 22:49:54 +01:00
jonasgaudian
3e3d6d9cd1 delete NewVocListScreen.kt, update NewWordScreen to display recently added items, and refactor VocabularyCard styling in LibraryComponents.kt. 2026-02-16 22:39:56 +01:00
jonasgaudian
a7c83bb846 implement CSV import for new words and refactor UI components to use AppCard 2026-02-16 22:22:11 +01:00
jonasgaudian
70e416d5e1 implement NewWordScreen and NewWordReviewScreen for AI-assisted and manual vocabulary entry 2026-02-16 21:55:59 +01:00
jonasgaudian
84cad31810 refactor AppTopAppBar navigation icon to use ArrowBackIosNew and update styling properties 2026-02-16 21:21:48 +01:00
jonasgaudian
89ac7cd9eb integrate ProgressViewModel and WeeklyActivityChartWidget into WeeklyProgressSection and implement navigation to vocabulary_heatmap 2026-02-16 21:14:30 +01:00
jonasgaudian
47d7e01f7f implement show/hide header on scroll in LibraryScreen and prevent haptic feedback on re-selecting the current bottom bar item 2026-02-16 17:56:49 +01:00
jonasgaudian
eae37715cd implement statsGraph and refactor StatsScreen with drag-and-drop widget reordering 2026-02-16 17:47:46 +01:00
jonasgaudian
6c669ac310 implement LibraryScreen with advanced filtering and refactor CategoryDetailScreen 2026-02-16 16:11:25 +01:00
jonasgaudian
af78bd316d implement LibraryScreen UI with search, filtering, and segmented view for cards and categories 2026-02-16 15:49:57 +01:00
jonasgaudian
24cebc4b15 implement LibraryScreen UI with search, filtering, and segmented view for cards and categories 2026-02-16 15:19:45 +01:00
jonasgaudian
cd5a53ff5f Redesign top app bar 2026-02-16 15:02:12 +01:00
jonasgaudian
972b2226d0 implement LibraryScreen, migrate Vocabulary to legacy, and refactor StartExerciseScreen UI 2026-02-16 14:28:28 +01:00
jonasgaudian
5ae96d1f5c Add dummy start exercise button and dummy screen 2026-02-16 13:52:02 +01:00
jonasgaudian
ef90df2150 Add dummy stats screen to bottom navigation 2026-02-16 13:20:06 +01:00
jonasgaudian
d2d2f53b59 Change bottom bar navigation and make space for new order 2026-02-16 13:12:15 +01:00
jonasgaudian
7fccda7f77 implement HomeScreen and refactor navigation to include a separate Home and Translation section 2026-02-16 12:48:52 +01:00
jonasgaudian
801b6f6404 cleanup gradle.properties, remove redundant Kotlin Android plugins, and update android.dependency.useConstraints 2026-02-16 11:23:50 +01:00
141 changed files with 9522 additions and 4981 deletions

View File

@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-02-15T19:51:37.987601800Z">
<DropdownSelection timestamp="2026-02-16T10:13:39.492968600Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\jonas\.android\avd\Medium_Phone_28.avd" />
<DeviceId pluginId="PhysicalDevice" identifier="serial=RFCW11ML1WV" />
</handle>
</Target>
</DropdownSelection>

View File

@@ -6,7 +6,6 @@ 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")
@@ -62,11 +61,8 @@ android {
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi"
)
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
}
buildFeatures {
compose = true
viewBinding = false
@@ -130,7 +126,8 @@ dependencies {
implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime
implementation(libs.androidx.room.ktx)
implementation(libs.core.ktx)
ksp(libs.room.compiler) // CHANGED: Use ksp instead of implementation
implementation(libs.androidx.compose.runtime)
ksp(libs.room.compiler)
// Networking
implementation(libs.retrofit)

View File

@@ -6,9 +6,6 @@ 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"

View File

@@ -32,17 +32,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".CorrectActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>

View File

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

View File

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

View File

@@ -1,45 +0,0 @@
@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))
}
}
}
}

View File

@@ -1,5 +1,3 @@
@file:Suppress("unused", "HardCodedStringLiteral")
package eu.gaudian.translator.di
import android.app.Application

View File

@@ -0,0 +1,261 @@
@file:OptIn(ExperimentalTime::class)
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
/**
* # Vocabulary Export/Import Data Models
*
* This file defines the data structures used for exporting and importing vocabulary data.
* The export format is designed to be:
* - **Portable**: Can be stored as JSON files, transmitted via REST APIs, shared via messaging apps
* - **Flexible**: Supports different export scopes (full repository, categories, individual items)
* - **Complete**: Preserves all data including learning stages, categories, and progress statistics
* - **Versioned**: Includes format version for future compatibility
*
* ## Export Scopes
*
* The system supports multiple export scopes via the [VocabularyExportData] sealed class:
* - [FullRepositoryExport]: Complete repository state with all items, categories, and mappings
* - [CategoryExport]: Single category with all its vocabulary items and their states
* - [ItemListExport]: Arbitrary list of vocabulary items with their associated data
* - [SingleItemExport]: Individual vocabulary item with its complete information
*
* ## Data Preservation
*
* Each export includes:
* - Vocabulary items (words, translations, features, creation dates)
* - Learning states (correct/incorrect counts, last answer timestamps)
* - Stage mappings (current learning stage for each item)
* - Categories (both manual tags and automatic filters)
* - Category memberships (which items belong to which categories)
* - Metadata (export date, format version, statistics)
*
* ## Usage Examples
*
* ### Exporting a full repository:
* ```kotlin
* val exportData = repository.exportFullRepository()
* val jsonString = Json.encodeToString(VocabularyExportData.serializer(), exportData)
* // Save to file, send via API, share via WhatsApp, etc.
* ```
*
* ### Exporting a single category:
* ```kotlin
* val exportData = repository.exportCategory(categoryId)
* val jsonString = Json.encodeToString(VocabularyExportData.serializer(), exportData)
* ```
*
* ### Importing data:
* ```kotlin
* val importData = Json.decodeFromString<VocabularyExportData>(jsonString)
* repository.importVocabularyData(importData, conflictStrategy = ConflictStrategy.MERGE)
* ```
*/
/**
* Sealed class representing different types of vocabulary exports.
* Each type contains the appropriate data for its scope.
*/
@Serializable
sealed class VocabularyExportData {
abstract val formatVersion: Int
abstract val exportDate: @Contextual Instant
abstract val metadata: ExportMetadata
}
/**
* Metadata about the export operation.
*
* @property itemCount Total number of vocabulary items included
* @property categoryCount Total number of categories included
* @property exportScope Description of what was exported
* @property appVersion Version of the app that created the export (optional)
*/
@Serializable
data class ExportMetadata(
val itemCount: Int,
val categoryCount: Int,
val exportScope: String,
val appVersion: String? = null
)
/**
* Export format for the complete repository state.
*
* This includes everything: all vocabulary items, all categories, all learning states,
* all mappings. Use this for full backups or transferring complete data between devices.
*
* @property items All vocabulary items
* @property categories All categories (tags and filters)
* @property states Learning states for all items
* @property categoryMappings Mappings between items and categories
* @property stageMappings Current learning stage for each item
*/
@Serializable
@SerialName("FullRepository")
data class FullRepositoryExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val items: List<VocabularyItem>,
val categories: List<VocabularyCategory>,
val states: List<VocabularyItemState>,
val categoryMappings: List<CategoryMappingData>,
val stageMappings: List<StageMappingData>
) : VocabularyExportData()
/**
* Export format for a single category and its vocabulary items.
*
* Use this to share a specific vocabulary list or category with others.
* All items in the category are included with their complete learning data.
*
* @property category The category being exported
* @property items All vocabulary items belonging to this category
* @property states Learning states for the items in this category
* @property stageMappings Learning stages for the items in this category
*/
@Serializable
@SerialName("Category")
data class CategoryExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val category: VocabularyCategory,
val items: List<VocabularyItem>,
val states: List<VocabularyItemState>,
val stageMappings: List<StageMappingData>
) : VocabularyExportData()
/**
* Export format for a custom list of vocabulary items.
*
* Use this to create custom vocabulary sets for sharing or studying specific words.
* Optionally includes category information if the items belong to specific categories.
*
* @property items The vocabulary items being exported
* @property states Learning states for these items
* @property stageMappings Learning stages for these items
* @property associatedCategories Categories that these items belong to (optional)
* @property categoryMappings Mappings between items and categories (optional)
*/
@Serializable
@SerialName("ItemList")
data class ItemListExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val items: List<VocabularyItem>,
val states: List<VocabularyItemState>,
val stageMappings: List<StageMappingData>,
val associatedCategories: List<VocabularyCategory> = emptyList(),
val categoryMappings: List<CategoryMappingData> = emptyList()
) : VocabularyExportData()
/**
* Export format for a single vocabulary item with all its details.
*
* Use this for sharing individual words or phrases with complete context.
*
* @property item The vocabulary item being exported
* @property state Learning state for this item (if available)
* @property stage Current learning stage for this item
* @property categories Categories this item belongs to
*/
@Serializable
@SerialName("SingleItem")
data class SingleItemExport(
override val formatVersion: Int = 1,
@Contextual override val exportDate: Instant,
override val metadata: ExportMetadata,
val item: VocabularyItem,
val state: VocabularyItemState?,
val stage: VocabularyStage,
val categories: List<VocabularyCategory> = emptyList()
) : VocabularyExportData()
/**
* Simplified representation of category mapping for export/import.
*
* Maps a vocabulary item ID to a category ID. During import, IDs may be
* remapped if conflicts exist.
*/
@Serializable
data class CategoryMappingData(
val vocabularyItemId: Int,
val categoryId: Int
)
/**
* Simplified representation of stage mapping for export/import.
*
* Maps a vocabulary item ID to its current learning stage.
*/
@Serializable
data class StageMappingData(
val vocabularyItemId: Int,
val stage: VocabularyStage
)
/**
* Strategy for handling conflicts during import operations.
*
* Conflicts occur when imported data has the same IDs or content as existing data.
* Different strategies handle these conflicts in different ways.
*/
enum class ConflictStrategy {
/**
* Skip importing items that already exist (based on ID or content).
* Preserves all existing data unchanged.
*/
SKIP,
/**
* Replace existing items with imported versions.
* Overwrites local data with imported data when conflicts occur.
*/
REPLACE,
/**
* Merge imported data with existing data.
* - For vocabulary items: Keep existing if duplicate, add new ones
* - For states: Keep the more advanced learning progress
* - For categories: Merge memberships
* - For stages: Keep the higher stage
*/
MERGE,
/**
* Assign new IDs to all imported items to avoid conflicts.
* Creates duplicates rather than merging or replacing.
* Useful when importing the same data multiple times for practice.
*/
RENAME
}
/**
* Result of an import operation with statistics.
*
* @property itemsImported Number of vocabulary items successfully imported
* @property itemsSkipped Number of items skipped due to conflicts
* @property itemsUpdated Number of existing items updated
* @property categoriesImported Number of categories imported
* @property errors List of errors encountered during import (if any)
*/
data class ImportResult(
val itemsImported: Int,
val itemsSkipped: Int,
val itemsUpdated: Int,
val categoriesImported: Int,
val errors: List<String> = emptyList()
) {
val isSuccess: Boolean get() = errors.isEmpty()
val totalProcessed: Int get() = itemsImported + itemsSkipped + itemsUpdated
}

View File

@@ -9,7 +9,6 @@ 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)
@@ -23,7 +22,6 @@ sealed class WidgetType(val id: String, @param:StringRes val titleRes: Int) {
val DEFAULT_ORDER = listOf(
Status,
Streak,
StartButtons,
AllVocabulary,
DueToday,
CategoryProgress ,

View File

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

View File

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

View File

@@ -15,6 +15,14 @@ import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
/**
* Enum representing different download sources.
*/
enum class DownloadSource(val baseUrl: String, val subdirectory: String) {
DICTIONARIES("http://23.88.48.47/", "dictionaries"),
FLASHCARDS("http://23.88.48.47/", "flashcard-collections")
}
/**
* Manages downloading files from the server, verifying checksums, and checking versions.
*/
@@ -190,4 +198,128 @@ class FileDownloadManager(private val context: Context) {
fun getLocalVersion(fileId: String): String {
return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
}
// ===== Flashcard Collections Support =====
private val flashcardRetrofit = Retrofit.Builder()
.baseUrl(DownloadSource.FLASHCARDS.baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val flashcardApiService = flashcardRetrofit.create<FlashcardApiService>()
/**
* Fetches the flashcard collection manifest from the server.
*/
suspend fun fetchFlashcardManifest(): FlashcardManifestResponse? = withContext(Dispatchers.IO) {
try {
val response = flashcardApiService.getFlashcardManifest().execute()
if (response.isSuccessful) {
response.body()
} else {
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
Log.e("FileDownloadManager", "Failed to fetch flashcard manifest: $errorMessage")
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error fetching flashcard manifest", e)
throw e
}
}
/**
* Downloads a flashcard collection file with checksum verification.
*/
suspend fun downloadFlashcardCollection(
flashcardInfo: FlashcardCollectionInfo,
onProgress: (Float) -> Unit = {}
): Boolean = withContext(Dispatchers.IO) {
val asset = flashcardInfo.asset
val source = DownloadSource.FLASHCARDS
val fileUrl = "${source.baseUrl}${source.subdirectory}/${asset.filename}"
val localFile = File(context.filesDir, "${source.subdirectory}/${asset.filename}")
// Create subdirectory if it doesn't exist
localFile.parentFile?.mkdirs()
try {
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
response.code,
response.message
)
Log.e("FileDownloadManager", errorMessage)
throw Exception(errorMessage)
}
val body = response.body
val contentLength = body.contentLength()
if (contentLength <= 0) {
throw Exception("Invalid file size: $contentLength")
}
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead: Long = 0
val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
digest.update(buffer, 0, bytesRead)
totalBytesRead += bytesRead
onProgress(totalBytesRead.toFloat() / contentLength)
}
output.flush()
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
Log.d("FileDownloadManager", "Flashcard download successful for ${asset.filename}")
// Save version
sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) }
true
} else {
Log.e("FileDownloadManager", context.getString(
R.string.text_checksum_mismatch_for_expected_got,
asset.filename,
asset.checksumSha256,
computedChecksum
))
localFile.delete()
throw Exception("Checksum verification failed for ${asset.filename}")
}
}
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error downloading flashcard collection", e)
if (localFile.exists()) {
localFile.delete()
}
throw e
}
}
/**
* Checks if a newer version is available for a flashcard collection.
*/
fun isNewerFlashcardVersionAvailable(flashcardInfo: FlashcardCollectionInfo): Boolean {
val localVersion = sharedPreferences.getString(flashcardInfo.id, "0.0.0") ?: "0.0.0"
return compareVersions(flashcardInfo.version, localVersion) > 0
}
/**
* Gets the local version of a flashcard collection.
*/
fun getFlashcardLocalVersion(collectionId: String): String {
return sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
}
}

View File

@@ -0,0 +1,17 @@
package eu.gaudian.translator.model.communication
import retrofit2.Call
import retrofit2.http.GET
/**
* API service for fetching flashcard collection manifests and downloading files.
*/
interface FlashcardApiService {
/**
* Fetches the flashcard collection manifest from the server.
*/
@GET("flashcard-collections/manifest.json")
fun getFlashcardManifest(): Call<FlashcardManifestResponse>
}

View File

@@ -0,0 +1,39 @@
package eu.gaudian.translator.model.communication
import com.google.gson.annotations.SerializedName
/**
* Data class representing the flashcard collection manifest response from the server.
*/
data class FlashcardManifestResponse(
@SerializedName("collections")
val collections: List<FlashcardCollectionInfo>
)
/**
* Data class representing information about a downloadable flashcard collection.
*/
data class FlashcardCollectionInfo(
@SerializedName("id")
val id: String,
@SerializedName("name")
val name: String,
@SerializedName("description")
val description: String,
@SerializedName("version")
val version: String,
@SerializedName("asset")
val asset: FlashcardAsset
)
/**
* Data class representing an asset file within a flashcard collection.
*/
data class FlashcardAsset(
@SerializedName("filename")
val filename: String,
@SerializedName("size_bytes")
val sizeBytes: Long,
@SerializedName("checksum_sha256")
val checksumSha256: String
)

View File

@@ -56,6 +56,7 @@ object LocalDictionaryMorphologyMapper {
/**
* Overload that uses parsed [DictionaryEntryData] instead of raw JSON.
*/
@Suppress("unused")
fun parseMorphology(
langCode: String,
pos: String?,

View File

@@ -144,19 +144,6 @@ 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 }

View File

@@ -76,6 +76,7 @@ class LanguageRepository(private val context: Context) {
}
}
@Suppress("unused")
suspend fun wipeHistoryAndFavorites() {
clearLanguages(LanguageListType.HISTORY)
clearLanguages(LanguageListType.FAVORITE)

View File

@@ -5,9 +5,19 @@ package eu.gaudian.translator.model.repository
import android.content.Context
import androidx.room.withTransaction
import eu.gaudian.translator.model.CategoryExport
import eu.gaudian.translator.model.CategoryMappingData
import eu.gaudian.translator.model.ConflictStrategy
import eu.gaudian.translator.model.ExportMetadata
import eu.gaudian.translator.model.FullRepositoryExport
import eu.gaudian.translator.model.ImportResult
import eu.gaudian.translator.model.ItemListExport
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.SingleItemExport
import eu.gaudian.translator.model.StageMappingData
import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyExportData
import eu.gaudian.translator.model.VocabularyFilter
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.model.VocabularyItemState
@@ -45,6 +55,7 @@ import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.math.max
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@@ -625,7 +636,7 @@ class VocabularyRepository private constructor(context: Context) {
suspend fun isTargetMetForDate(date: LocalDate): Boolean {
val dailyCorrectCount = getDailyCorrectCount(date)
val target = 10 // TODO: Replace with settingsRepository.dailyGoal.flow.first()
val target = settingsRepository.dailyGoal.flow.first()
return dailyCorrectCount >= target
}
@@ -796,6 +807,594 @@ class VocabularyRepository private constructor(context: Context) {
}
Log.d(TAG, "--- END REPOSITORY STATE ---")
}
// ==================== EXPORT/IMPORT FUNCTIONS ====================
/**
* Exports the complete repository state including all vocabulary items, categories,
* learning states, and mappings.
*
* This creates a full backup that can be used to restore the complete state on another
* device or after data loss.
*
* @return [FullRepositoryExport] containing all repository data
*
* @see importVocabularyData for importing the exported data
* @see exportToJson for converting to JSON string
*/
suspend fun exportFullRepository(): FullRepositoryExport {
Log.i(TAG, "exportFullRepository: Creating full repository export")
val items = getAllVocabularyItems()
val categories = getAllCategories()
val states = getAllVocabularyItemStates()
val categoryMappings = getCategoryMappings()
val stageMapping = loadStageMapping().first()
return FullRepositoryExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = items.size,
categoryCount = categories.size,
exportScope = "Full Repository"
),
items = items,
categories = categories,
states = states,
categoryMappings = categoryMappings.map { CategoryMappingData(it.vocabularyItemId, it.categoryId) },
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) }
).also {
Log.i(TAG, "exportFullRepository: Export complete. Items: ${items.size}, Categories: ${categories.size}")
}
}
/**
* Exports a single category with all its vocabulary items and associated data.
*
* @param categoryId The ID of the category to export
* @return [CategoryExport] containing the category and its items, or null if category not found
*
* @see importVocabularyData for importing the exported data
*/
suspend fun exportCategory(categoryId: Int): CategoryExport? {
Log.i(TAG, "exportCategory: Exporting category id=$categoryId")
val category = getCategoryById(categoryId) ?: run {
Log.w(TAG, "exportCategory: Category id=$categoryId not found")
return null
}
val items = getVocabularyItemsByCategory(categoryId)
val itemIds = items.map { it.id }
val states = getAllVocabularyItemStates().filter { it.vocabularyItemId in itemIds }
val stageMapping = loadStageMapping().first().filterKeys { it in itemIds }
return CategoryExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = items.size,
categoryCount = 1,
exportScope = "Category: ${category.name}"
),
category = category,
items = items,
states = states,
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) }
).also {
Log.i(TAG, "exportCategory: Export complete. Category: ${category.name}, Items: ${items.size}")
}
}
/**
* Exports a list of vocabulary items by their IDs.
*
* @param itemIds List of vocabulary item IDs to export
* @param includeCategories Whether to include category information for these items
* @return [ItemListExport] containing the items and their data
*
* @see importVocabularyData for importing the exported data
*/
suspend fun exportItemList(itemIds: List<Int>, includeCategories: Boolean = true): ItemListExport {
Log.i(TAG, "exportItemList: Exporting ${itemIds.size} items")
val items = itemDao.getItemsByIds(itemIds)
val states = getAllVocabularyItemStates().filter { it.vocabularyItemId in itemIds }
val stageMapping = loadStageMapping().first().filterKeys { it in itemIds }
val associatedCategories = if (includeCategories) {
val mappings = getCategoryMappings().filter { it.vocabularyItemId in itemIds }
val categoryIds = mappings.map { it.categoryId }.distinct()
getAllCategories().filter { it.id in categoryIds }
} else {
emptyList()
}
val categoryMappings = if (includeCategories) {
getCategoryMappings().filter { it.vocabularyItemId in itemIds }
.map { CategoryMappingData(it.vocabularyItemId, it.categoryId) }
} else {
emptyList()
}
return ItemListExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = items.size,
categoryCount = associatedCategories.size,
exportScope = "Item List (${items.size} items)"
),
items = items,
states = states,
stageMappings = stageMapping.map { StageMappingData(it.key, it.value) },
associatedCategories = associatedCategories,
categoryMappings = categoryMappings
).also {
Log.i(TAG, "exportItemList: Export complete. Items: ${items.size}")
}
}
/**
* Exports a single vocabulary item with all its details.
*
* @param itemId The ID of the vocabulary item to export
* @return [SingleItemExport] containing the item and its data, or null if item not found
*
* @see importVocabularyData for importing the exported data
*/
suspend fun exportSingleItem(itemId: Int): SingleItemExport? {
Log.i(TAG, "exportSingleItem: Exporting item id=$itemId")
val item = getVocabularyItemById(itemId) ?: run {
Log.w(TAG, "exportSingleItem: Item id=$itemId not found")
return null
}
val state = getVocabularyItemStateById(itemId)
val stage = loadStageMapping().first()[itemId] ?: VocabularyStage.NEW
val mappings = getCategoryMappings().filter { it.vocabularyItemId == itemId }
val categoryIds = mappings.map { it.categoryId }
val categories = getAllCategories().filter { it.id in categoryIds }
return SingleItemExport(
formatVersion = 1,
exportDate = Clock.System.now(),
metadata = ExportMetadata(
itemCount = 1,
categoryCount = categories.size,
exportScope = "Single Item: ${item.wordFirst}"
),
item = item,
state = state,
stage = stage,
categories = categories
).also {
Log.i(TAG, "exportSingleItem: Export complete. Item: ${item.wordFirst}")
}
}
/**
* Converts any [VocabularyExportData] to a JSON string.
*
* The resulting JSON can be:
* - Saved to a file
* - Sent via REST API
* - Shared through messaging apps (WhatsApp, Telegram, etc.)
* - Stored in cloud storage (Google Drive, Dropbox, etc.)
* - Transmitted via any text-based protocol
*
* @param exportData The export data to convert
* @param prettyPrint Whether to format the JSON for human readability (default: false)
* @return JSON string representation of the export data
*
* @see importFromJson for parsing JSON back into export data
*/
fun exportToJson(exportData: VocabularyExportData, prettyPrint: Boolean = false): String {
val json = Json {
ignoreUnknownKeys = true
this.prettyPrint = prettyPrint
}
return json.encodeToString(VocabularyExportData.serializer(), exportData)
}
/**
* Parses a JSON string into [VocabularyExportData].
*
* @param jsonString The JSON string to parse
* @return Parsed export data
* @throws kotlinx.serialization.SerializationException if JSON is invalid
*
* @see exportToJson for converting export data to JSON
* @see importVocabularyData for importing the parsed data
*/
fun importFromJson(jsonString: String): VocabularyExportData {
val json = Json { ignoreUnknownKeys = true }
return json.decodeFromString(VocabularyExportData.serializer(), jsonString)
}
/**
* Imports vocabulary data from an export.
*
* This function handles different export types (full repository, category, item list, single item)
* and applies the specified conflict resolution strategy.
*
* @param exportData The export data to import
* @param strategy The conflict resolution strategy to use (default: MERGE)
* @return [ImportResult] with statistics about the import operation
*
* @see ConflictStrategy for available strategies
* @see exportFullRepository, exportCategory, exportItemList, exportSingleItem for creating exports
*/
suspend fun importVocabularyData(
exportData: VocabularyExportData,
strategy: ConflictStrategy = ConflictStrategy.MERGE
): ImportResult {
Log.i(TAG, "importVocabularyData: Starting import with strategy=$strategy, scope=${exportData.metadata.exportScope}")
return when (exportData) {
is FullRepositoryExport -> importFullRepository(exportData, strategy)
is CategoryExport -> importCategory(exportData, strategy)
is ItemListExport -> importItemList(exportData, strategy)
is SingleItemExport -> importSingleItem(exportData, strategy)
}.also { result ->
Log.i(TAG, "importVocabularyData: Import complete. Imported: ${result.itemsImported}, " +
"Skipped: ${result.itemsSkipped}, Updated: ${result.itemsUpdated}, " +
"Categories: ${result.categoriesImported}, Errors: ${result.errors.size}")
}
}
/**
* Internal function to import a full repository export.
*/
private suspend fun importFullRepository(
export: FullRepositoryExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import categories first (they're referenced by items)
val categoryIdMap = importCategories(export.categories, strategy)
categoriesImported = categoryIdMap.size
// Import items
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Import category mappings with remapped IDs
importCategoryMappings(export.categoryMappings, itemIdMap, categoryIdMap)
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importFullRepository: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Internal function to import a category export.
*/
private suspend fun importCategory(
export: CategoryExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import the category
val categoryIdMap = importCategories(listOf(export.category), strategy)
categoriesImported = categoryIdMap.size
val newCategoryId = categoryIdMap[export.category.id] ?: export.category.id
// Import items
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Create category mappings for all imported items
val mappings = itemIdMap.filter { it.value >= 0 }.map { (oldId, newId) ->
CategoryMappingData(newId, newCategoryId)
}
importCategoryMappings(mappings, mapOf(), mapOf())
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importCategory: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Internal function to import an item list export.
*/
private suspend fun importItemList(
export: ItemListExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import associated categories if present
val categoryIdMap = if (export.associatedCategories.isNotEmpty()) {
importCategories(export.associatedCategories, strategy).also {
categoriesImported = it.size
}
} else {
emptyMap()
}
// Import items
val itemIdMap = importItems(export.items, export.states, export.stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Import category mappings if present
if (export.categoryMappings.isNotEmpty()) {
importCategoryMappings(export.categoryMappings, itemIdMap, categoryIdMap)
}
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importItemList: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Internal function to import a single item export.
*/
private suspend fun importSingleItem(
export: SingleItemExport,
strategy: ConflictStrategy
): ImportResult {
val errors = mutableListOf<String>()
var itemsImported = 0
var itemsSkipped = 0
var itemsUpdated = 0
var categoriesImported = 0
try {
db.withTransaction {
// Import categories if present
val categoryIdMap = if (export.categories.isNotEmpty()) {
importCategories(export.categories, strategy).also {
categoriesImported = it.size
}
} else {
emptyMap()
}
// Import the single item
val states = if (export.state != null) listOf(export.state) else emptyList()
val stageMappings = listOf(StageMappingData(export.item.id, export.stage))
val itemIdMap = importItems(listOf(export.item), states, stageMappings, strategy)
itemsImported = itemIdMap.count { it.value >= 0 }
itemsSkipped = itemIdMap.count { it.value == -1 }
// Create category mappings
val newItemId = itemIdMap[export.item.id] ?: export.item.id
if (newItemId >= 0) {
val mappings = export.categories.map { category ->
val newCategoryId = categoryIdMap[category.id] ?: category.id
CategoryMappingData(newItemId, newCategoryId)
}
importCategoryMappings(mappings, mapOf(), mapOf())
}
}
requestUpdateMappings()
} catch (e: Exception) {
Log.e(TAG, "importSingleItem: Error during import", e)
errors.add("Import failed: ${e.message}")
}
return ImportResult(itemsImported, itemsSkipped, itemsUpdated, categoriesImported, errors)
}
/**
* Helper function to import categories with conflict resolution.
* Returns a map of old category IDs to new category IDs.
*/
private suspend fun importCategories(
categories: List<VocabularyCategory>,
strategy: ConflictStrategy
): Map<Int, Int> {
val idMap = mutableMapOf<Int, Int>()
val existingCategories = getAllCategories()
for (category in categories) {
val existing = existingCategories.find { it.name == category.name && it::class == category::class }
when {
existing != null && strategy == ConflictStrategy.SKIP -> {
// Skip, but map old ID to existing ID
idMap[category.id] = existing.id
Log.d(TAG, "importCategories: Skipping existing category '${category.name}'")
}
existing != null && strategy == ConflictStrategy.REPLACE -> {
// Replace existing category
val updated = when (category) {
is TagCategory -> category.copy(id = existing.id)
is VocabularyFilter -> category.copy(id = existing.id)
}
saveCategory(updated)
idMap[category.id] = existing.id
Log.d(TAG, "importCategories: Replaced category '${category.name}'")
}
existing != null && strategy == ConflictStrategy.MERGE -> {
// Keep existing, map old ID to existing ID
idMap[category.id] = existing.id
Log.d(TAG, "importCategories: Merged with existing category '${category.name}'")
}
strategy == ConflictStrategy.RENAME || existing == null -> {
// Assign new ID
val maxId = categoryDao.getAllCategories().maxOfOrNull { it.id } ?: 0
val newId = maxId + 1
val newCategory = when (category) {
is TagCategory -> category.copy(id = newId)
is VocabularyFilter -> category.copy(id = newId)
}
saveCategory(newCategory)
idMap[category.id] = newId
Log.d(TAG, "importCategories: Created new category '${category.name}' with id=$newId")
}
}
}
return idMap
}
/**
* Helper function to import vocabulary items with their states and stage mappings.
* Returns a map of old item IDs to new item IDs (-1 means skipped).
*/
private suspend fun importItems(
items: List<VocabularyItem>,
states: List<VocabularyItemState>,
stageMappings: List<StageMappingData>,
strategy: ConflictStrategy
): Map<Int, Int> {
val idMap = mutableMapOf<Int, Int>()
val existingItems = getAllVocabularyItems()
val stateMap = states.associateBy { it.vocabularyItemId }
val stageMap = stageMappings.associate { it.vocabularyItemId to it.stage }
for (item in items) {
val duplicate = existingItems.find { it.isDuplicate(item) }
when {
duplicate != null && strategy == ConflictStrategy.SKIP -> {
// Skip this item
idMap[item.id] = -1
Log.d(TAG, "importItems: Skipping duplicate item '${item.wordFirst}'")
}
duplicate != null && strategy == ConflictStrategy.REPLACE -> {
// Replace with imported version
val updated = item.copy(id = duplicate.id)
itemDao.upsertItem(updated)
idMap[item.id] = duplicate.id
// Update state and stage
stateMap[item.id]?.let { state ->
stateDao.upsertState(state.copy(vocabularyItemId = duplicate.id))
}
stageMap[item.id]?.let { stage ->
mappingDao.upsertStageMapping(StageMappingEntity(duplicate.id, stage))
}
Log.d(TAG, "importItems: Replaced item '${item.wordFirst}'")
}
duplicate != null && strategy == ConflictStrategy.MERGE -> {
// Merge: keep item, merge states (keep better progress)
idMap[item.id] = duplicate.id
stateMap[item.id]?.let { importedState ->
val existingState = getVocabularyItemStateById(duplicate.id)
val mergedState = mergeStates(existingState, importedState, duplicate.id)
stateDao.upsertState(mergedState)
}
stageMap[item.id]?.let { importedStage ->
val existingStage = loadStageMapping().first()[duplicate.id] ?: VocabularyStage.NEW
val mergedStage = maxOf(importedStage, existingStage)
mappingDao.upsertStageMapping(StageMappingEntity(duplicate.id, mergedStage))
}
Log.d(TAG, "importItems: Merged item '${item.wordFirst}'")
}
strategy == ConflictStrategy.RENAME || duplicate == null -> {
// Assign new ID
val maxId = itemDao.getMaxItemId() ?: 0
val newId = maxId + idMap.size + 1
val newItem = item.copy(id = newId)
itemDao.upsertItem(newItem)
idMap[item.id] = newId
// Import state and stage with new ID
stateMap[item.id]?.let { state ->
stateDao.upsertState(state.copy(vocabularyItemId = newId))
}
stageMap[item.id]?.let { stage ->
mappingDao.upsertStageMapping(StageMappingEntity(newId, stage))
}
Log.d(TAG, "importItems: Created new item '${item.wordFirst}' with id=$newId")
}
}
}
return idMap
}
/**
* Helper function to import category mappings with remapped IDs.
*/
private suspend fun importCategoryMappings(
mappings: List<CategoryMappingData>,
itemIdMap: Map<Int, Int>,
categoryIdMap: Map<Int, Int>
) {
for (mapping in mappings) {
val newItemId = itemIdMap[mapping.vocabularyItemId] ?: mapping.vocabularyItemId
val newCategoryId = categoryIdMap[mapping.categoryId] ?: mapping.categoryId
// Skip if item was skipped during import
if (newItemId < 0) continue
mappingDao.addCategoryMapping(CategoryMappingEntity(newItemId, newCategoryId))
}
}
/**
* Helper function to merge two vocabulary item states.
* Keeps the more advanced learning progress.
*/
private fun mergeStates(
existing: VocabularyItemState?,
imported: VocabularyItemState,
itemId: Int
): VocabularyItemState {
if (existing == null) return imported.copy(vocabularyItemId = itemId)
return VocabularyItemState(
vocabularyItemId = itemId,
lastCorrectAnswer = maxOfNullable(existing.lastCorrectAnswer, imported.lastCorrectAnswer),
lastIncorrectAnswer = maxOfNullable(existing.lastIncorrectAnswer, imported.lastIncorrectAnswer),
correctAnswerCount = max(existing.correctAnswerCount, imported.correctAnswerCount),
incorrectAnswerCount = max(existing.incorrectAnswerCount, imported.incorrectAnswerCount)
)
}
/**
* Helper function to get the maximum of two nullable Instants.
*/
private fun maxOfNullable(a: Instant?, b: Instant?): Instant? {
return when {
a == null -> b
b == null -> a
else -> if (a > b) a else b
}
}
}
@Serializable

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -129,25 +129,6 @@ 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 }

View File

@@ -10,6 +10,7 @@ import timber.log.Timber
* "HardcodedText" lint warning for log messages, which are for
* development purposes only.
*/
@Suppress("unused")
object Log {
@SuppressLint("HardcodedText")

View File

@@ -55,6 +55,12 @@ 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

View File

@@ -75,7 +75,6 @@ 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))

View File

@@ -117,7 +117,6 @@ 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"

View File

@@ -1,11 +0,0 @@
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
}

View File

@@ -5,11 +5,6 @@ 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(

View File

@@ -253,7 +253,23 @@ fun TranslatorApp(
val currentDestination = navBackStackEntry?.destination
val selectedScreen = Screen.fromDestination(currentDestination)
@Suppress("HardCodedStringLiteral") val isBottomBarHidden = currentDestination?.route?.startsWith("vocabulary_exercise") == true
@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"
) || currentRoute?.startsWith("start_exercise") == true
|| currentRoute?.startsWith("vocabulary_exercise") == true
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)
BottomNavigationBar(
@@ -262,6 +278,12 @@ 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) {
@@ -274,6 +296,11 @@ 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) {
@@ -285,6 +312,10 @@ fun TranslatorApp(
restoreState = false
}
}
},
onPlayClicked = {
@Suppress("HardCodedStringLiteral")
navController.navigate("start_exercise")
}
)
},

View File

@@ -26,27 +26,51 @@ import eu.gaudian.translator.view.dictionary.EtymologyResultScreen
import eu.gaudian.translator.view.dictionary.MainDictionaryScreen
import eu.gaudian.translator.view.exercises.ExerciseSessionScreen
import eu.gaudian.translator.view.exercises.MainExerciseScreen
import eu.gaudian.translator.view.exercises.StartExerciseScreen
import eu.gaudian.translator.view.exercises.YouTubeBrowserScreen
import eu.gaudian.translator.view.exercises.YouTubeExerciseScreen
import eu.gaudian.translator.view.home.DailyReviewScreen
import eu.gaudian.translator.view.home.HomeScreen
import eu.gaudian.translator.view.library.LibraryScreen
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.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.NewWordReviewScreen
import eu.gaudian.translator.view.vocabulary.NewWordScreen
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"
}
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
@Composable
fun AppNavHost(
navController: NavHostController,
@@ -57,17 +81,26 @@ fun AppNavHost(
// 1. Define your Main Tab "Leaf" Routes (the actual start destinations of your graphs)
val mainTabRoutes = setOf(
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
Screen.Home.route,
Screen.Library.route,
Screen.Stats.route,
)
// 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 {
return mainTabRoutes.contains(initial) && mainTabRoutes.contains(target)
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
}
NavHost(
@@ -121,77 +154,77 @@ fun AppNavHost(
}
) {
composable(Screen.Home.route) {
TranslationScreen(navController = navController)
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(
route = "${NavigationRoutes.START_EXERCISE}?categoryId={categoryId}",
arguments = listOf(
navArgument("categoryId") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val categoryIdString = backStackEntry.arguments?.getString("categoryId")
val categoryId = categoryIdString?.toIntOrNull()
StartExerciseScreen(
navController = navController,
preselectedCategoryId = categoryId,
dueTodayOnly = false
)
}
composable(NavigationRoutes.START_EXERCISE_DAILY) {
StartExerciseScreen(
navController = navController,
preselectedCategoryId = null,
dueTodayOnly = true
)
}
// Define all other navigation graphs at the same top level.
homeGraph(navController)
libraryGraph(navController)
statsGraph(navController)
translationGraph(navController)
dictionaryGraph(navController)
vocabularyGraph(navController)
exerciseGraph(navController)
settingsGraph(navController)
}
}
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
fun NavGraphBuilder.translationGraph(navController: NavHostController) {
fun NavGraphBuilder.homeGraph(navController: NavHostController) {
navigation(
startDestination = "main_translation",
startDestination = "main_home",
route = Screen.Home.route
) {
composable("main_translation") {
TranslationScreen(navController = navController)
}
composable("custom_translation_prompt") {
TranslationSettingsScreen(navController = navController)
composable("main_home") {
HomeScreen(navController = navController)
}
}
}
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
navigation(
startDestination = "main_dictionary",
route = Screen.Dictionary.route
startDestination = "main_library",
route = Screen.Library.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
)
}
}
}
fun NavGraphBuilder.vocabularyGraph(
navController: NavHostController,
) {
navigation(
startDestination = "main_vocabulary",
route = Screen.Vocabulary.route
) {
composable("main_vocabulary") {
MainVocabularyScreen(navController = navController)
composable("main_library") {
LibraryScreen(navController = navController)
}
composable("vocabulary_sorting") {
VocabularySortingScreen(
@@ -224,7 +257,7 @@ fun NavGraphBuilder.vocabularyGraph(
composable("vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry ->
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
VocabularyListScreen(
AllCardsListScreen(
navController = navController,
showDueTodayOnly = showDueTodayOnly,
categoryId = categoryId,
@@ -241,7 +274,7 @@ fun NavGraphBuilder.vocabularyGraph(
)
}
composable("vocabulary_heatmap") {
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
VocabularyHeatmapScreen(
navController = navController,
)
@@ -253,7 +286,7 @@ fun NavGraphBuilder.vocabularyGraph(
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
}
VocabularyListScreen(
AllCardsListScreen(
navController = navController,
showDueTodayOnly = showDueTodayOnly,
stage = stage,
@@ -376,6 +409,159 @@ fun NavGraphBuilder.vocabularyGraph(
}
}
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) {
LanguageProgressScreen(
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,

View File

@@ -115,7 +115,7 @@ fun AppAlertDialog(
title: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null,
properties: DialogProperties = DialogProperties(),
hintContent: @Composable (() -> Unit)? = null,
hintContent:Hint? = null,
) {
val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) }
@@ -142,12 +142,14 @@ fun AppAlertDialog(
)
if (showBottomSheet) {
hintContent?.let {
HintBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
content = hintContent
content = it
)
}
}
}
/**
@@ -212,7 +214,7 @@ private fun DialogHeader(
@Composable
private fun DialogTitleWithHint(
title: @Composable () -> Unit,
hintContent: @Composable (() -> Unit)?,
hintContent: Hint? = null,
onHintClick: () -> Unit
) {
val showHints = LocalShowHints.current
@@ -424,7 +426,6 @@ 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.") }
)
}
@@ -492,7 +493,6 @@ 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.") }
)
}

View File

@@ -25,6 +25,7 @@ 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
@@ -550,7 +551,55 @@ 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,

View File

@@ -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.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowBackIos
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,6 +81,7 @@ 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
@@ -135,7 +136,7 @@ object AppIcons {
val AI = Default.AutoAwesome
val Appearance = Icons.Filled.ColorLens
val ApiKey = Default.Key
val ArrowBack = Icons.AutoMirrored.Filled.ArrowBack
val ArrowBack = Icons.AutoMirrored.Filled.ArrowBackIos
val ArrowCircleUp = Icons.Filled.ArrowCircleUp
val ArrowDropDown = Icons.Filled.KeyboardArrowDown
val ArrowDropUp = Icons.Filled.KeyboardArrowUp
@@ -202,6 +203,7 @@ 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

View File

@@ -2,26 +2,15 @@ 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(
@@ -58,37 +47,3 @@ 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
)
)
}

View File

@@ -1,5 +1,3 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.composable
import android.annotation.SuppressLint
@@ -20,9 +18,13 @@ 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
@@ -36,6 +38,7 @@ 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
/**
@@ -46,28 +49,50 @@ interface TabItem {
val title: String
val icon: ImageVector
}
/**
* 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")
@SuppressLint("UnusedBoxWithConstraintsScope", "LocalContextResourcesRead", "DiscouragedApi",
"SuspiciousIndentation"
)
@Composable
fun <T : TabItem> AppTabLayout(
tabs: List<T>,
selectedTab: T,
onTabSelected: (T) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onNavigateBack: (() -> Unit)? = null
) {
val selectedIndex = tabs.indexOf(selectedTab)
BoxWithConstraints(
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 8.dp)
.padding(vertical = 8.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
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),
@@ -139,8 +164,10 @@ fun <T : TabItem> AppTabLayout(
}
}
}
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Composable
fun ModernTabLayoutPreview() {

View File

@@ -1,20 +1,25 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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
@@ -25,8 +30,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.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
@@ -36,8 +43,8 @@ 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 = {},
@@ -47,25 +54,26 @@ fun AppTopAppBar(
val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) }
TopAppBar(
// Changed to CenterAlignedTopAppBar to perfectly match the design requirements
CenterAlignedTopAppBar(
modifier = modifier.height(56.dp),
windowInsets = WindowInsets(0.dp),
colors = colors,
title = {
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
val showHints = LocalShowHints.current
if (showHints && hintContent != null) {
// Simplified row: keeps the title and hint icon neatly centered together
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Box(modifier = Modifier.weight(1f)) {
title()
}
Box {
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,
@@ -74,62 +82,55 @@ fun AppTopAppBar(
)
}
}
}
} else {
title()
}
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
},
navigationIcon = {
if (onNavigateBack != null) {
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.Center
IconButton(
onClick = onNavigateBack,
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
IconButton(onClick = onNavigateBack) {
Icon(
AppIcons.ArrowBack,
contentDescription = stringResource(R.string.cd_navigate_back),
tint = LocalContentColor.current
imageVector = Icons.Default.ArrowBackIosNew,
contentDescription = "Back",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
} else if (navigationIcon != null) {
Box(modifier = Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) {
navigationIcon()
}
} else {
// No navigation icon
}
},
actions = actions
)
if (showBottomSheet) {
hintContent?.let {
HintBottomSheet(
onDismissRequest = {
@Suppress("AssignedValueIsNeverRead")
showBottomSheet = false
},
sheetState = sheetState,
content = {
hintContent?.Render()
}
content = it
)
}
}
}
/**
* 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(
@@ -139,7 +140,6 @@ 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,20 +148,21 @@ fun <T : TabItem> TabbedTopAppBar(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// Back navigation icon, similar to its usage in AppTopAppBar
// Updated back icon here as well to keep your entire app consistent!
IconButton(
onClick = onNavigateBack,
modifier = Modifier.padding(horizontal = 4.dp)
modifier = Modifier
.padding(start = 8.dp, end = 4.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = stringResource(R.string.cd_navigate_back),
tint = MaterialTheme.colorScheme.onSurface
tint = MaterialTheme.colorScheme.primary
)
}
// The AppTabLayout, taking up the remaining space.
// Its appearance matches the provided image.
AppTabLayout(
tabs = tabs,
selectedTab = selectedTab,
@@ -172,11 +173,12 @@ 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(
@@ -202,7 +204,7 @@ fun TabbedTopAppBarPreview() {
@Composable
fun AppTopAppBarPreview() {
AppTopAppBar(
title = { Text("Preview Title") }
title = "Previwe Title"
)
}
@@ -210,7 +212,7 @@ fun AppTopAppBarPreview() {
@Composable
fun AppTopAppBarWithNavigationIconPreview() {
AppTopAppBar(
title = { Text(stringResource(R.string.title_title_preview_title)) },
title = "Preview Title",
onNavigateBack = {}
)
}
@@ -219,13 +221,13 @@ fun AppTopAppBarWithNavigationIconPreview() {
@Composable
fun AppTopAppBarWithActionsPreview() {
AppTopAppBar(
title = { Text(stringResource(R.string.title_title_preview_title)) },
title = "Preview Title",
actions = {
IconButton(onClick = {}) {
Icon(AppIcons.Settings, contentDescription = stringResource(R.string.title_settings))
}
IconButton(onClick = {}) {
AppIcons.ArrowBack
Icon(AppIcons.ArrowBack, contentDescription = null)
}
}
)

View File

@@ -1,4 +1,4 @@
@file:Suppress("HardCodedStringLiteral")
@file:Suppress("HardCodedStringLiteral", "AssignedValueIsNeverRead")
package eu.gaudian.translator.view.composable
@@ -11,23 +11,44 @@ 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.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
import androidx.compose.ui.platform.LocalDensity
@@ -41,6 +62,7 @@ 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,
@@ -48,34 +70,42 @@ sealed class Screen(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector
) {
object Home : Screen("home", R.string.label_translation, AppIcons.TranslateFilled, AppIcons.TranslateOutlined)
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 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> {
val screens = mutableListOf(Home, Dictionary, Vocabulary, Settings)
if (showExperimental) {
screens.add(2, Exercises)
return listOf(Home, Library, Stats)
}
return screens
fun getMoreMenuItems(showExperimental: Boolean = false): List<Screen> {
val items = mutableListOf<Screen>()
items.add(Translation)
items.add(Dictionary)
items.add(Settings)
if (showExperimental) {
items.add(Exercises)
}
return items
}
@Composable
fun fromDestination(destination: NavDestination?): Screen {
val showExperimental = LocalShowExperimentalFeatures.current
return getAllScreens(showExperimental).find { screen ->
val allScreens = getAllScreens(showExperimental) + getMoreMenuItems(showExperimental) + More
return allScreens.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(
@@ -84,40 +114,87 @@ 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 = androidx.compose.animation.core.tween(durationMillis = 220),
animationSpec = spring(stiffness = Spring.StiffnessHigh),
initialOffsetY = { it }
),
exit = slideOutVertically(
animationSpec = androidx.compose.animation.core.tween(durationMillis = 220),
animationSpec = spring(stiffness = Spring.StiffnessHigh),
targetOffsetY = { it }
)
) {
val baseHeight = if (showLabels) 80.dp else 56.dp
val density = LocalDensity.current
val navBarDp = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
val height = baseHeight + navBarDp
NavigationBar(
modifier = modifier.height(height),
containerColor = MaterialTheme.colorScheme.surface, // Cleaner look than surfaceVariant
tonalElevation = 8.dp, // Slight elevation for depth
// Outer Box height is purely determined by the NavigationBar now
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
screens.forEach { screen ->
val isSelected = screen == selectedItem
// 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)
// 1. Spring Animation for the Icon Scale
val scale by animateFloatAsState(
targetValue = if (isSelected) 1.2f else 1.0f, // Subtle pop effect
targetValue = if (isSelected) 1.2f else 1.0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
@@ -129,8 +206,8 @@ fun BottomNavigationBar(
selected = isSelected,
onClick = {
if (!isSelected) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) // 2. Tactile feedback
onItemSelected(screen)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (screen == Screen.More) showMoreMenu = true else onItemSelected(screen)
}
},
label = if (showLabels) {
@@ -145,12 +222,11 @@ fun BottomNavigationBar(
}
} else null,
icon = {
// 3. Crossfade between Outlined and Filled icons
Crossfade(targetState = isSelected, label = "iconFade") { selected ->
Icon(
imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon,
contentDescription = title,
modifier = Modifier.scale(scale) // Apply the spring scale
modifier = Modifier.scale(scale)
)
}
},
@@ -165,8 +241,147 @@ fun BottomNavigationBar(
}
}
}
// 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
)
)
// Actual clickable button
Box(
modifier = Modifier
.size(playButtonSize)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayClicked()
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
}
}
}
// 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() {

View File

@@ -28,6 +28,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
@@ -36,6 +37,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -56,6 +58,9 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.ui.theme.semanticColors
import eu.gaudian.translator.view.composable.ComponentDefaults.DefaultElevation
import eu.gaudian.translator.view.hints.Hint
import eu.gaudian.translator.view.hints.HintBottomSheet
import eu.gaudian.translator.view.hints.LocalShowHints
object ComponentDefaults {
@@ -97,13 +102,16 @@ object ComponentDefaults {
fun AppCard(
modifier: Modifier = Modifier,
title: String? = null,
icon: ImageVector? = null, // New optional icon parameter
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,
@@ -113,6 +121,21 @@ fun AppCard(
// 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
@@ -125,7 +148,7 @@ fun AppCard(
// Animate height changes when expanding/collapsing
.animateContentSize(),
shape = ComponentDefaults.CardShape,
color = MaterialTheme.colorScheme.surfaceContainer
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column {
// --- Header Row ---
@@ -133,12 +156,18 @@ fun AppCard(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = expandable) { isExpanded = !isExpanded }
.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,
@@ -172,6 +201,16 @@ fun AppCard(
}
}
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(
@@ -182,21 +221,32 @@ fun AppCard(
)
}
}
}
// --- Content Area ---
if (!expandable || isExpanded) {
Column(
modifier = Modifier.padding(
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
)
}
}
}
}

View File

@@ -56,6 +56,8 @@ 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,
) {
@@ -68,9 +70,13 @@ 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) {
val languages = remember(alternateLanguages, defaultLanguages, restrictToAlternateLanguages) {
if (restrictToAlternateLanguages) {
alternateLanguages
} else {
alternateLanguages.ifEmpty { defaultLanguages }
}
}
val buttonText = when {
enableMultipleSelection && selectedLanguagesCount > 0 -> stringResource(
@@ -90,6 +96,7 @@ 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
) {
@@ -222,7 +229,12 @@ fun BaseLanguageDropDown(
val isSearching = searchText.isNotBlank()
if (isSearching) {
val searchResults = (favoriteLanguages + languageHistory + languages)
val searchBase = if (restrictToAlternateLanguages) {
alternateLanguages
} else {
favoriteLanguages + languageHistory + languages
}
val searchResults = searchBase
.distinctBy { it.nameResId }
.filter { language ->
val matchesName = language.name.contains(searchText, ignoreCase = true)
@@ -237,6 +249,16 @@ 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 }
if (enableMultipleSelection) {
@@ -458,7 +480,9 @@ fun SingleLanguageDropDown(
onAutoSelected: () -> Unit = {},
showNoneOption: Boolean = false,
onNoneSelected: () -> Unit = {},
alternateLanguages: List<Language> = emptyList()
alternateLanguages: List<Language> = emptyList(),
restrictToAlternateLanguages: Boolean = false,
enabled: Boolean = true
) {
val languageHistory by languageViewModel.languageHistory.collectAsState()
@@ -477,6 +501,10 @@ fun SingleLanguageDropDown(
showNoneOption = showNoneOption,
onNoneSelected = onNoneSelected,
enableMultipleSelection = false,
alternateLanguages = alternateLanguages
alternateLanguages = alternateLanguages,
restrictToAlternateLanguages = restrictToAlternateLanguages,
enabled = enabled,
iconEnabled = enabled,
noBorder = !enabled
)
}

View File

@@ -6,8 +6,6 @@ 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
@@ -29,8 +27,6 @@ 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 = {

View File

@@ -1,219 +0,0 @@
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"
)
}

View File

@@ -1,132 +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.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))
}
}
}
}
}

View File

@@ -1,73 +0,0 @@
@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 }
)
}
}

View File

@@ -35,7 +35,6 @@ 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
@@ -45,10 +44,8 @@ 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>() }
@@ -65,7 +62,7 @@ fun VocabularyReviewScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.found_items)) },
title = stringResource(R.string.found_items),
hintContent = HintDefinition.REVIEW.hint()
)
},

View File

@@ -381,7 +381,7 @@ fun DefinitionPart(part: EntryPart) {
// Fallback for JsonObject or other top-level types
else -> contentElement.toString()
}
} catch (e: Exception) {
} catch (_: Exception) {
// Ultimate fallback if something else goes wrong during parsing
part.content.toString()
}
@@ -466,12 +466,6 @@ 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

View File

@@ -10,8 +10,6 @@ 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
@@ -28,7 +26,6 @@ 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
@@ -345,30 +342,12 @@ fun DictionarySimpleTopBar(
languageName: String?,
onNavigateBack: () -> Unit
) {
word?.let {
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
title = it,
onNavigateBack = onNavigateBack
)
}
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
},
actions = {}
)
}
@Composable

View File

@@ -1,3 +1,5 @@
@file:Suppress("SameParameterValue")
package eu.gaudian.translator.view.dictionary
import androidx.compose.animation.animateContentSize

View File

@@ -30,7 +30,6 @@ 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
@@ -94,27 +93,8 @@ fun EtymologyResultScreen(
AppScaffold(
topBar = {
AppTopAppBar(
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))
}
},
title = "Result",
onNavigateBack = { navController.popBackStack() },
actions = {
etymologyData?.let { data ->
if (isTtsAvailable) {

View File

@@ -21,6 +21,7 @@ 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
@@ -63,7 +64,15 @@ fun MainDictionaryScreen(
AppTabLayout(
tabs = dictionaryTabs,
selectedTab = selectedTab,
onTabSelected = { selectedTab = it }
onTabSelected = { selectedTab = it },
onNavigateBack = {
if (!navController.popBackStack()) {
navController.navigate(Screen.Home.route) {
launchSingleTop = true
restoreState = false
}
}
}
)
when (selectedTab) {

View File

@@ -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.VocabularyListScreen
import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
@Composable
fun ExerciseVocabularyScreen(
@@ -24,7 +24,7 @@ fun ExerciseVocabularyScreen(
) {
Scaffold(
topBar = {
AppTopAppBar(title = { Text(stringResource(R.string.text_new_vocabulary_for_this_exercise)) })
AppTopAppBar(title =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)) {
VocabularyListScreen(
AllCardsListScreen(
navController = navController as NavHostController?,
onNavigateToItem = { item ->
// Navigate to the detail screen for a specific vocabulary item

View File

@@ -38,6 +38,7 @@ 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
@@ -76,7 +77,15 @@ fun MainExerciseScreen(
AppTabLayout(
tabs = ExerciseTab.entries,
selectedTab = selectedTab,
onTabSelected = { selectedTab = it }
onTabSelected = { selectedTab = it },
onNavigateBack = {
if (!navController.popBackStack()) {
navController.navigate(Screen.Home.route) {
launchSingleTop = true
restoreState = false
}
}
}
)
Box(modifier = Modifier.weight(1f)) {

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,6 @@ 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
@@ -26,12 +24,10 @@ 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
@@ -61,12 +57,8 @@ fun YouTubeBrowserScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text("YouTube") },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, stringResource(R.string.cd_back))
}
}
title = "YouTube" ,
onNavigateBack = { navController.popBackStack() }
)
}
) { padding ->

View File

@@ -183,14 +183,8 @@ fun YouTubeExerciseScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(title, maxLines = 1) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(
R.string.cd_back
))
}
},
title = title,
onNavigateBack = { navController.popBackStack() },
actions = {
IconButton(
onClick = { onFinishVideo() },

View File

@@ -21,7 +21,7 @@ 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),
IMPORT("import_hint", R.string.hint_how_to_generate_vocabulary_with_ai),
VOCABULARY_GENERATE_AI("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),
@@ -40,7 +40,6 @@ 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),

View File

@@ -1,5 +1,3 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.hints
import androidx.compose.foundation.layout.Arrangement

View File

@@ -32,7 +32,7 @@ import kotlinx.coroutines.launch
fun HintBottomSheet(
onDismissRequest: () -> Unit,
sheetState: SheetState,
content: @Composable (() -> Unit)?
content: Hint,
) {
val scope = rememberCoroutineScope()
ModalBottomSheet(
@@ -50,7 +50,7 @@ fun HintBottomSheet(
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) {
content?.invoke()
content.Render()
}
Spacer(modifier = Modifier.height(16.dp))

View File

@@ -5,7 +5,6 @@ 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
@@ -16,7 +15,6 @@ 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
@@ -39,8 +37,8 @@ val LocalShowHints = compositionLocalOf { false }
*/
@Composable
fun WithHint(
hintContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
hintContent: Hint? = null,
content: @Composable () -> Unit
) {
val showHints = LocalShowHints.current
@@ -69,27 +67,16 @@ fun WithHint(
}
if (showBottomSheet) {
hintContent?.let {
HintBottomSheet(
onDismissRequest = {
@Suppress("AssignedValueIsNeverRead")
showBottomSheet = false
},
sheetState = sheetState,
hintContent
content = it,
)
}
}
}
@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))
}
}
}

View File

@@ -5,15 +5,9 @@ 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
@@ -30,12 +24,8 @@ fun HintScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
}
title = title,
onNavigateBack = { navController.popBackStack() }
)
}
) { paddingValues ->

View File

@@ -40,7 +40,7 @@ fun HintsOverviewScreen(
val showExperimental = LocalShowExperimentalFeatures.current
// Get hints using the new function-based approach
val importHint = HintDefinition.IMPORT.hint()
val importHint = HintDefinition.VOCABULARY_GENERATE_AI.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 = { Text(stringResource(R.string.hint_title_hints_overview), style = MaterialTheme.typography.titleLarge) }
title = stringResource(R.string.hint_title_hints_overview)
)
}
) { paddingValues ->

View File

@@ -47,6 +47,7 @@ object MarkdownHintLoader {
append(language.lowercase())
}
if (country.isNotEmpty()) {
@Suppress("HardCodedStringLiteral")
append("-r")
append(country.uppercase())
}

View File

@@ -0,0 +1,168 @@
package eu.gaudian.translator.view.home
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import eu.gaudian.translator.R
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.NavigationRoutes
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.library.VocabularyCard
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
@Composable
fun DailyReviewScreen(
navController: NavHostController,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val activity = context.findActivity()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val dueTodayItems by vocabularyViewModel.dueTodayItems.collectAsStateWithLifecycle(initialValue = emptyList())
val allLanguages by languageViewModel.allLanguages.collectAsStateWithLifecycle(initialValue = emptyList())
val listState = rememberLazyListState()
AppScaffold(
topBar = {
AppTopAppBar(
title = stringResource(R.string.label_daily_review),
onNavigateBack = { navController.popBackStack() }
)
},
modifier = modifier.fillMaxSize()
) { paddingValues ->
if (dueTodayItems.isEmpty()) {
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
modifier = Modifier.size(200.dp),
painter = painterResource(id = R.drawable.ic_nothing_found),
contentDescription = null
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.no_items_due_for_review),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
} else {
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.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)
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,437 @@
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.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.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.material3.TextButton
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.Screen
import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.view.vocabulary.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()
Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.label_weekly_progress), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
TextButton(onClick = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }) {
Text(stringResource(R.string.label_see_history))
}
}
Spacer(modifier = Modifier.height(8.dp))
AppCard(
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(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Total Words
AppCard(
modifier = Modifier.weight(1f),
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),
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))
}
}
}
}

View File

@@ -0,0 +1,740 @@
package eu.gaudian.translator.view.library
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
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.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AddCircleOutline
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.LocalMall
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.insertBreakOpportunities
/**
* Top bar for the library screen with title and add button
*/
@Composable
fun LibraryTopBar(
onAddClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.label_library),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
IconButton(
onClick = onAddClick,
modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.cd_add),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
/**
* Top bar shown when items are selected for batch operations
*/
@Composable
fun SelectionTopBar(
selectionCount: Int,
onCloseClick: () -> Unit,
onSelectAllClick: () -> Unit,
onDeleteClick: () -> Unit,
onMoveToCategoryClick: () -> Unit,
onMoveToStageClick: () -> Unit,
onExportClick: () -> Unit,
isRemoveEnabled: Boolean,
onRemoveFromCategoryClick: () -> Unit,
modifier: Modifier = Modifier
) {
var showOverflowMenu by remember { mutableStateOf(false) }
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onCloseClick) {
Icon(imageVector = AppIcons.Close, contentDescription = stringResource(R.string.label_close_selection_mode))
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.d_selected, selectionCount),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
Row {
IconButton(onClick = onSelectAllClick) {
Icon(
imageVector = AppIcons.SelectAll,
contentDescription = stringResource(R.string.select_all)
)
}
IconButton(onClick = onDeleteClick) {
Icon(AppIcons.Delete, contentDescription = stringResource(R.string.label_delete))
}
Box {
IconButton(onClick = { showOverflowMenu = true }) {
Icon(imageVector = AppIcons.More, contentDescription = stringResource(R.string.more_actions))
}
DropdownMenu(
expanded = showOverflowMenu,
onDismissRequest = { showOverflowMenu = false }
) {
DropdownMenuItem(
text = { Text("Export Selected") },
onClick = {
onExportClick()
showOverflowMenu = false
},
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.move_to_category)) },
onClick = {
onMoveToCategoryClick()
showOverflowMenu = false
},
leadingIcon = { Icon(AppIcons.Category, contentDescription = null) }
)
if (isRemoveEnabled) {
DropdownMenuItem(
text = { Text(stringResource(R.string.remove_from_category)) },
onClick = {
onRemoveFromCategoryClick()
showOverflowMenu = false
},
leadingIcon = { Icon(AppIcons.Remove, contentDescription = null) }
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.move_to_stage)) },
onClick = {
onMoveToStageClick()
showOverflowMenu = false
},
leadingIcon = { Icon(AppIcons.Stages, contentDescription = null) }
)
}
}
}
}
}
/**
* Search bar with filter button
*/
@Composable
fun SearchBar(
searchQuery: String,
onQueryChanged: (String) -> Unit,
onFilterClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(56.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(start = 16.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.cd_search),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
androidx.compose.foundation.text.BasicTextField(
value = searchQuery,
onValueChange = onQueryChanged,
modifier = Modifier.weight(1f),
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface
),
singleLine = true,
cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (searchQuery.isEmpty()) {
Text(
text = stringResource(R.string.label_search_cards),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyLarge
)
}
innerTextField()
}
}
)
IconButton(onClick = onFilterClick) {
Icon(
imageVector = Icons.Default.Tune,
contentDescription = stringResource(R.string.cd_filter_options),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
/**
* Segmented control for switching between All Cards and Categories view
*/
@Composable
fun SegmentedControl(
isCategoriesView: Boolean,
onTabSelected: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.padding(4.dp)
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.background(if (!isCategoriesView) MaterialTheme.colorScheme.primary else Color.Transparent)
.clickable { onTabSelected(false) },
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.label_all_cards),
color = if (!isCategoriesView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelLarge
)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.background(if (isCategoriesView) MaterialTheme.colorScheme.primary else Color.Transparent)
.clickable { onTabSelected(true) },
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.label_categories),
color = if (isCategoriesView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.labelLarge
)
}
}
}
/**
* List view of all vocabulary cards
*/
@Composable
fun AllCardsView(
vocabularyItems: List<VocabularyItem>,
allLanguages: List<Language>,
selection: Set<Long>,
onItemClick: (VocabularyItem) -> Unit,
onItemLongClick: (VocabularyItem) -> Unit,
onDeleteClick: (VocabularyItem) -> Unit,
listState: LazyListState,
modifier: Modifier = Modifier
) {
if (vocabularyItems.isEmpty()) {
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Image(
modifier = Modifier.size(200.dp),
painter = painterResource(id = R.drawable.ic_nothing_found),
contentDescription = stringResource(id = R.string.no_vocabulary_items_found_perhaps_try_changing_the_filters)
)
Spacer(modifier = Modifier.size(16.dp))
Text(
text = stringResource(R.string.no_vocabulary_items_found_perhaps_try_changing_the_filters),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
} else {
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 100.dp)
) {
items(
items = vocabularyItems,
key = { it.id }
) { item ->
val isSelected = selection.contains(item.id.toLong())
VocabularyCard(
item = item,
allLanguages = allLanguages,
isSelected = isSelected,
onItemClick = { onItemClick(item) },
onItemLongClick = { onItemLongClick(item) },
onDeleteClick = { onDeleteClick(item) }
)
}
}
}
}
/**
* Individual vocabulary card component
*/
@Composable
fun VocabularyCard(
item: VocabularyItem,
allLanguages: List<Language>,
isSelected: Boolean,
onItemClick: () -> Unit,
onItemLongClick: () -> Unit,
onDeleteClick: () -> Unit,
modifier: Modifier = Modifier
) {
val languageMap = remember(allLanguages) { allLanguages.associateBy { it.nameResId } }
val langFirst = item.languageFirstId?.let { languageMap[it]?.name } ?: ""
val langSecond = item.languageSecondId?.let { languageMap[it]?.name } ?: ""
Card(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.combinedClickable(
onClick = onItemClick,
onLongClick = onItemLongClick
),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainer,
contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.surfaceContainer
),
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
// Top row: First word + Language Pill
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = insertBreakOpportunities(item.wordFirst),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = langFirst,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
// Bottom row: Second word + Language Pill
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = insertBreakOpportunities(item.wordSecond),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = langSecond,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
}
if (isSelected) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = stringResource(R.string.cd_selected),
tint = MaterialTheme.colorScheme.primary
)
} else {
IconButton(onClick = { /* Options menu could go here */ }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.cd_options),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
/**
* Grid view of categories
*/
@Composable
fun CategoriesView(
categories: List<VocabularyCategory>,
onCategoryClick: (VocabularyCategory) -> Unit,
onExploreMoreClick: () -> Unit,
modifier: Modifier = Modifier
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 100.dp)
) {
items(categories) { category ->
CategoryCard(
category = category,
onClick = { onCategoryClick(category) }
)
}
item(span = { GridItemSpan(2) }) {
ExploreMoreCard(onClick = onExploreMoreClick)
}
}
}
/**
* Individual category card in grid view
*/
@Composable
fun CategoryCard(
category: VocabularyCategory,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.height(140.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.LocalMall,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
Column {
Text(
text = category.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = { 0.5f },
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp)),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
)
}
}
}
}
/**
* Card to explore more categories
*/
@Composable
fun ExploreMoreCard(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val borderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
Box(
modifier = modifier
.fillMaxWidth()
.padding(top = 8.dp)
.height(80.dp)
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
.drawBehind {
val stroke = Stroke(
width = 2.dp.toPx(),
pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 20f), 0f)
)
drawRoundRect(
color = borderColor,
style = stroke,
cornerRadius = CornerRadius(16.dp.toPx(), 16.dp.toPx())
)
},
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.AddCircleOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.text_explore_more_categories),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
/**
* Crossfade container for switching between views
*/
@Composable
fun LibraryViewContainer(
isCategoriesView: Boolean,
categoriesContent: @Composable () -> Unit,
allCardsContent: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Crossfade(
targetState = isCategoriesView,
label = "LibraryViewTransition",
modifier = modifier
) { showCategories ->
if (showCategories) {
categoriesContent()
} else {
allCardsContent()
}
}
}
// ==================== PREVIEWS ====================
@Preview(showBackground = true)
@Composable
fun LibraryTopBarPreview() {
MaterialTheme {
LibraryTopBar(onAddClick = {})
}
}
@Preview(showBackground = true)
@Composable
fun SelectionTopBarPreview() {
MaterialTheme {
SelectionTopBar(
selectionCount = 5,
onCloseClick = {},
onSelectAllClick = {},
onDeleteClick = {},
onMoveToCategoryClick = {},
onMoveToStageClick = {},
onExportClick = {},
isRemoveEnabled = true,
onRemoveFromCategoryClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun SearchBarPreview() {
MaterialTheme {
SearchBar(
searchQuery = "",
onQueryChanged = {},
onFilterClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun SegmentedControlPreview() {
MaterialTheme {
SegmentedControl(
isCategoriesView = false,
onTabSelected = {}
)
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun VocabularyCardPreview() {
MaterialTheme {
VocabularyCard(
item = VocabularyItem(
id = 1,
wordFirst = "Hello",
wordSecond = "Hola",
languageFirstId = 1,
languageSecondId = 2,
createdAt = null,
features = null,
zipfFrequencyFirst = null,
zipfFrequencySecond = null
),
allLanguages = emptyList(),
isSelected = false,
onItemClick = {},
onItemLongClick = {},
onDeleteClick = {}
)
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun CategoryCardPreview() {
MaterialTheme {
CategoryCard(
category = eu.gaudian.translator.model.TagCategory(
1,
"Travel Phrases"
),
onClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun ExploreMoreCardPreview() {
MaterialTheme {
ExploreMoreCard(onClick = {})
}
}

View File

@@ -0,0 +1,620 @@
@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 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())
// 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,
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(order.name.replace('_', ' ').lowercase()
.replaceFirstChar { it.titlecase() })
}
)
}
}
}
// 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
)
}
}
}

View File

@@ -17,7 +17,6 @@ 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
@@ -73,12 +72,8 @@ fun AboutScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.label_about)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
}
title = stringResource(R.string.label_about),
onNavigateBack = { navController.popBackStack() }
)
}
) { paddingValues ->

View File

@@ -134,12 +134,8 @@ fun AddModelScreen(navController: NavController, providerKey: String) {
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(providerName) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
},
title = providerName,
onNavigateBack = { navController.popBackStack() },
hintContent = HintDefinition.ADD_MODEL_SCAN.hint()
)
},

View File

@@ -115,12 +115,8 @@ fun ApiKeyScreen(navController: NavController) {
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.label_ai_configuration)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
},
title = stringResource(R.string.label_ai_configuration),
onNavigateBack = { navController.popBackStack() },
hintContent = HintDefinition.API_KEY.hint()
)
}
@@ -137,7 +133,7 @@ fun ApiKeyScreen(navController: NavController) {
AppTabLayout(
tabs = apiTabs,
selectedTab = selectedTab,
onTabSelected = { selectedTab = it }
onTabSelected = { selectedTab = it },
)
// Tab Content

View File

@@ -5,9 +5,6 @@ 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
@@ -22,7 +19,6 @@ 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
@@ -55,12 +51,8 @@ fun CustomVocabularyPromptScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.text_vocabulary_prompt)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
},
title = stringResource(R.string.text_vocabulary_prompt),
onNavigateBack = { navController.popBackStack() },
hintContent = null //TODO: Add hint
)

View File

@@ -8,9 +8,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.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
@@ -31,7 +28,6 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.LocalShowExperimentalFeatures
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.OptionItemSwitch
@@ -66,12 +62,8 @@ fun DictionaryOptionsScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.label_dictionary_options)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
},
title = stringResource(R.string.label_dictionary_options),
onNavigateBack = { navController.popBackStack() },
hintContent = HintDefinition.DICTIONARY_OPTIONS.hint()
)
}

View File

@@ -8,8 +8,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
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
@@ -31,7 +29,6 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.ApiModelDropDown
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
@@ -71,12 +68,8 @@ fun ExerciseSettingsScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.exercise_settings)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
}
title = stringResource(R.string.exercise_settings),
onNavigateBack = { navController.popBackStack() },
)
}
) { paddingValues ->

View File

@@ -7,8 +7,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -24,7 +22,6 @@ import androidx.navigation.NavController
import eu.gaudian.translator.R
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.AppSwitch
import eu.gaudian.translator.view.composable.AppTopAppBar
@@ -41,12 +38,8 @@ fun GeneralSettingsScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.label_general)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
}
title = stringResource(R.string.label_general),
onNavigateBack = { navController.popBackStack() },
)
}
) { paddingValues ->

View File

@@ -61,12 +61,8 @@ fun LanguageOptionsScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.text_language_options)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
}
title = stringResource(R.string.text_language_options),
onNavigateBack = { navController.popBackStack() },
)
}
) { paddingValues ->
@@ -132,6 +128,7 @@ fun LanguageOptionsScreen(
}
if (showAddLanguageDialog) {
@Suppress("KotlinConstantConditions")
AddCustomLanguageDialog(
showDialog = showAddLanguageDialog,
onDismiss = { showAddLanguageDialog = false },

View File

@@ -35,7 +35,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
@@ -97,16 +96,11 @@ fun LayoutOptionsScreen(navController: NavController) {
val selectedFontName by settingsViewModel.fontPreference.collectAsStateWithLifecycle()
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle()
val cdBack = stringResource(R.string.cd_back)
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.label_appearance)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = cdBack)
}
}
title = stringResource(R.string.label_appearance),
onNavigateBack = { navController.popBackStack() },
)
}
) { paddingValues ->

View File

@@ -101,15 +101,8 @@ fun LogsScreen(navController: NavController) {
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.label_logs)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = stringResource(R.string.cd_back)
)
}
},
title = stringResource(R.string.label_logs),
onNavigateBack = { navController.popBackStack() },
actions = {
TextButton(onClick = {
settingsViewModel.clearApiLogs()

View File

@@ -27,6 +27,7 @@ import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.Screen
private data class Setting(val titleRes: Int, val icon: ImageVector, val route: String)
@@ -84,7 +85,15 @@ fun MainSettingsScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.title_settings), style = MaterialTheme.typography.titleLarge) }
title =stringResource(R.string.title_settings),
onNavigateBack = {
if (!navController.popBackStack()) {
navController.navigate(Screen.Home.route) {
launchSingleTop = true
restoreState = false
}
}
}
)
}
) { paddingValues ->
@@ -96,7 +105,7 @@ fun MainSettingsScreen(
}
item {
AppCard(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Column {
settings.forEachIndexed { index, setting ->

View File

@@ -113,7 +113,7 @@ fun NavGraphBuilder.settingsGraph(navController: NavController) {
HintScreen(navController, HintDefinition.DICTIONARY_OPTIONS)
}
composable(SettingsRoutes.HINTS_IMPORT) {
HintScreen(navController, HintDefinition.IMPORT)
HintScreen(navController, HintDefinition.VOCABULARY_GENERATE_AI)
}
composable(SettingsRoutes.HINTS_SORTING) {
HintScreen(navController, HintDefinition.SORTING)

View File

@@ -15,8 +15,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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
@@ -86,12 +84,8 @@ fun TextToSpeechSettingsScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.settings_title_voice)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
}
title = stringResource(R.string.settings_title_voice),
onNavigateBack = { navController.popBackStack() },
)
}
) { paddingValues ->

View File

@@ -9,9 +9,6 @@ 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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -27,7 +24,6 @@ import androidx.navigation.NavController
import eu.gaudian.translator.R
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.OptionItemSwitch
@@ -64,12 +60,8 @@ fun TranslationSettingsScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.label_translation_settings)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
},
title = stringResource(R.string.label_translation_settings),
onNavigateBack = { navController.popBackStack() },
hintContent = null //TODO add hint
)
}

View File

@@ -21,7 +21,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.material3.TextButton
@@ -78,13 +77,8 @@ fun VocabularyProgressOptionsScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.vocabulary_settings)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
},
// Here is the new hint content
title = stringResource(R.string.label_vocabulary_settings),
onNavigateBack = { navController.popBackStack() },
hintContent = HintDefinition.VOCABULARY_PROGRESS.hint()
)
}
@@ -97,44 +91,27 @@ fun VocabularyProgressOptionsScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Interval Settings
AppCard(
expandable = true,
initiallyExpanded = true,
title = stringResource(R.string.text_interval_settings_in_days),
text = stringResource(R.string.text_customize_the_intervals),
) {
val intervals by settingsViewModel.intervals.collectAsStateWithLifecycle()
// Daily Goal Settings
AppCard {
val dailyGoal by settingsViewModel.dailyGoal.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.padding(16.dp)
.animateContentSize(),
verticalArrangement = Arrangement.spacedBy(16.dp)
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
IntervalTimeline(intervals = intervals)
intervals.forEach { (stageKey, days) ->
val displayLabel = labelForStage(stageKey)
IntervalSlider(
label = displayLabel,
value = days,
onValueChange = { newValue ->
settingsViewModel.setInterval(stageKey, newValue)
}
)
}
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = { settingsViewModel.resetIntervalsToDefaults() }) {
Text(stringResource(R.string.reset_to_defaults))
}
}
@Suppress("USELESS_ELVIS", "HardCodedStringLiteral")
SettingsSlider(
label = stringResource(R.string.label_target_correct_answers_per_day),
value = dailyGoal ?: 10,
onValueChange = { settingsViewModel.setDailyGoal(it) },
valueRange = 10f..100f,
steps = 17 // Allows snapping in steps of 5
)
Text(
text = stringResource(R.string.text_daily_goal_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -169,31 +146,42 @@ fun VocabularyProgressOptionsScreen(
}
}
// Daily Goal Settings
AppCard {
val dailyGoal by settingsViewModel.dailyGoal.collectAsStateWithLifecycle()
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
// Interval Settings
AppCard(
expandable = true,
initiallyExpanded = true,
title = stringResource(R.string.label_interval_settings_in_days),
text = stringResource(R.string.text_customize_the_intervals),
) {
Text(
text = stringResource(R.string.daily_learning_goal),
style = MaterialTheme.typography.titleMedium
)
@Suppress("USELESS_ELVIS", "HardCodedStringLiteral")
SettingsSlider(
label = stringResource(R.string.target_correct_answers_per_day),
value = dailyGoal ?: 10,
onValueChange = { settingsViewModel.setDailyGoal(it) },
valueRange = 10f..100f,
steps = 17 // Allows snapping in steps of 5
)
Text(
text = stringResource(R.string.text_daily_goal_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
val intervals by settingsViewModel.intervals.collectAsStateWithLifecycle()
Column(
modifier = Modifier
.padding(16.dp)
.animateContentSize(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
IntervalTimeline(intervals = intervals)
intervals.forEach { (stageKey, days) ->
val displayLabel = labelForStage(stageKey)
IntervalSlider(
label = displayLabel,
value = days,
onValueChange = { newValue ->
settingsViewModel.setInterval(stageKey, newValue)
}
)
}
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = { settingsViewModel.resetIntervalsToDefaults() }) {
Text(stringResource(R.string.reset_to_defaults))
}
}
}
}
}
}

View File

@@ -1,43 +1,57 @@
@file:Suppress("AssignedValueIsNeverRead")
@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
package eu.gaudian.translator.view.settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import eu.gaudian.translator.R
import eu.gaudian.translator.model.ConflictStrategy
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService
@@ -51,37 +65,103 @@ import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.PrimaryButton
import eu.gaudian.translator.view.composable.SecondaryButton
import eu.gaudian.translator.view.composable.SingleLanguageDropDown
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.ExportState
import eu.gaudian.translator.viewmodel.ImportState
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.launch
@Composable
fun VocabularyRepositoryOptionsScreen(
navController: NavController
) {
val activity = LocalContext.current.findActivity()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val statusMessageService = StatusMessageService
val context = LocalContext.current
val repositoryStateImportedFrom = stringResource(R.string.repository_state_imported_from)
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
// State management
val exportState by exportImportViewModel.exportState.collectAsState()
val importState by exportImportViewModel.importState.collectAsState()
val categories by categoryViewModel.categories.collectAsState()
// Dialog states
var showExportDialog by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) }
var showConflictStrategyDialog by remember { mutableStateOf(false) }
var pendingImportJson by remember { mutableStateOf<String?>(null) }
var selectedConflictStrategy by remember { mutableStateOf(ConflictStrategy.MERGE) }
// Export options
val selectedCategories = remember { mutableStateListOf<Int>() }
// Handle export/import state changes
LaunchedEffect(exportState) {
when (exportState) {
is ExportState.Success -> {
val shareIntent = exportImportViewModel.createShareIntent()
if (shareIntent != null) {
context.startActivity(shareIntent)
}
scope.launch {
snackbarHostState.showSnackbar("Export successful!")
}
exportImportViewModel.resetExportState()
}
is ExportState.Error -> {
scope.launch {
snackbarHostState.showSnackbar((exportState as ExportState.Error).message)
}
exportImportViewModel.resetExportState()
}
else -> {}
}
}
LaunchedEffect(importState) {
when (importState) {
is ImportState.Success -> {
val result = (importState as ImportState.Success).result
scope.launch {
snackbarHostState.showSnackbar(
"Imported: ${result.itemsImported}, Skipped: ${result.itemsSkipped}, Errors: ${result.errors.size}"
)
}
exportImportViewModel.resetImportState()
}
is ImportState.Error -> {
scope.launch {
snackbarHostState.showSnackbar((importState as ImportState.Error).message)
}
exportImportViewModel.resetImportState()
}
else -> {}
}
}
// File picker for import
val importFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri ->
uri?.let {
context.contentResolver.openInputStream(it)?.use { inputStream ->
val jsonString = inputStream.bufferedReader().use { reader -> reader.readText() }
vocabularyViewModel.importVocabulary(jsonString)
statusMessageService.showInfoMessage(repositoryStateImportedFrom + " " +it.path)
pendingImportJson = jsonString
showConflictStrategyDialog = true
}
}
}
)
// CSV/Excel import state
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val showTableImportDialog = remember { mutableStateOf(false) }
var parsedTable by remember { mutableStateOf<List<List<String>>>(emptyList()) }
var selectedColFirst by remember { mutableIntStateOf(0) }
@@ -93,7 +173,6 @@ fun VocabularyRepositoryOptionsScreen(
fun parseCsv(text: String): List<List<String>> {
if (text.isBlank()) return emptyList()
// Detect delimiter by highest occurrence among comma, semicolon, tab
val candidates = listOf(',', ';', '\t')
val sampleLine = text.lineSequence().firstOrNull { it.isNotBlank() } ?: return emptyList()
val delimiter = candidates.maxBy { c -> sampleLine.count { it == c } }
@@ -109,14 +188,13 @@ fun VocabularyRepositoryOptionsScreen(
'"' -> {
if (inQuotes && i + 1 < text.length && text[i + 1] == '"') {
current.append('"')
i++ // skip escaped quote
i++
} else {
inQuotes = !inQuotes
}
}
'\r' -> { /* ignore, handle on \n */ }
'\r' -> { /* ignore */ }
'\n' -> {
// end of line
val field = current.toString()
current = StringBuilder()
currentRow.add(if (inQuotes) field else field)
@@ -136,12 +214,10 @@ fun VocabularyRepositoryOptionsScreen(
}
i++
}
// flush last field/row if any
if (current.isNotEmpty() || currentRow.isNotEmpty()) {
currentRow.add(current.toString())
rows.add(currentRow.toList())
}
// Normalize: trim and drop trailing empty columns
return rows.map { row ->
row.map { it.trim().trim('"') }
}.filter { r -> r.any { it.isNotBlank() } }
@@ -196,16 +272,12 @@ fun VocabularyRepositoryOptionsScreen(
vocabularyViewModel.initializeSaveFileLauncher(exportFileLauncher)
}
AppScaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
AppTopAppBar(
title = { Text(stringResource(R.string.vocabulary_repository)) },
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
}
title = stringResource(R.string.vocabulary_repository),
onNavigateBack = { navController.popBackStack() },
)
}
) { paddingValues ->
@@ -216,31 +288,95 @@ fun VocabularyRepositoryOptionsScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Export Section
item {
// Backup and Restore Section
AppCard {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.label_backup_and_restore),
style = MaterialTheme.typography.titleMedium
text = "Export Vocabulary",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (exportState is ExportState.Loading) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
}
Text(
text = "Export your vocabulary data to share or backup",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Divider(modifier = Modifier.padding(vertical = 8.dp))
PrimaryButton(
onClick = { vocabularyViewModel.saveRepositoryState() },
text = stringResource(R.string.export_vocabulary_data),
modifier = Modifier.fillMaxWidth()
onClick = { exportImportViewModel.exportFullRepository() },
text = "Export Complete Repository",
icon = AppIcons.Download,
modifier = Modifier.fillMaxWidth(),
enabled = exportState !is ExportState.Loading
)
SecondaryButton(
onClick = { importFileLauncher.launch(arrayOf("application/json")) },
text = stringResource(R.string.import_vocabulary_data),
modifier = Modifier.fillMaxWidth()
onClick = { showExportDialog = true },
text = "Export Selected Categories",
icon = AppIcons.Category,
modifier = Modifier.fillMaxWidth(),
enabled = exportState !is ExportState.Loading && categories.isNotEmpty()
)
}
}
}
// Import Section
item {
AppCard {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Import Vocabulary",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (importState is ImportState.Loading) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp))
}
}
Text(
text = "Import vocabulary from JSON files. Duplicates will be handled based on your chosen strategy.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Divider(modifier = Modifier.padding(vertical = 8.dp))
PrimaryButton(
onClick = { importFileLauncher.launch(arrayOf("application/json", "text/plain")) },
text = "Import from File",
icon = AppIcons.Upload,
modifier = Modifier.fillMaxWidth(),
enabled = importState !is ImportState.Loading
)
SecondaryButton(
onClick = {
// Allow CSV and Excel mime types, but we only support CSV parsing in-app
@Suppress("HardCodedStringLiteral")
importTableLauncher.launch(
arrayOf(
"text/csv",
@@ -253,11 +389,43 @@ fun VocabularyRepositoryOptionsScreen(
)
},
text = stringResource(R.string.label_import_table_csv_excel),
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
enabled = importState !is ImportState.Loading
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Conflict Strategy:",
style = MaterialTheme.typography.bodySmall
)
TextButton(onClick = { showImportDialog = true }) {
Text(
text = when (selectedConflictStrategy) {
ConflictStrategy.MERGE -> "Merge (Recommended)"
ConflictStrategy.SKIP -> "Skip Duplicates"
ConflictStrategy.REPLACE -> "Replace Existing"
ConflictStrategy.RENAME -> "Keep Both"
},
style = MaterialTheme.typography.bodySmall
)
Icon(
imageVector = AppIcons.Settings,
contentDescription = "Change strategy",
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
}
}
// Danger Zone
item {
AppCard {
Column(
@@ -270,7 +438,7 @@ fun VocabularyRepositoryOptionsScreen(
color = MaterialTheme.colorScheme.error
)
val showConfirm = androidx.compose.runtime.remember { mutableStateOf(false) }
val showConfirm = remember { mutableStateOf(false) }
AppButton(
onClick = { showConfirm.value = true },
@@ -311,7 +479,185 @@ fun VocabularyRepositoryOptionsScreen(
}
}
// Export Dialog
if (showExportDialog) {
AlertDialog(
onDismissRequest = { showExportDialog = false },
title = { Text("Export Categories") },
text = {
LazyColumn {
item {
Text(
"Select categories to export:",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
}
items(categories) { category ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selectedCategories.contains(category.id),
onCheckedChange = { checked ->
if (checked) {
selectedCategories.add(category.id)
} else {
selectedCategories.removeAt(selectedCategories.indexOf(category.id))
}
}
)
Text(
text = category.name,
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
if (selectedCategories.size == 1) {
exportImportViewModel.exportCategory(selectedCategories.first())
} else if (selectedCategories.isNotEmpty()) {
// Simplified: export first selected category
exportImportViewModel.exportCategory(selectedCategories.first())
}
showExportDialog = false
selectedCategories.clear()
},
enabled = selectedCategories.isNotEmpty()
) {
Text("Export")
}
},
dismissButton = {
TextButton(onClick = {
showExportDialog = false
selectedCategories.clear()
}) {
Text("Cancel")
}
}
)
}
// Import Strategy Selection Dialog
if (showImportDialog) {
AlertDialog(
onDismissRequest = { showImportDialog = false },
title = { Text("Import Conflict Strategy") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Choose how to handle duplicates during import:",
style = MaterialTheme.typography.bodyMedium
)
ConflictStrategyOption(
strategy = ConflictStrategy.MERGE,
selected = selectedConflictStrategy == ConflictStrategy.MERGE,
onSelected = { selectedConflictStrategy = ConflictStrategy.MERGE },
title = "Merge (Recommended)",
description = "Keep existing items, merge progress and categories"
)
ConflictStrategyOption(
strategy = ConflictStrategy.SKIP,
selected = selectedConflictStrategy == ConflictStrategy.SKIP,
onSelected = { selectedConflictStrategy = ConflictStrategy.SKIP },
title = "Skip Duplicates",
description = "Keep existing items unchanged, only add new ones"
)
ConflictStrategyOption(
strategy = ConflictStrategy.REPLACE,
selected = selectedConflictStrategy == ConflictStrategy.REPLACE,
onSelected = { selectedConflictStrategy = ConflictStrategy.REPLACE },
title = "Replace Existing",
description = "Overwrite existing items with imported versions"
)
ConflictStrategyOption(
strategy = ConflictStrategy.RENAME,
selected = selectedConflictStrategy == ConflictStrategy.RENAME,
onSelected = { selectedConflictStrategy = ConflictStrategy.RENAME },
title = "Keep Both",
description = "Create duplicates with new IDs"
)
}
},
confirmButton = {
TextButton(onClick = { showImportDialog = false }) {
Text("Done")
}
}
)
}
// Conflict Strategy Confirmation Dialog
if (showConflictStrategyDialog && pendingImportJson != null) {
AlertDialog(
onDismissRequest = {
showConflictStrategyDialog = false
pendingImportJson = null
},
icon = { Icon(AppIcons.Warning, contentDescription = null) },
title = { Text("Confirm Import") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Import strategy: ${
when (selectedConflictStrategy) {
ConflictStrategy.MERGE -> "Merge"
ConflictStrategy.SKIP -> "Skip Duplicates"
ConflictStrategy.REPLACE -> "Replace"
ConflictStrategy.RENAME -> "Keep Both"
}
}")
Text(
when (selectedConflictStrategy) {
ConflictStrategy.MERGE -> "Existing items will be kept. Progress and categories will be merged intelligently."
ConflictStrategy.SKIP -> "Only new items will be added. Existing items remain unchanged."
ConflictStrategy.REPLACE -> "Existing items will be replaced with imported versions."
ConflictStrategy.RENAME -> "All imported items will be added as new entries."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = { showImportDialog = true }) {
Text("Change Strategy")
}
}
},
confirmButton = {
TextButton(onClick = {
pendingImportJson?.let { json ->
exportImportViewModel.importFromJson(json, selectedConflictStrategy)
}
showConflictStrategyDialog = false
pendingImportJson = null
}) {
Text("Import")
}
},
dismissButton = {
TextButton(onClick = {
showConflictStrategyDialog = false
pendingImportJson = null
}) {
Text("Cancel")
}
}
)
}
// CSV Import Dialog
if (showTableImportDialog.value) {
AlertDialog(
onDismissRequest = { showTableImportDialog.value = false },
@@ -319,7 +665,6 @@ fun VocabularyRepositoryOptionsScreen(
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
// Column selectors
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
var menu1Expanded by remember { mutableStateOf(false) }
@@ -348,7 +693,6 @@ fun VocabularyRepositoryOptionsScreen(
}
}
}
// Language selectors
Text(stringResource(R.string.label_languages))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
@@ -368,13 +712,11 @@ fun VocabularyRepositoryOptionsScreen(
)
}
}
// Header toggle
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.label_header_row))
}
// Previews
val startIdx = if (skipHeader) 1 else 0
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
@@ -432,3 +774,54 @@ fun VocabularyRepositoryOptionsScreen(
}
}
}
@Composable
private fun ConflictStrategyOption(
strategy: ConflictStrategy,
selected: Boolean,
onSelected: () -> Unit,
title: String,
description: String,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth()
.clickable { onSelected() },
colors = CardDefaults.cardColors(
containerColor = if (selected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selected,
onClick = { onSelected() }
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -0,0 +1,678 @@
@file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.stats
import android.annotation.SuppressLint
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import eu.gaudian.translator.R
import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.model.WidgetType
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.NavigationRoutes
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.dialogs.MissingLanguageDialog
import eu.gaudian.translator.view.vocabulary.widgets.AllVocabularyWidget
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressWidget
import eu.gaudian.translator.view.vocabulary.widgets.DueTodayWidget
import eu.gaudian.translator.view.vocabulary.widgets.LevelWidget
import eu.gaudian.translator.view.vocabulary.widgets.StatusWidget
import eu.gaudian.translator.view.vocabulary.widgets.StreakWidget
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel
import eu.gaudian.translator.viewmodel.SettingsViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
@SuppressLint("FrequentlyChangingValue")
@Composable
fun StatsScreen(
modifier: Modifier = Modifier,
navController: NavHostController,
onNavigateToCategoryDetail: ((Int) -> Unit)? = null,
onNavigateToCategoryList: (() -> Unit)? = null,
onScroll: (Boolean) -> Unit = {},
) {
val activity = LocalContext.current.findActivity()
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity)
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
var showMissingLanguageDialog by remember { mutableStateOf(false) }
var selectedMissingLanguageId by remember { mutableStateOf<Int?>(null) }
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val affectedItems by remember(selectedMissingLanguageId) {
selectedMissingLanguageId?.let {
vocabularyViewModel.getItemsForLanguage(it)
} ?: flowOf(emptyList())
}.collectAsState(initial = emptyList())
if (showMissingLanguageDialog && selectedMissingLanguageId != null) {
MissingLanguageDialog(
showDialog = true,
missingLanguageId = selectedMissingLanguageId!!,
affectedItems = affectedItems,
onDismiss = { showMissingLanguageDialog = false },
onDelete = { items ->
vocabularyViewModel.deleteVocabularyItemsById(items.map { it.id })
showMissingLanguageDialog = false
},
onReplace = { oldId, newId ->
vocabularyViewModel.replaceLanguageId(oldId, newId)
showMissingLanguageDialog = false
},
onCreate = { newLanguage ->
languageViewModel.addCustomLanguage(newLanguage)
},
languageViewModel = languageViewModel
)
}
val handleNavigateToCategoryDetail = onNavigateToCategoryDetail ?: { categoryId ->
@Suppress("HardCodedStringLiteral")
navController.navigate("${NavigationRoutes.STATS_CATEGORY_DETAIL}/$categoryId")
}
val handleNavigateToCategoryList = onNavigateToCategoryList ?: {
navController.navigate(NavigationRoutes.STATS_CATEGORY_LIST)
}
AppOutlinedCard(modifier = modifier) {
// We collect the order from DB initially
val initialWidgetOrder by settingsViewModel.widgetOrder.collectAsState(initial = null)
val collapsedWidgetIds by settingsViewModel.collapsedWidgetIds.collectAsState(initial = emptySet())
val dashboardScrollState by settingsViewModel.dashboardScrollState.collectAsState()
val scope = rememberCoroutineScope()
if (initialWidgetOrder == null) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 64.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
// BEST PRACTICE: Use a SnapshotStateList for immediate UI updates.
// We only initialize this once, so DB updates don't reset the list while dragging.
val orderedWidgets = remember { mutableStateListOf<WidgetType>() }
// Sync with DB only on first load
LaunchedEffect(initialWidgetOrder) {
if (orderedWidgets.isEmpty() && !initialWidgetOrder.isNullOrEmpty()) {
orderedWidgets.addAll(initialWidgetOrder!!)
} else if (orderedWidgets.isEmpty()) {
orderedWidgets.addAll(WidgetType.DEFAULT_ORDER)
}
}
val lazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = dashboardScrollState.first,
initialFirstVisibleItemScrollOffset = dashboardScrollState.second
)
// Save scroll state
LaunchedEffect(lazyListState.firstVisibleItemIndex, lazyListState.firstVisibleItemScrollOffset) {
settingsViewModel.saveDashboardScrollState(
lazyListState.firstVisibleItemIndex,
lazyListState.firstVisibleItemScrollOffset
)
}
// Detect scroll and notify parent
LaunchedEffect(lazyListState.isScrollInProgress) {
onScroll(lazyListState.isScrollInProgress)
}
DisposableEffect(Unit) {
onDispose {
settingsViewModel.saveDashboardScrollState(
lazyListState.firstVisibleItemIndex,
lazyListState.firstVisibleItemScrollOffset
)
}
}
// --- Robust Drag and Drop State ---
val dragDropState = rememberDragDropState(
lazyListState = lazyListState,
onSwap = { fromIndex, toIndex ->
// Swap data immediately for responsiveness
orderedWidgets.apply {
add(toIndex, removeAt(fromIndex))
}
},
onDragEnd = {
// Persist to DB only when user drops
settingsViewModel.saveWidgetOrder(orderedWidgets.toList())
}
)
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxSize()
.dragContainer(dragDropState),
contentPadding = PaddingValues(bottom = 160.dp)
) {
itemsIndexed(
items = orderedWidgets,
key = { _, widget -> widget.id }
) { index, widgetType ->
val isDragging = index == dragDropState.draggingItemIndex
// Calculate translation: distinct logic for dragged vs. stationary items
val translationY = if (isDragging) {
dragDropState.draggingItemOffset
} else {
0f
}
Box(
modifier = Modifier
.zIndex(if (isDragging) 1f else 0f)
.graphicsLayer {
this.translationY = translationY
this.shadowElevation = if (isDragging) 8.dp.toPx() else 0f
this.scaleX = if (isDragging) 1.02f else 1f
this.scaleY = if (isDragging) 1.02f else 1f
}
// CRITICAL FIX: Only apply animation to items NOT being dragged.
// This prevents the "flicker" by stopping the layout animation
// from fighting your manual drag offset.
.then(
if (!isDragging) {
Modifier.animateItem(
placementSpec = spring(
stiffness = Spring.StiffnessLow,
visibilityThreshold = IntOffset.VisibilityThreshold
)
)
} else {
Modifier
}
)
) {
WidgetContainer(
widgetType = widgetType,
isExpanded = widgetType.id !in collapsedWidgetIds,
onExpandedChange = { newExpandedState ->
scope.launch {
settingsViewModel.setWidgetExpandedState(widgetType.id, newExpandedState)
}
},
onDragStart = { dragDropState.onDragStart(index) },
onDrag = { dragAmount -> dragDropState.onDrag(dragAmount) },
onDragEnd = { dragDropState.onDragEnd() },
onDragCancel = { dragDropState.onDragInterrupted() },
modifier = Modifier.fillMaxWidth()
) {
LazyWidget(
widgetType = widgetType,
navController = navController,
vocabularyViewModel = vocabularyViewModel,
progressViewModel = progressViewModel,
onNavigateToCategoryDetail = handleNavigateToCategoryDetail,
onNavigateToCategoryList = handleNavigateToCategoryList,
onMissingLanguage = { missingId ->
selectedMissingLanguageId = missingId
showMissingLanguageDialog = true
}
)
}
}
}
}
}
}
}
@Composable
private fun WidgetContainer(
widgetType: WidgetType,
isExpanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
onDragStart: () -> Unit,
onDrag: (Float) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
content: @Composable () -> Unit
) {
AppCard(
modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.animateContentSize(animationSpec = tween(300, easing = FastOutSlowInEasing))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(widgetType.titleRes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { onExpandedChange(!isExpanded) }) {
Icon(
imageVector = if (isExpanded) AppIcons.ArrowDropUp
else AppIcons.ArrowDropDown,
contentDescription = if (isExpanded) stringResource(R.string.text_collapse_widget)
else stringResource(R.string.text_expand_widget)
)
}
// Drag Handle with specific pointer input
Icon(
imageVector = AppIcons.DragHandle,
contentDescription = stringResource(R.string.text_drag_to_reorder),
tint = if (isExpanded) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(end = 8.dp, start = 8.dp)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { _ -> onDragStart() },
onDrag = { change, dragAmount ->
change.consume()
onDrag(dragAmount.y)
},
onDragEnd = { onDragEnd() },
onDragCancel = { onDragCancel() }
)
}
)
}
if (isExpanded) {
content()
}
}
}
}
// --------------------------------------------------------------------------------
// Fixed Drag and Drop Logic
// --------------------------------------------------------------------------------
@Composable
fun rememberDragDropState(
lazyListState: LazyListState,
onSwap: (Int, Int) -> Unit,
onDragEnd: () -> Unit
): DragDropState {
val scope = rememberCoroutineScope()
return remember(lazyListState, scope) {
DragDropState(
state = lazyListState,
onSwap = onSwap,
onDragFinished = onDragEnd,
scope = scope
)
}
}
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
return this.pointerInput(dragDropState) {
// Just allows the modifier to exist in the chain, logic is in the handle
}
}
class DragDropState(
private val state: LazyListState,
private val onSwap: (Int, Int) -> Unit,
private val onDragFinished: () -> Unit,
private val scope: CoroutineScope
) {
var draggingItemIndex by mutableIntStateOf(-1)
private set
private val _draggingItemOffset = Animatable(0f)
val draggingItemOffset: Float
get() = _draggingItemOffset.value
private val scrollChannel = Channel<Float>(Channel.CONFLATED)
init {
scope.launch {
for (scrollAmount in scrollChannel) {
if (scrollAmount != 0f) {
state.scrollBy(scrollAmount)
checkSwap()
}
}
}
}
fun onDragStart(index: Int) {
draggingItemIndex = index
scope.launch { _draggingItemOffset.snapTo(0f) }
}
fun onDrag(dragAmount: Float) {
if (draggingItemIndex == -1) return
scope.launch {
_draggingItemOffset.snapTo(_draggingItemOffset.value + dragAmount)
checkSwap()
checkOverscroll()
}
}
private fun checkSwap() {
val draggedIndex = draggingItemIndex
if (draggedIndex == -1) return
val visibleItems = state.layoutInfo.visibleItemsInfo
val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return
// Calculate the visual center of the dragged item
val draggedCenter = draggedItemInfo.offset + (draggedItemInfo.size / 2) + _draggingItemOffset.value
// Find a target to swap with
// FIX: We strictly check if we have crossed the CENTER of the target item.
// This acts as a hysteresis buffer to prevent flickering at the edges.
val targetItem = visibleItems.find { item ->
item.index != draggedIndex &&
draggedCenter > item.offset &&
draggedCenter < (item.offset + item.size)
}
if (targetItem != null) {
// Extra Check: Ensure we have actually crossed the midpoint of the target
val targetCenter = itemCenter(targetItem.offset, targetItem.size)
val isAboveAndMovingDown = draggedIndex < targetItem.index && draggedCenter > targetCenter
val isBelowAndMovingUp = draggedIndex > targetItem.index && draggedCenter < targetCenter
if (isAboveAndMovingDown || isBelowAndMovingUp) {
val targetIndex = targetItem.index
// 1. Swap Data
onSwap(draggedIndex, targetIndex)
// 2. Adjust Offset
// We calculate the physical distance the item moved in the layout (e.g. 150px).
// We subtract this from the current drag offset to keep the item visually stationary under the finger.
val layoutJumpDistance = (targetItem.offset - draggedItemInfo.offset).toFloat()
scope.launch {
_draggingItemOffset.snapTo(_draggingItemOffset.value - layoutJumpDistance)
}
// 3. Update Index
draggingItemIndex = targetIndex
}
}
}
private fun itemCenter(offset: Int, size: Int): Float {
return offset + (size / 2f)
}
private fun checkOverscroll() {
val draggedIndex = draggingItemIndex
if (draggedIndex == -1) {
scrollChannel.trySend(0f)
return
}
val layoutInfo = state.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val draggedItemInfo = visibleItems.find { it.index == draggedIndex } ?: return
val viewportStart = layoutInfo.viewportStartOffset
val viewportEnd = layoutInfo.viewportEndOffset
// Increased threshold slightly for smoother top-edge scrolling
val boundsStart = viewportStart + (viewportEnd * 0.15f)
val boundsEnd = viewportEnd - (viewportEnd * 0.15f)
val itemTop = draggedItemInfo.offset + _draggingItemOffset.value
val itemBottom = itemTop + draggedItemInfo.size
val scrollAmount = when {
itemTop < boundsStart -> -10f // Slower, more controlled scroll speed
itemBottom > boundsEnd -> 10f
else -> 0f
}
scrollChannel.trySend(scrollAmount)
}
fun onDragEnd() {
resetDrag()
onDragFinished()
}
fun onDragInterrupted() {
resetDrag()
}
private fun resetDrag() {
draggingItemIndex = -1
scrollChannel.trySend(0f)
scope.launch { _draggingItemOffset.snapTo(0f) }
}
}
// --------------------------------------------------------------------------------
// Remainder of your existing components
// --------------------------------------------------------------------------------
@Suppress("HardCodedStringLiteral")
@Composable
private fun LazyWidget(
widgetType: WidgetType,
navController: NavController,
vocabularyViewModel: VocabularyViewModel,
progressViewModel: ProgressViewModel,
onNavigateToCategoryDetail: (Int) -> Unit,
onNavigateToCategoryList: () -> Unit,
onMissingLanguage: (Int) -> Unit
) {
when (widgetType) {
WidgetType.Status -> LazyStatusWidget(
vocabularyViewModel = vocabularyViewModel,
onNavigateToNew = { navController.navigate("${NavigationRoutes.STATS_VOCABULARY_SORTING}?mode=NEW") },
onNavigateToDuplicates = { navController.navigate("${NavigationRoutes.STATS_VOCABULARY_SORTING}?mode=DUPLICATES") },
onNavigateToFaulty = { navController.navigate("${NavigationRoutes.STATS_VOCABULARY_SORTING}?mode=FAULTY") },
onNavigateToNoGrammar = { navController.navigate(NavigationRoutes.STATS_NO_GRAMMAR_ITEMS) },
onNavigateToMissingLanguage = onMissingLanguage
)
else -> {
// Regular widgets that load immediately
when (widgetType) {
WidgetType.Streak -> StreakWidget(
streak = progressViewModel.streak.collectAsState(initial = 0).value,
lastSevenDays = progressViewModel.lastSevenDays.collectAsState().value,
dueTodayCount = progressViewModel.dueTodayCount.collectAsState().value,
wordsCompleted = progressViewModel.totalWordsCompleted.collectAsState().value,
onStatisticsClicked = { navController.navigate(NavigationRoutes.STATS_VOCABULARY_HEATMAP) }
)
WidgetType.WeeklyActivityChart -> WeeklyActivityChartWidget(
weeklyStats = progressViewModel.weeklyActivityStats.collectAsState().value
)
WidgetType.AllVocabulary -> AllVocabularyWidget(
vocabularyViewModel = vocabularyViewModel,
onOpenAllVocabulary = { navController.navigate("${NavigationRoutes.STATS_VOCABULARY_LIST}/false/null") },
onStageClicked = { stage ->
@Suppress("HardCodedStringLiteral")
navController.navigate("${NavigationRoutes.STATS_VOCABULARY_LIST}/false/$stage")
}
)
WidgetType.DueToday -> DueTodayWidget(
vocabularyViewModel = vocabularyViewModel,
onStageClicked = { stage ->
@Suppress("HardCodedStringLiteral")
navController.navigate("${NavigationRoutes.STATS_VOCABULARY_LIST}/false/$stage")
}
)
WidgetType.CategoryProgress -> CategoryProgressWidget(
onCategoryClicked = { category ->
category?.let { onNavigateToCategoryDetail(it.id) }
},
onViewAllClicked = onNavigateToCategoryList
)
WidgetType.Levels -> LevelWidget(
totalWords = vocabularyViewModel.vocabularyItems.collectAsState().value.size,
learnedWords = vocabularyViewModel.stageStats.collectAsState().value
.firstOrNull { it.stage == VocabularyStage.LEARNED }?.itemCount ?: 0,
onNavigateToProgress = { navController.navigate(NavigationRoutes.STATS_LANGUAGE_PROGRESS) }
)
}
}
}
}
@Composable
private fun LazyStatusWidget(
vocabularyViewModel: VocabularyViewModel,
onNavigateToNew: () -> Unit,
onNavigateToDuplicates: () -> Unit,
onNavigateToFaulty: () -> Unit,
onNavigateToNoGrammar: () -> Unit,
onNavigateToMissingLanguage: (Int) -> Unit
) {
var isLoading by remember { mutableStateOf(true) }
// Collect all flows asynchronously
val newItemsCount by vocabularyViewModel.newItemsCount.collectAsState()
val duplicateCount by vocabularyViewModel.duplicateItemsCount.collectAsState()
val faultyItemsCount by vocabularyViewModel.faultyItemsCount.collectAsState()
val itemsWithoutGrammarCount by vocabularyViewModel.itemsWithoutGrammarCount.collectAsState()
val missingLanguageInfo by vocabularyViewModel.missingLanguageInfo.collectAsState()
LaunchedEffect(
newItemsCount,
duplicateCount,
faultyItemsCount,
itemsWithoutGrammarCount,
missingLanguageInfo
) {
delay(100)
isLoading = false
}
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
} else {
StatusWidget(
onNavigateToNew = onNavigateToNew,
onNavigateToDuplicates = onNavigateToDuplicates,
onNavigateToFaulty = onNavigateToFaulty,
onNavigateToNoGrammar = onNavigateToNoGrammar,
onNavigateToMissingLanguage = onNavigateToMissingLanguage
)
}
}
@Preview
@Composable
fun StatsScreenPreview() {
val navController = rememberNavController()
StatsScreen(
navController = navController,
onNavigateToCategoryDetail = {},
onNavigateToCategoryList = {},
)
}
@Preview
@Composable
fun WidgetContainerPreview() {
WidgetContainer(
widgetType = WidgetType.Streak,
isExpanded = true,
onExpandedChange = {},
onDragStart = { },
onDrag = { },
onDragEnd = { },
onDragCancel = { }
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
@Suppress("HardCodedStringLiteral")
Text("Preview Content")
}
}
}

View File

@@ -37,6 +37,7 @@ import eu.gaudian.translator.R
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.SourceLanguageDropdown
import eu.gaudian.translator.view.composable.TargetLanguageDropdown
import eu.gaudian.translator.view.hints.Hint
import eu.gaudian.translator.view.hints.WithHint
import eu.gaudian.translator.viewmodel.LanguageViewModel
@@ -67,9 +68,23 @@ fun ActionBar(
}
@Composable
fun TopBarActions(languageViewModel: LanguageViewModel, onSettingsClick: () -> Unit, hintContent: (@Composable () -> Unit)? = null) {
fun TopBarActions(
languageViewModel: LanguageViewModel,
onSettingsClick: () -> Unit,
onNavigateBack: (() -> Unit)? = null,
hintContent: Hint? = null
) {
ActionBar(modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp)) {
if (onNavigateBack != null) {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = stringResource(R.string.cd_navigate_back)
)
}
}
if (hintContent != null) {
WithHint(hintContent = hintContent) {
}

View File

@@ -106,6 +106,14 @@ fun TranslationScreen(
settingsViewModel = settingsViewModel,
onHistoryClick = onHistoryClick,
onSettingsClick = onSettingsClick,
onNavigateBack = {
if (!navController.popBackStack()) {
navController.navigate(eu.gaudian.translator.view.composable.Screen.Home.route) {
launchSingleTop = true
restoreState = false
}
}
},
context = context
)
}
@@ -119,6 +127,7 @@ private fun LoadedTranslationContent(
settingsViewModel: SettingsViewModel,
onHistoryClick: () -> Unit,
onSettingsClick: () -> Unit,
onNavigateBack: () -> Unit,
context: Context
) {
val inputText by translationViewModel.inputText.collectAsState()
@@ -167,7 +176,8 @@ private fun LoadedTranslationContent(
TopBarActions(
languageViewModel = languageViewModel,
onSettingsClick = onSettingsClick,
hintContent = { HintDefinition.TRANSLATION.Render() }
onNavigateBack = onNavigateBack,
hintContent = HintDefinition.TRANSLATION.hint()
)
AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) {

View File

@@ -5,32 +5,39 @@ package eu.gaudian.translator.view.vocabulary
import android.annotation.SuppressLint
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.TextOverflow
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
@@ -39,6 +46,7 @@ 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
@@ -47,10 +55,15 @@ import eu.gaudian.translator.view.dialogs.DeleteCategoryDialog
import eu.gaudian.translator.view.dialogs.DeleteItemsDialog
import eu.gaudian.translator.view.dialogs.EditCategoryDialog
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle
import eu.gaudian.translator.view.vocabulary.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
@@ -66,12 +79,16 @@ fun CategoryDetailScreen(
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val category by categoryViewModel.getCategoryById(categoryId).collectAsState(initial = null)
val categoryProgressList by progressViewModel.categoryProgressList.collectAsState()
val categoryProgress = categoryProgressList.find { it.vocabularyCategory.id == categoryId }
val exportState by exportImportViewModel.exportState.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val title = when (val cat = category) {
is TagCategory -> cat.name
is VocabularyFilter -> cat.name
@@ -89,11 +106,10 @@ fun CategoryDetailScreen(
if (!hasLangList && !hasPair && !hasStages) {
append(stringResource(R.string.text_filter_all_items))
} else {
//append(stringResource(R.string.filter))
append(" ")
if (hasPair) {
val (a,b) = cat.languagePairs
append("[${languages.value.find{ it.nameResId == a }?.name} - ${languages.value.find{ it.nameResId == b }?.name}]")
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 {
@@ -111,37 +127,57 @@ fun CategoryDetailScreen(
val showDeleteItemsDialog by categoryViewModel.showDeleteItemsDialog.collectAsState(initial = false)
val showEditCategoryDialog by categoryViewModel.showEditCategoryDialog.collectAsState(initial = false)
// Handle export state changes
LaunchedEffect(exportState) {
when (exportState) {
is ExportState.Success -> {
// Create and launch share intent
val shareIntent = exportImportViewModel.createShareIntent()
if (shareIntent != null) {
context.startActivity(shareIntent)
}
exportImportViewModel.resetExportState()
}
is ExportState.Error -> {
scope.launch {
snackbarHostState.showSnackbar(
message = (exportState as ExportState.Error).message
)
}
exportImportViewModel.resetExportState()
}
else -> { /* Idle or Loading */ }
}
}
// Scroll state for animation
val listState = rememberLazyListState()
var isHeaderVisible by remember { mutableStateOf(true) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
// Detect scroll direction to show/hide header (same as LibraryScreen)
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.collect { (index, offset) ->
val isScrollingDown = index > previousIndex || (index == previousIndex && offset > previousScrollOffset)
val isAtTop = index == 0 && offset <= 4
isHeaderVisible = if (isAtTop) true else !isScrollingDown
previousIndex = index
previousScrollOffset = offset
}
}
AppScaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
) {
AppTopAppBar(
title = {
Column {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
AppIcons.ArrowBack,
contentDescription = stringResource(R.string.cd_back)
)
}
},
title = title,
onNavigateBack = { navController.popBackStack() },
actions = {
IconButton(onClick = { showMenu = !showMenu }) {
Icon(
@@ -150,97 +186,96 @@ fun CategoryDetailScreen(
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.width(220.dp)
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.text_edit_category)) },
onClick = {
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
showMenu = false
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(stringResource(R.string.text_export_category)) },
onClick = {
vocabularyViewModel.saveCategory(categoryId)
showMenu = false
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.delete_items_category)) },
text = { Text("Delete Items") },
onClick = {
categoryViewModel.setShowDeleteItemsDialog(true, categoryId)
showMenu = false
}
},
leadingIcon = { Icon(AppIcons.Delete, contentDescription = null) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.text_delete_category)) },
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(
// TODO: Review this
containerColor = MaterialTheme.colorScheme.surface
)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
// Category Header Card with Progress and Action Buttons (animated)
androidx.compose.animation.AnimatedVisibility(
visible = isHeaderVisible,
enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(),
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically()
) {
if (categoryProgress != null) {
Box(modifier = Modifier.weight(1f)) {
CategoryProgressCircle(
totalItems = categoryProgress.totalItems,
itemsCompleted = categoryProgress.itemsCompleted,
itemsInStages = categoryProgress.itemsInStages,
newItems = categoryProgress.newItems,
circleSize = 80.dp,
)
}
} else {
Spacer(modifier = Modifier.weight(1f))
}
Box(
modifier = Modifier
.weight(1f)
.padding(start = 8.dp)
) {
PrimaryButton(
text = stringResource(R.string.label_start),
icon = AppIcons.Play,
onClick = {
val categories = listOf(category)
val categoryIds = categories.joinToString(",") { it?.id.toString() }
navController.navigate("vocabulary_exercise/false?categories=$categoryIds")
CategoryHeaderCard(
subtitle = subtitle,
categoryProgress = categoryProgress,
onStartExerciseClick = {
navController.navigate("start_exercise?categoryId=$categoryId")
},
modifier = Modifier.heightIn(max = 80.dp)
)
onEditClick = {
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
},
onDeleteClick = {
categoryViewModel.setShowDeleteCategoryDialog(true, categoryId)
}
)
}
}
}
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
VocabularyListScreen(
AllCardsListScreen(
categoryId = categoryId,
showDueTodayOnly = false,
onNavigateToItem = onNavigateToItem,
navController = navController, // Pass the received navController here
navController = navController,
isRemoveFromCategoryEnabled = category is TagCategory,
showTopBar = false,
enableNavigationButtons = true
enableNavigationButtons = true,
listState = listState
)
// Dialogs
@@ -266,3 +301,106 @@ fun CategoryDetailScreen(
}
}
}
@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 = {}
)
}
}

View File

@@ -100,13 +100,7 @@ fun CategoryListScreen(
AppScaffold(
topBar = {
AppTopAppBar(
title = {
if (isSelectionMode && selectedCategories.isNotEmpty()) {
Text(stringResource(R.string.text_2d_categories_selected, selectedCategories.size))
} else {
Text(stringResource(R.string.label_categories))
}
},
title = stringResource(R.string.label_all_categories),
navigationIcon = {
if (isSelectionMode) {
IconButton(onClick = {

View File

@@ -55,7 +55,6 @@ import androidx.navigation.compose.rememberNavController
import eu.gaudian.translator.R
import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.model.WidgetType
import eu.gaudian.translator.utils.Log
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
@@ -65,7 +64,6 @@ import eu.gaudian.translator.view.vocabulary.widgets.AllVocabularyWidget
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressWidget
import eu.gaudian.translator.view.vocabulary.widgets.DueTodayWidget
import eu.gaudian.translator.view.vocabulary.widgets.LevelWidget
import eu.gaudian.translator.view.vocabulary.widgets.ModernStartButtons
import eu.gaudian.translator.view.vocabulary.widgets.StatusWidget
import eu.gaudian.translator.view.vocabulary.widgets.StreakWidget
import eu.gaudian.translator.view.vocabulary.widgets.WeeklyActivityChartWidget
@@ -530,17 +528,7 @@ private fun LazyWidget(
onMissingLanguage: (Int) -> Unit
) {
when (widgetType) {
WidgetType.StartButtons -> ModernStartButtons(
onCustomClick = onShowCustomExerciseDialog,
onDailyClick = { isSpelling ->
if (isSpelling) {
onShowWordPairExerciseDialog()
} else {
startDailyExercise(true)
Log.d("DailyExercise", "Starting daily exercise")
}
}
)
WidgetType.Status -> LazyStatusWidget(
vocabularyViewModel = vocabularyViewModel,

Some files were not shown because too many files have changed in this diff Show More