Compare commits

...

82 Commits

Author SHA1 Message Date
jonasgaudian
199f5ae33f Refactor the CSV import logic into a reusable CsvImportDialog component and centralize download configurations. 2026-02-21 11:44:45 +01:00
jonasgaudian
cfd71162a0 Refactor the dictionary and corrector navigation by promoting the Corrector to a top-level destination and removing the tabbed MainDictionaryScreen. 2026-02-20 00:03:19 +01:00
jonasgaudian
c94b29073f implement conditional AI generator UI and improve "No Connection" handling 2026-02-19 22:50:25 +01:00
jonasgaudian
95dfd3c7eb implement automated translation and caching for vocabulary pack names and descriptions in ExplorePacksScreen using LibreTranslate. 2026-02-19 18:37:53 +01:00
jonasgaudian
d6a9ccf4e3 Implement a StageIndicator to visualize vocabulary learning progress and refine the VocabularyCard UI. 2026-02-19 17:47:44 +01:00
jonasgaudian
863920143d Refactor the WeeklyActivityChartWidget into an interactive smooth line chart and update vocabulary import labels. 2026-02-19 16:16:24 +01:00
jonasgaudian
15d03ef57f Update grammar info string resource naming, add hasFeatures helper to Vocabulary model, and update grammar count logic in VocabularyViewModel 2026-02-19 15:29:08 +01:00
jonasgaudian
f737657cdb refactor UI components and layout in NewWordScreen and HomeScreen using new reusable composables: AppActionCard, AppIconContainer, AppTextField, and LabeledSection. 2026-02-19 15:24:27 +01:00
jonasgaudian
b75f5f32a0 implement vocabulary packs exploration and request system 2026-02-19 13:01:55 +01:00
jonasgaudian
0f8d605df7 implement CEFR level filtering and language-based sorting in ExplorePacksScreen 2026-02-18 23:35:57 +01:00
jonasgaudian
0a202191eb implement vocabulary packs exploration and download functionality 2026-02-18 23:11:32 +01:00
jonasgaudian
d12a21909c adjust UI layout in StartExerciseScreen and disable text wrapping for action buttons in StartExerciseScreen and HomeScreen 2026-02-18 20:58:31 +01:00
jonasgaudian
37d8c2a6c5 Refactor the project structure by reorganizing exercise, category, and statistics components, and extract AppCard into a dedicated file. 2026-02-18 20:54:18 +01:00
jonasgaudian
8f42fa79ef add a bold title header and adjust padding in StatsScreen 2026-02-18 01:23:04 +01:00
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
jonasgaudian
2b8b9a84a3 implement internationalization for status messages using StatusMessageId enum and refactor StatusMessageService and StatusViewModel to support ID-based message resolution 2026-02-16 10:19:46 +01:00
jonasgaudian
59f5f5e668 Update help documentation and refine settings configuration 2026-02-15 23:32:01 +01:00
jonasgaudian
15f7eae068 add find_ai_model.md guide and integrate StatusMessageService into TranslationViewModel 2026-02-15 22:47:43 +01:00
jonasgaudian
8e610259ca Refactor the hint system by consolidating hint definitions into a central HintDefinition enum and migrating individual hint files to a markdown-based approach. 2026-02-15 21:46:11 +01:00
jonasgaudian
7d18f8eb04 update providers_config.json models and refactor IntroFlow.kt UI 2026-02-15 20:53:21 +01:00
jonasgaudian
f4fcffe90a Updated preconfigured models 2026-02-15 18:11:11 +01:00
jonasgaudian
5e920c43b3 Bumped version to 0.5.0 and added changelog (English) 2026-02-15 17:55:06 +01:00
jonasgaudian
61a97a1119 implement delayed FAB text display in MainVocabularyScreen 2026-02-15 17:39:39 +01:00
jonasgaudian
2e0fe76fbf Step 1 in unifying dropdowns 2026-02-15 17:33:57 +01:00
jonasgaudian
a715ab78e9 refactor CategoryDropdown and improve vocabulary filtering with multi-category support 2026-02-15 14:56:23 +01:00
jonasgaudian
fa3524268a implement DebugTheme and update system bar colors in MainActivity 2026-02-15 13:01:56 +01:00
jonasgaudian
77b86208c3 implement demotion logic in VocabularyRepository and refactor VocabularyExerciseViewModel answer checking 2026-02-15 12:14:24 +01:00
jonasgaudian
03e9aeedae update LanguageRepository to dynamically handle language count and improve synchronization of enabled language IDs 2026-02-14 23:55:16 +01:00
jonasgaudian
05a1b2b71a add Filipino language support 2026-02-14 23:28:58 +01:00
jonasgaudian
18474b072e update help documentation and re-enable hints in settings 2026-02-14 23:19:41 +01:00
jonasgaudian
858c73fd0d update and reorganize German and Portuguese string resources 2026-02-14 18:06:16 +01:00
jonasgaudian
b8baf0cd84 Clean up string resources by deleting hint_strings.xml and refactoring hint-related strings into the main strings.xml 2026-02-14 17:20:42 +01:00
jonasgaudian
d2e77083ad migrate hints system to a localized markdown-based architecture and refactor related UI components 2026-02-14 17:15:26 +01:00
jonasgaudian
306d0c7432 implement markdown-based hint system and add MarkdownHint component 2026-02-14 15:53:05 +01:00
jonasgaudian
f829174bcb refactor CategoryDropdown to a stateless component and relocate ApiModelDropDown 2026-02-14 14:33:53 +01:00
225 changed files with 17299 additions and 10534 deletions

View File

@@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-02-06T10:01:23.649270100Z"> <DropdownSelection timestamp="2026-02-20T17:14:10.736481200Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\jonas\.android\avd\Medium_Phone_28.avd" /> <DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\jonas\.android\avd\Pixel_6.avd" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>

View File

@@ -6,7 +6,6 @@ import java.util.Locale
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt.android) alias(libs.plugins.hilt.android)
id("kotlin-parcelize") id("kotlin-parcelize")
@@ -22,8 +21,8 @@ android {
applicationId = "eu.gaudian.translator" applicationId = "eu.gaudian.translator"
minSdk = 28 minSdk = 28
targetSdk = 36 targetSdk = 36
versionCode = 22 versionCode = 23
versionName = "0.4.1" versionName = "0.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -62,11 +61,8 @@ android {
"-opt-in=androidx.compose.ui.ExperimentalComposeUiApi" "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi"
) )
} }
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
}
buildFeatures { buildFeatures {
compose = true compose = true
viewBinding = false viewBinding = false
@@ -130,7 +126,8 @@ dependencies {
implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
implementation(libs.core.ktx) implementation(libs.core.ktx)
ksp(libs.room.compiler) // CHANGED: Use ksp instead of implementation implementation(libs.androidx.compose.runtime)
ksp(libs.room.compiler)
// Networking // Networking
implementation(libs.retrofit) implementation(libs.retrofit)
@@ -170,6 +167,9 @@ dependencies {
//noinspection UseTomlInstead //noinspection UseTomlInstead
implementation("com.pierfrancescosoffritti.androidyoutubeplayer:core:13.0.0") implementation("com.pierfrancescosoffritti.androidyoutubeplayer:core:13.0.0")
// Markdown rendering
implementation(libs.compose.markdown)
// Compression // Compression
testImplementation (libs.zstd.jni) testImplementation (libs.zstd.jni)
implementation(libs.zstd.jni.get().toString() + "@aar") implementation(libs.zstd.jni.get().toString() + "@aar")

View File

@@ -6,9 +6,6 @@ object TestConfig {
// REPLACE with your actual API Key for the test // REPLACE with your actual API Key for the test
const val API_KEY = "YOUR_REAL_API_KEY_HERE" 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") // Optional: If your ApiManager requires a specific provider (e.g., "Mistral", "OpenAI")
const val PROVIDER_NAME = "Mistral" const val PROVIDER_NAME = "Mistral"

View File

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

@@ -0,0 +1,70 @@
## What is an API Key?
An API key is like a password that lets your app talk to AI services. You need one to use an AI provider like OpenAI (ChatGPT), Anthropic, Mistral, or DeepSeek.
## Getting an API Key
Some providers offer a limited free use of their API, which should be sufficient for most functions of this app; however, it is recommended to use a faster and better-paid service.
### For Cloud Providers
1. Create an account on the provider's website
2. Choose a plan or billing option or free tier if available
3. Create a new key and copy it
4. Paste it into this app
### For Local AI Servers
Running a local AI server (like Ollama or LM Studio), you don't need an API key. Just add a custom provider:
1. Tap **"Add Custom Provider"**
2. Enter your local server IP and endpoint
3. Tap **"Check Availability"** to test the connection
## Choosing a Model
### What are Models?
A model is a specific AI brain. Different models have different strengths:
- **Smaller models**: Faster and cheaper
- **Larger models**: Smarter but slower and more expensive
For pre-configured providers, some models are already added by default and proven to work with this app.
### Adding Models
1. Open a provider's details
2. Tap **Add Model**
3. Choose **Scan for Models** to find available ones automatically
4. Select the models you want to use
### Assigning Models to Tasks
You can use different models for different features:
1. Go to the **Tasks** tab
2. Select which model to use for:
- **Translation**: Translates text between languages
- **Exercises**: Creates practice exercises
- **Vocabulary**: Generates vocabulary and synonyms
- **Dictionary**: Looks up definitions
## Common Problems
### "Invalid API Key"
- Check for typos or extra spaces
- Make sure your key is still active and valid on the provider's website
### "No Models Available"
- Make sure your API key is valid first
- If on a local network, make sure that your connection and endpoint is configured correctly
### Slow Responses
- Try a faster provider, you might need to choose a paid option
- Use a smaller model (look for names with "small" "light" "fast" "nano")
### Local Server Not Working
- Make sure your local server is running
- Check that the URL is correct
- Your phone and computer might need to be on the same WiFi for local servers.

View File

@@ -0,0 +1,40 @@
## What Are Categories?
Categories help you organize your vocabulary into meaningful groups. You can use them to track words by topic, language, stage, or any custom system that works for you.
## Two Types of Categories
### List Categories
List categories are simple groupings of vocabulary items. You can simply just add words to a list and they stay there forever.
**Use cases:**
- Group words by topic (e.g., "Food", "Travel", "Business")
- Create custom decks for specific purposes
### Filter Categories
Filter categories automatically include all vocabulary items that match certain criteria. Words are dynamically added or removed based on the filter rules.
**Use cases:**
- Filter by learning stage (e.g., "Words I'm learning")
- Filter by language
- Combine multiple criteria for complex filtering (e.g., "Words in Spanish that I know already")
## Creating Categories
1. **Tap the + button** to create a new category
2. **Choose the type** - List or Filter
3. **Add a name** and optional description
4. **Set the rules** (for filter categories)
5. **Save** your category
## Managing Categories
- **Edit** - Enter a category to modify its settings
## Tips
- Use filter categories for learning stages to automatically track progress across all words at a certain level.
- The same vocabulary card can appear in several categories
- You can also use categories to manage large groups of vocabulary items at once by using the "selct all" feature inside a category

View File

@@ -0,0 +1,50 @@
# Dictionary Options
# TODO REWRITE
Learn how to configure and use the dictionary options for better translations.
## What Are Dictionary Options?
Dictionary options allow you to customize how translations appear and how the dictionary feature works.
## Key Features
### Synonyms
Enable synonyms to see alternative translations:
- Toggle the **Synonyms** switch
- View multiple translation options
- Choose the most appropriate meaning
### Part of Speech
Display grammatical information:
- See if a word is noun, verb, adjective
- Helps understand context
- Available for supported languages
### Example Sentences
View usage examples:
- See words in context
- Learn proper usage patterns
- Understand nuances
## Configuration
### Enable/Disable Features
1. Go to Settings → Dictionary
2. Toggle desired options on/off
3. Changes apply immediately
### Custom Dictionary
Add custom entries:
- Tap the **+** button
- Enter word and translation
- Save to your personal dictionary
---
*Tip: Enable all options for the richest translation experience!*

View File

@@ -0,0 +1,81 @@
## What Are Exercises?
Exercises help you practice and memorize your vocabulary through interactive activities. You can practice with flashcards, test your spelling, or challenge yourself with word puzzles.
## Exercise Types
### Flashcard Mode
A simple card shows one word, and you guess the translation. Tap to flip and reveal the answer, then mark it as correct or wrong.
### Spelling Practice
A word appears and you must type the translation yourself. This is great for memorizing spelling and reinforcing what you've learned.
### Multiple Choice
Four answer options are shown and you pick the correct translation. Good for quick recognition practice.
### Word Jumble
Letters are scrambled and you must click them in the correct order to spell the word. Excellent for mastering spelling.
## Starting an Exercise
1. **From the Dashboard** - Tap the Start Exercise button
2. **From a Category** - Open any category and tap Start
3. **From the Main Screen** - Tap the floating action button
## Before You Start
You can customize your exercise:
- **How many cards** - Use the slider to pick 1 to 100+ cards
- **Quick select** - Tap 10, 25, 50, or 100 for fast selection
- **Language direction** - Choose which language to translate from and to
- **Exercise types** - Pick one type or mix several together
- **Shuffle cards** - Randomize the order
- **Shuffle languages** - Randomize which side (word/translation) is shown
- **Training mode** - Practice without affecting your progress
- **Due today only** - Only practice items scheduled for today
## Training Mode
When training mode is ON:
- Your answers won't affect your progress stages
- No statistics are recorded
- Perfect for casual practice or testing yourself
When training mode is OFF (default):
- Correct answers may advance items to higher stages
- Progress is saved and tracked
- Affects your learning statistics
## During an Exercise
The progress bar at the top shows:
- **Green** - How many you've gotten right
- **Red** - How many you've gotten wrong
- **Total** - How many cards in this session
Tap the close button anytime to exit (you'll be asked to confirm).
## After Finishing
Your results screen shows:
- Your overall score as a percentage
- How many you got right and wrong
- Total cards practiced
You can then:
- **Start Over** - Begin a fresh exercise
- **Repeat Wrong** - Practice only the cards you missed
- **Finish** - Return to the main screen
## Tips
- **Mix it up** - Combine different exercise types for variety
- **Daily practice** - Use "Due Today Only" for regular reviews
- **Focus on mistakes** - Use "Repeat Wrong" to learn from errors
- **Start small** - Begin with 10-25 cards and increase over time
- **Use training mode** - When you want to practice without pressure

View File

@@ -0,0 +1,24 @@
All vocabulary lists in this section were generated automatically. While I strive for accuracy and quality, please keep in mind that these are machine-generated collections.
I'm a single developer building and maintaining this app in my spare time. I'm passionate about creating tools that help people learn languages, but I have limited resources and time.
### Your Feedback Matters
I greatly appreciate any feedback, suggestions, or ideas you might have! If you:
- Find errors in any vocabulary pack
- Have ideas for new topics, languages, or categories
- Want to request a specific vocabulary pack
- Have suggestions for improving existing packs
Please don't hesitate to reach out through the Request feature or contact me directly. Your input helps make this app better for everyone!
### How Packs Work
- **Download** packs that interest you
- **Preview** the words before adding them
- **Import** them into your library with options to handle duplicates
- **Organize** them into categories which are created automatically
Thank you for using this app and your feedback!

View File

@@ -0,0 +1,34 @@
The scan feature searches for available AI models for your configured provider
> **Note:** Results depend on your API key permissions. The provider must support the OpenAI API format.
### Key Points
- Only public models are shown by default
- Try again if no models are found
### Model Tiers
Not all models are suitable for every task:
- **Nano** - Fastest, good for simple tasks like translations
- **Mini** - Balanced speed and capability
- **Small** - Good for most tasks
- **Medium** - More capable, recommended for execise and vocabulary generation
- **Large** - Most capable, mostly paid, best results
## Tips for Success
1. **Verify your API key** is active and has correct permissions
2. Choose a capable model that supports text generation
3. For local providers, make sure your connection and endponts are set up correctly
## Can't Find Your Model?
If your model doesn't appear in the scan results:
1. Check if the model is running locally or accessible via API
2. Verify network connectivity
3. Try adding it manually by entering the model details
> Check the logs in case of validation error

View File

@@ -0,0 +1,46 @@
Generate vocabulary lists automatically using AI assistance.
## Getting Started
Use AI to quickly create vocabulary lists for a certain topic.
### Step 1: Enter Search Term
Type a topic, theme, or concept for your vocabulary list:
- Be specific for better results
- Example: "German food and restaurant phrases"
- Example: "Things to do in Paris"
- Example: "Difficult verbs that are confusing"
### Step 2: Select Languages
Choose source and target languages:
- **Source language** - The first language of the flashcard
- **Target language** - The second language of the flashcard
### Step 3: Set Amount
Choose how many words to generate:
- Slide to select 1-25 words
- More words = longer processing time
- Start small for your first import
### Step 4: Generate
Tap the generate button:
- AI creates the vocabulary list
## After Generation
Once generated, you can:
- Choose which terms to keep
- Optionally, add it to a category
## Tips
- In the settings, you can give additional instructions to the AI, like "Use only nouns" or "European Portuguese orthography"
- Start with a small number of items to see how many words your AI can generate.
- Check the logs in the settings in case of failure
- Try out different providers and AI models as results can vary greatly

View File

@@ -0,0 +1,44 @@
## The Learning Stages
Your vocabulary items move through these stages as you learn. In "daily" exercises you get presented with vocabulary items according to their interval.
| Stage | Interval | Description |
|------|----------|-------------|
| New | 1 day | Just added vocabulary |
| Stage 1 | 3 days | Recently learned |
| Stage 2 | 1 week | Reinforcement |
| Stage 3 | 2 weeks | Consolidation |
| Stage 4 | 1 month | Deep learning |
| Stage 5 | 2 month | Mastery |
| Learned | 3 months | Fully learned |
## How It Works
### Answer Correctly
When you correctly identify a word during an exercise:
- The word **moves forward** to the next stage
- The interval until next review **increases**
- This helps you focus on words that need more practice
### Answer Incorrectly
When you make a mistake:
- The word **moves back** one stage
- The review interval **decreases**
- This ensures you practice challenging words more often
## Customization
All intervals and rules can be customized:
- **Adjust intervals** for each stage
- **Change how many attempts** it takes to move up a stage or get demoted
- **Skip stages** You can also manually move items to a stage
## Visual Progress
In the dashboard, the app displays your progress visually:
- Stage indicators show current status
- Progress bars track advancement
- Statistics display overall mastery

View File

@@ -0,0 +1,64 @@
# Review Vocabulary
# TODO REWRITE
Master your vocabulary through systematic review sessions.
## The Review Screen
When you start a review session, you'll see:
- The **source word** to translate
- Options to reveal the **translation**
- Buttons to indicate your **knowledge level**
## How to Review
### 1. View the Word
Read the source word carefully:
- Pay attention to the spelling
- Think of the meaning
- Recall the translation
### 2. Reveal Translation
Tap to show the translation:
- Compare with your recall
- Note any differences
- Learn from mistakes
### 3. Rate Your Knowledge
Rate how well you knew the answer:
| Button | Meaning | Action |
|--------|---------|-------------------|
| 😓 | Hard | Moves back stages |
| 🤔 | Okay | Stays current |
| ✅ | Easy | Advances stage |
## Review Statistics
Track your progress:
- **Cards reviewed** - Total in session
- **Accuracy** - Percentage correct
- **Time spent** - Learning duration
## Best Practices
### Daily Reviews
- Review **every day** for best results
- Complete **all due** cards before adding new
- Focus on **problem areas**
### Spaced Repetition
The system uses spaced repetition:
- **Hard cards** - Review sooner (1-2 days)
- **Okay cards** - Review normally (as scheduled)
- **Easy cards** - Review later (weeks)
---
*Consistent daily review is the key to long-term retention!*

View File

@@ -0,0 +1,48 @@
After you imported vocabulary, you can sort vocabulary
- Review each word-translation pair
- Decide the next action for each item
- Handle duplicates and conflicts
## Actions
### Mark as Learned
If you already know the word, move the word directly to Stage "Learned". This prevents the word from reappearing in your exercises.
### Delete
Remove the word entirely:
- Use for duplicates or unwanted entries
- This action is permanent
### Edit
Tap on any word or translation to edit:
- Correct typos
- Improve translations
- Add additional context
## Duplicate Handling
When duplicates are detected, you can choose how to handle them:
**Options for duplicates:**
- Keep only the original
- Keep the newer entry
- Keep both (merge): the newer entry will get deleted but all its information (categories) will be added the old item.
- Delete the duplicate
## Helper Features
### Remove Articles
Toggle to automatically strip articles from words:
- "der Hund" → "Hund"
- "the dog" → "dog"
- Useful for cleaner vocabulary lists
## Tips
You can edit your flashcards at any point in the flashcard itself

View File

@@ -0,0 +1,68 @@
# Translation Features
# TODO REWRITE
Discover the powerful translation capabilities of this app.
## Alternative Translations
Sometimes a word has multiple meanings:
- Tap the **Show alternatives** option
- See all possible translations
- Choose the most appropriate context
## Custom Prompts
Customize how translations are generated:
### Create Custom Prompt
1. Go to Settings → Translation
2. Tap **Add Custom Prompt**
3. Write your prompt template
4. Save and use in translations
### Example Prompts
```
Translate {word} as used in {context}
Provide formal translation of: {word}
Casual translation for: {word}
```
## Multiple Translation Services
Use different translation backends:
| Service | Best For | Languages |
|---------|----------|-----------|
| AI Models | Context-aware | Many |
| Dictionary | Quick lookup | Limited |
| Online API | Accuracy | All |
## Translation History
Track all your translations:
- Automatically saved
- Search by word or date
- Export for review
## Text-to-Speech (TTS)
Hear words pronounced:
- Tap the **speaker icon**
- Choose voice for each language
- Adjust speed as needed
## Quick Actions
Fast access to common tasks:
- **Copy** - Copy translation to clipboard
- **Add** - Add directly to vocabulary
- **Share** - Send to other apps
---
*Pro Tip: Use custom prompts for domain-specific vocabulary!*

View File

@@ -0,0 +1,7 @@
Monitor your vocabulary learning journey with detailed progress statistics.
## Progress Overview
Track your learning with these key metrics:
TODO Rewrite

View File

@@ -21,10 +21,10 @@
"description": "Next-gen efficient architecture; outperforms older 70B models." "description": "Next-gen efficient architecture; outperforms older 70B models."
}, },
{ {
"modelId": "deepseek-ai/DeepSeek-V3", "modelId": "deepseek-ai/DeepSeek-V3.1",
"displayName": "DeepSeek V3", "displayName": "DeepSeek V3.1",
"provider": "together", "provider": "together",
"description": "Top-tier open-source model specializing in code and logic." "description": "Latest 671B MoE model with hybrid thinking/non-thinking modes."
} }
] ]
}, },
@@ -37,10 +37,10 @@
"isCustom": false, "isCustom": false,
"models": [ "models": [
{ {
"modelId": "ministral-8b-latest", "modelId": "mistral-medium-latest",
"displayName": "Ministral 8B", "displayName": "Mistral Medium",
"provider": "mistral", "provider": "mistral",
"description": "Extremely efficient edge model for low-latency tasks." "description": "Balanced performance and cost for a wide range of tasks."
}, },
{ {
"modelId": "mistral-large-latest", "modelId": "mistral-large-latest",
@@ -58,17 +58,17 @@
"websiteUrl": "https://platform.openai.com/", "websiteUrl": "https://platform.openai.com/",
"isCustom": false, "isCustom": false,
"models": [ "models": [
{
"modelId": "gpt-5.2",
"displayName": "GPT-5.2",
"provider": "openai",
"description": "Balanced performance with enhanced reasoning and creativity."
},
{ {
"modelId": "gpt-5.1-instant", "modelId": "gpt-5.1-instant",
"displayName": "GPT-5.1 Instant", "displayName": "GPT-5.1 Instant",
"provider": "openai", "provider": "openai",
"description": "The standard high-speed efficiency model replacing older 'Nano' tiers." "description": "The standard high-speed efficiency model replacing older 'Nano' tiers."
},
{
"modelId": "gpt-5-nano",
"displayName": "GPT-5 Nano",
"provider": "openai",
"description": "Fast and cheap model sufficient for most tasks."
} }
] ]
}, },
@@ -81,16 +81,16 @@
"isCustom": false, "isCustom": false,
"models": [ "models": [
{ {
"modelId": "claude-sonnet-5-20260203", "modelId": "claude-opus-4-6",
"displayName": "Claude Sonnet 5", "displayName": "Claude Opus 4.6",
"provider": "anthropic", "provider": "anthropic",
"description": "Latest stable workhorse (Feb 2026), balancing speed and top-tier reasoning." "description": "Most intelligent model for building agents and coding with 1M context."
}, },
{ {
"modelId": "claude-4.5-haiku", "modelId": "claude-sonnet-4-5",
"displayName": "Claude 4.5 Haiku", "displayName": "Claude Sonnet 4.5",
"provider": "anthropic", "provider": "anthropic",
"description": "Fastest Claude model for pure speed and simple tasks." "description": "Best combination of speed and intelligence with extended thinking."
} }
] ]
}, },
@@ -110,9 +110,9 @@
}, },
{ {
"modelId": "deepseek-chat", "modelId": "deepseek-chat",
"displayName": "DeepSeek V3", "displayName": "DeepSeek V3.1",
"provider": "deepseek", "provider": "deepseek",
"description": "General purpose chat model, specialized in code and reasoning." "description": "Latest 671B MoE with hybrid thinking/non-thinking modes, 128K context."
} }
] ]
}, },
@@ -120,15 +120,15 @@
"key": "gemini", "key": "gemini",
"displayName": "Google Gemini", "displayName": "Google Gemini",
"baseUrl": "https://generativelanguage.googleapis.com/", "baseUrl": "https://generativelanguage.googleapis.com/",
"endpoint": "v1beta/models/gemini-3-flash-preview:generateContent", "endpoint": "v1beta/models/gemini-2.5-pro:generateContent",
"websiteUrl": "https://ai.google/", "websiteUrl": "https://ai.google/",
"isCustom": false, "isCustom": false,
"models": [ "models": [
{ {
"modelId": "gemini-3-flash-preview", "modelId": "gemini-2.5-pro",
"displayName": "Gemini 3 Flash", "displayName": "Gemini 2.5 Pro",
"provider": "gemini", "provider": "gemini",
"description": "Current default: Massive context, grounded, and extremely fast." "description": "Stable release: State-of-the-art reasoning with 1M context."
}, },
{ {
"modelId": "gemini-3-pro-preview", "modelId": "gemini-3-pro-preview",
@@ -156,16 +156,10 @@
"isCustom": false, "isCustom": false,
"models": [ "models": [
{ {
"modelId": "llama-4-scout-17b", "modelId": "meta-llama/llama-4-maverick",
"displayName": "Llama 4 Scout", "displayName": "Llama 4 Maverick",
"provider": "groq", "provider": "groq",
"description": "Powerful Llama 4 model running at extreme speed." "description": "400B MoE powerhouse with industry-leading image and text understanding."
},
{
"modelId": "llama-3.3-70b-versatile",
"displayName": "Llama 3.3 70B",
"provider": "groq",
"description": "Previous gen flagship, highly reliable and fast on Groq chips."
} }
] ]
}, },
@@ -216,10 +210,10 @@
"description": "World's fastest inference (2000+ tokens/sec) on Wafer-Scale Engines." "description": "World's fastest inference (2000+ tokens/sec) on Wafer-Scale Engines."
}, },
{ {
"modelId": "llama3.1-8b", "modelId": "llama-4-scout",
"displayName": "Llama 3.1 8B", "displayName": "Llama 4 Scout",
"provider": "cerebras", "provider": "cerebras",
"description": "Instant speed for simple tasks." "description": "High-quality 17B active param model running at 2,600 tokens/sec."
} }
] ]
}, },
@@ -238,10 +232,10 @@
"description": "Hosted via the Hugging Face serverless router (Free tier limits apply)." "description": "Hosted via the Hugging Face serverless router (Free tier limits apply)."
}, },
{ {
"modelId": "microsoft/Phi-3.5-mini-instruct", "modelId": "Qwen/Qwen2.5-72B-Instruct",
"displayName": "Phi 3.5 Mini", "displayName": "Qwen 2.5 72B",
"provider": "huggingface", "provider": "huggingface",
"description": "Highly capable small model from Microsoft." "description": "High-quality open model with excellent reasoning and multilingual capabilities."
} }
] ]
} }

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 package eu.gaudian.translator.di
import android.app.Application import android.app.Application

View File

@@ -57,6 +57,10 @@ data class VocabularyItem(
features = switchedFeaturesJson features = switchedFeaturesJson
) )
} }
fun hasFeatures(): Boolean {
return !features.isNullOrBlank() && features != "{}"
}
} }
@Serializable @Serializable

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) { sealed class WidgetType(val id: String, @param:StringRes val titleRes: Int) {
data object Status : WidgetType("status", R.string.label_status) data object Status : WidgetType("status", R.string.label_status)
data object Streak : WidgetType("streak", R.string.title_widget_streak) 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 AllVocabulary : WidgetType("all_vocabulary", R.string.label_all_vocabulary)
data object DueToday : WidgetType("due_today", R.string.title_widget_due_today) data object DueToday : WidgetType("due_today", R.string.title_widget_due_today)
data object CategoryProgress : WidgetType("category_progress", R.string.label_categories) 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( val DEFAULT_ORDER = listOf(
Status, Status,
Streak, Streak,
StartButtons,
AllVocabulary, AllVocabulary,
DueToday, DueToday,
CategoryProgress , CategoryProgress ,

View File

@@ -10,6 +10,7 @@ import eu.gaudian.translator.model.repository.SettingsRepository
import eu.gaudian.translator.utils.ApiCallback import eu.gaudian.translator.utils.ApiCallback
import eu.gaudian.translator.utils.Log import eu.gaudian.translator.utils.Log
import eu.gaudian.translator.utils.StatusAction import eu.gaudian.translator.utils.StatusAction
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.viewmodel.MessageAction import eu.gaudian.translator.viewmodel.MessageAction
import eu.gaudian.translator.viewmodel.MessageDisplayType import eu.gaudian.translator.viewmodel.MessageDisplayType
@@ -228,10 +229,7 @@ class ApiManager(private val context: Context) {
val allowNoKey = provider.isCustom || isLocalHost val allowNoKey = provider.isCustom || isLocalHost
val effectiveKey = if (key.isNotEmpty()) key else if (allowNoKey) "" else key val effectiveKey = if (key.isNotEmpty()) key else if (allowNoKey) "" else key
// Perplexity does not support listing models via /v1/models; fail fast with a clear message
if (provider.key.equals("perplexity", ignoreCase = true)) {
return Pair(emptyList(), "Perplexity does not support fetching modeles.") //TODO this must be transalted!
}
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
try { try {
@@ -406,11 +404,7 @@ class ApiManager(private val context: Context) {
if (languageModel == null) { if (languageModel == null) {
val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context)) val errorMsg = context.getString(R.string.no_model_selected_for_the_task, modelType.getLocalizedName(context))
StatusMessageService.trigger(StatusAction.ShowActionableMessage( StatusMessageService.showErrorById(StatusMessageId.ERROR_NO_MODEL_CONFIGURED)
text = errorMsg,
type = MessageDisplayType.ACTIONABLE_ERROR,
action = MessageAction.NAVIGATE_TO_API_KEYS
))
callback.onFailure(errorMsg) callback.onFailure(errorMsg)
return@launch return@launch
} }

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

View File

@@ -1,193 +0,0 @@
package eu.gaudian.translator.model.communication
import android.content.Context
import androidx.core.content.edit
import eu.gaudian.translator.R
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
/**
* Manages downloading files from the server, verifying checksums, and checking versions.
*/
class FileDownloadManager(private val context: Context) {
private val baseUrl = "http://23.88.48.47/"
private val retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val manifestApiService = retrofit.create<ManifestApiService>()
@Suppress("HardCodedStringLiteral")
private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
/**
* Fetches the manifest from the server.
*/
suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
try {
val response = manifestApiService.getManifest().execute()
if (response.isSuccessful) {
response.body()
} else {
@Suppress("HardCodedStringLiteral") val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
@Suppress("HardCodedStringLiteral")
Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
}
} catch (e: Exception) {
@Suppress("HardCodedStringLiteral")
Log.e("FileDownloadManager", "Error fetching manifest", e)
throw e
}
}
/**
* Downloads all assets for a file and verifies their checksums.
*/
suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
val totalAssets = fileInfo.assets.size
for ((completedAssets, asset) in fileInfo.assets.withIndex()) {
val success = downloadAsset(asset) { assetProgress ->
// Calculate overall progress
val assetContribution = assetProgress / totalAssets
val previousAssetsProgress = completedAssets.toFloat() / totalAssets
onProgress(previousAssetsProgress + assetContribution)
}
if (!success) {
return@withContext false
}
}
// Save version after all assets are downloaded successfully
sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
@Suppress("HardCodedStringLiteral")
Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
true
}
/**
* Downloads a specific asset and verifies its checksum.
*/
private suspend fun downloadAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
val fileUrl = "${baseUrl}${asset.filename}"
val localFile = File(context.filesDir, asset.filename)
try {
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
response.code,
response.message
)
Log.e("FileDownloadManager", errorMessage)
throw Exception(errorMessage)
}
val body = response.body
val contentLength = body.contentLength()
if (contentLength <= 0) {
throw Exception("Invalid file size: $contentLength")
}
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead: Long = 0
@Suppress("HardCodedStringLiteral") val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
digest.update(buffer, 0, bytesRead)
totalBytesRead += bytesRead
onProgress((totalBytesRead.toFloat() / contentLength))
}
output.flush()
// Compute checksum
val computedChecksum = digest.digest().joinToString("") {
@Suppress("HardCodedStringLiteral")
"%02X".format(it)
}
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
@Suppress("HardCodedStringLiteral")
Log.d("FileDownloadManager", "Download successful for ${asset.filename}")
true
} else {
Log.e("FileDownloadManager",
context.getString(
R.string.text_checksum_mismatch_for_expected_got,
asset.filename,
asset.checksumSha256,
computedChecksum
))
localFile.delete() // Delete corrupted file
throw Exception("Checksum verification failed for ${asset.filename}")
}
}
}
} catch (e: Exception) {
@Suppress("HardCodedStringLiteral")
Log.e("FileDownloadManager", "Error downloading asset", e)
// Clean up partial download
if (localFile.exists()) {
localFile.delete()
}
throw e
}
}
/**
* Checks if a newer version is available for a file.
*/
fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0"
return compareVersions(fileInfo.version, localVersion) > 0
}
/**
* Compares two version strings (assuming semantic versioning).
*/
private fun compareVersions(version1: String, version2: String): Int {
val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
for (i in 0 until maxOf(parts1.size, parts2.size)) {
val part1 = parts1.getOrElse(i) { 0 }
val part2 = parts2.getOrElse(i) { 0 }
if (part1 != part2) {
return part1.compareTo(part2)
}
}
return 0
}
/**
* Gets the local version of a file.
*/
fun getLocalVersion(fileId: String): String {
return sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
}
}

View File

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

View File

@@ -0,0 +1,62 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.communication.files_download
/**
* Centralized configuration for all download-related URLs and paths.
* Update this file when server configuration changes.
*/
object DownloadConfig {
// ===== BASE URLS =====
/** Base URL for all polly content */
const val POLLY_BASE_URL = "http://pollyapp.b-cdn.net/"
// ===== DICTIONARIES CONFIG =====
/** Subdirectory for dictionary files on the server */
const val DICTIONARIES_SUBDIRECTORY = "dictionaries"
/** Full URL for dictionary files (baseUrl + subdirectory) */
const val DICTIONARIES_BASE_URL = "$POLLY_BASE_URL$DICTIONARIES_SUBDIRECTORY/"
/** Manifest file name for dictionaries */
const val DICTIONARIES_MANIFEST_FILE = "manifest.json"
/** Full URL for the dictionary manifest */
const val DICTIONARIES_MANIFEST_URL = "$DICTIONARIES_BASE_URL$DICTIONARIES_MANIFEST_FILE"
// ===== FLASHCARDS CONFIG =====
/** Subdirectory for flashcard/vocab files on the server */
const val FLASHCARDS_SUBDIRECTORY = "flashcards"
/** Full URL for flashcard files (baseUrl + subdirectory) */
const val FLASHCARDS_BASE_URL = "$POLLY_BASE_URL$FLASHCARDS_SUBDIRECTORY/"
/** Manifest file name for flashcards/vocab packs */
const val FLASHCARDS_MANIFEST_FILE = "vocab_manifest.json"
/** Full URL for the flashcard manifest */
const val FLASHCARDS_MANIFEST_URL = "$FLASHCARDS_BASE_URL$FLASHCARDS_MANIFEST_FILE"
// ===== LOCAL STORAGE PATHS =====
/** Local subdirectory for storing flashcard files (relative to filesDir) */
const val LOCAL_FLASHCARDS_PATH = FLASHCARDS_SUBDIRECTORY
// ===== HELPER METHODS =====
/**
* Returns the full remote URL for a dictionary asset.
* @param filename The asset filename (e.g., "dictionary_de.db")
*/
fun getDictionaryAssetUrl(filename: String): String = "$DICTIONARIES_BASE_URL$filename"
/**
* Returns the full remote URL for a flashcard asset.
* @param filename The asset filename (e.g., "2026_02_20_verbs_beginners_zh_pl_A1.json")
*/
fun getFlashcardAssetUrl(filename: String): String = "$FLASHCARDS_BASE_URL$filename"
}

View File

@@ -0,0 +1,560 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.model.communication.files_download
import android.content.Context
import androidx.core.content.edit
import eu.gaudian.translator.R
import eu.gaudian.translator.model.communication.Asset
import eu.gaudian.translator.model.communication.FileInfo
import eu.gaudian.translator.model.communication.ManifestResponse
import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
/**
* Manages downloading files from the server, verifying checksums, and checking versions.
* All URLs and paths are centralized in [DownloadConfig].
*/
class FileDownloadManager(private val context: Context) {
init {
Log.d("FileDownloadManager", "=== FileDownloadManager initialized ===")
Log.d("FileDownloadManager", "Context filesDir: ${context.filesDir.absolutePath}")
Log.d("FileDownloadManager", "Polly base URL: ${DownloadConfig.POLLY_BASE_URL}")
Log.d("FileDownloadManager", "Dictionaries URL: ${DownloadConfig.DICTIONARIES_BASE_URL}")
Log.d("FileDownloadManager", "Flashcards URL: ${DownloadConfig.FLASHCARDS_BASE_URL}")
}
// ===== Retrofit Services =====
private val dictionaryRetrofit = Retrofit.Builder()
.baseUrl(DownloadConfig.DICTIONARIES_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val manifestApiService = dictionaryRetrofit.create<ManifestApiService>()
private val flashcardRetrofit = Retrofit.Builder()
.baseUrl(DownloadConfig.POLLY_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(OkHttpClient.Builder().build())
.build()
private val flashcardApiService = flashcardRetrofit.create<FlashcardApiService>()
private val sharedPreferences = context.getSharedPreferences("file_versions", Context.MODE_PRIVATE)
// ===== Dictionary Manifest =====
/**
* Fetches the manifest from the server.
*/
suspend fun fetchManifest(): ManifestResponse? = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== fetchManifest() called ===")
Log.d("FileDownloadManager", "Fetching manifest from: ${DownloadConfig.DICTIONARIES_MANIFEST_URL}")
try {
val response = manifestApiService.getManifest().execute()
Log.d("FileDownloadManager", "Manifest response received - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
if (response.isSuccessful) {
val manifest = response.body()
Log.d("FileDownloadManager", "Manifest parsed successfully, files count: ${manifest?.files?.size ?: 0}")
manifest?.files?.forEach { file ->
Log.d("FileDownloadManager", " - File: ${file.id}, name: ${file.name}, version: ${file.version}, assets: ${file.assets.size}")
}
manifest
} else {
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
Log.e("FileDownloadManager", "Failed to fetch manifest: $errorMessage")
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error fetching manifest", e)
throw e
}
}
// ===== Dictionary Downloads =====
/**
* Downloads all assets for a file and verifies their checksums.
*/
suspend fun downloadFile(fileInfo: FileInfo, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== downloadFile() called ===")
Log.d("FileDownloadManager", "File info - id: ${fileInfo.id}, name: ${fileInfo.name}, version: ${fileInfo.version}")
Log.d("FileDownloadManager", "Total assets to download: ${fileInfo.assets.size}")
val totalAssets = fileInfo.assets.size
for ((completedAssets, asset) in fileInfo.assets.withIndex()) {
Log.d("FileDownloadManager", "Processing asset ${completedAssets + 1}/$totalAssets: ${asset.filename}")
val success = downloadDictionaryAsset(asset) { assetProgress ->
// Calculate overall progress
val assetContribution = assetProgress / totalAssets
val previousAssetsProgress = completedAssets.toFloat() / totalAssets
onProgress(previousAssetsProgress + assetContribution)
}
if (!success) {
Log.e("FileDownloadManager", "Failed to download asset: ${asset.filename}")
return@withContext false
}
}
// Save version after all assets are downloaded successfully
sharedPreferences.edit { putString(fileInfo.id, fileInfo.version) }
Log.d("FileDownloadManager", "Download successful for all assets of ${fileInfo.name}")
Log.d("FileDownloadManager", "Saved version ${fileInfo.version} for id ${fileInfo.id}")
true
}
/**
* Downloads a specific dictionary asset and verifies its checksum.
*/
private suspend fun downloadDictionaryAsset(asset: Asset, onProgress: (Float) -> Unit = {}): Boolean = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== downloadDictionaryAsset() called ===")
val fileUrl = DownloadConfig.getDictionaryAssetUrl(asset.filename)
val localFile = File(context.filesDir, asset.filename)
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
try {
Log.d("FileDownloadManager", "Creating HTTP request...")
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
response.code,
response.message
)
Log.e("FileDownloadManager", errorMessage)
throw Exception(errorMessage)
}
val body = response.body
val contentLength = body.contentLength()
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
if (contentLength <= 0) {
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
throw Exception("Invalid file size: $contentLength")
}
Log.d("FileDownloadManager", "Starting file download to: ${localFile.absolutePath}")
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead: Long = 0
val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
digest.update(buffer, 0, bytesRead)
totalBytesRead += bytesRead
onProgress((totalBytesRead.toFloat() / contentLength))
}
output.flush()
// Compute checksum
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
Log.d("FileDownloadManager", "Checksum VERIFIED for ${asset.filename}")
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
true
} else {
Log.e("FileDownloadManager", "Checksum MISMATCH for ${asset.filename}")
Log.e("FileDownloadManager",
context.getString(
R.string.text_checksum_mismatch_for_expected_got,
asset.filename,
asset.checksumSha256,
computedChecksum
))
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete() // Delete corrupted file
throw Exception("Checksum verification failed for ${asset.filename}")
}
}
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error downloading asset from $fileUrl", e)
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
// Clean up partial download
if (localFile.exists()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete()
}
throw e
}
}
// ===== Version Management =====
/**
* Checks if a newer version is available for a file.
*/
fun isNewerVersionAvailable(fileInfo: FileInfo): Boolean {
Log.d("FileDownloadManager", "=== isNewerVersionAvailable() called ===")
Log.d("FileDownloadManager", "Checking file: ${fileInfo.id} (${fileInfo.name})")
val localVersion = sharedPreferences.getString(fileInfo.id, "0.0.0") ?: "0.0.0"
Log.d("FileDownloadManager", "Local version: $localVersion, Server version: ${fileInfo.version}")
val result = compareVersions(fileInfo.version, localVersion) > 0
Log.d("FileDownloadManager", "Newer version available: $result")
return result
}
/**
* Compares two version strings (assuming semantic versioning).
*/
private fun compareVersions(version1: String, version2: String): Int {
Log.d("FileDownloadManager", "Comparing versions: '$version1' vs '$version2'")
val parts1 = version1.split(".").map { it.toIntOrNull() ?: 0 }
val parts2 = version2.split(".").map { it.toIntOrNull() ?: 0 }
for (i in 0 until maxOf(parts1.size, parts2.size)) {
val part1 = parts1.getOrElse(i) { 0 }
val part2 = parts2.getOrElse(i) { 0 }
if (part1 != part2) {
val result = part1.compareTo(part2)
Log.d("FileDownloadManager", "Version comparison result: $result (at part $i: $part1 vs $part2)")
return result
}
}
Log.d("FileDownloadManager", "Versions are equal, returning 0")
return 0
}
/**
* Gets the local version of a file.
*/
fun getLocalVersion(fileId: String): String {
val version = sharedPreferences.getString(fileId, "0.0.0") ?: "0.0.0"
Log.d("FileDownloadManager", "getLocalVersion($fileId) = $version")
return version
}
// ===== Flashcard Collections =====
/**
* Downloads a flashcard collection file with checksum verification.
*/
suspend fun downloadFlashcardCollection(
flashcardInfo: FlashcardCollectionInfo,
onProgress: (Float) -> Unit = {}
): Boolean = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== downloadFlashcardCollection() called ===")
Log.d("FileDownloadManager", "Flashcard info - id: ${flashcardInfo.id}, version: ${flashcardInfo.version}")
val asset = flashcardInfo.asset
val fileUrl = DownloadConfig.getFlashcardAssetUrl(asset.filename)
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${asset.filename}")
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
// Create subdirectory if it doesn't exist
val parentDir = localFile.parentFile
if (parentDir != null && !parentDir.exists()) {
Log.d("FileDownloadManager", "Creating subdirectory: ${parentDir.absolutePath}")
val created = parentDir.mkdirs()
Log.d("FileDownloadManager", "Subdirectory created: $created")
}
try {
Log.d("FileDownloadManager", "Creating HTTP request for flashcard...")
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
response.code,
response.message
)
Log.e("FileDownloadManager", errorMessage)
throw Exception(errorMessage)
}
val body = response.body
val contentLength = body.contentLength()
Log.d("FileDownloadManager", "Content length: $contentLength bytes")
if (contentLength <= 0) {
Log.e("FileDownloadManager", "Invalid file size: $contentLength")
throw Exception("Invalid file size: $contentLength")
}
Log.d("FileDownloadManager", "Starting flashcard download to: ${localFile.absolutePath}")
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead: Long = 0
val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
digest.update(buffer, 0, bytesRead)
totalBytesRead += bytesRead
onProgress(totalBytesRead.toFloat() / contentLength)
}
output.flush()
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
Log.d("FileDownloadManager", "Expected checksum: ${asset.checksumSha256}")
if (computedChecksum.equals(asset.checksumSha256, ignoreCase = true)) {
Log.d("FileDownloadManager", "Checksum VERIFIED for flashcard ${asset.filename}")
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
// Save version
sharedPreferences.edit { putString(flashcardInfo.id, flashcardInfo.version) }
Log.d("FileDownloadManager", "Saved version ${flashcardInfo.version} for id ${flashcardInfo.id}")
true
} else {
Log.e("FileDownloadManager", "Checksum MISMATCH for flashcard ${asset.filename}")
Log.e("FileDownloadManager", context.getString(
R.string.text_checksum_mismatch_for_expected_got,
asset.filename,
asset.checksumSha256,
computedChecksum
))
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete()
throw Exception("Checksum verification failed for ${asset.filename}")
}
}
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error downloading flashcard collection from $fileUrl", e)
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
if (localFile.exists()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete()
}
throw e
}
}
/**
* Checks if a newer version is available for a flashcard collection.
*/
fun isNewerFlashcardVersionAvailable(flashcardInfo: FlashcardCollectionInfo): Boolean {
Log.d("FileDownloadManager", "=== isNewerFlashcardVersionAvailable() called ===")
Log.d("FileDownloadManager", "Checking flashcard: ${flashcardInfo.id}")
val localVersion = sharedPreferences.getString(flashcardInfo.id, "0.0.0") ?: "0.0.0"
Log.d("FileDownloadManager", "Local version: $localVersion, Server version: ${flashcardInfo.version}")
val result = compareVersions(flashcardInfo.version, localVersion) > 0
Log.d("FileDownloadManager", "Newer version available: $result")
return result
}
/**
* Gets the local version of a flashcard collection.
*/
fun getFlashcardLocalVersion(collectionId: String): String {
val version = sharedPreferences.getString(collectionId, "0.0.0") ?: "0.0.0"
Log.d("FileDownloadManager", "getFlashcardLocalVersion($collectionId) = $version")
return version
}
// ===== Vocab Packs =====
/**
* Fetches the vocabulary-pack manifest (vocab_manifest.json).
* Unwraps the top-level [VocabManifestResponse] and returns the `lists` array.
*/
suspend fun fetchVocabManifest(): List<VocabCollectionInfo>? = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== fetchVocabManifest() called ===")
Log.d("FileDownloadManager", "Fetching vocab manifest from: ${DownloadConfig.FLASHCARDS_MANIFEST_URL}")
try {
val response = flashcardApiService.getVocabManifest().execute()
Log.d("FileDownloadManager", "Vocab manifest response - isSuccessful: ${response.isSuccessful}, code: ${response.code()}")
if (response.isSuccessful) {
val manifest = response.body()
val lists = manifest?.lists
Log.d("FileDownloadManager", "Vocab manifest parsed successfully, lists count: ${lists?.size ?: 0}")
lists?.forEach { list ->
Log.d("FileDownloadManager", " - Vocab list: ${list.id}, name: ${list.name}, version: ${list.version}, filename: ${list.filename}")
}
lists
} else {
val errorMessage = response.errorBody()?.string() ?: "HTTP ${response.code()}"
Log.e("FileDownloadManager", "Failed to fetch vocab manifest: $errorMessage")
throw Exception(context.getString(R.string.failed_to_fetch_manifest, errorMessage))
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error fetching vocab manifest", e)
throw e
}
}
/**
* Downloads a single vocabulary pack file and verifies its SHA-256 checksum.
* The file is stored at [filesDir]/[DownloadConfig.LOCAL_FLASHCARDS_PATH]/[filename].
*
* @return true on success, false (or throws) on failure.
*/
suspend fun downloadVocabCollection(
info: VocabCollectionInfo,
onProgress: (Float) -> Unit = {}
): Boolean = withContext(Dispatchers.IO) {
Log.d("FileDownloadManager", "=== downloadVocabCollection() called ===")
Log.d("FileDownloadManager", "Vocab info - id: ${info.id}, name: ${info.name}, version: ${info.version}")
Log.d("FileDownloadManager", "Vocab filename: ${info.filename}, size: ${info.sizeBytes} bytes")
val fileUrl = DownloadConfig.getFlashcardAssetUrl(info.filename)
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
Log.d("FileDownloadManager", "Remote URL: $fileUrl")
Log.d("FileDownloadManager", "Local file path: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "Local file parent: ${localFile.parentFile?.absolutePath}")
Log.d("FileDownloadManager", "Expected checksum: ${info.checksumSha256}")
// Create subdirectory if it doesn't exist
val parentDir = localFile.parentFile
if (parentDir != null && !parentDir.exists()) {
Log.d("FileDownloadManager", "Creating subdirectory: ${parentDir.absolutePath}")
val created = parentDir.mkdirs()
Log.d("FileDownloadManager", "Subdirectory created: $created")
}
try {
Log.d("FileDownloadManager", "Creating HTTP request for vocab pack...")
val client = OkHttpClient()
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
Log.d("FileDownloadManager", "Response received - code: ${response.code}, message: ${response.message}")
if (!response.isSuccessful) {
val errorMessage = context.getString(
R.string.text_download_failed_http,
response.code,
response.message
)
Log.e("FileDownloadManager", errorMessage)
throw Exception(errorMessage)
}
val body = response.body
val contentLength = body.contentLength().takeIf { it > 0 } ?: info.sizeBytes
Log.d("FileDownloadManager", "Content length from header: ${body.contentLength()}, using: $contentLength bytes")
Log.d("FileDownloadManager", "Starting vocab pack download to: ${localFile.absolutePath}")
FileOutputStream(localFile).use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
var totalBytesRead: Long = 0
val digest = MessageDigest.getInstance("SHA-256")
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
digest.update(buffer, 0, bytesRead)
totalBytesRead += bytesRead
if (contentLength > 0) onProgress(totalBytesRead.toFloat() / contentLength)
}
output.flush()
val computedChecksum = digest.digest().joinToString("") { "%02X".format(it) }
Log.d("FileDownloadManager", "Download complete. Total bytes read: $totalBytesRead")
Log.d("FileDownloadManager", "Computed checksum: $computedChecksum")
Log.d("FileDownloadManager", "Expected checksum: ${info.checksumSha256}")
if (computedChecksum.equals(info.checksumSha256, ignoreCase = true)) {
Log.d("FileDownloadManager", "Checksum VERIFIED for vocab pack ${info.filename}")
Log.d("FileDownloadManager", "File saved successfully at: ${localFile.absolutePath}")
Log.d("FileDownloadManager", "File exists: ${localFile.exists()}, size: ${localFile.length()} bytes")
sharedPreferences.edit(commit = true) {
putString("vocab_${info.id}", info.version.toString())
}
Log.d("FileDownloadManager", "Saved version ${info.version} for vocab_${info.id}")
true
} else {
Log.e("FileDownloadManager", "Checksum MISMATCH for vocab pack ${info.filename}")
Log.e("FileDownloadManager",
context.getString(
R.string.text_checksum_mismatch_for_expected_got,
info.filename,
info.checksumSha256,
computedChecksum
)
)
Log.d("FileDownloadManager", "Deleting corrupted file: ${localFile.absolutePath}")
localFile.delete()
throw Exception("Checksum verification failed for ${info.filename}")
}
}
}
} catch (e: Exception) {
Log.e("FileDownloadManager", "Error downloading vocab pack from $fileUrl", e)
Log.e("FileDownloadManager", "Target path was: ${localFile.absolutePath}")
if (localFile.exists()) {
Log.d("FileDownloadManager", "Cleaning up partial download: ${localFile.absolutePath}")
localFile.delete()
}
throw e
}
}
/** Returns true if the local file for this collection exists. */
fun isVocabCollectionDownloaded(info: VocabCollectionInfo): Boolean {
val localFile = File(context.filesDir, "${DownloadConfig.LOCAL_FLASHCARDS_PATH}/${info.filename}")
val exists = localFile.exists()
Log.d("FileDownloadManager", "isVocabCollectionDownloaded(${info.id}): $exists (path: ${localFile.absolutePath})")
return exists
}
/** Returns true if the server version is newer than the locally saved version. */
fun isNewerVocabVersionAvailable(info: VocabCollectionInfo): Boolean {
Log.d("FileDownloadManager", "=== isNewerVocabVersionAvailable() called ===")
Log.d("FileDownloadManager", "Checking vocab: ${info.id} (${info.name})")
val localVersion = sharedPreferences.getString("vocab_${info.id}", "0") ?: "0"
val serverVersion = info.version.toString().toIntOrNull() ?: 0
val localVersionInt = localVersion.toIntOrNull() ?: 0
Log.d("FileDownloadManager", "Local version: $localVersionInt, Server version: $serverVersion")
val result = serverVersion > localVersionInt
Log.d("FileDownloadManager", "Newer version available: $result")
return result
}
/** Returns the locally saved version number string for a vocab pack (default "0"). */
fun getVocabLocalVersion(packId: String): String {
val version = sharedPreferences.getString("vocab_$packId", "0") ?: "0"
Log.d("FileDownloadManager", "getVocabLocalVersion($packId) = $version")
return version
}
}

View File

@@ -0,0 +1,18 @@
package eu.gaudian.translator.model.communication.files_download
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Url
/**
* API service for flashcard / vocabulary-pack downloads.
* Base URL should be set to DownloadConfig.POLLY_BASE_URL
*/
interface FlashcardApiService {
/**
* Fetches the vocab manifest using the full URL from DownloadConfig.
*/
@GET
fun getVocabManifest(@Url url: String = DownloadConfig.FLASHCARDS_MANIFEST_URL): Call<VocabManifestResponse>
}

View File

@@ -0,0 +1,68 @@
package eu.gaudian.translator.model.communication.files_download
import com.google.gson.annotations.SerializedName
// ---------------------------------------------------------------------------
// New: vocab_manifest.json schema
// ---------------------------------------------------------------------------
/**
* Top-level wrapper returned by vocab_manifest.json.
*
* {
* "manifest_version": "1.0",
* "updated_at": "…",
* "lists": [ … ]
* }
*/
data class VocabManifestResponse(
@SerializedName("manifest_version") val manifestVersion: String = "",
@SerializedName("updated_at") val updatedAt: String = "",
@SerializedName("lists") val lists: List<VocabCollectionInfo> = emptyList(),
)
/**
* One entry inside the `lists` array of vocab_manifest.json.
*/
data class VocabCollectionInfo(
@SerializedName("id") val id: String,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String,
@SerializedName("filename") val filename: String,
/** [lang_first_id, lang_second_id] matching Language IDs in the app */
@SerializedName("language_ids") val languageIds: List<Int>,
@SerializedName("category") val category: String,
@SerializedName("item_count") val itemCount: Int,
@SerializedName("emoji") val emoji: String,
@SerializedName("version") val version: Int,
/** CEFR difficulty level: A1, A2, B1, B2, C1, C2 (empty string if not set) */
@SerializedName("level") val level: String = "",
@SerializedName("size_bytes") val sizeBytes: Long,
@SerializedName("checksum_sha256") val checksumSha256: String,
@SerializedName("created_at") val createdAt: String,
@SerializedName("updated_at") val updatedAt: String,
)
// ---------------------------------------------------------------------------
// Legacy models (kept for backward compatibility with the old manifest.json
// dictionary download path)
// ---------------------------------------------------------------------------
data class FlashcardManifestResponse(
@SerializedName("collections")
val collections: List<FlashcardCollectionInfo>
)
data class FlashcardCollectionInfo(
@SerializedName("id") val id: String,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String,
@SerializedName("version") val version: String,
@SerializedName("asset") val asset: FlashcardAsset
)
data class FlashcardAsset(
@SerializedName("filename") val filename: String,
@SerializedName("size_bytes") val sizeBytes: Long,
@SerializedName("checksum_sha256") val checksumSha256: String
)

View File

@@ -0,0 +1,20 @@
package eu.gaudian.translator.model.communication.files_download
import eu.gaudian.translator.model.communication.ManifestResponse
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Url
/**
* API service for fetching the dictionary manifest.
* Base URL should be set to DownloadConfig.DICTIONARIES_BASE_URL
*/
interface ManifestApiService {
/**
* Fetches the manifest from the server using the full URL.
*/
@GET
fun getManifest(@Url url: String = DownloadConfig.DICTIONARIES_MANIFEST_URL): Call<ManifestResponse>
}

View File

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

View File

@@ -144,19 +144,6 @@ class ApiRepository(private val context: Context) {
var configurationValid = true 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 // Fallback checks
if (currentTrans == null || !availableModels.any { it.modelId == currentTrans.modelId && it.providerKey == currentTrans.providerKey }) { if (currentTrans == null || !availableModels.any { it.modelId == currentTrans.modelId && it.providerKey == currentTrans.providerKey }) {
findFallbackModel(availableModels)?.let { setTranslationModel(it) } ?: run { configurationValid = false } findFallbackModel(availableModels)?.let { setTranslationModel(it) } ?: run { configurationValid = false }

View File

@@ -6,9 +6,9 @@ import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.communication.Asset import eu.gaudian.translator.model.communication.Asset
import eu.gaudian.translator.model.communication.FileDownloadManager
import eu.gaudian.translator.model.communication.FileInfo import eu.gaudian.translator.model.communication.FileInfo
import eu.gaudian.translator.model.communication.ManifestResponse import eu.gaudian.translator.model.communication.ManifestResponse
import eu.gaudian.translator.model.communication.files_download.FileDownloadManager
import eu.gaudian.translator.utils.Log import eu.gaudian.translator.utils.Log
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow

View File

@@ -4,6 +4,7 @@ package eu.gaudian.translator.model.repository
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.parseLanguagesFromResources import eu.gaudian.translator.model.parseLanguagesFromResources
import eu.gaudian.translator.model.repository.DataStoreKeys.LANGUAGE_INIT_METADATA_KEY import eu.gaudian.translator.model.repository.DataStoreKeys.LANGUAGE_INIT_METADATA_KEY
@@ -75,6 +76,7 @@ class LanguageRepository(private val context: Context) {
} }
} }
@Suppress("unused")
suspend fun wipeHistoryAndFavorites() { suspend fun wipeHistoryAndFavorites() {
clearLanguages(LanguageListType.HISTORY) clearLanguages(LanguageListType.HISTORY)
clearLanguages(LanguageListType.FAVORITE) clearLanguages(LanguageListType.FAVORITE)
@@ -88,13 +90,12 @@ class LanguageRepository(private val context: Context) {
// Check if we already have default languages saved // Check if we already have default languages saved
val savedDefaultLanguages = loadLanguages(LanguageListType.DEFAULT).firstOrNull() ?: emptyList() val savedDefaultLanguages = loadLanguages(LanguageListType.DEFAULT).firstOrNull() ?: emptyList()
// Check if we need to re-parse languages (first run, version change, or language change) // Check if we need to reparse languages (first run, version change, or language change)
val shouldReparse = shouldReparseLanguages(savedDefaultLanguages) val shouldReparse = shouldReparseLanguages(savedDefaultLanguages)
if (shouldReparse) { if (shouldReparse) {
Log.d("LanguageRepository", "Parsing languages from resources") Log.d("LanguageRepository", "Parsing languages from resources")
val parsedLanguages = parseLanguagesFromResources(context) val parsedLanguages = parseLanguagesFromResources(context)
wipeHistoryAndFavorites()
saveLanguages(LanguageListType.DEFAULT, parsedLanguages) saveLanguages(LanguageListType.DEFAULT, parsedLanguages)
// Save the current app version and locale to detect changes next time // Save the current app version and locale to detect changes next time
saveLanguageInitializationMetadata() saveLanguageInitializationMetadata()
@@ -112,8 +113,11 @@ class LanguageRepository(private val context: Context) {
return true return true
} }
// Check if the number of languages matches expected count (51) // Get expected language count from resources dynamically
if (savedLanguages.size != 51) { val expectedCount = context.resources.getStringArray(R.array.language_codes).size
// Check if the number of languages matches expected count from resources
if (savedLanguages.size != expectedCount) {
return true return true
} }
@@ -167,15 +171,22 @@ class LanguageRepository(private val context: Context) {
val customLanguages = loadLanguages(LanguageListType.CUSTOM).first() val customLanguages = loadLanguages(LanguageListType.CUSTOM).first()
val master = (defaultLanguages + customLanguages).distinctBy { it.nameResId } val master = (defaultLanguages + customLanguages).distinctBy { it.nameResId }
// Sanitize existing enabled IDs and initialize if empty // Get existing enabled IDs
val existingEnabled: List<Int> = try { val existingEnabled: List<Int> = try {
context.dataStore.loadObjectList<Int>(DataStoreKeys.ALL_LANGUAGES_KEY).firstOrNull() ?: emptyList() context.dataStore.loadObjectList<Int>(DataStoreKeys.ALL_LANGUAGES_KEY).firstOrNull() ?: emptyList()
} catch (_: Exception) { } catch (_: Exception) {
emptyList() emptyList()
} }
val masterIds = master.map { it.nameResId }.toSet() val masterIds = master.map { it.nameResId }.toSet()
val sanitized = existingEnabled.filter { it in masterIds }
val finalIds = sanitized.ifEmpty { master.filter { it.isSelected == true }.map { it.nameResId } } // Sanitize existing enabled IDs (remove any that are no longer valid)
val existingValidIds = existingEnabled.filter { it in masterIds }.toSet()
// Add any new languages from master that aren't already enabled
val newLanguageIds = masterIds - existingValidIds
// Combine: keep existing enabled + add new languages
val finalIds = (existingValidIds + newLanguageIds).toList()
context.dataStore.saveObjectList(DataStoreKeys.ALL_LANGUAGES_KEY, finalIds) context.dataStore.saveObjectList(DataStoreKeys.ALL_LANGUAGES_KEY, finalIds)

View File

@@ -102,8 +102,8 @@ class SettingsRepository(private val context: Context) {
val intervalStage4 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_4, 30) val intervalStage4 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_4, 30)
val intervalStage5 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_5, 60) val intervalStage5 = Setting(context.dataStore, PrefKeys.INTERVAL_STAGE_5, 60)
val intervalLearned = Setting(context.dataStore, PrefKeys.INTERVAL_LEARNED, 90) val intervalLearned = Setting(context.dataStore, PrefKeys.INTERVAL_LEARNED, 90)
val criteriaCorrect = Setting(context.dataStore, PrefKeys.CRITERIA_CORRECT, 3) val criteriaCorrect = Setting(context.dataStore, PrefKeys.CRITERIA_CORRECT, 1)
val criteriaWrong = Setting(context.dataStore, PrefKeys.CRITERIA_WRONG, 2) val criteriaWrong = Setting(context.dataStore, PrefKeys.CRITERIA_WRONG, 1)
val showHints = Setting(context.dataStore, PrefKeys.SHOW_HINTS, true) val showHints = Setting(context.dataStore, PrefKeys.SHOW_HINTS, true)
val experimentalFeatures = Setting(context.dataStore, PrefKeys.EXPERIMENTAL_FEATURES, false) val experimentalFeatures = Setting(context.dataStore, PrefKeys.EXPERIMENTAL_FEATURES, false)
val tryWiktionaryFirst = Setting(context.dataStore, PrefKeys.TRY_WIKTIONARY_FIRST, false) val tryWiktionaryFirst = Setting(context.dataStore, PrefKeys.TRY_WIKTIONARY_FIRST, false)

View File

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

View File

@@ -5,21 +5,22 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import eu.gaudian.translator.ui.theme.themes.AutumnSpiceTheme import eu.gaudian.translator.ui.theme.themes.AutumnSpiceTheme
import eu.gaudian.translator.ui.theme.themes.CitrusSplashTheme
import eu.gaudian.translator.ui.theme.themes.CoffeeTheme import eu.gaudian.translator.ui.theme.themes.CoffeeTheme
import eu.gaudian.translator.ui.theme.themes.CrimsonTheme
import eu.gaudian.translator.ui.theme.themes.CyberpunkTheme import eu.gaudian.translator.ui.theme.themes.CyberpunkTheme
import eu.gaudian.translator.ui.theme.themes.DebugTheme
import eu.gaudian.translator.ui.theme.themes.DefaultTheme import eu.gaudian.translator.ui.theme.themes.DefaultTheme
import eu.gaudian.translator.ui.theme.themes.ElectricVioletTheme
import eu.gaudian.translator.ui.theme.themes.ForestTheme import eu.gaudian.translator.ui.theme.themes.ForestTheme
import eu.gaudian.translator.ui.theme.themes.LavenderDreamTheme
import eu.gaudian.translator.ui.theme.themes.MossStoneTheme
import eu.gaudian.translator.ui.theme.themes.NeonPulseTheme
import eu.gaudian.translator.ui.theme.themes.NordTheme import eu.gaudian.translator.ui.theme.themes.NordTheme
import eu.gaudian.translator.ui.theme.themes.OceanicCalmTheme import eu.gaudian.translator.ui.theme.themes.OceanicCalmTheme
import eu.gaudian.translator.ui.theme.themes.PixelTheme import eu.gaudian.translator.ui.theme.themes.PixelTheme
import eu.gaudian.translator.ui.theme.themes.SakuraTheme import eu.gaudian.translator.ui.theme.themes.SageGardenTheme
import eu.gaudian.translator.ui.theme.themes.SlateAndStoneTheme import eu.gaudian.translator.ui.theme.themes.SlateAndStoneTheme
import eu.gaudian.translator.ui.theme.themes.SpaceTheme
import eu.gaudian.translator.ui.theme.themes.SynthwaveTheme
import eu.gaudian.translator.ui.theme.themes.TealTheme import eu.gaudian.translator.ui.theme.themes.TealTheme
import eu.gaudian.translator.ui.theme.themes.TwilightSerenityTheme import eu.gaudian.translator.ui.theme.themes.TerracottaEarthTheme
/** /**
* A data class to hold the core colors for a theme variation (light or dark). * A data class to hold the core colors for a theme variation (light or dark).
@@ -96,25 +97,23 @@ data class AppTheme(
val AllThemes = listOf( val AllThemes = listOf(
DefaultTheme, DefaultTheme,
PixelTheme, PixelTheme,
CrimsonTheme,
SakuraTheme,
AutumnSpiceTheme, AutumnSpiceTheme,
TealTheme, TealTheme,
ForestTheme, ForestTheme,
CoffeeTheme, CoffeeTheme,
CitrusSplashTheme,
OceanicCalmTheme, OceanicCalmTheme,
SlateAndStoneTheme, SlateAndStoneTheme,
NordTheme, NordTheme,
TwilightSerenityTheme,
SpaceTheme,
CyberpunkTheme, CyberpunkTheme,
SynthwaveTheme, DebugTheme,
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,123 @@
@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 DebugTheme = AppTheme(
name = "Debug",
lightColors = ThemeColorSet(
// Primary: Bright Red
primary = Color(0xFFFF0000),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFFFCCCC),
onPrimaryContainer = Color(0xFF660000),
// Secondary: Bright Blue
secondary = Color(0xFF0000FF),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFCCCCFF),
onSecondaryContainer = Color(0xFF000066),
// Tertiary: Bright Green
tertiary = Color(0xFF00FF00),
onTertiary = Color(0xFF000000),
tertiaryContainer = Color(0xFFCCFFCC),
onTertiaryContainer = Color(0xFF006600),
// Error: Standard Material Red
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002),
// Background: Light Gray
background = Color(0xFFF5F5F5),
onBackground = Color(0xFF333333),
// Surface: White
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF333333),
surfaceVariant = Color(0xFFE0E0E0),
onSurfaceVariant = Color(0xFF666666),
// Outline
outline = Color(0xFF999999),
outlineVariant = Color(0xFFCCCCCC),
// Scrim
scrim = Color(0xFF000000),
// Inverse colors
inverseSurface = Color(0xFF333333),
inverseOnSurface = Color(0xFFFFFFFF),
inversePrimary = Color(0xFFFF6666),
// Surface containers
surfaceDim = Color(0xFFE8E8E8),
surfaceBright = Color(0xFFFFFFFF),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFEEEEEE),
surfaceContainer = Color(0xFFF5F5F5),
surfaceContainerHigh = Color(0xFFFAFAFA),
surfaceContainerHighest = Color(0xFFFFFFFF)
),
darkColors = ThemeColorSet(
// Primary: Bright Cyan
primary = Color(0xFF00FFFF),
onPrimary = Color(0xFF000000),
primaryContainer = Color(0xFF66FFFF),
onPrimaryContainer = Color(0xFF003333),
// Secondary: Bright Magenta
secondary = Color(0xFFFF00FF),
onSecondary = Color(0xFF000000),
secondaryContainer = Color(0xFFFF66FF),
onSecondaryContainer = Color(0xFF330033),
// Tertiary: Bright Yellow
tertiary = Color(0xFFFFFF00),
onTertiary = Color(0xFF000000),
tertiaryContainer = Color(0xFFFFFF66),
onTertiaryContainer = Color(0xFF333300),
// Error: Standard Material Red
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
// Background: Dark Gray
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
// Surface: Dark Gray
surface = Color(0xFF1E1E1E),
onSurface = Color(0xFFE0E0E0),
surfaceVariant = Color(0xFF2D2D2D),
onSurfaceVariant = Color(0xFFB0B0B0),
// Outline
outline = Color(0xFF555555),
outlineVariant = Color(0xFF333333),
// Scrim
scrim = Color(0xFF000000),
// Inverse colors
inverseSurface = Color(0xFFE0E0E0),
inverseOnSurface = Color(0xFF121212),
inversePrimary = Color(0xFF006666),
// Surface containers
surfaceDim = Color(0xFF121212),
surfaceBright = Color(0xFF333333),
surfaceContainerLowest = Color(0xFF0A0A0A),
surfaceContainerLow = Color(0xFF181818),
surfaceContainer = Color(0xFF1E1E1E),
surfaceContainerHigh = Color(0xFF252525),
surfaceContainerHighest = Color(0xFF2D2D2D)
)
)

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) 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 { object JsonCleanUtil {
private val jsonParser = Json { isLenient = true; ignoreUnknownKeys = true } 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 * "HardcodedText" lint warning for log messages, which are for
* development purposes only. * development purposes only.
*/ */
@Suppress("unused")
object Log { object Log {
@SuppressLint("HardcodedText") @SuppressLint("HardcodedText")

View File

@@ -60,6 +60,7 @@ val LANGUAGE_STRING_IDS: IntArray = intArrayOf(
R.string.language_49, R.string.language_49,
R.string.language_50, R.string.language_50,
R.string.language_51, R.string.language_51,
R.string.language_52,
R.string.native_language_1, R.string.native_language_1,
R.string.native_language_2, R.string.native_language_2,
@@ -112,6 +113,7 @@ val LANGUAGE_STRING_IDS: IntArray = intArrayOf(
R.string.native_language_49, R.string.native_language_49,
R.string.native_language_50, R.string.native_language_50,
R.string.native_language_51, R.string.native_language_51,
R.string.native_language_52,

View File

@@ -0,0 +1,124 @@
package eu.gaudian.translator.utils
import eu.gaudian.translator.R
import eu.gaudian.translator.viewmodel.MessageAction
import eu.gaudian.translator.viewmodel.MessageDisplayType
/**
* Simplified status message IDs for internationalization.
* Each ID stores its metadata directly, reducing repetitive mapping code.
*/
enum class StatusMessageId(
val stringResId: Int,
val defaultType: MessageDisplayType,
val defaultTimeout: Int,
val associatedAction: MessageAction? = null
) {
// Generic messages
SUCCESS_GENERIC(R.string.message_success_generic, MessageDisplayType.SUCCESS, 3),
INFO_GENERIC(R.string.message_info_generic, MessageDisplayType.INFO, 3),
ERROR_GENERIC(R.string.message_error_generic, MessageDisplayType.ERROR, 5),
LOADING_GENERIC(R.string.message_loading_generic, MessageDisplayType.LOADING, 0),
TEST_INFO(R.string.message_test_info, MessageDisplayType.INFO, 3),
TEST_SUCCESS(R.string.message_test_success, MessageDisplayType.SUCCESS, 3),
TEST_ERROR(R.string.message_test_error, MessageDisplayType.ERROR, 5),
// Language related
ERROR_LANGUAGE_NOT_SELECTED(R.string.message_error_language_not_selected, MessageDisplayType.ERROR, 5),
ERROR_NO_WORDS_FOUND(R.string.message_error_no_words_found, MessageDisplayType.ERROR, 5),
SUCCESS_LANGUAGE_REPLACED(R.string.message_success_language_replaced, MessageDisplayType.SUCCESS, 3),
// Vocabulary related
SUCCESS_VOCABULARY_IMPORTED(R.string.message_success_vocabulary_imported, MessageDisplayType.SUCCESS, 3),
ERROR_VOCABULARY_IMPORT_FAILED(R.string.message_error_vocabulary_import_failed, MessageDisplayType.ERROR, 5),
SUCCESS_ITEMS_MERGED(R.string.message_success_items_merged, MessageDisplayType.SUCCESS, 3),
SUCCESS_ITEMS_ADDED(R.string.message_success_items_added, MessageDisplayType.SUCCESS, 3),
SUCCESS_All_ITEMS_IMPORTED(R.string.message_success_all_items_imported, MessageDisplayType.SUCCESS, 3),
ERROR_ITEMS_ADD_FAILED(R.string.message_error_items_add_failed, MessageDisplayType.ERROR, 5),
SUCCESS_ITEMS_DELETED(R.string.message_success_items_deleted, MessageDisplayType.SUCCESS, 3),
ERROR_ITEMS_DELETE_FAILED(R.string.message_error_items_delete_failed, MessageDisplayType.ERROR, 5),
ERROR_NO_CARDS_FOUND(R.string.message_error_no_cards_found, MessageDisplayType.ERROR, 5),
SUCCESS_CARDS_LOADED(R.string.message_success_cards_loaded, MessageDisplayType.SUCCESS, 3),
// Grammar related
SUCCESS_GRAMMAR_UPDATED(R.string.message_success_grammar_updated, MessageDisplayType.SUCCESS, 3),
ERROR_GRAMMAR_FETCH_FAILED(R.string.message_error_grammar_fetch_failed, MessageDisplayType.ERROR, 5),
LOADING_GRAMMAR_FETCH(R.string.message_loading_grammar_fetch, MessageDisplayType.LOADING, 0),
// File operations
SUCCESS_FILE_SAVED(R.string.message_success_file_saved, MessageDisplayType.SUCCESS, 3),
ERROR_FILE_SAVE_FAILED(R.string.message_error_file_save_failed, MessageDisplayType.ERROR, 5),
ERROR_FILE_SAVE_CANCELLED(R.string.message_error_file_save_cancelled, MessageDisplayType.ERROR, 5),
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
ERROR_API_KEY_MISSING(R.string.message_error_api_key_missing, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
ERROR_API_KEY_INVALID(R.string.message_error_api_key_invalid, MessageDisplayType.ERROR, 0, MessageAction.NAVIGATE_TO_API_KEYS),
ERROR_NO_MODEL_CONFIGURED(R.string.message_error_no_model_configured, MessageDisplayType.ERROR, 5),
// Translation related
LOADING_TRANSLATING(R.string.message_loading_translating, MessageDisplayType.LOADING, 0),
SUCCESS_TRANSLATION_COMPLETED(R.string.message_success_translation_completed, MessageDisplayType.SUCCESS, 3),
ERROR_TRANSLATION_FAILED(R.string.message_error_translation_failed, MessageDisplayType.ERROR, 5),
// Repository operations
SUCCESS_REPOSITORY_WIPED(R.string.message_success_repository_wiped, MessageDisplayType.SUCCESS, 3),
ERROR_REPOSITORY_WIPE_FAILED(R.string.message_error_repository_wipe_failed, MessageDisplayType.ERROR, 5),
LOADING_CARD_SET(R.string.message_loading_card_set, MessageDisplayType.LOADING, 0),
// Stage operations
SUCCESS_STAGE_UPDATED(R.string.message_success_stage_updated, MessageDisplayType.SUCCESS, 3),
ERROR_STAGE_UPDATE_FAILED(R.string.message_error_stage_update_failed, MessageDisplayType.ERROR, 5),
// Category operations
SUCCESS_CATEGORY_UPDATED(R.string.message_success_category_updated, MessageDisplayType.SUCCESS, 3),
ERROR_CATEGORY_UPDATE_FAILED(R.string.message_error_category_update_failed, MessageDisplayType.ERROR, 5),
// Article removal
SUCCESS_ARTICLES_REMOVED(R.string.message_success_articles_removed, MessageDisplayType.SUCCESS, 3),
ERROR_ARTICLES_REMOVE_FAILED(R.string.message_error_articles_remove_failed, MessageDisplayType.ERROR, 5),
// Synonyms
SUCCESS_SYNONYMS_GENERATED(R.string.message_success_synonyms_generated, MessageDisplayType.SUCCESS, 3),
ERROR_SYNONYMS_GENERATION_FAILED(R.string.message_error_synonyms_generation_failed, MessageDisplayType.ERROR, 5),
// Operation status
ERROR_OPERATION_FAILED(R.string.message_error_operation_failed, MessageDisplayType.ERROR, 5),
LOADING_OPERATION_IN_PROGRESS(R.string.message_loading_operation_in_progress, MessageDisplayType.LOADING, 0);
companion object {
/**
* Convenience function to get the string resource ID from a StatusMessageId.
* Kept for backward compatibility with existing code.
*/
fun StatusMessageId.getStringResId(): Int = this.stringResId
/**
* Convenience function to get the default display type.
*/
fun StatusMessageId.getDefaultDisplayType(): MessageDisplayType = this.defaultType
/**
* Convenience function to get the default timeout.
*/
fun StatusMessageId.getDefaultTimeoutSeconds(): Int = this.defaultTimeout
/**
* Convenience function to get the associated action.
*/
fun StatusMessageId.getAssociatedAction(): MessageAction? = this.associatedAction
}
}

View File

@@ -11,8 +11,10 @@ import kotlinx.coroutines.launch
/** /**
* A sealed class representing all possible actions that can be sent to the status system. * A sealed class representing all possible actions that can be sent to the status system.
* Supports both legacy string-based messages and new ID-based messages for internationalization.
*/ */
sealed class StatusAction { sealed class StatusAction {
// Legacy string-based actions (deprecated in favor of ID-based actions)
data class ShowMessage(val text: String, val type: MessageDisplayType, val timeoutInSeconds: Int) : StatusAction() data class ShowMessage(val text: String, val type: MessageDisplayType, val timeoutInSeconds: Int) : StatusAction()
data class ShowPermanentMessage(val text: String, val type: MessageDisplayType) : StatusAction() data class ShowPermanentMessage(val text: String, val type: MessageDisplayType) : StatusAction()
object CancelPermanentMessage : StatusAction() object CancelPermanentMessage : StatusAction()
@@ -20,38 +22,69 @@ sealed class StatusAction {
object CancelLoadingOperation : StatusAction() object CancelLoadingOperation : StatusAction()
object HideMessageBar : StatusAction() object HideMessageBar : StatusAction()
object CancelAllMessages : StatusAction() object CancelAllMessages : StatusAction()
data class ShowActionableMessage(val text: String, val type: MessageDisplayType, val action: MessageAction) : StatusAction() data class ShowActionableMessage(val text: String, val type: MessageDisplayType, val action: MessageAction) : StatusAction()
// New ID-based actions for internationalization
data class ShowMessageById(
val messageId: StatusMessageId,
val type: MessageDisplayType = messageId.defaultType,
val timeoutInSeconds: Int = messageId.defaultTimeout
) : StatusAction()
data class ShowPermanentMessageById(
val messageId: StatusMessageId,
val type: MessageDisplayType = messageId.defaultType
) : StatusAction()
data class ShowActionableMessageById(
val messageId: StatusMessageId,
val type: MessageDisplayType = messageId.defaultType,
val action: MessageAction = messageId.associatedAction ?: MessageAction.NAVIGATE_TO_API_KEYS
) : StatusAction()
} }
/** /**
* A singleton object that acts as a central event bus for status messages. * A singleton object that acts as a central event bus for status messages.
* Any part of the app can trigger an action, and any StatusViewModel listening will receive it. * Any part of the app can trigger an action, and any StatusViewModel listening will receive it.
*
* NOTE: All message display requests should go through this service.
*/ */
object StatusMessageService { object StatusMessageService {
private val _actions = MutableSharedFlow<StatusAction>() private val _actions = MutableSharedFlow<StatusAction>()
val actions = _actions.asSharedFlow() val actions = _actions.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.Default) private val scope = CoroutineScope(Dispatchers.Default)
suspend fun trigger(action: StatusAction) { /**
* Triggers a status action. This is the primary way to display messages.
* Internally launches a coroutine, so this function is not suspend.
*/
fun trigger(action: StatusAction) {
Log.d("StatusMessageService", "Received action: $action") Log.d("StatusMessageService", "Received action: $action")
_actions.emit(action)
}
fun triggerNonSuspend(action: StatusAction) {
Log.d("StatusMessageService", "Received non-suspend action: $action")
scope.launch { scope.launch {
_actions.emit(action) _actions.emit(action)
} }
} }
@Suppress("unused") /**
* @deprecated Use trigger() instead.
*/
@Deprecated("Use trigger() instead", ReplaceWith("trigger(action)"))
fun triggerNonSuspend(action: StatusAction) {
trigger(action)
}
/**
* @deprecated Use showMessageById() instead for internationalization support.
*/
@Deprecated("Use showMessageById() for internationalization support", ReplaceWith("showMessageById(messageId)"))
fun showSimpleMessage(text: String, type: MessageDisplayType = MessageDisplayType.INFO) { fun showSimpleMessage(text: String, type: MessageDisplayType = MessageDisplayType.INFO) {
scope.launch { scope.launch {
_actions.emit(StatusAction.ShowMessage(text, type, 5)) _actions.emit(StatusAction.ShowMessage(text, type, 5))
} }
} }
/**
* @deprecated Use showErrorById() instead for internationalization support.
*/
@Deprecated("Use showErrorById() for internationalization support", ReplaceWith("showErrorById(messageId)"))
fun showErrorMessage(text: String, timeoutInSeconds: Int = 5) { fun showErrorMessage(text: String, timeoutInSeconds: Int = 5) {
scope.launch { scope.launch {
_actions.emit(StatusAction.ShowMessage( _actions.emit(StatusAction.ShowMessage(
@@ -62,6 +95,10 @@ object StatusMessageService {
} }
} }
/**
* @deprecated Use showLoadingById() instead for internationalization support.
*/
@Deprecated("Use showLoadingById() for internationalization support", ReplaceWith("showLoadingById(messageId)"))
fun showLoadingMessage(text: String, timeoutInSeconds: Int = 0) { fun showLoadingMessage(text: String, timeoutInSeconds: Int = 0) {
scope.launch { scope.launch {
_actions.emit(StatusAction.ShowMessage( _actions.emit(StatusAction.ShowMessage(
@@ -71,6 +108,10 @@ object StatusMessageService {
} }
} }
/**
* @deprecated Use showInfoById() instead for internationalization support.
*/
@Deprecated("Use showInfoById() for internationalization support", ReplaceWith("showInfoById(messageId)"))
fun showInfoMessage(text: String, timeoutInSeconds: Int = 3) { fun showInfoMessage(text: String, timeoutInSeconds: Int = 3) {
scope.launch { scope.launch {
_actions.emit(StatusAction.ShowMessage( _actions.emit(StatusAction.ShowMessage(
@@ -80,6 +121,10 @@ object StatusMessageService {
} }
} }
/**
* @deprecated Use showSuccessById() instead for internationalization support.
*/
@Deprecated("Use showSuccessById() for internationalization support", ReplaceWith("showSuccessById(messageId)"))
fun showSuccessMessage(text: String, timeoutInSeconds: Int = 3) { fun showSuccessMessage(text: String, timeoutInSeconds: Int = 3) {
scope.launch { scope.launch {
_actions.emit(StatusAction.ShowMessage( _actions.emit(StatusAction.ShowMessage(
@@ -89,33 +134,102 @@ object StatusMessageService {
} }
} }
/**
* @deprecated Use showPermanentMessageById() instead for internationalization support.
*/
@Deprecated("Use showPermanentMessageById() for internationalization support", ReplaceWith("showPermanentMessageById(messageId)"))
fun showPermanentMessage(text: String, type: MessageDisplayType) { fun showPermanentMessage(text: String, type: MessageDisplayType) {
scope.launch { scope.launch {
_actions.emit(StatusAction.ShowPermanentMessage(text, type)) _actions.emit(StatusAction.ShowPermanentMessage(text, type))
} }
} }
/**
* @deprecated Use StatusAction.CancelPermanentMessage via trigger() if needed.
*/
@Deprecated("Use StatusAction.CancelPermanentMessage via trigger() if needed")
fun cancelPermanentMessage() { fun cancelPermanentMessage() {
scope.launch { trigger(StatusAction.CancelPermanentMessage)
_actions.emit(StatusAction.CancelPermanentMessage)
}
} }
/**
* @deprecated Use StatusAction.HideMessageBar via trigger() if needed.
*/
@Deprecated("Use StatusAction.HideMessageBar via trigger() if needed")
fun hideMessageBar() { fun hideMessageBar() {
scope.launch { trigger(StatusAction.HideMessageBar)
_actions.emit(StatusAction.HideMessageBar)
}
} }
/**
* @deprecated Use StatusAction.CancelAllMessages via trigger() if needed.
*/
@Deprecated("Use StatusAction.CancelAllMessages via trigger() if needed")
fun cancelAllMessages() { fun cancelAllMessages() {
scope.launch { trigger(StatusAction.CancelAllMessages)
_actions.emit(StatusAction.CancelAllMessages)
}
} }
/**
* @deprecated Use showActionableMessageById() instead for internationalization support.
*/
@Deprecated("Use showActionableMessageById() for internationalization support", ReplaceWith("showActionableMessageById(messageId)"))
fun showActionableMessage(text: String, type: MessageDisplayType, action: MessageAction) { fun showActionableMessage(text: String, type: MessageDisplayType, action: MessageAction) {
scope.launch { scope.launch {
_actions.emit(StatusAction.ShowActionableMessage(text, type, action)) _actions.emit(StatusAction.ShowActionableMessage(text, type, action))
} }
} }
// === NEW ID-BASED METHODS (for internationalization) ===
/**
* Shows a message by its ID. The actual text is resolved by StatusViewModel using string resources.
* @param messageId The StatusMessageId that maps to a string resource
* @param type Optional override for the display type
* @param timeoutInSeconds Optional override for the timeout
*/
fun showMessageById(
messageId: StatusMessageId,
type: MessageDisplayType = messageId.defaultType,
timeoutInSeconds: Int = messageId.defaultTimeout
) {
trigger(StatusAction.ShowMessageById(messageId, type, timeoutInSeconds))
}
/**
* Shows a permanent message (until dismissed) by its ID.
*/
fun showPermanentMessageById(
messageId: StatusMessageId,
type: MessageDisplayType = messageId.defaultType
) {
trigger(StatusAction.ShowPermanentMessageById(messageId, type))
}
/**
* Shows an actionable message by its ID with an optional action.
*/
fun showActionableMessageById(
messageId: StatusMessageId,
type: MessageDisplayType = messageId.defaultType,
action: MessageAction = messageId.associatedAction ?: MessageAction.NAVIGATE_TO_API_KEYS
) {
trigger(StatusAction.ShowActionableMessageById(messageId, type, action))
}
// Convenience methods for common message types
fun showErrorById(messageId: StatusMessageId, timeoutInSeconds: Int = messageId.defaultTimeout) {
showMessageById(messageId, MessageDisplayType.ERROR, timeoutInSeconds)
}
fun showSuccessById(messageId: StatusMessageId, timeoutInSeconds: Int = messageId.defaultTimeout) {
showMessageById(messageId, MessageDisplayType.SUCCESS, timeoutInSeconds)
}
fun showInfoById(messageId: StatusMessageId, timeoutInSeconds: Int = messageId.defaultTimeout) {
showMessageById(messageId, MessageDisplayType.INFO, timeoutInSeconds)
}
fun showLoadingById(messageId: StatusMessageId, timeoutInSeconds: Int = messageId.defaultTimeout) {
showMessageById(messageId, MessageDisplayType.LOADING, timeoutInSeconds)
}
} }

View File

@@ -55,7 +55,9 @@ class TranslationService(private val context: Context) {
} }
} }
private suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) { // Public method to directly use LibreTranslate (bypasses AI)
suspend fun libreTranslate(text: String, source: String?, target: String, alternatives: Int = 1): Result<String> = withContext(Dispatchers.IO) {
Log.d("libreTranslate: $text, $source, $target")
try { try {
val json = org.json.JSONObject().apply { val json = org.json.JSONObject().apply {
put("q", text) put("q", text)

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. * Either a simple list or a complex, grouped verb conjugation table.
*/ */
sealed class DisplayInflectionData { sealed class DisplayInflectionData {
data class VerbConjugation(
val gerund: String? = null,
val participle: String? = null,
val moods: List<DisplayMood>
) : DisplayInflectionData()
} }
data class DisplayMood( data class DisplayMood(

View File

@@ -5,11 +5,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -42,7 +40,7 @@ import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.PrimaryButton import eu.gaudian.translator.view.composable.PrimaryButton
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun IntroNavHost(onIntroFinished: () -> Unit) { fun IntroNavHost(onIntroFinished: () -> Unit) {
val pages = listOf( val pages = listOf(
@@ -55,9 +53,16 @@ fun IntroNavHost(onIntroFinished: () -> Unit) {
title = stringResource(R.string.intro_title_ai_assistant), title = stringResource(R.string.intro_title_ai_assistant),
description = stringResource(R.string.intro_desc_ai_assistant), description = stringResource(R.string.intro_desc_ai_assistant),
content = { content = {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
IconContent(iconRes = R.drawable.ic_intro_ai_agents) IconContent(iconRes = R.drawable.ic_intro_ai_agents)
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) { FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxWidth()
) {
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_mistral)) }) SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_mistral)) })
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_your_own_ai)) }) SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_your_own_ai)) })
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_openai)) }) SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_openai)) })
@@ -89,7 +94,7 @@ fun IntroNavHost(onIntroFinished: () -> Unit) {
IntroPageData( IntroPageData(
title = stringResource(R.string.intro_title_learning_journey), title = stringResource(R.string.intro_title_learning_journey),
description = stringResource(R.string.intro_desc_learning_journey), description = stringResource(R.string.intro_desc_learning_journey),
content = { IconContent(iconRes = R.drawable.ic_intro_learning_journey)} content = { IconContent(iconRes = R.drawable.ic_intro_learning_journey) }
), ),
IntroPageData( IntroPageData(
title = stringResource(R.string.intro_title_categories), title = stringResource(R.string.intro_title_categories),
@@ -128,7 +133,6 @@ fun IntroNavHost(onIntroFinished: () -> Unit) {
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp) .padding(horizontal = 16.dp, vertical = 8.dp)
) { ) {
// Full-width Skip intro button aligned to end but sized like primary (fillMaxWidth)
eu.gaudian.translator.view.composable.SecondaryButton( eu.gaudian.translator.view.composable.SecondaryButton(
onClick = { onIntroFinished() }, onClick = { onIntroFinished() },
text = stringResource(R.string.intro_skip), text = stringResource(R.string.intro_skip),
@@ -145,7 +149,9 @@ fun IntroNavHost(onIntroFinished: () -> Unit) {
) { ) {
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.weight(1f) modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { pageIndex -> ) { pageIndex ->
IntroPage(pageData = pages[pageIndex]) IntroPage(pageData = pages[pageIndex])
} }
@@ -170,7 +176,7 @@ fun IntroNavHost(onIntroFinished: () -> Unit) {
} }
}, },
text = if (pagerState.currentPage < pages.size - 1) stringResource(R.string.next) else stringResource(R.string.get_started), text = if (pagerState.currentPage < pages.size - 1) stringResource(R.string.next) else stringResource(R.string.get_started),
icon = if (pagerState.currentPage < pages.size - 1)AppIcons.ArrowForwardNoChevron else null, icon = if (pagerState.currentPage < pages.size - 1) AppIcons.ArrowForwardNoChevron else null,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@@ -189,9 +195,9 @@ private fun IntroPage(pageData: IntroPageData) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically), verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically),
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxSize() // Fixed: This was previously fillMaxHeight()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()) // Allow scrolling for larger hint content .verticalScroll(rememberScrollState())
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
@@ -240,9 +246,8 @@ private fun IconContent(iconRes: Int) {
tint = Color.Unspecified, tint = Color.Unspecified,
modifier = Modifier.size(250.dp) modifier = Modifier.size(250.dp)
) )
}
} }
}
@Composable @Composable
private fun FlashcardTopicsPreview() { private fun FlashcardTopicsPreview() {

View File

@@ -24,6 +24,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -35,12 +36,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
@@ -56,8 +59,11 @@ import eu.gaudian.translator.MyApplication
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.AllFonts import eu.gaudian.translator.ui.theme.AllFonts
import eu.gaudian.translator.ui.theme.AllThemes import eu.gaudian.translator.ui.theme.AllThemes
import eu.gaudian.translator.ui.theme.ProvideSemanticColors
import eu.gaudian.translator.ui.theme.buildColorScheme import eu.gaudian.translator.ui.theme.buildColorScheme
import eu.gaudian.translator.utils.Log import eu.gaudian.translator.utils.Log
import eu.gaudian.translator.utils.StatusAction
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppAlertDialog import eu.gaudian.translator.view.composable.AppAlertDialog
import eu.gaudian.translator.view.composable.BottomNavigationBar import eu.gaudian.translator.view.composable.BottomNavigationBar
@@ -76,19 +82,14 @@ val LocalConnectionConfigured = compositionLocalOf { true }
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels() private val settingsViewModel: SettingsViewModel by viewModels()
private var isReady = false private var isReady = false
private var isUiLoaded = false private var isUiLoaded = false
private var isInitializing = true
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().apply { installSplashScreen().apply {
// The splash screen will now correctly wait until isReady is true
setKeepOnScreenCondition { !isReady } setKeepOnScreenCondition { !isReady }
} }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
lifecycleScope.launch { lifecycleScope.launch {
@@ -98,28 +99,22 @@ class MainActivity : ComponentActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
// Show UI immediately and load data in background
setContent { setContent {
AppTheme(settingsViewModel = settingsViewModel) { AppTheme(settingsViewModel = settingsViewModel) {
TranslatorApp(settingsViewModel = settingsViewModel) TranslatorApp(settingsViewModel = settingsViewModel)
} }
} }
// Mark UI as loaded immediately after setContent
isUiLoaded = true isUiLoaded = true
// Start initialization in background without blocking UI
initializeData() initializeData()
} }
private fun initializeData() { private fun initializeData() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
// Get repositories from the Application instance (lazy initialization)
val myApp = application as MyApplication val myApp = application as MyApplication
val languageRepository = myApp.languageRepository val languageRepository = myApp.languageRepository
val apiRepository = myApp.apiRepository val apiRepository = myApp.apiRepository
// Perform initialization in parallel where possible
val languageJob = launch { val languageJob = launch {
languageRepository.initializeDefaultLanguages() languageRepository.initializeDefaultLanguages()
languageRepository.initializeAllLanguages() languageRepository.initializeAllLanguages()
@@ -129,13 +124,10 @@ class MainActivity : ComponentActivity() {
apiRepository.initialInit() apiRepository.initialInit()
} }
// Wait for both to complete
languageJob.join() languageJob.join()
apiJob.join() apiJob.join()
// Signal readiness after all work is done.
isReady = true isReady = true
isInitializing = false
} }
} }
} }
@@ -143,15 +135,10 @@ class MainActivity : ComponentActivity() {
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
@SuppressLint("LocalContextResourcesRead") @SuppressLint("LocalContextResourcesRead")
@Composable @Composable
fun TranslatorApp( fun TranslatorApp(settingsViewModel: SettingsViewModel) {
settingsViewModel: SettingsViewModel
) {
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val statusViewModel: StatusViewModel = hiltViewModel(activity) val statusViewModel: StatusViewModel = hiltViewModel(activity)
val statusMessageService = StatusMessageService
val navController = rememberNavController() val navController = rememberNavController()
val statusState by statusViewModel.status.collectAsStateWithLifecycle() val statusState by statusViewModel.status.collectAsStateWithLifecycle()
@@ -175,7 +162,6 @@ fun TranslatorApp(
showExitDialog = true showExitDialog = true
} }
if (showExitDialog) { if (showExitDialog) {
AppAlertDialog( AppAlertDialog(
onDismissRequest = { showExitDialog = false }, onDismissRequest = { showExitDialog = false },
@@ -184,7 +170,6 @@ fun TranslatorApp(
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
showExitDialog = false showExitDialog = false
// Minimize the app similar to default back at root behavior
activity.moveTaskToBack(true) activity.moveTaskToBack(true)
}) { }) {
Text(stringResource(R.string.quit)) Text(stringResource(R.string.quit))
@@ -198,7 +183,6 @@ fun TranslatorApp(
) )
} }
// Check for app updates and show "What's New" dialog if needed
var showWhatsNewDialog by remember { mutableStateOf(false) } var showWhatsNewDialog by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
val changelogEntries = context.resources.getStringArray(R.array.changelog_entries) val changelogEntries = context.resources.getStringArray(R.array.changelog_entries)
@@ -206,7 +190,6 @@ fun TranslatorApp(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
try { try {
// Only check for updates if the intro is completed
if (introCompleted) { if (introCompleted) {
val currentVersion = BuildConfig.VERSION_NAME val currentVersion = BuildConfig.VERSION_NAME
val hasSeenCurrentVersion = settingsViewModel.hasSeenCurrentVersion(currentVersion) val hasSeenCurrentVersion = settingsViewModel.hasSeenCurrentVersion(currentVersion)
@@ -249,7 +232,25 @@ fun TranslatorApp(
val currentDestination = navBackStackEntry?.destination val currentDestination = navBackStackEntry?.destination
val selectedScreen = Screen.fromDestination(currentDestination) val selectedScreen = Screen.fromDestination(currentDestination)
@Suppress("HardCodedStringLiteral") val isBottomBarHidden = currentDestination?.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,
Screen.Corrector.route
)
} == true
val isBottomBarHidden = isHiddenByHierarchy || currentRoute in setOf(
"new_word",
"new_word_review",
"vocabulary_detail/{itemId}",
"daily_review",
"explore_packs"
) || currentRoute?.startsWith("start_exercise") == true
|| currentRoute?.startsWith("vocabulary_exercise") == true
val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false) val showBottomNavLabels by settingsViewModel.showBottomNavLabels.collectAsStateWithLifecycle(initialValue = false)
BottomNavigationBar( BottomNavigationBar(
@@ -258,10 +259,15 @@ fun TranslatorApp(
showLabels = showBottomNavLabels, showLabels = showBottomNavLabels,
onItemSelected = { screen -> onItemSelected = { screen ->
val inSameSection = currentDestination?.hierarchy?.any { it.route == screen.route } == true val inSameSection = currentDestination?.hierarchy?.any { it.route == screen.route } == true
val isMoreSection = screen in setOf(
Screen.Translation,
Screen.Dictionary,
Screen.Settings,
Screen.Exercises,
Screen.Corrector
)
// Always reset the selected section to its root and clear back stack between sections
if (inSameSection) { if (inSameSection) {
// If already within the same section, ensure we are at its graph root
navController.navigate(screen.route) { navController.navigate(screen.route) {
popUpTo(screen.route) { popUpTo(screen.route) {
inclusive = false inclusive = false
@@ -270,10 +276,14 @@ fun TranslatorApp(
launchSingleTop = true launchSingleTop = true
restoreState = false restoreState = false
} }
} else { } else if (isMoreSection) {
// Switching sections: clear entire back stack to start to avoid back navigation results
navController.navigate(screen.route) { navController.navigate(screen.route) {
popUpTo(0) { // Pop everything launchSingleTop = true
restoreState = false
}
} else {
navController.navigate(screen.route) {
popUpTo(0) {
inclusive = true inclusive = true
saveState = false saveState = false
} }
@@ -281,6 +291,10 @@ fun TranslatorApp(
restoreState = false restoreState = false
} }
} }
},
onPlayClicked = {
@Suppress("HardCodedStringLiteral")
navController.navigate("start_exercise")
} }
) )
}, },
@@ -303,9 +317,8 @@ fun TranslatorApp(
StatusMessageSystem( StatusMessageSystem(
statusState = statusState, statusState = statusState,
navController = navController, navController = navController,
onDismiss = { statusViewModel.hideMessageBar() }, onDismiss = { statusMessageService.trigger(StatusAction.HideMessageBar) },
modifier = Modifier modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
) )
AppNavHost( AppNavHost(
navController = navController, navController = navController,
@@ -357,9 +370,10 @@ private fun AppTheme(
val window = (view.context as Activity).window val window = (view.context as Activity).window
val windowInsetsController = WindowInsetsControllerCompat(window, view) val windowInsetsController = WindowInsetsControllerCompat(window, view)
//window.statusBarColor = android.graphics.Color.TRANSPARENT @Suppress("DEPRECATION")
//window.navigationBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = colorScheme.surface.toArgb()
//TODO remove eventually @Suppress("DEPRECATION")
window.navigationBarColor = colorScheme.surfaceColorAtElevation(8.dp).toArgb()
windowInsetsController.isAppearanceLightStatusBars = !useDarkTheme windowInsetsController.isAppearanceLightStatusBars = !useDarkTheme
windowInsetsController.isAppearanceLightNavigationBars = !useDarkTheme windowInsetsController.isAppearanceLightNavigationBars = !useDarkTheme
@@ -400,7 +414,7 @@ private fun AppTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = dynamicTypography, typography = dynamicTypography,
) { ) {
eu.gaudian.translator.ui.theme.ProvideSemanticColors { ProvideSemanticColors {
content() content()
} }
} }

View File

@@ -20,99 +20,115 @@ import androidx.navigation.compose.composable
import androidx.navigation.navArgument import androidx.navigation.navArgument
import androidx.navigation.navigation import androidx.navigation.navigation
import eu.gaudian.translator.model.VocabularyStage import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.view.categories.CategoryDetailScreen
import eu.gaudian.translator.view.categories.CategoryListScreen
import eu.gaudian.translator.view.composable.Screen import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.dictionary.CorrectionScreen
import eu.gaudian.translator.view.dictionary.DictionaryResultScreen import eu.gaudian.translator.view.dictionary.DictionaryResultScreen
import eu.gaudian.translator.view.dictionary.DictionaryScreen
import eu.gaudian.translator.view.dictionary.EtymologyResultScreen import eu.gaudian.translator.view.dictionary.EtymologyResultScreen
import eu.gaudian.translator.view.dictionary.MainDictionaryScreen import eu.gaudian.translator.view.home.DailyReviewScreen
import eu.gaudian.translator.view.exercises.ExerciseSessionScreen import eu.gaudian.translator.view.home.HomeScreen
import eu.gaudian.translator.view.exercises.MainExerciseScreen import eu.gaudian.translator.view.library.LibraryScreen
import eu.gaudian.translator.view.exercises.YouTubeBrowserScreen import eu.gaudian.translator.view.new_ecercises.ExerciseSessionScreen
import eu.gaudian.translator.view.exercises.YouTubeExerciseScreen import eu.gaudian.translator.view.new_ecercises.MainExerciseScreen
import eu.gaudian.translator.view.new_ecercises.StartExerciseScreen
import eu.gaudian.translator.view.new_ecercises.YouTubeBrowserScreen
import eu.gaudian.translator.view.new_ecercises.YouTubeExerciseScreen
import eu.gaudian.translator.view.settings.DictionaryOptionsScreen import eu.gaudian.translator.view.settings.DictionaryOptionsScreen
import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.view.settings.TranslationSettingsScreen import eu.gaudian.translator.view.settings.TranslationSettingsScreen
import eu.gaudian.translator.view.settings.settingsGraph import eu.gaudian.translator.view.settings.settingsGraph
import eu.gaudian.translator.view.stats.StatsScreen
import eu.gaudian.translator.view.translation.TranslationScreen import eu.gaudian.translator.view.translation.TranslationScreen
import eu.gaudian.translator.view.vocabulary.CategoryDetailScreen import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
import eu.gaudian.translator.view.vocabulary.CategoryListScreen import eu.gaudian.translator.view.vocabulary.ExplorePacksScreen
import eu.gaudian.translator.view.vocabulary.LanguageProgressScreen import eu.gaudian.translator.view.vocabulary.LanguageJourneyScreen
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.NoGrammarItemsScreen
import eu.gaudian.translator.view.vocabulary.StageDetailScreen import eu.gaudian.translator.view.vocabulary.StageDetailScreen
import eu.gaudian.translator.view.vocabulary.VocabularyCardHost import eu.gaudian.translator.view.vocabulary.VocabularyCardHost
import eu.gaudian.translator.view.vocabulary.VocabularyExerciseHostScreen import eu.gaudian.translator.view.vocabulary.VocabularyExerciseHostScreen
import eu.gaudian.translator.view.vocabulary.VocabularyHeatmapScreen import eu.gaudian.translator.view.vocabulary.VocabularyHeatmapScreen
import eu.gaudian.translator.view.vocabulary.VocabularyListScreen
import eu.gaudian.translator.view.vocabulary.VocabularySortingScreen import eu.gaudian.translator.view.vocabulary.VocabularySortingScreen
private const val TRANSITION_DURATION = 300 private const val TRANSITION_DURATION = 300
object NavigationRoutes {
const val DAILY_REVIEW = "daily_review"
const val NEW_WORD = "new_word"
const val NEW_WORD_REVIEW = "new_word_review"
const val VOCABULARY_DETAIL = "vocabulary_detail"
const val START_EXERCISE = "start_exercise"
const val START_EXERCISE_DAILY = "start_exercise_daily"
const val CATEGORY_DETAIL = "category_detail"
const val CATEGORY_LIST = "category_list_screen"
const val STATS_VOCABULARY_HEATMAP = "stats/vocabulary_heatmap"
const val STATS_LANGUAGE_PROGRESS = "stats/language_progress"
const val STATS_CATEGORY_DETAIL = "stats/category_detail"
const val STATS_CATEGORY_LIST = "stats/category_list_screen"
const val STATS_VOCABULARY_SORTING = "stats/vocabulary_sorting"
const val STATS_NO_GRAMMAR_ITEMS = "stats/no_grammar_items"
const val STATS_VOCABULARY_LIST = "stats/vocabulary_list"
const val EXPLORE_PACKS = "explore_packs"
}
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
@Composable @Composable
fun AppNavHost( fun AppNavHost(
navController: NavHostController, navController: NavHostController,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// 1. Define your Main Tab "Leaf" Routes (the actual start destinations of your graphs)
val mainTabRoutes = setOf( val mainTabRoutes = setOf(
Screen.Home.route, // "home" or "main_translation" depending on which one you actually navigate to Screen.Home.route,
"main_translation", Screen.Library.route,
"main_dictionary", Screen.Stats.route,
"main_vocabulary",
"main_exercise",
SettingsRoutes.LIST
) )
// Helper to check if a route is a top-level tab
fun isTabTransition(initial: String?, target: String?): Boolean { fun isTabTransition(initial: String?, target: String?): Boolean {
return mainTabRoutes.contains(initial) && mainTabRoutes.contains(target) if (initial == null || target == null) return false
val initialIsTab = mainTabRoutes.contains(initial) ||
mainTabRoutes.any { route ->
initial == "main_${route}" || initial.startsWith("${route}_")
}
val targetIsTab = mainTabRoutes.contains(target) ||
mainTabRoutes.any { route ->
target == "main_${route}" || target.startsWith("${route}_")
}
return initialIsTab && targetIsTab
} }
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Screen.Home.route, startDestination = Screen.Home.route,
modifier = modifier, modifier = modifier,
// ENTER TRANSITION
enterTransition = { enterTransition = {
if (isTabTransition(initialState.destination.route, targetState.destination.route)) { if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
// Tab Switch: Just Fade In (Subtle Scale for modern feel)
fadeIn(animationSpec = tween(TRANSITION_DURATION)) + fadeIn(animationSpec = tween(TRANSITION_DURATION)) +
scaleIn(initialScale = 0.96f, animationSpec = tween(TRANSITION_DURATION)) scaleIn(initialScale = 0.96f, animationSpec = tween(TRANSITION_DURATION))
} else { } else {
// Detail Screen: Slide in from Right
slideInHorizontally( slideInHorizontally(
initialOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() }, initialOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing) animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(TRANSITION_DURATION)) ) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
} }
}, },
// EXIT TRANSITION
exitTransition = { exitTransition = {
if (isTabTransition(initialState.destination.route, targetState.destination.route)) { if (isTabTransition(initialState.destination.route, targetState.destination.route)) {
// Tab Switch: Just Fade Out
fadeOut(animationSpec = tween(TRANSITION_DURATION)) fadeOut(animationSpec = tween(TRANSITION_DURATION))
} else { } else {
// Detail Screen: Slide out to Left
slideOutHorizontally( slideOutHorizontally(
targetOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() }, targetOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing) animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(TRANSITION_DURATION)) ) + fadeOut(animationSpec = tween(TRANSITION_DURATION))
} }
}, },
// POP ENTER (Pressing Back) -> Always Slide back from left
popEnterTransition = { popEnterTransition = {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() }, initialOffsetX = { fullWidth -> -(fullWidth * 0.1f).toInt() },
animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing) animationSpec = tween(TRANSITION_DURATION, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(TRANSITION_DURATION)) ) + fadeIn(animationSpec = tween(TRANSITION_DURATION))
}, },
// POP EXIT (Pressing Back) -> Always Slide away to right
popExitTransition = { popExitTransition = {
slideOutHorizontally( slideOutHorizontally(
targetOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() }, targetOffsetX = { fullWidth -> (fullWidth * 0.1f).toInt() },
@@ -121,22 +137,277 @@ fun AppNavHost(
} }
) { ) {
composable(Screen.Home.route) { composable(Screen.Home.route) {
TranslationScreen(navController = navController) HomeScreen(navController = navController)
} }
composable(NavigationRoutes.DAILY_REVIEW) {
// Define all other navigation graphs at the same top level. DailyReviewScreen(navController = navController)
}
composable(NavigationRoutes.NEW_WORD) {
NewWordScreen(navController = navController)
}
composable(NavigationRoutes.NEW_WORD_REVIEW) {
NewWordReviewScreen(navController = navController)
}
composable(NavigationRoutes.EXPLORE_PACKS) {
ExplorePacksScreen(navController = navController)
}
composable(
route = "${NavigationRoutes.START_EXERCISE}?categoryId={categoryId}",
arguments = listOf(
navArgument("categoryId") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val categoryIdString = backStackEntry.arguments?.getString("categoryId")
val categoryId = categoryIdString?.toIntOrNull()
StartExerciseScreen(
navController = navController,
preselectedCategoryId = categoryId,
dueTodayOnly = false
)
}
composable(NavigationRoutes.START_EXERCISE_DAILY) {
StartExerciseScreen(
navController = navController,
preselectedCategoryId = null,
dueTodayOnly = true
)
}
homeGraph(navController)
libraryGraph(navController)
statsGraph(navController)
translationGraph(navController) translationGraph(navController)
dictionaryGraph(navController) dictionaryGraph(navController)
vocabularyGraph(navController) correctorGraph(navController)
exerciseGraph(navController) exerciseGraph(navController)
settingsGraph(navController) settingsGraph(navController)
} }
} }
fun NavGraphBuilder.homeGraph(navController: NavHostController) {
navigation(
startDestination = "main_home",
route = Screen.Home.route
) {
composable("main_home") {
HomeScreen(navController = navController)
}
}
}
fun NavGraphBuilder.libraryGraph(navController: NavHostController) {
navigation(
startDestination = "main_library",
route = Screen.Library.route
) {
composable("main_library") {
LibraryScreen(navController = navController)
}
composable("vocabulary_sorting") {
VocabularySortingScreen(navController = navController)
}
composable("vocabulary_detail/{itemId}") { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId")?.toIntOrNull()
if (itemId != null) {
VocabularyCardHost(
navController = navController,
itemId = itemId,
onBackPressed = { navController.popBackStack() }
)
} else {
Text("Error: Invalid Vocabulary Item ID")
}
}
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("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("language_progress") {
LanguageJourneyScreen(navController = navController)
}
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
VocabularyHeatmapScreen(navController = navController)
}
composable("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(
route = "vocabulary_exercise/{isSpelling}?categories={categories}&stages={stages}&languages={languages}&dailyOnly={dailyOnly}",
arguments = listOf(
navArgument("isSpelling") { type = NavType.BoolType },
navArgument("categories") { type = NavType.StringType; nullable = true },
navArgument("stages") { type = NavType.StringType; nullable = true },
navArgument("languages") { type = NavType.StringType; nullable = true },
navArgument("dailyOnly") { type = NavType.BoolType; defaultValue = false }
)
) { backStackEntry ->
val arguments = backStackEntry.arguments
val dailyOnly = arguments?.getBoolean("dailyOnly") ?: false
val categoryIds = arguments?.getString("categories")
val stageNames = arguments?.getString("stages")
val languageIds = arguments?.getString("languages")
val dailyOnlyJson = "{\"dailyOnly\": $dailyOnly}"
VocabularyExerciseHostScreen(
categoryIdsAsJson = categoryIds,
stageNamesAsJson = stageNames,
languageIdsAsJson = languageIds,
dailyOnlyAsJson = dailyOnlyJson,
onClose = { navController.popBackStack() },
navController = navController
)
}
composable("vocabulary_exercise/{dailyOnly}?", arguments = listOf(navArgument("dailyOnly") { type = NavType.BoolType })) { _ ->
VocabularyExerciseHostScreen(
categoryIdsAsJson = null,
stageNamesAsJson = null,
languageIdsAsJson = null,
onClose = { navController.popBackStack() },
navController = navController,
dailyOnlyAsJson = "{\"dailyOnly\": true}"
)
}
composable("stage_detail/{stage}", arguments = listOf(navArgument("stage") { type = NavType.EnumType(VocabularyStage::class.java) })) { backStackEntry ->
@Suppress("DEPRECATION")
val stage = backStackEntry.arguments?.getSerializable("stage") as VocabularyStage
StageDetailScreen(navController = navController, stage = stage)
}
composable("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("category_list_screen") {
CategoryListScreen(
onNavigateBack = { navController.popBackStack() },
onCategoryClicked = { categoryId -> navController.navigate("category_detail/$categoryId") }
)
}
composable("vocabulary_sorting?mode={mode}", arguments = listOf(navArgument("mode") { type = NavType.StringType; nullable = true })) { backStackEntry ->
VocabularySortingScreen(
navController = navController,
initialFilterMode = backStackEntry.arguments?.getString("mode")
)
}
composable("no_grammar_items") {
NoGrammarItemsScreen(navController = navController)
}
}
}
fun NavGraphBuilder.statsGraph(navController: NavHostController) {
navigation(
startDestination = "main_stats",
route = Screen.Stats.route
) {
composable("main_stats") {
StatsScreen(navController = navController)
}
composable("stats/vocabulary_sorting") {
VocabularySortingScreen(navController = navController)
}
composable("stats/vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry ->
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
AllCardsListScreen(
navController = navController,
showDueTodayOnly = showDueTodayOnly,
categoryId = categoryId,
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
onNavigateBack = { navController.popBackStack() },
enableNavigationButtons = true
)
}
composable(NavigationRoutes.STATS_LANGUAGE_PROGRESS) {
LanguageJourneyScreen(navController = navController)
}
composable(NavigationRoutes.STATS_VOCABULARY_HEATMAP) {
VocabularyHeatmapScreen(navController = navController)
}
composable("stats/vocabulary_list/{showDueTodayOnly}/{stage}") { backStackEntry ->
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
val stageString = backStackEntry.arguments?.getString("stage")
val stage = stageString?.let {
if (it.equals("null", ignoreCase = true)) null else VocabularyStage.valueOf(it)
}
AllCardsListScreen(
navController = navController,
showDueTodayOnly = showDueTodayOnly,
stage = stage,
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
onNavigateBack = { navController.popBackStack() },
categoryId = 0,
enableNavigationButtons = true
)
}
composable("stats/category_detail/{categoryId}") { backStackEntry ->
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
if (categoryId != null) {
CategoryDetailScreen(
categoryId = categoryId,
onBackClick = { navController.popBackStack() },
onNavigateToItem = { item -> navController.navigate("vocabulary_detail/${item.id}") },
navController = navController
)
}
}
composable("stats/category_list_screen") {
CategoryListScreen(
onNavigateBack = { navController.popBackStack() },
onCategoryClicked = { categoryId -> navController.navigate("stats/category_detail/$categoryId") }
)
}
composable("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) @OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
fun NavGraphBuilder.translationGraph(navController: NavHostController) { fun NavGraphBuilder.translationGraph(navController: NavHostController) {
navigation( navigation(
startDestination = "main_translation", startDestination = "main_translation",
route = Screen.Home.route route = Screen.Translation.route
) { ) {
composable("main_translation") { composable("main_translation") {
TranslationScreen(navController = navController) TranslationScreen(navController = navController)
@@ -154,15 +425,16 @@ fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
route = Screen.Dictionary.route route = Screen.Dictionary.route
) { ) {
composable("main_dictionary") { composable("main_dictionary") {
MainDictionaryScreen(navController = navController) DictionaryScreen(
navController = navController,
onEntryClick = { entry -> navController.navigate("dictionary_result/${entry.id}") },
onNavigateToOptions = { navController.navigate("dictionary_options") }
)
} }
composable("dictionary_result/{entryId}") { backStackEntry -> composable("dictionary_result/{entryId}") { backStackEntry ->
val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull() val entryId = backStackEntry.arguments?.getString("entryId")?.toIntOrNull()
if (entryId != null) { if (entryId != null) {
DictionaryResultScreen( DictionaryResultScreen(entryId = entryId, navController = navController)
entryId = entryId,
navController = navController,
)
} else { } else {
Text("Error: Invalid Entry ID") Text("Error: Invalid Entry ID")
} }
@@ -173,236 +445,39 @@ fun NavGraphBuilder.dictionaryGraph(navController: NavHostController) {
composable("etymology_result/{word}/{languageCode}") { backStackEntry -> composable("etymology_result/{word}/{languageCode}") { backStackEntry ->
val word = backStackEntry.arguments?.getString("word") ?: "" val word = backStackEntry.arguments?.getString("word") ?: ""
val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1 val languageCode = backStackEntry.arguments?.getString("languageCode")?.toIntOrNull() ?: 1
EtymologyResultScreen( EtymologyResultScreen(navController = navController, word = word, languageCode = languageCode)
navController = navController,
word = word,
languageCode = languageCode
)
} }
} }
} }
fun NavGraphBuilder.vocabularyGraph( fun NavGraphBuilder.correctorGraph(navController: NavHostController) {
navController: NavHostController,
) {
navigation( navigation(
startDestination = "main_vocabulary", startDestination = "main_corrector",
route = Screen.Vocabulary.route route = Screen.Corrector.route
) { ) {
composable("main_vocabulary") { composable("main_corrector") {
MainVocabularyScreen(navController = navController) CorrectionScreen(navController = navController)
}
composable("vocabulary_sorting") {
VocabularySortingScreen(
navController = navController
)
}
composable("vocabulary_detail/{itemId}") { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId")?.toIntOrNull()
if (itemId != null) {
VocabularyCardHost(
navController = navController,
itemId = itemId,
onBackPressed = { navController.popBackStack() }
)
} else {
Text("Error: Invalid Vocabulary Item ID")
}
}
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("vocabulary_list/{showDueTodayOnly}/{categoryId}") { backStackEntry ->
val showDueTodayOnly = backStackEntry.arguments?.getString("showDueTodayOnly")?.toBoolean() ?: false
val categoryId = backStackEntry.arguments?.getString("categoryId")?.toIntOrNull()
VocabularyListScreen(
navController = navController,
showDueTodayOnly = showDueTodayOnly,
categoryId = categoryId,
onNavigateToItem = { item ->
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateBack = { navController.popBackStack() },
enableNavigationButtons = true
)
}
composable("language_progress") {
LanguageProgressScreen(
navController = navController
)
}
composable("vocabulary_heatmap") {
VocabularyHeatmapScreen(
navController = navController,
)
}
composable("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)
}
VocabularyListScreen(
navController = navController,
showDueTodayOnly = showDueTodayOnly,
stage = stage,
onNavigateToItem = { item ->
navController.navigate("vocabulary_detail/${item.id}")
},
onNavigateBack = { navController.popBackStack() },
categoryId = 0,
enableNavigationButtons = true
)
}
composable(
route = "vocabulary_exercise/{isSpelling}?categories={categories}&stages={stages}&languages={languages}&dailyOnly={dailyOnly}",
arguments = listOf(
navArgument("isSpelling") { type = NavType.BoolType },
navArgument("categories") { type = NavType.StringType; nullable = true },
navArgument("stages") { type = NavType.StringType; nullable = true },
navArgument("languages") { type = NavType.StringType; nullable = true },
navArgument("dailyOnly") {
type = NavType.BoolType
defaultValue = false
},
)
) { backStackEntry ->
val arguments = backStackEntry.arguments
val dailyOnly = arguments?.getBoolean("dailyOnly") ?: false
val categoryIds = arguments?.getString("categories")
val stageNames = arguments?.getString("stages")
val languageIds = arguments?.getString("languages")
val dailyOnlyJson = "{\"dailyOnly\": $dailyOnly}"
VocabularyExerciseHostScreen(
categoryIdsAsJson = categoryIds,
stageNamesAsJson = stageNames,
languageIdsAsJson = languageIds,
dailyOnlyAsJson = dailyOnlyJson,
onClose = { navController.popBackStack() },
navController = navController
)
}
composable(
route = "vocabulary_exercise/{dailyOnly}?",
arguments = listOf(
navArgument("dailyOnly") { type = NavType.BoolType },
)
) { _ ->
VocabularyExerciseHostScreen(
categoryIdsAsJson = null,
stageNamesAsJson = null,
languageIdsAsJson = null,
onClose = { navController.popBackStack() },
navController = navController,
dailyOnlyAsJson = "{\"dailyOnly\": true}"
)
}
composable(
"stage_detail/{stage}",
arguments = listOf(
navArgument("stage") {
type = NavType.EnumType(VocabularyStage::class.java)
}
)
)
{ backStackEntry ->
@Suppress("DEPRECATION") val stage = backStackEntry.arguments?.getSerializable("stage") as VocabularyStage
//NOTE: can ignore warning for now, once moved away from min SDK 28, use:
// val stage = backStackEntry.arguments?.getSerializable("stage", VocabularyStage::class.java)
StageDetailScreen(
navController = navController,
stage = stage
)
}
composable("category_detail/{categoryId}") { backStackEntry ->
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("category_list_screen") {
CategoryListScreen(
onNavigateBack = { navController.popBackStack() },
onCategoryClicked = { categoryId ->
navController.navigate("category_detail/$categoryId")
}
)
}
composable(
route = "vocabulary_sorting?mode={mode}", // Route now accepts an optional 'mode'
arguments = listOf(
navArgument("mode") { // Define the argument
type = NavType.StringType
nullable = true
}
)
) { backStackEntry ->
VocabularySortingScreen(
navController = navController,
// Pass the argument to the screen
initialFilterMode = backStackEntry.arguments?.getString("mode")
)
}
composable("no_grammar_items") {
NoGrammarItemsScreen(
navController = navController
)
} }
} }
} }
@OptIn(androidx.compose.animation.ExperimentalAnimationApi::class) @OptIn(androidx.compose.animation.ExperimentalAnimationApi::class)
fun NavGraphBuilder.exerciseGraph( fun NavGraphBuilder.exerciseGraph(navController: NavHostController) {
navController: NavHostController,
) {
navigation( navigation(
startDestination = "main_exercise", startDestination = "main_exercise",
route = Screen.Exercises.route route = Screen.Exercises.route
) { ) {
composable("main_exercise") { composable("main_exercise") {
MainExerciseScreen( MainExerciseScreen(navController = navController)
navController = navController,
)
} }
composable("exercise_session") { composable("exercise_session") {
ExerciseSessionScreen( ExerciseSessionScreen(navController = navController)
navController = navController,
)
} }
composable("youtube_exercise") { composable("youtube_exercise") {
YouTubeExerciseScreen( YouTubeExerciseScreen(navController = navController)
navController = navController
)
} }
composable("youtube_browse") { composable("youtube_browse") {
YouTubeBrowserScreen( YouTubeBrowserScreen(navController = navController)
navController = navController,
)
} }
} }
} }

View File

@@ -16,13 +16,18 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppTopAppBar
@Composable @Composable
fun NoConnectionScreen(onSettingsClick: () -> Unit) { fun NoConnectionScreen(onSettingsClick: () -> Unit, navController: NavController) {
AppTopAppBar(
title = "No Connection",
onNavigateBack = {navController.popBackStack()},
)
Column( Column(
modifier = Modifier.fillMaxSize().padding(24.dp), modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
@@ -41,7 +46,7 @@ fun NoConnectionScreen(onSettingsClick: () -> Unit) {
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
AppButton(onClick = onSettingsClick, modifier = Modifier.padding(top = 16.dp)) { AppButton(onClick = onSettingsClick, modifier = Modifier.padding(top = 16.dp)) {
Text(text = stringResource(id = R.string.settings_title_connection)) Text(text = "Configure Connection")
} }
} }
} }

View File

@@ -0,0 +1,412 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.categories
import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import eu.gaudian.translator.R
import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyFilter
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.PrimaryButton
import eu.gaudian.translator.view.dialogs.DeleteCategoryDialog
import eu.gaudian.translator.view.dialogs.DeleteItemsDialog
import eu.gaudian.translator.view.dialogs.EditCategoryDialog
import eu.gaudian.translator.view.vocabulary.AllCardsListScreen
import eu.gaudian.translator.view.stats.widgets.CategoryProgressCircle
import eu.gaudian.translator.view.stats.widgets.ChartLegend
import eu.gaudian.translator.viewmodel.CategoryProgress
import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ExportImportViewModel
import eu.gaudian.translator.viewmodel.ExportState
import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.launch
@SuppressLint("ContextCastToActivity")
@Composable
fun CategoryDetailScreen(
categoryId: Int,
onBackClick: () -> Unit,
onNavigateToItem: (VocabularyItem) -> Unit,
navController: NavHostController,
modifier: Modifier = Modifier
) {
val activity = LocalContext.current.findActivity()
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val progressViewModel: ProgressViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val exportImportViewModel: ExportImportViewModel = hiltViewModel(viewModelStoreOwner = activity)
val category by categoryViewModel.getCategoryById(categoryId).collectAsState(initial = null)
val categoryProgressList by progressViewModel.categoryProgressList.collectAsState()
val categoryProgress = categoryProgressList.find { it.vocabularyCategory.id == categoryId }
val exportState by exportImportViewModel.exportState.collectAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val title = when (val cat = category) {
is TagCategory -> cat.name
is VocabularyFilter -> cat.name
else -> stringResource(R.string.text_loading_3d)
}
val languages = languageViewModel.allLanguages.collectAsState(initial = emptyList())
val subtitle = when (val cat = category) {
is TagCategory -> stringResource(R.string.text_manual_vocabulary_list)
is VocabularyFilter -> buildString {
val hasLangList = !cat.languages.isNullOrEmpty()
val hasPair = cat.languagePairs != null
val hasStages = !cat.stages.isNullOrEmpty()
if (!hasLangList && !hasPair && !hasStages) {
append(stringResource(R.string.text_filter_all_items))
} else {
append(" ")
if (hasPair) {
val (a, b) = cat.languagePairs
append("[${languages.value.find { it.nameResId == a }?.name} - ${languages.value.find { it.nameResId == b }?.name}]")
} else if (hasLangList) {
append(cat.languages.joinToString(", ") { langId -> languages.value.find { it.nameResId == langId }?.name.toString() })
} else {
append(stringResource(R.string.text_all_languages))
}
append(" | ")
if (hasStages) append(cat.stages.joinToString(", ") { it.toString(context) }) else append(stringResource(R.string.label_all_stages))
}
}
else -> ""
}
var showMenu by remember { mutableStateOf(false) }
val showDeleteCategoryDialog by categoryViewModel.showDeleteCategoryDialog.collectAsState(initial = false)
val showDeleteItemsDialog by categoryViewModel.showDeleteItemsDialog.collectAsState(initial = false)
val showEditCategoryDialog by categoryViewModel.showEditCategoryDialog.collectAsState(initial = false)
// Handle export state changes
LaunchedEffect(exportState) {
when (exportState) {
is ExportState.Success -> {
// Create and launch share intent
val shareIntent = exportImportViewModel.createShareIntent()
if (shareIntent != null) {
context.startActivity(shareIntent)
}
exportImportViewModel.resetExportState()
}
is ExportState.Error -> {
scope.launch {
snackbarHostState.showSnackbar(
message = (exportState as ExportState.Error).message
)
}
exportImportViewModel.resetExportState()
}
else -> { /* Idle or Loading */ }
}
}
// Scroll state for animation
val listState = rememberLazyListState()
var isHeaderVisible by remember { mutableStateOf(true) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
// Detect scroll direction to show/hide header (same as LibraryScreen)
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.collect { (index, offset) ->
val isScrollingDown = index > previousIndex || (index == previousIndex && offset > previousScrollOffset)
val isAtTop = index == 0 && offset <= 4
isHeaderVisible = if (isAtTop) true else !isScrollingDown
previousIndex = index
previousScrollOffset = offset
}
}
AppScaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.surface)
) {
AppTopAppBar(
title = title,
onNavigateBack = { navController.popBackStack() },
actions = {
IconButton(onClick = { showMenu = !showMenu }) {
Icon(
imageVector = AppIcons.MoreVert,
contentDescription = stringResource(R.string.text_more_options)
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.width(220.dp)
) {
DropdownMenuItem(
text = {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("Export Category")
if (exportState is ExportState.Loading) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp
)
}
}
},
onClick = {
exportImportViewModel.exportCategory(categoryId)
showMenu = false
},
leadingIcon = { Icon(AppIcons.Share, contentDescription = null) },
enabled = exportState !is ExportState.Loading
)
DropdownMenuItem(
text = { Text("Delete Items") },
onClick = {
categoryViewModel.setShowDeleteItemsDialog(true, categoryId)
showMenu = false
},
leadingIcon = { Icon(AppIcons.Delete, contentDescription = null) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.label_edit)) },
onClick = {
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
showMenu = false
},
leadingIcon = { Icon(AppIcons.Edit, contentDescription = null) }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.label_delete)) },
onClick = {
categoryViewModel.setShowDeleteCategoryDialog(true, categoryId)
showMenu = false
},
leadingIcon = { Icon(AppIcons.Delete, contentDescription = null) }
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
// Category Header Card with Progress and Action Buttons (animated)
AnimatedVisibility(
visible = isHeaderVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
CategoryHeaderCard(
subtitle = subtitle,
categoryProgress = categoryProgress,
onStartExerciseClick = {
navController.navigate("start_exercise?categoryId=$categoryId")
},
onEditClick = {
categoryViewModel.setShowEditCategoryDialog(true, categoryId)
},
onDeleteClick = {
categoryViewModel.setShowDeleteCategoryDialog(true, categoryId)
}
)
}
}
}
) { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
AllCardsListScreen(
categoryId = categoryId,
showDueTodayOnly = false,
onNavigateToItem = onNavigateToItem,
navController = navController,
isRemoveFromCategoryEnabled = category is TagCategory,
showTopBar = false,
enableNavigationButtons = true,
listState = listState
)
// Dialogs
if (showDeleteCategoryDialog) {
DeleteCategoryDialog(
onDismiss = { categoryViewModel.setShowDeleteCategoryDialog(false, categoryId) },
viewModel = categoryViewModel,
)
}
if (showDeleteItemsDialog) {
DeleteItemsDialog(
onDismiss = { categoryViewModel.setShowDeleteItemsDialog(false, categoryId) },
categoryId = categoryId
)
}
if (showEditCategoryDialog) {
EditCategoryDialog(
onDismiss = { categoryViewModel.setShowEditCategoryDialog(false, categoryId) },
languageViewModel = languageViewModel,
categoryViewModel = categoryViewModel,
)
}
}
}
}
@Composable
fun CategoryHeaderCard(
subtitle: String,
categoryProgress: CategoryProgress?,
onStartExerciseClick: () -> Unit,
onEditClick: () -> Unit,
onDeleteClick: () -> Unit,
modifier: Modifier = Modifier
) {
AppCard(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Subtitle
if (subtitle.isNotBlank()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp)
)
}
// Progress Circle - smaller size
if (categoryProgress != null) {
CategoryProgressCircle(
totalItems = categoryProgress.totalItems,
itemsCompleted = categoryProgress.itemsCompleted,
itemsInStages = categoryProgress.itemsInStages,
newItems = categoryProgress.newItems,
circleSize = 100.dp,
)
Spacer(modifier = Modifier.height(4.dp))
ChartLegend()
Spacer(modifier = Modifier.height(16.dp))
}
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Start Exercise Button (Primary)
PrimaryButton(
text = stringResource(R.string.label_start_exercise),
icon = AppIcons.Play,
onClick = onStartExerciseClick,
modifier = Modifier.weight(1f)
)
}
}
}
}
// ==================== PREVIEWS ====================
@Preview(showBackground = true)
@Composable
fun CategoryHeaderCardPreview() {
MaterialTheme {
CategoryHeaderCard(
subtitle = "German - English | All Stages",
categoryProgress = null,
onStartExerciseClick = {},
onEditClick = {},
onDeleteClick = {}
)
}
}
@Preview(showBackground = true)
@Composable
fun CategoryHeaderCardWithProgressPreview() {
MaterialTheme {
CategoryHeaderCard(
subtitle = "Travel Vocabulary",
categoryProgress = CategoryProgress(
vocabularyCategory = TagCategory(
1,
"Travel"
),
totalItems = 50,
newItems = 15,
itemsInStages = 25,
itemsCompleted = 10
),
onStartExerciseClick = {},
onEditClick = {},
onDeleteClick = {}
)
}
}

View File

@@ -1,6 +1,6 @@
@file:Suppress("AssignedValueIsNeverRead") @file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.vocabulary package eu.gaudian.translator.view.categories
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -44,8 +44,8 @@ import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.dialogs.AddCategoryDialog import eu.gaudian.translator.view.dialogs.AddCategoryDialog
import eu.gaudian.translator.view.dialogs.DeleteMultipleCategoriesDialog import eu.gaudian.translator.view.dialogs.DeleteMultipleCategoriesDialog
import eu.gaudian.translator.view.vocabulary.widgets.CategoryCircleType import eu.gaudian.translator.view.stats.widgets.CategoryCircleType
import eu.gaudian.translator.view.vocabulary.widgets.CategoryProgressCircle import eu.gaudian.translator.view.stats.widgets.CategoryProgressCircle
import eu.gaudian.translator.viewmodel.CategoryViewModel import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.ProgressViewModel import eu.gaudian.translator.viewmodel.ProgressViewModel
@@ -100,13 +100,7 @@ fun CategoryListScreen(
AppScaffold( AppScaffold(
topBar = { topBar = {
AppTopAppBar( AppTopAppBar(
title = { title = stringResource(R.string.label_all_categories),
if (isSelectionMode && selectedCategories.isNotEmpty()) {
Text(stringResource(R.string.text_2d_categories_selected, selectedCategories.size))
} else {
Text(stringResource(R.string.label_categories))
}
},
navigationIcon = { navigationIcon = {
if (isSelectionMode) { if (isSelectionMode) {
IconButton(onClick = { IconButton(onClick = {

View File

@@ -0,0 +1,218 @@
package eu.gaudian.translator.view.composable
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
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.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.model.LanguageModel
import eu.gaudian.translator.model.communication.ApiProvider
@Composable
fun ApiModelDropDown(
models: List<LanguageModel>,
providers: List<ApiProvider>,
selectedModel: LanguageModel?,
onModelSelected: (LanguageModel?) -> Unit,
enabled: Boolean = true
) {
var expanded by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
val activeModels = models.filter { model -> providers.any { it.key == model.providerKey && (it.hasValidKey || it.isCustom) } }
val groupedModels = activeModels.groupBy { it.providerKey }
val providerNames = remember(providers) { providers.associate { it.key to it.displayName } }
val providerStatuses = remember(providers) { providers.associate { it.key to (it.hasValidKey || it.isCustom) } }
val filteredGroupedModels = remember(groupedModels, searchQuery) {
if (searchQuery.isBlank()) {
groupedModels
} else {
groupedModels.mapValues { (_, models) ->
models.filter { model ->
model.displayName.contains(searchQuery, ignoreCase = true) ||
model.modelId.contains(searchQuery, ignoreCase = true) ||
model.description.contains(searchQuery, ignoreCase = true)
}
}.filterValues { it.isNotEmpty() }
}
}
// Custom button content showing selected model and provider
val buttonContent: @Composable () -> Unit = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = selectedModel?.displayName ?: stringResource(R.string.text_select_model),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (selectedModel != null) {
Text(
text = providerNames[selectedModel.providerKey] ?: selectedModel.providerKey,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand)
)
}
}
AppDropdownContainer(
expanded = expanded,
onDismissRequest = {
expanded = false
searchQuery = ""
},
onExpandRequest = { expanded = true },
buttonText = "", // Not used with custom button content
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
showSearch = true,
searchQuery = searchQuery,
onSearchQueryChange = { searchQuery = it },
searchPlaceholder = stringResource(R.string.label_search_models),
buttonContent = buttonContent
) {
Column(
modifier = Modifier
.heightIn(max = 400.dp)
.verticalScroll(rememberScrollState())
) {
if (filteredGroupedModels.isNotEmpty()) {
filteredGroupedModels.entries.forEachIndexed { index, entry ->
val providerKey = entry.key
val providerModels = entry.value
val isActive = providerStatuses[providerKey] == true
val providerName = providerNames[providerKey] ?: providerKey
if (index > 0) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Provider header
AppDropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = if (isActive) AppIcons.CheckCircle else AppIcons.Warning,
contentDescription = null,
tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = providerName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Text(
text = stringResource(
R.string.labels_1d_models,
providerModels.size
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
},
onClick = {},
enabled = false,
selected = false
)
// Models for this provider
providerModels.forEach { model ->
AppDropdownMenuItem(
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = model.displayName,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
fontWeight = if (model == selectedModel) FontWeight.Medium else FontWeight.Normal
)
Spacer(modifier = Modifier.width(8.dp))
ModelBadges(
modelDisplayOrId = model.displayName.ifBlank { model.modelId },
providerKey = model.providerKey,
)
}
if (model.description.isNotBlank()) {
Text(
text = model.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
},
onClick = {
onModelSelected(model)
expanded = false
searchQuery = ""
},
selected = model == selectedModel
)
}
}
} else if (searchQuery.isNotBlank()) {
AppDropdownMenuItem(
text = {
Text(
stringResource(R.string.text_no_models_found),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth()
)
},
onClick = {},
enabled = false,
selected = false
)
}
}
}
}

View File

@@ -0,0 +1,124 @@
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A compact action card with an icon and label, designed for use in rows or grids.
* Used for quick action buttons like "Explore Packs", "Import CSV", etc.
*
* @param label The text label below the icon
* @param icon The icon to display
* @param onClick Callback when the card is clicked
* @param modifier Modifier for the card
* @param height The height of the card (default 120.dp)
*/
@Composable
fun AppActionCard(
label: String,
icon: ImageVector,
onClick: () -> Unit,
modifier: Modifier = Modifier,
height: Dp = 120.dp,
iconContainerSize: Dp = 48.dp,
iconSize: Dp = 24.dp
) {
AppCard(
modifier = modifier.height(height),
onClick = onClick
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularIconContainer(
imageVector = icon,
size = iconContainerSize,
iconSize = iconSize
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
}
}
}
/**
* A section header label with consistent styling.
* Used for section titles like "Recently Added", etc.
*
* @param text The section title text
* @param modifier Modifier for the text
*/
@Composable
fun SectionLabel(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = modifier
)
}
/**
* A labeled section with an optional action button.
* Provides consistent header styling for sections with a title and optional action.
*
* @param title The section title
* @param modifier Modifier for the section header
* @param actionLabel Optional label for the action button
* @param onActionClick Optional callback for the action button
* @param content The content below the header
*/
@Composable
fun LabeledSection(
title: String,
modifier: Modifier = Modifier,
actionLabel: String? = null,
onActionClick: (() -> Unit)? = null,
content: @Composable () -> Unit
) {
Column(modifier = modifier) {
// Header row with title and optional action
if (actionLabel != null && onActionClick != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
SectionLabel(text = title)
androidx.compose.material3.TextButton(onClick = onActionClick) {
Text(actionLabel)
}
}
} else {
SectionLabel(text = title)
}
Spacer(modifier = Modifier.height(12.dp))
content()
}
}

View File

@@ -0,0 +1,249 @@
package eu.gaudian.translator.view.composable
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.view.composable.ComponentDefaults.DefaultElevation
import eu.gaudian.translator.view.hints.Hint
import eu.gaudian.translator.view.hints.HintBottomSheet
import eu.gaudian.translator.view.hints.LocalShowHints
/**
* A styled card container for displaying content with a consistent floating look.
*
* @param modifier The modifier to be applied to the card.
* @param content The content to be displayed inside the card.
*/
@Composable
fun AppCard(
modifier: Modifier = Modifier,
title: String? = null,
icon: ImageVector? = null,
text: String? = null,
expandable: Boolean = false,
initiallyExpanded: Boolean = false,
onClick: (() -> Unit)? = null,
hintContent : Hint? = null,
content: @Composable ColumnScope.() -> Unit,
) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
val showHints = LocalShowHints.current
val rotationState by animateFloatAsState(
targetValue = if (isExpanded) 180f else 0f,
label = "Chevron Rotation"
)
// Check if we need to render the header row
// Updated to include icon in the check
val hasHeader = title != null || text != null || expandable || icon != null
val canClickHeader = expandable || onClick != null
var showBottomSheet by remember { mutableStateOf(false) }
if (showBottomSheet) {
hintContent?.let {
HintBottomSheet(
onDismissRequest = { showBottomSheet = false },
content = it,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
)
}
}
Surface(
modifier = modifier
.fillMaxWidth()
.shadow(
DefaultElevation,
shape = ComponentDefaults.CardShape
)
.clip(ComponentDefaults.CardClipShape)
// Animate height changes when expanding/collapsing
.animateContentSize(),
shape = ComponentDefaults.CardShape,
color = MaterialTheme.colorScheme.surfaceContainer,
) {
Column {
// --- Header Row ---
if (hasHeader) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = canClickHeader) {
if (expandable) {
isExpanded = !isExpanded
}
onClick?.invoke()
}
.padding(ComponentDefaults.CardPadding),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Optional Icon on the left
if (icon != null) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
}
// 2. Title and Text Column
Column(modifier = Modifier.weight(1f)) {
if (!title.isNullOrBlank()) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
// Only show spacer if both title and text exist
if (!title.isNullOrBlank() && !text.isNullOrBlank()) {
Spacer(Modifier.size(4.dp))
}
if (!text.isNullOrBlank()) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (showHints && hintContent != null) {
IconButton(onClick = { showBottomSheet = true }) {
Icon(
imageVector = AppIcons.Help,
contentDescription = stringResource(R.string.show_hint),
tint = MaterialTheme.colorScheme.secondary
)
}
}
// 3. Expand Chevron (Far right)
if (expandable) {
Icon(
imageVector = AppIcons.ArrowDropDown,
contentDescription = if (isExpanded) "Collapse" else "Expand",
modifier = Modifier.rotate(rotationState),
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
// --- Content Area ---
if (!expandable || isExpanded) {
val contentModifier = Modifier
.padding(
start = ComponentDefaults.CardPadding,
end = ComponentDefaults.CardPadding,
bottom = ComponentDefaults.CardPadding,
// If we have a header, remove the top padding so content sits closer to the title.
// If no header (legacy behavior), keep the top padding.
top = if (hasHeader) 0.dp else ComponentDefaults.CardPadding
)
if (!hasHeader && onClick != null) {
Column(
modifier = contentModifier.clickable { onClick() },
content = content
)
} else {
Column(
modifier = contentModifier,
content = content
)
}
}
}
}
}
@Preview
@Composable
fun AppCardPreview() {
AppCard {
Text(stringResource(R.string.this_is_the_content_inside_the_card))
PrimaryButton(onClick = { }, text = stringResource(R.string.label_continue))
}
}
@Preview(showBackground = true)
@Composable
fun AppCardPreview2() {
MaterialTheme {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 1. Expandable Card (Initially Collapsed)
AppCard(
title = "Advanced Settings",
text = "Click to reveal more options",
expandable = true,
initiallyExpanded = false
) {
Text("Here are some hidden settings.")
Text("They are only visible when expanded.")
}
// 2. Expandable Card (Initially Expanded)
AppCard(
title = "Translation History",
text = "Recent items",
expandable = true,
initiallyExpanded = true
) {
Text("• Hello -> Hallo")
Text("• World -> Welt")
Text("• Sun -> Sonne")
}
// 3. Static Card (No Title/Expand logic - Legacy behavior)
AppCard {
Text("This is a standard card without a header.")
}
}
}
}

View File

@@ -41,6 +41,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.view.hints.Hint
import eu.gaudian.translator.view.hints.HintBottomSheet import eu.gaudian.translator.view.hints.HintBottomSheet
import eu.gaudian.translator.view.hints.LocalShowHints import eu.gaudian.translator.view.hints.LocalShowHints
@@ -48,7 +49,7 @@ import eu.gaudian.translator.view.hints.LocalShowHints
fun AppDialog( fun AppDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
title: (@Composable () -> Unit)? = null, title: (@Composable () -> Unit)? = null,
hintContent: @Composable (() -> Unit)? = null, hintContent: Hint? = null,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
// 1. Swipe Resistance: Prevent accidental dismissal // 1. Swipe Resistance: Prevent accidental dismissal
@@ -98,7 +99,7 @@ fun AppDialog(
if (showBottomSheet) { if (showBottomSheet) {
EnhancedHintBottomSheet( EnhancedHintBottomSheet(
onDismissRequest = { showBottomSheet = false }, onDismissRequest = { showBottomSheet = false },
content = hintContent, content = {hintContent?.Render()},
parentTitle = title parentTitle = title
) )
} }
@@ -114,7 +115,7 @@ fun AppAlertDialog(
title: @Composable (() -> Unit)? = null, title: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null, text: @Composable (() -> Unit)? = null,
properties: DialogProperties = DialogProperties(), properties: DialogProperties = DialogProperties(),
hintContent: @Composable (() -> Unit)? = null, hintContent:Hint? = null,
) { ) {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
@@ -141,12 +142,14 @@ fun AppAlertDialog(
) )
if (showBottomSheet) { if (showBottomSheet) {
hintContent?.let {
HintBottomSheet( HintBottomSheet(
onDismissRequest = { showBottomSheet = false }, onDismissRequest = { showBottomSheet = false },
sheetState = sheetState, sheetState = sheetState,
content = hintContent content = it
) )
} }
}
} }
/** /**
@@ -156,7 +159,7 @@ fun AppAlertDialog(
@Composable @Composable
private fun DialogHeader( private fun DialogHeader(
title: (@Composable () -> Unit)?, title: (@Composable () -> Unit)?,
hintContent: @Composable (() -> Unit)?, hintContent: Hint? = null,
onHintClick: () -> Unit, onHintClick: () -> Unit,
onCloseClick: () -> Unit onCloseClick: () -> Unit
) { ) {
@@ -211,7 +214,7 @@ private fun DialogHeader(
@Composable @Composable
private fun DialogTitleWithHint( private fun DialogTitleWithHint(
title: @Composable () -> Unit, title: @Composable () -> Unit,
hintContent: @Composable (() -> Unit)?, hintContent: Hint? = null,
onHintClick: () -> Unit onHintClick: () -> Unit
) { ) {
val showHints = LocalShowHints.current val showHints = LocalShowHints.current
@@ -327,7 +330,6 @@ fun AppDialogPreview() {
AppDialog( AppDialog(
onDismissRequest = {}, onDismissRequest = {},
title = { Text("Dialog Title") }, title = { Text("Dialog Title") },
hintContent = { Text("This is a hint.") },
content = { content = {
Column { Column {
Text("Content line 1") Text("Content line 1")
@@ -378,7 +380,6 @@ fun AppDialogLongContentPreview() {
AppDialog( AppDialog(
onDismissRequest = {}, onDismissRequest = {},
title = { Text("Long Content Dialog") }, title = { Text("Long Content Dialog") },
hintContent = { Text("Hint for long content dialog") },
content = { content = {
Column { Column {
Text("This is a long content dialog to test scrolling") Text("This is a long content dialog to test scrolling")
@@ -425,7 +426,6 @@ fun AppAlertDialogPreview() {
}, },
title = { Text("Alert Dialog Title") }, title = { Text("Alert Dialog Title") },
text = { Text("This is the alert dialog text.") }, text = { Text("This is the alert dialog text.") },
hintContent = { Text("This is a hint for the alert dialog.") }
) )
} }
@@ -493,7 +493,6 @@ fun AppAlertDialogLongTextPreview() {
Text("Third paragraph with additional information that users need to be aware of.") 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

@@ -2,30 +2,46 @@
package eu.gaudian.translator.view.composable package eu.gaudian.translator.view.composable
import androidx.compose.foundation.BorderStroke import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -34,6 +50,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
@@ -42,31 +59,428 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource 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.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize import androidx.compose.ui.unit.toSize
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import com.google.android.material.color.MaterialColors.ALPHA_DISABLED
import com.google.android.material.color.MaterialColors.ALPHA_FULL
import eu.gaudian.translator.R import eu.gaudian.translator.R
// =========================================
// UNIFIED DROPDOWN STYLES & CONSTANTS
// =========================================
object DropdownDefaults {
val shape = RoundedCornerShape(8.dp)
val itemPaddingHorizontal = 8.dp
val itemPaddingVertical = 2.dp
@Composable
fun containerColor(): Color = MaterialTheme.colorScheme.surface
@Composable
fun itemBackground(selected: Boolean): Color {
return if (selected) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
} else {
Color.Transparent
}
}
@Composable
fun itemContentColor(selected: Boolean, enabled: Boolean): Color {
return when {
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
selected -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
}
}
}
/** /**
* A modern, custom dropdown menu composable that provides a styled text field with a dropdown list of options. * A drop-in replacement for [androidx.compose.material3.DropdownMenu] that opens
* This implementation uses a custom dropdown for a more tailored look compared to the stock menu, behaving like a normal ExposedDropdownMenu. * as a BottomSheet. Compatible with the standard M3 signature.
* Allows managing selection and expansion, making it a convenient wrapper for dropdowns.
*
* @param expanded Whether the dropdown menu is expanded.
* @param onDismissRequest Callback invoked when the dropdown menu should be dismissed.
* @param modifier Modifier for the composable.
* @param label Composable for the label displayed in the text field.
* @param enabled Whether the dropdown is enabled.
* @param placeholder Optional placeholder text when no option is selected.
* @param selectedText The text to display in the text field for the selected option.
* @param onExpandRequest Callback invoked when the dropdown should expand.
* @param content Composable content for the dropdown items, typically using AppDropdownMenuItem.
*/ */
@Suppress("unused", "HardCodedStringLiteral")
@Composable
fun AppDropDownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp), // Retained for signature compatibility
scrollState: ScrollState = rememberScrollState(),
properties: PopupProperties = PopupProperties(focusable = true), // Retained for signature compatibility
content: @Composable ColumnScope.() -> Unit
) {
if (expanded) {
// skipPartiallyExpanded = true ensures it behaves more like a menu
// (fully open or completely closed) rather than a peekable sheet.
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
// Container color, shape, etc., can be linked to your DropdownDefaults here if needed.
) {
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(scrollState)
) {
// Execute standard DropdownMenuItems here
content()
// Extra padding to ensure the last item isn't hidden behind the system navigation bar
Spacer(modifier = Modifier.navigationBarsPadding().height(16.dp))
}
}
}
}
// =========================================
// UNIFIED DROPDOWN CONTAINER
// =========================================
/**
* A unified dropdown container that provides consistent styling and behavior
* for all dropdown menus in the app.
*
* @param expanded Whether the dropdown is currently expanded
* @param onDismissRequest Callback when the dropdown should be dismissed
* @param onExpandRequest Callback when the dropdown should expand (click on button)
* @param buttonText The text to display on the dropdown button
* @param modifier Modifier for the container
* @param enabled Whether the dropdown is enabled
* @param showSearch Whether to show the search field at the top of the dropdown
* @param searchQuery Current search query (only used if showSearch is true)
* @param onSearchQueryChange Callback when search query changes (only used if showSearch is true)
* @param searchPlaceholder Placeholder text for search field
* @param showDoneButton Whether to show a "Done" button at the bottom (for multi-select)
* @param onDoneClick Callback when Done button is clicked
* @param buttonContent Custom content for the button (if null, uses default text-based button)
* @param dropdownContent Content to display inside the dropdown menu
*/
@Composable
fun AppDropdownContainer(
expanded: Boolean,
onDismissRequest: () -> Unit,
onExpandRequest: () -> Unit,
buttonText: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
showSearch: Boolean = false,
searchQuery: String = "",
onSearchQueryChange: ((String) -> Unit)? = null,
searchPlaceholder: String? = null,
showDoneButton: Boolean = false,
onDoneClick: (() -> Unit)? = null,
buttonContent: @Composable (() -> Unit)? = null,
dropdownContent: @Composable ColumnScope.() -> Unit
) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
// Dropdown Button
if (buttonContent != null) {
AppOutlinedButton(
onClick = onExpandRequest,
modifier = Modifier.fillMaxWidth(),
enabled = enabled
) {
buttonContent()
}
} else {
AppOutlinedButton(
shape = DropdownDefaults.shape,
onClick = onExpandRequest,
modifier = Modifier.fillMaxWidth(),
enabled = enabled
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = buttonText,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded)
stringResource(R.string.cd_collapse)
else
stringResource(R.string.cd_expand)
)
}
}
}
// Bottom Sheet "Dropdown" Menu
if (expanded) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
containerColor = DropdownDefaults.containerColor()
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
// Pinned Search field (optional)
if (showSearch && onSearchQueryChange != null) {
DropdownSearchField(
searchQuery = searchQuery,
onSearchQueryChange = onSearchQueryChange,
placeholder = {
Text(searchPlaceholder ?: stringResource(R.string.text_search))
}
)
HorizontalDivider()
}
// Scrollable Content
// Weight ensures this takes up available space without pushing
// the done button off-screen if the list is very long.
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) {
dropdownContent()
}
// Pinned Done button (optional, for multi-select)
if (showDoneButton && onDoneClick != null) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AppButton(
onClick = {
onDoneClick()
onDismissRequest() // Often expected to close on 'Done'
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(stringResource(R.string.label_done))
}
}
// Extra padding for the system navigation bar so the bottom
// item/button isn't cut off by gesture hints or software keys.
Spacer(modifier = Modifier.navigationBarsPadding().height(16.dp))
}
}
}
}
}
// =========================================
// UNIFIED DROPDOWN SEARCH FIELD
// =========================================
/**
* A standardized search field for dropdown menus.
* Provides consistent styling across all dropdowns with search functionality.
*/
@Composable
fun DropdownSearchField(
modifier: Modifier = Modifier,
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
placeholder: @Composable () -> Unit = { Text(stringResource(R.string.text_search)) },
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = AppIcons.Search,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(8.dp))
TextField(
value = searchQuery,
onValueChange = onSearchQueryChange,
placeholder = placeholder,
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.weight(1f)
)
if (searchQuery.isNotBlank()) {
IconButton(
onClick = { onSearchQueryChange("") },
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = AppIcons.Close,
contentDescription = stringResource(R.string.cd_clear_search),
modifier = Modifier.size(16.dp)
)
}
}
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true, name = "Search Field - Empty")
@Composable
fun DropdownSearchFieldEmptyPreview() {
MaterialTheme {
Surface {
DropdownSearchField(
searchQuery = "",
onSearchQueryChange = {}
)
}
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true, name = "Search Field - Filled")
@Composable
fun DropdownSearchFieldFilledPreview() {
MaterialTheme {
Surface {
DropdownSearchField(
searchQuery = "English",
onSearchQueryChange = {}
)
}
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true, name = "Search Field - With Close Button")
@Composable
fun DropdownSearchFieldWithClosePreview() {
MaterialTheme {
Surface {
DropdownSearchField(
searchQuery = "German",
onSearchQueryChange = {}
// Providing this triggers the right-most close icon
)
}
}
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true, name = "Search Field - Interactive")
@Composable
fun DropdownSearchFieldInteractivePreview() {
MaterialTheme {
Surface {
var query by remember { mutableStateOf("") }
DropdownSearchField(
searchQuery = query,
onSearchQueryChange = { query = it }
)
}
}
}
// =========================================
// UNIFIED DROPDOWN HEADER
// =========================================
/**
* A standardized header for dropdown sections.
* Provides consistent styling for section headers like "Favorites", "Recent", etc.
*/
@Composable
fun DropdownHeader(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
// =========================================
// UNIFIED DROPDOWN ITEM COMPONENT
// =========================================
@Composable
fun AppDropdownMenuItem(
text: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
selected: Boolean = false,
) {
val contentColor by animateColorAsState(
targetValue = DropdownDefaults.itemContentColor(selected, enabled),
label = "contentColor"
)
val backgroundColor by animateColorAsState(
targetValue = DropdownDefaults.itemBackground(selected),
label = "backgroundColor"
)
Box(
modifier = modifier
.fillMaxWidth()
.padding(
horizontal = DropdownDefaults.itemPaddingHorizontal,
vertical = DropdownDefaults.itemPaddingVertical
)
.clip(DropdownDefaults.shape)
.background(backgroundColor)
.clickable(enabled = enabled) { onClick() }
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
leadingIcon?.invoke()
if (leadingIcon != null) {
Spacer(modifier = Modifier.width(12.dp))
}
Box(modifier = Modifier.weight(1f)) {
CompositionLocalProvider(LocalContentColor provides contentColor) {
text()
}
}
if (trailingIcon != null) {
Spacer(modifier = Modifier.width(12.dp))
trailingIcon()
}
}
}
}
/**
* A lightweight, modern dropdown menu composable with a clean text field and dropdown list.
*/
@Suppress("unused", "HardCodedStringLiteral")
@Composable @Composable
fun AppDropdownMenu( fun AppDropdownMenu(
expanded: Boolean, expanded: Boolean,
@@ -77,11 +491,10 @@ fun AppDropdownMenu(
placeholder: @Composable (() -> Unit)? = null, placeholder: @Composable (() -> Unit)? = null,
selectedText: String = "", selectedText: String = "",
onExpandRequest: () -> Unit = {}, onExpandRequest: () -> Unit = {},
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
var textFieldSize by remember { mutableStateOf(Size.Zero) } var textFieldSize by remember { mutableStateOf(Size.Zero) }
val interactionSource = remember { MutableInteractionSource() }
val interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
Column(modifier = modifier) { Column(modifier = modifier) {
OutlinedTextField( OutlinedTextField(
@@ -102,21 +515,20 @@ fun AppDropdownMenu(
label = label, label = label,
placeholder = placeholder, placeholder = placeholder,
trailingIcon = { trailingIcon = {
val icon = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown
Icon( Icon(
imageVector = icon, imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand), contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
}, },
shape = ComponentDefaults.DefaultShape, shape = DropdownDefaults.shape,
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary, focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_LOW), unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.6f),
focusedLabelColor = MaterialTheme.colorScheme.primary, focusedLabelColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary, cursorColor = MaterialTheme.colorScheme.primary,
disabledBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_LOW), disabledBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
disabledLabelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_MEDIUM) disabledLabelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
), ),
enabled = enabled, enabled = enabled,
interactionSource = interactionSource interactionSource = interactionSource
@@ -125,152 +537,99 @@ fun AppDropdownMenu(
DropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
modifier = Modifier modifier = Modifier.width(with(LocalDensity.current) { textFieldSize.width.toDp() }),
.width(with(LocalDensity.current) { textFieldSize.width.toDp() }), offset = DpOffset(0.dp, 2.dp),
offset = DpOffset(0.dp, 0.dp),
scrollState = rememberScrollState(),
properties = PopupProperties(focusable = true), properties = PopupProperties(focusable = true),
shape = RoundedCornerShape(8.dp), shape = DropdownDefaults.shape,
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = DropdownDefaults.containerColor()
tonalElevation = 0.dp,
shadowElevation = 4.dp,
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.14f))
) { ) {
content() content()
} }
} }
} }
/** // =========================================
* A modern and stylish composable for individual dropdown items, featuring enhanced visual design // LEGACY COMPONENTS (UPDATED TO USE UNIFIED STYLES)
* with subtle shadows, rounded corners, and smooth interactions. This provides a cool, contemporary look // =========================================
* that aligns with modern UI trends while maintaining accessibility and usability.
*
* @param text Composable lambda for the text to display in the item.
* @param onClick Callback invoked when the item is clicked.
* @param modifier Modifier for the item.
* @param enabled Whether the item is enabled.
* @param leadingIcon Optional leading icon for the item.
* @param trailingIcon Optional trailing icon for the item.
*/
@Composable @Composable
fun AppDropdownMenuItem( fun BottomSheetMenuItem(
text: @Composable () -> Unit, icon: ImageVector,
onClick: () -> Unit, title: String,
modifier: Modifier = Modifier, subtitle: String,
enabled: Boolean = true, onClick: () -> Unit
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
selected: Boolean = false,
) { ) {
val contentColor = if (enabled) { Row(
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface modifier = Modifier
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) // Equivalent to disabled alpha
}
Box(
modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = enabled) { onClick() } .clickable { onClick() }
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
androidx.compose.foundation.layout.Row( // Circular Icon Background
modifier = Modifier.fillMaxWidth(), Surface(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(52.dp)
) { ) {
leadingIcon?.invoke() Box(contentAlignment = Alignment.Center) {
if (leadingIcon != null) { Icon(
androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp)) imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(28.dp)
)
} }
Box(modifier = Modifier.weight(1f)) { }
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,
selected: Boolean,
enabled: Boolean,
onClick: () -> Unit,
) {
val contentColor = DropdownDefaults.itemContentColor(selected, enabled)
val backgroundColor = DropdownDefaults.itemBackground(selected)
val fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
CompositionLocalProvider(LocalContentColor provides contentColor) { CompositionLocalProvider(LocalContentColor provides contentColor) {
text() Box(
} modifier = Modifier
} .fillMaxWidth()
if (trailingIcon != null) { .padding(
androidx.compose.foundation.layout.Spacer(modifier = Modifier.width(8.dp)) horizontal = DropdownDefaults.itemPaddingHorizontal,
trailingIcon() vertical = DropdownDefaults.itemPaddingVertical
} )
} .clip(DropdownDefaults.shape)
} .background(backgroundColor)
} .clickable(enabled) { onClick() }
.padding(horizontal = 16.dp, vertical = 14.dp)
@Suppress("HardCodedStringLiteral") ) {
@Preview(showBackground = true) Text(
@Composable text = text,
fun AppDropdownMenuPreview() { style = MaterialTheme.typography.titleSmall.copy(fontWeight = fontWeight),
val options = listOf("Option 1", "Option 2", "Option 3")
AppDropdownMenu(
expanded = false,
onDismissRequest = {},
label = { Text("Select Option") },
content = {
options.forEach { option ->
AppDropdownMenuItem(
text = { Text(text = option) },
onClick = {}
) )
} }
} }
)
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun AppDropdownMenuExpandedPreview() {
val options = listOf("English", "Spanish", "French", "German", "Italian", "Portuguese")
var expanded by remember { mutableStateOf(true) } // Force expanded state for preview
// Since previews are static, we'll simulate the expanded state by showing the dropdown
AppDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
label = { Text("Language") },
content = {
options.forEach { option ->
AppDropdownMenuItem(
text = { Text(text = option) },
onClick = {}
)
}
}
)
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun DropDownItemPreview() {
AppDropdownMenuItem(
text = { Text("Sample Item", style = MaterialTheme.typography.titleSmall) },
onClick = {},
leadingIcon = {
Icon(
imageVector = AppIcons.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
)
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun DropDownItemSelectedPreview() {
AppDropdownMenuItem(
text = { Text("Selected Item", style = MaterialTheme.typography.titleSmall) },
onClick = {},
selected = true,
trailingIcon = {
Icon(
imageVector = AppIcons.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
)
} }
@Composable @Composable
@@ -282,12 +641,12 @@ fun <T> LargeDropdownMenu(
items: List<T>, items: List<T>,
selectedIndex: Int = -1, selectedIndex: Int = -1,
onItemSelected: (index: Int, item: T) -> Unit, onItemSelected: (index: Int, item: T) -> Unit,
selectedItemToString: (T) -> String = { it.toString() }, selectedItemToString: (T) -> String = { item: T -> item.toString() },
drawItem: @Composable (T, Boolean, Boolean, () -> Unit) -> Unit = { item, selected, itemEnabled, onClick -> drawItem: @Composable (T, Boolean, Boolean, () -> Unit) -> Unit = { item: T, selected: Boolean, _: Boolean, onClick: () -> Unit ->
LargeDropdownMenuItem( LargeDropdownMenuItem(
text = item.toString(), text = item.toString(),
selected = selected, selected = selected,
enabled = itemEnabled, enabled = true,
onClick = onClick, onClick = onClick,
) )
}, },
@@ -308,7 +667,6 @@ fun <T> LargeDropdownMenu(
readOnly = true, readOnly = true,
) )
// Transparent clickable surface on top of OutlinedTextField
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -320,11 +678,11 @@ fun <T> LargeDropdownMenu(
} }
if (expanded) { if (expanded) {
Dialog( Dialog(onDismissRequest = { expanded = false }) {
onDismissRequest = { expanded = true },
) {
Surface( Surface(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
if (selectedIndex > -1) { if (selectedIndex > -1) {
@@ -333,7 +691,11 @@ fun <T> LargeDropdownMenu(
} }
} }
LazyColumn(modifier = Modifier.fillMaxWidth(), state = listState) { LazyColumn(
modifier = Modifier.fillMaxWidth(),
state = listState,
contentPadding = PaddingValues(vertical = 8.dp)
) {
if (notSetLabel != null) { if (notSetLabel != null) {
item { item {
LargeDropdownMenuItem( LargeDropdownMenuItem(
@@ -344,7 +706,7 @@ fun <T> LargeDropdownMenu(
) )
} }
} }
itemsIndexed(items) { index, item -> itemsIndexed(items) { index: Int, item: T ->
val selectedItem = index == selectedIndex val selectedItem = index == selectedIndex
drawItem( drawItem(
item, item,
@@ -354,10 +716,6 @@ fun <T> LargeDropdownMenu(
onItemSelected(index, item) onItemSelected(index, item)
expanded = false expanded = false
} }
if (index < items.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
} }
} }
} }
@@ -365,31 +723,7 @@ fun <T> LargeDropdownMenu(
} }
} }
@Composable // ============== PREVIEWS ==============
fun LargeDropdownMenuItem(
text: String,
selected: Boolean,
enabled: Boolean,
onClick: () -> Unit,
) {
val contentColor = when {
!enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_DISABLED)
selected -> MaterialTheme.colorScheme.primary.copy(alpha = ALPHA_FULL)
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_FULL)
}
CompositionLocalProvider(LocalContentColor provides contentColor) {
Box(modifier = Modifier
.clickable(enabled) { onClick() }
.fillMaxWidth()
.padding(16.dp)) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
)
}
}
}
@Suppress("HardCodedStringLiteral") @Suppress("HardCodedStringLiteral")
@Preview(showBackground = true) @Preview(showBackground = true)
@@ -415,6 +749,30 @@ fun LargeDropdownMenuItemSelectedPreview() {
) )
} }
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun AppDropdownMenuItemPreview() {
AppDropdownMenuItem(
text = { Text("Sample Item") },
onClick = {},
selected = false,
enabled = true
)
}
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun AppDropdownMenuItemSelectedPreview() {
AppDropdownMenuItem(
text = { Text("Selected Item") },
onClick = {},
selected = true,
enabled = true
)
}
@Suppress("HardCodedStringLiteral") @Suppress("HardCodedStringLiteral")
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
@@ -426,53 +784,8 @@ fun LargeDropdownMenuPreview() {
label = "Select Option", label = "Select Option",
items = options, items = options,
selectedIndex = selectedIndex, selectedIndex = selectedIndex,
onItemSelected = { index, _ -> onItemSelected = { index: Int, _: String ->
selectedIndex = index selectedIndex = index
} }
) )
} }
@Suppress("HardCodedStringLiteral")
@Preview(showBackground = true)
@Composable
fun LargeDropdownMenuExpandedPreview() {
val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5", "Option 6")
var selectedIndex by remember { mutableIntStateOf(2) }
// Simulate expanded state by showing the dropdown and the dialog content
Column {
LargeDropdownMenu(
label = "Select Option",
items = options,
selectedIndex = selectedIndex,
onItemSelected = { index, _ ->
selectedIndex = index
}
)
// Manually show the expanded dialog content for preview
Dialog(onDismissRequest = {}) {
Surface(shape = RoundedCornerShape(12.dp)) {
val listState = rememberLazyListState()
LaunchedEffect("ScrollToSelected") {
listState.scrollToItem(index = selectedIndex)
}
LazyColumn(modifier = Modifier.fillMaxWidth(), state = listState) {
itemsIndexed(options) { index, item ->
LargeDropdownMenuItem(
text = item,
selected = index == selectedIndex,
enabled = true,
onClick = { selectedIndex = index }
)
if (index < options.lastIndex) {
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
}
}
}
}
}
}

View File

@@ -52,6 +52,7 @@ data class FabMenuItem(
) )
@Deprecated("We don't want to use floating butto menus anymore")
@Composable @Composable
fun AppFabMenu( fun AppFabMenu(
items: List<FabMenuItem>, items: List<FabMenuItem>,

View File

@@ -0,0 +1,81 @@
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A reusable icon container that displays an icon inside a shaped background.
* Used throughout the app for consistent icon presentation in cards, buttons, and action items.
*
* @param imageVector The icon to display
* @param modifier Modifier to be applied to the container
* @param size The size of the container (default 40.dp)
* @param iconSize The size of the icon itself (default 24.dp)
* @param shape The shape of the container (default RoundedCornerShape(12.dp))
* @param backgroundColor Background color of the container
* @param iconTint Tint color for the icon
*/
@Composable
fun AppIconContainer(
imageVector: ImageVector,
modifier: Modifier = Modifier,
size: Dp = 40.dp,
iconSize: Dp = 24.dp,
shape: androidx.compose.ui.graphics.Shape = RoundedCornerShape(12.dp),
backgroundColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant,
iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary,
contentDescription: String? = null
) {
Box(
modifier = modifier
.size(size)
.clip(shape)
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = imageVector,
contentDescription = contentDescription,
tint = iconTint,
modifier = Modifier.size(iconSize)
)
}
}
/**
* A circular variant of AppIconContainer.
* Convenience wrapper for circular icon containers.
*/
@Composable
fun CircularIconContainer(
imageVector: ImageVector,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
iconSize: Dp = 24.dp,
backgroundColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceVariant,
iconTint: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.primary,
contentDescription: String? = null
) {
AppIconContainer(
imageVector = imageVector,
modifier = modifier,
size = size,
iconSize = iconSize,
shape = CircleShape,
backgroundColor = backgroundColor,
iconTint = iconTint,
contentDescription = contentDescription
)
}

View File

@@ -10,7 +10,7 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.Icons.Default 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.ArrowForward
import androidx.compose.material.icons.automirrored.filled.DriveFileMove import androidx.compose.material.icons.automirrored.filled.DriveFileMove
import androidx.compose.material.icons.automirrored.filled.ExitToApp 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.Merge
import androidx.compose.material.icons.filled.ModelTraining import androidx.compose.material.icons.filled.ModelTraining
import androidx.compose.material.icons.filled.MonitorHeart 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.MoreVert
import androidx.compose.material.icons.filled.NoteAdd import androidx.compose.material.icons.filled.NoteAdd
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
@@ -135,7 +136,7 @@ object AppIcons {
val AI = Default.AutoAwesome val AI = Default.AutoAwesome
val Appearance = Icons.Filled.ColorLens val Appearance = Icons.Filled.ColorLens
val ApiKey = Default.Key val ApiKey = Default.Key
val ArrowBack = Icons.AutoMirrored.Filled.ArrowBack val ArrowBack = Icons.AutoMirrored.Filled.ArrowBackIos
val ArrowCircleUp = Icons.Filled.ArrowCircleUp val ArrowCircleUp = Icons.Filled.ArrowCircleUp
val ArrowDropDown = Icons.Filled.KeyboardArrowDown val ArrowDropDown = Icons.Filled.KeyboardArrowDown
val ArrowDropUp = Icons.Filled.KeyboardArrowUp val ArrowDropUp = Icons.Filled.KeyboardArrowUp
@@ -202,6 +203,7 @@ object AppIcons {
val Merge = Icons.Filled.Merge val Merge = Icons.Filled.Merge
val ModelTraining = Icons.Filled.ModelTraining val ModelTraining = Icons.Filled.ModelTraining
val More = Default.MoreVert val More = Default.MoreVert
val MoreHorizontal = Icons.Filled.MoreHoriz
val MoreVert = Default.MoreVert val MoreVert = Default.MoreVert
val MoveTo = Icons.AutoMirrored.Filled.DriveFileMove val MoveTo = Icons.AutoMirrored.Filled.DriveFileMove
val Paste = Default.ContentPaste val Paste = Default.ContentPaste

View File

@@ -115,7 +115,7 @@ fun AppOutlinedTextField(
OutlinedTextField( OutlinedTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(), modifier = modifier,
label = label, label = label,
trailingIcon = finalTrailingIcon, trailingIcon = finalTrailingIcon,
shape = ComponentDefaults.DefaultShape, shape = ComponentDefaults.DefaultShape,

View File

@@ -2,26 +2,15 @@ package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets 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.FabPosition
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.contentColorFor import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
@Composable @Composable
fun AppScaffold( 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 package eu.gaudian.translator.view.composable
import android.annotation.SuppressLint 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.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews import eu.gaudian.translator.ui.theme.ThemePreviews
/** /**
@@ -46,28 +49,51 @@ interface TabItem {
val title: String val title: String
val icon: ImageVector val icon: ImageVector
} }
@Deprecated("Migrate to new (like used in LibraryScreen")
/** @SuppressLint("UnusedBoxWithConstraintsScope", "LocalContextResourcesRead", "DiscouragedApi",
* A generic, reusable tab layout composable. "SuspiciousIndentation"
* @param T The type of the tab item, which must implement the TabItem interface. )
* @param tabs A list of all tab items to display.
* @param selectedTab The currently selected tab item.
* @param onTabSelected A lambda function to be invoked when a tab is clicked.
*/
@SuppressLint("UnusedBoxWithConstraintsScope", "LocalContextResourcesRead", "DiscouragedApi")
@Composable @Composable
fun <T : TabItem> AppTabLayout( fun <T : TabItem> AppTabLayout(
tabs: List<T>, tabs: List<T>,
selectedTab: T, selectedTab: T,
onTabSelected: (T) -> Unit, onTabSelected: (T) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
onNavigateBack: (() -> Unit)? = null
) { ) {
val selectedIndex = tabs.indexOf(selectedTab) val selectedIndex = tabs.indexOf(selectedTab)
BoxWithConstraints( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .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) .height(56.dp)
.background( .background(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
@@ -139,8 +165,10 @@ fun <T : TabItem> AppTabLayout(
} }
} }
} }
}
} }
@Suppress("HardCodedStringLiteral")
@ThemePreviews @ThemePreviews
@Composable @Composable
fun ModernTabLayoutPreview() { fun ModernTabLayoutPreview() {

View File

@@ -0,0 +1,74 @@
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* A styled filled text input field.
* Different from AppOutlinedTextField - this uses a filled background style.
*
* @param value The input text to be shown in the text field.
* @param onValueChange The callback that is triggered when the input service updates the text.
* @param modifier The modifier to be applied to the text field.
* @param placeholder The placeholder text to display when the field is empty.
* @param enabled Whether the text field is enabled.
* @param readOnly Whether the text field is read-only.
* @param singleLine Whether the text field is single line.
* @param minLines Minimum number of lines.
* @param maxLines Maximum number of lines.
*/
@Composable
fun AppTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
placeholder: String? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
singleLine: Boolean = true,
minLines: Int = 1,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
) {
val cornerRadius = 12.dp
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
placeholder = placeholder?.let {
{
Text(
text = it,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
},
shape = RoundedCornerShape(cornerRadius),
colors = TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.primary,
focusedTextColor = MaterialTheme.colorScheme.onSurface,
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
),
singleLine = singleLine,
minLines = minLines,
maxLines = maxLines,
enabled = enabled,
readOnly = readOnly,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon
)
}

View File

@@ -1,20 +1,25 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.composable 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.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
@@ -25,46 +30,50 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.view.hints.Hint
import eu.gaudian.translator.view.hints.HintBottomSheet import eu.gaudian.translator.view.hints.HintBottomSheet
import eu.gaudian.translator.view.hints.LocalShowHints import eu.gaudian.translator.view.hints.LocalShowHints
@Composable @Composable
fun AppTopAppBar( fun AppTopAppBar(
title: @Composable () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String,
onNavigateBack: (() -> Unit)? = null, onNavigateBack: (() -> Unit)? = null,
navigationIcon: @Composable (() -> Unit)? = null, navigationIcon: @Composable (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
hintContent: @Composable (() -> Unit)? = null hint: Hint? = null
) { ) {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
TopAppBar( // Changed to CenterAlignedTopAppBar to perfectly match the design requirements
CenterAlignedTopAppBar(
modifier = modifier.height(56.dp), modifier = modifier.height(56.dp),
windowInsets = WindowInsets(0.dp), windowInsets = WindowInsets(0.dp),
colors = colors, colors = colors,
title = { title = {
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
val showHints = LocalShowHints.current val showHints = LocalShowHints.current
if (showHints && hintContent != null) { if (showHints && hint != null) {
// Simplified row: keeps the title and hint icon neatly centered together
Row( Row(
modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically horizontalArrangement = Arrangement.Center
) { ) {
Box(modifier = Modifier.weight(1f)) { Text(
title() text = title,
} style = MaterialTheme.typography.titleLarge,
Box { fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
IconButton(onClick = { showBottomSheet = true }) { IconButton(onClick = { showBottomSheet = true }) {
Icon( Icon(
imageVector = AppIcons.Help, imageVector = AppIcons.Help,
@@ -73,60 +82,55 @@ fun AppTopAppBar(
) )
} }
} }
}
} else { } else {
title() Text(
} text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
} }
}, },
navigationIcon = { navigationIcon = {
if (onNavigateBack != null) { if (onNavigateBack != null) {
Box( IconButton(
modifier = Modifier.fillMaxHeight(), onClick = onNavigateBack,
contentAlignment = Alignment.Center modifier = Modifier
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) { ) {
IconButton(onClick = onNavigateBack) {
Icon( Icon(
AppIcons.ArrowBack, imageVector = Icons.Default.ArrowBackIosNew,
contentDescription = stringResource(R.string.cd_navigate_back), contentDescription = "Back",
tint = LocalContentColor.current modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary
) )
} }
}
} else if (navigationIcon != null) { } else if (navigationIcon != null) {
Box(modifier = Modifier.fillMaxHeight(), contentAlignment = Alignment.Center) {
navigationIcon() navigationIcon()
} }
} else {
// No navigation icon
}
}, },
actions = actions actions = actions
) )
if (showBottomSheet) { if (showBottomSheet) {
hint?.let {
HintBottomSheet( HintBottomSheet(
onDismissRequest = { onDismissRequest = {
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
showBottomSheet = false showBottomSheet = false
}, },
sheetState = sheetState, sheetState = sheetState,
content = hintContent content = it
) )
} }
}
} }
/** /**
* A composable that acts as a TopAppBar, containing a back navigation icon * A composable that acts as a TopAppBar, containing a back navigation icon
* and an [AppTabLayout]. * 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 @Composable
fun <T : TabItem> TabbedTopAppBar( fun <T : TabItem> TabbedTopAppBar(
@@ -136,7 +140,6 @@ fun <T : TabItem> TabbedTopAppBar(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
// Use a Surface to provide background color and context for the app bar
Surface( Surface(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface color = MaterialTheme.colorScheme.surface
@@ -145,20 +148,21 @@ fun <T : TabItem> TabbedTopAppBar(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically 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( IconButton(
onClick = onNavigateBack, 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( Icon(
imageVector = AppIcons.ArrowBack, imageVector = AppIcons.ArrowBack,
contentDescription = stringResource(R.string.cd_navigate_back), 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( AppTabLayout(
tabs = tabs, tabs = tabs,
selectedTab = selectedTab, selectedTab = selectedTab,
@@ -169,11 +173,12 @@ fun <T : TabItem> TabbedTopAppBar(
} }
} }
// ... [Previews remain exactly the same below]
@Suppress("HardCodedStringLiteral") @Suppress("HardCodedStringLiteral")
@ThemePreviews @ThemePreviews
@Composable @Composable
fun TabbedTopAppBarPreview() { fun TabbedTopAppBarPreview() {
// Sample data for preview, similar to ModernTabLayoutPreview
data class SampleTabItem(override val title: String, override val icon: ImageVector) : TabItem data class SampleTabItem(override val title: String, override val icon: ImageVector) : TabItem
val tabs = listOf( val tabs = listOf(
@@ -199,7 +204,7 @@ fun TabbedTopAppBarPreview() {
@Composable @Composable
fun AppTopAppBarPreview() { fun AppTopAppBarPreview() {
AppTopAppBar( AppTopAppBar(
title = { Text("Preview Title") } title = "Previwe Title"
) )
} }
@@ -207,7 +212,7 @@ fun AppTopAppBarPreview() {
@Composable @Composable
fun AppTopAppBarWithNavigationIconPreview() { fun AppTopAppBarWithNavigationIconPreview() {
AppTopAppBar( AppTopAppBar(
title = { Text(stringResource(R.string.title_title_preview_title)) }, title = "Preview Title",
onNavigateBack = {} onNavigateBack = {}
) )
} }
@@ -216,13 +221,13 @@ fun AppTopAppBarWithNavigationIconPreview() {
@Composable @Composable
fun AppTopAppBarWithActionsPreview() { fun AppTopAppBarWithActionsPreview() {
AppTopAppBar( AppTopAppBar(
title = { Text(stringResource(R.string.title_title_preview_title)) }, title = "Preview Title",
actions = { actions = {
IconButton(onClick = {}) { IconButton(onClick = {}) {
Icon(AppIcons.Settings, contentDescription = stringResource(R.string.title_settings)) Icon(AppIcons.Settings, contentDescription = stringResource(R.string.title_settings))
} }
IconButton(onClick = {}) { 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 package eu.gaudian.translator.view.composable
@@ -11,23 +11,45 @@ import androidx.compose.animation.core.spring
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale 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.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -41,6 +63,7 @@ import androidx.navigation.NavDestination.Companion.hierarchy
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.view.LocalShowExperimentalFeatures import eu.gaudian.translator.view.LocalShowExperimentalFeatures
import kotlinx.coroutines.launch
sealed class Screen( sealed class Screen(
val route: String, val route: String,
@@ -48,34 +71,44 @@ sealed class Screen(
val selectedIcon: ImageVector, val selectedIcon: ImageVector,
val unselectedIcon: 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 Dictionary : Screen("dictionary", R.string.label_dictionary, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined) object Stats : Screen("stats", R.string.label_stats, AppIcons.Statistics, AppIcons.Statistics)
object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined) object Translation : Screen("translation", R.string.label_translation, AppIcons.TranslateFilled, AppIcons.TranslateOutlined)
object Vocabulary : Screen("vocabulary", R.string.label_vocabulary, AppIcons.VocabularyFilled, AppIcons.VocabularyOutlined) 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 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 Corrector : Screen("corrector", R.string.title_corrector, AppIcons.SpellCheck, AppIcons.SpellCheck)
object Exercises : Screen("exercises", R.string.label_exercises, AppIcons.DictionaryFilled, AppIcons.DictionaryOutlined)
companion object { companion object {
fun getAllScreens(showExperimental: Boolean = false): List<Screen> { fun getAllScreens(showExperimental: Boolean = false): List<Screen> {
val screens = mutableListOf(Home, Dictionary, Vocabulary, Settings) return listOf(Home, Library, Stats)
if (showExperimental) {
screens.add(2, Exercises)
} }
return screens
fun getMoreMenuItems(showExperimental: Boolean = false): List<Screen> {
val items = mutableListOf<Screen>()
items.add(Translation)
items.add(Dictionary)
items.add(Corrector)
items.add(Settings)
if (showExperimental) {
items.add(Exercises)
}
return items
} }
@Composable @Composable
fun fromDestination(destination: NavDestination?): Screen { fun fromDestination(destination: NavDestination?): Screen {
val showExperimental = LocalShowExperimentalFeatures.current 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 destination?.hierarchy?.any { it.route == screen.route } == true
} ?: Home } ?: Home
} }
} }
} }
/**
* A modernized Material 3 bottom navigation bar with spring animations and haptic feedback.
*/
@SuppressLint("UnusedBoxWithConstraintsScope") @SuppressLint("UnusedBoxWithConstraintsScope")
@Composable @Composable
fun BottomNavigationBar( fun BottomNavigationBar(
@@ -84,40 +117,87 @@ fun BottomNavigationBar(
showLabels: Boolean, showLabels: Boolean,
onItemSelected: (Screen) -> Unit, onItemSelected: (Screen) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onPlayClicked: () -> Unit = {}
) { ) {
val showExperimental = LocalShowExperimentalFeatures.current val showExperimental = LocalShowExperimentalFeatures.current
val screens = remember(showExperimental) { Screen.getAllScreens(showExperimental) } val screens = remember(showExperimental) { Screen.getAllScreens(showExperimental) }
val moreScreen = remember { Screen.More }
val haptic = LocalHapticFeedback.current 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( AnimatedVisibility(
visible = isVisible, visible = isVisible,
enter = slideInVertically( enter = slideInVertically(
animationSpec = androidx.compose.animation.core.tween(durationMillis = 220), animationSpec = spring(stiffness = Spring.StiffnessHigh),
initialOffsetY = { it } initialOffsetY = { it }
), ),
exit = slideOutVertically( exit = slideOutVertically(
animationSpec = androidx.compose.animation.core.tween(durationMillis = 220), animationSpec = spring(stiffness = Spring.StiffnessHigh),
targetOffsetY = { it } targetOffsetY = { it }
) )
) { ) {
val baseHeight = if (showLabels) 80.dp else 56.dp val baseHeight = if (showLabels) 80.dp else 56.dp
val density = LocalDensity.current val density = LocalDensity.current
val navBarDp = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } val navBarDp = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
val height = baseHeight + navBarDp val height = baseHeight + navBarDp
NavigationBar( // Outer Box height is purely determined by the NavigationBar now
modifier = modifier.height(height), Box(
containerColor = MaterialTheme.colorScheme.surface, // Cleaner look than surfaceVariant modifier = modifier.fillMaxWidth(),
tonalElevation = 8.dp, // Slight elevation for depth 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) val title = stringResource(id = screen.title)
// 1. Spring Animation for the Icon Scale
val scale by animateFloatAsState( 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( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
@@ -129,8 +209,8 @@ fun BottomNavigationBar(
selected = isSelected, selected = isSelected,
onClick = { onClick = {
if (!isSelected) { if (!isSelected) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress) // 2. Tactile feedback haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onItemSelected(screen) if (screen == Screen.More) showMoreMenu = true else onItemSelected(screen)
} }
}, },
label = if (showLabels) { label = if (showLabels) {
@@ -145,12 +225,11 @@ fun BottomNavigationBar(
} }
} else null, } else null,
icon = { icon = {
// 3. Crossfade between Outlined and Filled icons
Crossfade(targetState = isSelected, label = "iconFade") { selected -> Crossfade(targetState = isSelected, label = "iconFade") { selected ->
Icon( Icon(
imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon, imageVector = if (selected) screen.selectedIcon else screen.unselectedIcon,
contentDescription = title, contentDescription = title,
modifier = Modifier.scale(scale) // Apply the spring scale modifier = Modifier.scale(scale)
) )
} }
}, },
@@ -165,8 +244,153 @@ 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.primaryContainer.copy(alpha = 0.5f),
Color.Transparent
)
),
shape = CircleShape
)
)
// Actual clickable button
Box(
modifier = Modifier
.size(playButtonSize)
.clip(CircleShape)
// CHANGED: Added a border to give the button definition
.border(
width = 4.dp, // Adjust this thickness to your liking
color = MaterialTheme.colorScheme.surfaceVariant, // Creates a nice "cutout" separation
shape = CircleShape
)
.background(MaterialTheme.colorScheme.primaryContainer)
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayClicked()
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play",
tint = MaterialTheme.colorScheme.onPrimaryContainer,
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 @ThemePreviews
@Composable @Composable
fun BottomNavigationBarPreview() { fun BottomNavigationBarPreview() {

View File

@@ -2,23 +2,17 @@
package eu.gaudian.translator.view.composable package eu.gaudian.translator.view.composable
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonColors
@@ -31,7 +25,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -41,11 +34,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -55,7 +44,6 @@ import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.ui.theme.semanticColors import eu.gaudian.translator.ui.theme.semanticColors
import eu.gaudian.translator.view.composable.ComponentDefaults.DefaultElevation
object ComponentDefaults { object ComponentDefaults {
@@ -85,173 +73,6 @@ object ComponentDefaults {
const val ALPHA_LOW = 0.3f const val ALPHA_LOW = 0.3f
} }
/**
* A styled card container for displaying content with a consistent floating look.
*
* @param modifier The modifier to be applied to the card.
* @param content The content to be displayed inside the card.
*/
@Composable
fun AppCard(
modifier: Modifier = Modifier,
title: String? = null,
icon: ImageVector? = null, // New optional icon parameter
text: String? = null,
expandable: Boolean = false,
initiallyExpanded: Boolean = false,
content: @Composable ColumnScope.() -> Unit,
) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
val rotationState by animateFloatAsState(
targetValue = if (isExpanded) 180f else 0f,
label = "Chevron Rotation"
)
// 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
Surface(
modifier = modifier
.fillMaxWidth()
.shadow(
DefaultElevation,
shape = ComponentDefaults.CardShape
)
.clip(ComponentDefaults.CardClipShape)
// Animate height changes when expanding/collapsing
.animateContentSize(),
shape = ComponentDefaults.CardShape,
color = MaterialTheme.colorScheme.surfaceContainer
) {
Column {
// --- Header Row ---
if (hasHeader) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = expandable) { isExpanded = !isExpanded }
.padding(ComponentDefaults.CardPadding),
verticalAlignment = Alignment.CenterVertically
) {
// 1. Optional Icon on the left
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
}
// 2. Title and Text Column
Column(modifier = Modifier.weight(1f)) {
if (!title.isNullOrBlank()) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
// Only show spacer if both title and text exist
if (!title.isNullOrBlank() && !text.isNullOrBlank()) {
Spacer(Modifier.size(4.dp))
}
if (!text.isNullOrBlank()) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// 3. Expand Chevron (Far right)
if (expandable) {
Icon(
imageVector = AppIcons.ArrowDropDown,
contentDescription = if (isExpanded) "Collapse" else "Expand",
modifier = Modifier.rotate(rotationState),
tint = MaterialTheme.colorScheme.onSurface
)
}
}
}
// --- Content Area ---
if (!expandable || isExpanded) {
Column(
modifier = 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
),
content = content
)
}
}
}
}
@Preview
@Composable
fun AppCardPreview() {
AppCard {
Text(stringResource(R.string.this_is_the_content_inside_the_card))
PrimaryButton(onClick = { }, text = stringResource(R.string.label_continue))
}
}
@Preview(showBackground = true)
@Composable
fun AppCardPreview2() {
MaterialTheme {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 1. Expandable Card (Initially Collapsed)
AppCard(
title = "Advanced Settings",
text = "Click to reveal more options",
expandable = true,
initiallyExpanded = false
) {
Text("Here are some hidden settings.")
Text("They are only visible when expanded.")
}
// 2. Expandable Card (Initially Expanded)
AppCard(
title = "Translation History",
text = "Recent items",
expandable = true,
initiallyExpanded = true
) {
Text("• Hello -> Hallo")
Text("• World -> Welt")
Text("• Sun -> Sonne")
}
// 3. Static Card (No Title/Expand logic - Legacy behavior)
AppCard {
Text("This is a standard card without a header.")
}
}
}
}
/** /**
* The primary button for the most important actions. * The primary button for the most important actions.
* *
@@ -586,6 +407,7 @@ fun WrongOutlinedButtonPreview(){
WrongOutlinedButton(onClick = { }, text = stringResource(R.string.label_continue)) WrongOutlinedButton(onClick = { }, text = stringResource(R.string.label_continue))
} }
//This is basically just a wrapper for screens to control width (tablet mode) etc.
@Composable @Composable
fun AppOutlinedCard( fun AppOutlinedCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View File

@@ -0,0 +1,188 @@
@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language
import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.viewmodel.LanguageViewModel
@Composable
fun CsvImportDialog(
showDialog: Boolean,
parsedTable: List<List<String>>,
languageViewModel: LanguageViewModel,
onDismiss: () -> Unit,
onImport: (List<VocabularyItem>) -> Unit,
statusMessageService: StatusMessageService
) {
if (!showDialog) return
var selectedColFirst by remember { mutableIntStateOf(0) }
var selectedColSecond by remember { mutableIntStateOf(1.coerceAtMost((parsedTable.maxOfOrNull { it.size } ?: 1) - 1)) }
var skipHeader by remember { mutableStateOf(true) }
var selectedLangFirst by remember { mutableStateOf<Language?>(null) }
var selectedLangSecond by remember { mutableStateOf<Language?>(null) }
val errorSelectTwoColumns = stringResource(R.string.error_select_two_columns)
val errorSelectLanguages = stringResource(R.string.error_select_languages)
val errorNoRowsToImport = stringResource(R.string.error_no_rows_to_import)
AppDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.label_import_table_csv_excel)) }
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val columnCount = parsedTable.maxOfOrNull { it.size } ?: 0
// First Column Selection
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_first_column), modifier = Modifier.weight(1f))
var menu1Expanded by remember { mutableStateOf(false) }
AppOutlinedButton(onClick = { menu1Expanded = true }) {
Text(stringResource(R.string.label_column_n, selectedColFirst + 1))
}
DropdownMenu(expanded = menu1Expanded, onDismissRequest = { menu1Expanded = false }) {
(0 until columnCount).forEach { idx ->
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
DropdownMenuItem(
text = { Text("#${idx + 1}$header") },
onClick = { selectedColFirst = idx; menu1Expanded = false }
)
}
}
}
// Second Column Selection
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.label_second_column), modifier = Modifier.weight(1f))
var menu2Expanded by remember { mutableStateOf(false) }
AppOutlinedButton(onClick = { menu2Expanded = true }) {
Text(stringResource(R.string.label_column_n, selectedColSecond + 1))
}
DropdownMenu(expanded = menu2Expanded, onDismissRequest = { menu2Expanded = false }) {
(0 until columnCount).forEach { idx ->
val header = parsedTable.firstOrNull()?.getOrNull(idx).orEmpty()
DropdownMenuItem(
text = { Text("#${idx + 1}$header") },
onClick = { selectedColSecond = idx; menu2Expanded = false }
)
}
}
}
// Language Selection
Text(stringResource(R.string.label_languages))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_first_language))
SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangFirst,
onLanguageSelected = { selectedLangFirst = it }
)
}
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.label_second_language))
SingleLanguageDropDown(
languageViewModel = languageViewModel,
selectedLanguage = selectedLangSecond,
onLanguageSelected = { selectedLangSecond = it }
)
}
}
// Skip Header Checkbox
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = skipHeader, onCheckedChange = { skipHeader = it })
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.label_header_row))
}
// Preview
val startIdx = if (skipHeader) 1 else 0
val previewA = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColFirst) }.joinToString(", ")
val previewB = parsedTable.drop(startIdx).take(5).mapNotNull { it.getOrNull(selectedColSecond) }.joinToString(", ")
Text(stringResource(R.string.label_preview_first, previewA))
Text(stringResource(R.string.label_preview_second, previewB))
// Row Count
val totalRows = parsedTable.drop(startIdx).count { row ->
val a = row.getOrNull(selectedColFirst).orEmpty().isNotBlank()
val b = row.getOrNull(selectedColSecond).orEmpty().isNotBlank()
a || b
}
Text(stringResource(R.string.text_rows_to_import_1d, totalRows))
// Action Buttons
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.label_cancel))
}
Spacer(modifier = Modifier.width(8.dp))
PrimaryButton(
onClick = {
if (selectedColFirst == selectedColSecond) {
statusMessageService.showErrorMessage(errorSelectTwoColumns)
return@PrimaryButton
}
val langA = selectedLangFirst
val langB = selectedLangSecond
if (langA == null || langB == null) {
statusMessageService.showErrorMessage(errorSelectLanguages)
return@PrimaryButton
}
val items = parsedTable.drop(startIdx).mapNotNull { row ->
val a = row.getOrNull(selectedColFirst)?.trim().orEmpty()
val b = row.getOrNull(selectedColSecond)?.trim().orEmpty()
if (a.isBlank() && b.isBlank()) null else VocabularyItem(
id = 0,
languageFirstId = langA.nameResId,
languageSecondId = langB.nameResId,
wordFirst = a,
wordSecond = b
)
}
if (items.isEmpty()) {
statusMessageService.showErrorMessage(errorNoRowsToImport)
return@PrimaryButton
}
onImport(items)
},
text = stringResource(R.string.label_import)
)
}
}
}
}

View File

@@ -1,3 +1,5 @@
@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
package eu.gaudian.translator.view.composable package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -6,20 +8,19 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -34,7 +35,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -56,6 +56,8 @@ fun BaseLanguageDropDown(
enableMultipleSelection: Boolean = false, enableMultipleSelection: Boolean = false,
onLanguagesSelected: (List<Language>) -> Unit = {}, onLanguagesSelected: (List<Language>) -> Unit = {},
alternateLanguages: List<Language> = emptyList(), alternateLanguages: List<Language> = emptyList(),
restrictToAlternateLanguages: Boolean = false,
enabled: Boolean = true,
iconEnabled: Boolean = true, iconEnabled: Boolean = true,
noBorder: Boolean = false, noBorder: Boolean = false,
) { ) {
@@ -68,9 +70,13 @@ fun BaseLanguageDropDown(
var tempSelection by remember { mutableStateOf<List<Language>>(emptyList()) } var tempSelection by remember { mutableStateOf<List<Language>>(emptyList()) }
var selectedLanguagesCount by remember { mutableIntStateOf(if (enableMultipleSelection) tempSelection.size else 0) } 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 } alternateLanguages.ifEmpty { defaultLanguages }
} }
}
val buttonText = when { val buttonText = when {
enableMultipleSelection && selectedLanguagesCount > 0 -> stringResource( enableMultipleSelection && selectedLanguagesCount > 0 -> stringResource(
@@ -83,10 +89,14 @@ fun BaseLanguageDropDown(
else -> stringResource(R.string.label_language_none) else -> stringResource(R.string.label_language_none)
} }
Box(modifier = modifier) { Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
AppOutlinedButton( AppOutlinedButton(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
onClick = { expanded = true }, onClick = { expanded = true },
enabled = enabled,
contentPadding = if (!iconEnabled) PaddingValues(0.dp) else PaddingValues(horizontal = 16.dp, vertical = 8.dp), contentPadding = if (!iconEnabled) PaddingValues(0.dp) else PaddingValues(horizontal = 16.dp, vertical = 8.dp),
borderColor = if (noBorder) Color.Unspecified else null borderColor = if (noBorder) Color.Unspecified else null
) { ) {
@@ -105,23 +115,27 @@ fun BaseLanguageDropDown(
} }
} }
DropdownMenu(modifier = modifier.fillMaxWidth(), expanded = expanded, onDismissRequest = { if (expanded) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = {
expanded = false expanded = false
searchText = "" searchText = ""
tempSelection = emptyList() // Also reset temp selection on dismiss tempSelection = emptyList()
}) { },
// Helper composable for a single language row in multiple selection mode sheetState = sheetState
) {
@Composable @Composable
fun MultiSelectItem(language: Language) { fun MultiSelectItem(language: Language) {
val isSelected = tempSelection.contains(language) val isSelected = tempSelection.contains(language)
DropdownMenuItem( AppDropdownMenuItem(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
AppCheckbox( AppCheckbox(
checked = isSelected, checked = isSelected,
onCheckedChange = { onCheckedChange = {
tempSelection = if (isSelected) tempSelection - language else tempSelection + language tempSelection = if (isSelected) tempSelection - language else tempSelection + language
@Suppress("AssignedValueIsNeverRead")
selectedLanguagesCount = tempSelection.size selectedLanguagesCount = tempSelection.size
onLanguagesSelected(tempSelection) onLanguagesSelected(tempSelection)
} }
@@ -142,20 +156,18 @@ fun BaseLanguageDropDown(
}, },
onClick = { onClick = {
tempSelection = if (isSelected) tempSelection - language else tempSelection + language tempSelection = if (isSelected) tempSelection - language else tempSelection + language
@Suppress("AssignedValueIsNeverRead")
selectedLanguagesCount = tempSelection.size selectedLanguagesCount = tempSelection.size
} }
) )
} }
// Helper composable for a single language row in single selection mode
@Composable @Composable
fun SingleSelectItem(language: Language) { fun SingleSelectItem(language: Language) {
val languageNames = languages.map { it.name } val languageNames = languages.map { it.name }
val duplicateNames = languageNames.groupingBy { it }.eachCount().filter { it.value > 1 }.keys val duplicateNames = languageNames.groupingBy { it }.eachCount().filter { it.value > 1 }.keys
val isDuplicate = duplicateNames.contains(language.name) val isDuplicate = duplicateNames.contains(language.name)
DropdownMenuItem( AppDropdownMenuItem(
text = { text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column { Column {
@@ -198,47 +210,31 @@ fun BaseLanguageDropDown(
) )
} }
// --- Main Dropdown Content ---
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth()
.heightIn(max = 900.dp) // Constrain the height
) { ) {
// Search bar with a back arrow DropdownSearchField(
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { searchQuery = searchText,
IconButton(onClick = { expanded = false; searchText = "" }) { onSearchQueryChange = { searchText = it },
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.label_close))
}
TextField(
value = searchText,
onValueChange = { searchText = it },
singleLine = true,
placeholder = { Text(stringResource(R.string.text_search_3d)) }, placeholder = { Text(stringResource(R.string.text_search_3d)) },
trailingIcon = {
if (searchText.isNotBlank()) {
IconButton(onClick = { searchText = "" }) {
Icon(imageVector = AppIcons.Close, contentDescription = stringResource(R.string.cd_clear_search))
}
}
},
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
modifier = Modifier.weight(1f)
) )
}
HorizontalDivider() HorizontalDivider()
// Replaced height(max = 900.dp) with standard weight logic to allow proper scrolling bounds
Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Column(
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) {
val isSearching = searchText.isNotBlank() val isSearching = searchText.isNotBlank()
if (isSearching) { if (isSearching) {
val searchResults = (favoriteLanguages + languageHistory + languages) val searchBase = if (restrictToAlternateLanguages) {
alternateLanguages
} else {
favoriteLanguages + languageHistory + languages
}
val searchResults = searchBase
.distinctBy { it.nameResId } .distinctBy { it.nameResId }
.filter { language -> .filter { language ->
val matchesName = language.name.contains(searchText, ignoreCase = true) val matchesName = language.name.contains(searchText, ignoreCase = true)
@@ -253,83 +249,104 @@ fun BaseLanguageDropDown(
searchResults.forEach { language -> SingleSelectItem(language) } 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()) { } else if (alternateLanguages.isNotEmpty()) {
val sortedAlternate = alternateLanguages.sortedBy { it.name } val sortedAlternate = alternateLanguages.sortedBy { it.name }
if (enableMultipleSelection) { if (enableMultipleSelection) {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) DropdownHeader(text = stringResource(R.string.text_all_languages))
sortedAlternate.forEach { language -> MultiSelectItem(language) } sortedAlternate.forEach { language -> MultiSelectItem(language) }
} else { } else {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) DropdownHeader(text = stringResource(R.string.text_all_languages))
sortedAlternate.forEach { language -> SingleSelectItem(language) } sortedAlternate.forEach { language -> SingleSelectItem(language) }
} }
} else { } else {
if (enableMultipleSelection) { if (enableMultipleSelection) {
if (favoriteLanguages.isNotEmpty()) { if (favoriteLanguages.isNotEmpty()) {
Text(stringResource(R.string.text_favorites), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) DropdownHeader(text = stringResource(R.string.text_favorites))
favoriteLanguages.forEach { language -> MultiSelectItem(language) } favoriteLanguages.forEach { language -> MultiSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
} }
val recentHistoryFiltered = languageHistory.filter { it !in favoriteLanguages }.takeLast(5) val recentHistoryFiltered = languageHistory.filter { it !in favoriteLanguages }.takeLast(5)
if (recentHistoryFiltered.isNotEmpty() && alternateLanguages.isEmpty()) { if (recentHistoryFiltered.isNotEmpty() && alternateLanguages.isEmpty()) {
Text(stringResource(R.string.text_recent_history), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) DropdownHeader(text = stringResource(R.string.text_recent_history))
recentHistoryFiltered.forEach { language -> MultiSelectItem(language) } recentHistoryFiltered.forEach { language -> MultiSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
} }
val remainingLanguages = languages.sortedBy { it.name } val remainingLanguages = languages.sortedBy { it.name }
if (remainingLanguages.isNotEmpty()) { if (remainingLanguages.isNotEmpty()) {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) DropdownHeader(text = stringResource(R.string.text_all_languages))
remainingLanguages.forEach { language -> MultiSelectItem(language) } remainingLanguages.forEach { language -> MultiSelectItem(language) }
} }
} else { } else {
// Logic for single selection default view
if (showAutoOption) { if (showAutoOption) {
DropdownMenuItem(text = { Text(stringResource(R.string.text_select_auto_recognition)) }, onClick = { onAutoSelected(); expanded = false; searchText = "" }) LargeDropdownMenuItem(
text = stringResource(R.string.text_select_auto_recognition),
selected = false, // Set to true if you want to highlight it when active
enabled = true,
onClick = {
onAutoSelected()
expanded = false
searchText = ""
}
)
HorizontalDivider() HorizontalDivider()
} }
if (showNoneOption) { if (showNoneOption) {
DropdownMenuItem(text = { Text(stringResource(R.string.text_select_none)) }, onClick = { onNoneSelected(); expanded = false; searchText = "" }) LargeDropdownMenuItem(
text = stringResource(R.string.text_select_no_language),
selected = false, // Set to true if you want to highlight it when active
enabled = true,
onClick = {
onNoneSelected()
expanded = false
searchText = ""
}
)
HorizontalDivider() HorizontalDivider()
} }
if (favoriteLanguages.any { if (favoriteLanguages.any {
@Suppress("HardCodedStringLiteral")
it.code != "none" && it.code != "auto" it.code != "none" && it.code != "auto"
}) { }) {
Text(stringResource(R.string.text_favorites), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) DropdownHeader(text = stringResource(R.string.text_favorites))
favoriteLanguages.filter { favoriteLanguages.filter {
@Suppress("HardCodedStringLiteral")
it.code != "none" && it.code != "auto" it.code != "none" && it.code != "auto"
}.forEach { language -> SingleSelectItem(language) } }.forEach { language -> SingleSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
} }
val recentHistoryFiltered = languageHistory.filter { val recentHistoryFiltered = languageHistory.filter {
@Suppress("HardCodedStringLiteral")
it !in favoriteLanguages && it.code != "none" && it.code != "auto" it !in favoriteLanguages && it.code != "none" && it.code != "auto"
}.takeLast(5) }.takeLast(5)
if (recentHistoryFiltered.isNotEmpty()) { if (recentHistoryFiltered.isNotEmpty()) {
Text(stringResource(R.string.text_recent_history), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) DropdownHeader(text = stringResource(R.string.text_recent_history))
recentHistoryFiltered.forEach { language -> SingleSelectItem(language) } recentHistoryFiltered.forEach { language -> SingleSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
} }
val remainingLanguages = languages.filter { val remainingLanguages = languages.filter {
@Suppress("HardCodedStringLiteral")
it !in favoriteLanguages && it !in recentHistoryFiltered && it.code != "none" && it.code != "auto" it !in favoriteLanguages && it !in recentHistoryFiltered && it.code != "none" && it.code != "auto"
}.sortedBy { it.name } }.sortedBy { it.name }
if (remainingLanguages.isNotEmpty()) { if (remainingLanguages.isNotEmpty()) {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp)) DropdownHeader(text = stringResource(R.string.text_all_languages))
remainingLanguages.forEach { language -> SingleSelectItem(language) } remainingLanguages.forEach { language -> SingleSelectItem(language) }
} }
} }
} }
} }
// Done button for multiple selection mode
if (enableMultipleSelection) { if (enableMultipleSelection) {
HorizontalDivider() HorizontalDivider()
AppButton( AppButton(
onClick = { onClick = {
onLanguagesSelected(tempSelection) onLanguagesSelected(tempSelection)
@Suppress("AssignedValueIsNeverRead")
selectedLanguagesCount = tempSelection.size selectedLanguagesCount = tempSelection.size
expanded = false expanded = false
searchText = "" searchText = ""
@@ -341,6 +358,10 @@ fun BaseLanguageDropDown(
Text(stringResource(R.string.label_done)) Text(stringResource(R.string.label_done))
} }
} }
// Provides breathing room for system gestures at bottom of sheet
Spacer(modifier = Modifier.navigationBarsPadding().height(16.dp))
}
} }
} }
} }
@@ -459,7 +480,9 @@ fun SingleLanguageDropDown(
onAutoSelected: () -> Unit = {}, onAutoSelected: () -> Unit = {},
showNoneOption: Boolean = false, showNoneOption: Boolean = false,
onNoneSelected: () -> Unit = {}, onNoneSelected: () -> Unit = {},
alternateLanguages: List<Language> = emptyList() alternateLanguages: List<Language> = emptyList(),
restrictToAlternateLanguages: Boolean = false,
enabled: Boolean = true
) { ) {
val languageHistory by languageViewModel.languageHistory.collectAsState() val languageHistory by languageViewModel.languageHistory.collectAsState()
@@ -478,6 +501,10 @@ fun SingleLanguageDropDown(
showNoneOption = showNoneOption, showNoneOption = showNoneOption,
onNoneSelected = onNoneSelected, onNoneSelected = onNoneSelected,
enableMultipleSelection = false, enableMultipleSelection = false,
alternateLanguages = alternateLanguages alternateLanguages = alternateLanguages,
restrictToAlternateLanguages = restrictToAlternateLanguages,
enabled = enabled,
iconEnabled = enabled,
noBorder = !enabled
) )
} }

View File

@@ -48,10 +48,11 @@ import eu.gaudian.translator.view.composable.AppDialog
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedTextField import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.MultipleLanguageDropdown import eu.gaudian.translator.view.composable.MultipleLanguageDropdown
import eu.gaudian.translator.view.hints.CategoryHint import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.viewmodel.CategoryViewModel import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
enum class DialogCategoryType { TAG, FILTER } enum class DialogCategoryType { TAG, FILTER }
@Composable @Composable
@@ -79,7 +80,7 @@ fun AddCategoryDialog(
AppDialog( AppDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.label_add_category)) }, title = { Text(stringResource(R.string.label_add_category)) },
hintContent = { CategoryHint() }, hintContent = HintDefinition.CATEGORY.hint(),
content = { content = {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {

View File

@@ -41,6 +41,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.VocabularyItem import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.ui.theme.ThemePreviews import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.LocalConnectionConfigured import eu.gaudian.translator.view.LocalConnectionConfigured
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.view.composable.AppButton
@@ -50,7 +51,6 @@ import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.SourceLanguageDropdown import eu.gaudian.translator.view.composable.SourceLanguageDropdown
import eu.gaudian.translator.view.composable.TargetLanguageDropdown import eu.gaudian.translator.view.composable.TargetLanguageDropdown
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.StatusViewModel
import eu.gaudian.translator.viewmodel.TranslationViewModel import eu.gaudian.translator.viewmodel.TranslationViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -67,12 +67,12 @@ fun AddVocabularyDialog(
showMultiple: Boolean = true showMultiple: Boolean = true
) { ) {
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val statusViewModel: StatusViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val vocabularyViewModel = hiltViewModel<VocabularyViewModel>(viewModelStoreOwner = activity) val vocabularyViewModel = hiltViewModel<VocabularyViewModel>(viewModelStoreOwner = activity)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val translationViewModel: TranslationViewModel = hiltViewModel(viewModelStoreOwner = activity) val translationViewModel: TranslationViewModel = hiltViewModel(viewModelStoreOwner = activity)
val connectionConfigured = LocalConnectionConfigured.current val connectionConfigured = LocalConnectionConfigured.current
val statusMessageService = StatusMessageService
@@ -186,7 +186,7 @@ fun AddVocabularyDialog(
selectedTranslations.clear() selectedTranslations.clear()
} }
.onFailure { exception -> .onFailure { exception ->
statusViewModel.showErrorMessage( statusMessageService.showErrorMessage(
textFailedToGetTranslations + exception.message) textFailedToGetTranslations + exception.message)
} }
} }

View File

@@ -1,16 +1,17 @@
@file:Suppress("AssignedValueIsNeverRead", "HardCodedStringLiteral")
package eu.gaudian.translator.view.dialogs package eu.gaudian.translator.view.dialogs
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -22,157 +23,195 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.TagCategory import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCheckbox import eu.gaudian.translator.view.composable.AppCheckbox
import eu.gaudian.translator.view.composable.AppDropdownContainer
import eu.gaudian.translator.view.composable.AppDropdownMenuItem import eu.gaudian.translator.view.composable.AppDropdownMenuItem
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppOutlinedTextField import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.DropdownHeader
import eu.gaudian.translator.viewmodel.CategoryViewModel import eu.gaudian.translator.viewmodel.CategoryViewModel
/**
* State class representing the internal state of CategoryDropdown.
* Used for previews and testing.
*/
data class CategoryDropdownState(
val expanded: Boolean = false,
val selectedCategories: List<VocabularyCategory?> = emptyList(),
val newCategoryName: String = "",
val categories: List<VocabularyCategory> = emptyList(),
val searchQuery: String = "",
)
/**
* Stateless dropdown content composable for category selection.
* This component is fully controlled by its parameters and does not maintain any internal state.
*/
@Composable @Composable
fun CategoryDropdown( fun CategoryDropdownContent(
initialCategoryId: Int? = null, modifier: Modifier = Modifier,
state: CategoryDropdownState,
onExpand: (Boolean) -> Unit,
onCategorySelected: (List<VocabularyCategory?>) -> Unit, onCategorySelected: (List<VocabularyCategory?>) -> Unit,
noneSelectable: Boolean? = true, onNewCategoryNameChange: (String) -> Unit,
onAddCategory: (String) -> Unit,
onSearchQueryChange: (String) -> Unit = {},
noneSelectable: Boolean = true,
multipleSelectable: Boolean = false, multipleSelectable: Boolean = false,
onlyLists: Boolean = false, onlyLists: Boolean = false,
addCategory: Boolean = false addCategory: Boolean = false,
enableSearch: Boolean = false,
) { ) {
val activity = LocalContext.current.findActivity() val selectableCategories = if (onlyLists) {
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity) state.categories.filterIsInstance<TagCategory>()
var expanded by remember { mutableStateOf(false) } } else {
val categories by categoryViewModel.categories.collectAsState(initial = emptyList()) state.categories
val selectableCategories = if (onlyLists) categories.filterIsInstance<TagCategory>() else categories
val initialCategory = remember(categories, initialCategoryId) {
categories.find { it.id == initialCategoryId }
} }
var selectedCategories by remember {
mutableStateOf<List<VocabularyCategory?>>(if (initialCategory != null) listOf(initialCategory) else emptyList()) // Filter categories by search query if search is enabled
val filteredCategories = if (enableSearch && state.searchQuery.isNotBlank()) {
selectableCategories.filter { category ->
category.name.contains(state.searchQuery, ignoreCase = true)
}
} else {
selectableCategories
} }
var newCategoryName by remember { mutableStateOf("") }
val buttonText = when {
state.selectedCategories.isEmpty() -> stringResource(R.string.text_select_category)
state.selectedCategories.size == 1 -> state.selectedCategories.first()?.name
?: stringResource(R.string.label_no_category)
else -> stringResource(R.string.text_2d_categories_selected, state.selectedCategories.size)
}
AppOutlinedButton( AppDropdownContainer(
shape = RoundedCornerShape(8.dp), expanded = state.expanded,
onClick = { expanded = true }, onDismissRequest = { onExpand(false) },
modifier = Modifier.fillMaxWidth(), onExpandRequest = { onExpand(true) },
buttonText = buttonText,
modifier = modifier,
showSearch = enableSearch,
searchQuery = state.searchQuery,
onSearchQueryChange = onSearchQueryChange,
searchPlaceholder = stringResource(R.string.text_search),
showDoneButton = multipleSelectable,
onDoneClick = { onExpand(false) }
) { ) {
Row(modifier = Modifier.fillMaxWidth()) { if (noneSelectable) {
Text(text = when { val noneSelected = state.selectedCategories.contains(null)
selectedCategories.isEmpty() -> stringResource(R.string.text_select_category)
selectedCategories.size == 1 -> selectedCategories.first()?.name ?: stringResource(R.string.text_none)
else -> stringResource(R.string.text_2d_categories_selected, selectedCategories.size)
},
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
Icon(
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(
R.string.cd_expand
)
)
}
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth(),
) {
if (noneSelectable == true) {
val noneSelected = selectedCategories.contains(null)
AppDropdownMenuItem( AppDropdownMenuItem(
text = { text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (multipleSelectable) { if (multipleSelectable) {
AppCheckbox( AppCheckbox(
checked = noneSelected, checked = noneSelected,
onCheckedChange = { onCheckedChange = { _ ->
selectedCategories = if (noneSelected) selectedCategories.filterNotNull() else selectedCategories + listOf(null) val newSelection = if (noneSelected) {
onCategorySelected(selectedCategories) state.selectedCategories.filterNotNull()
} else {
state.selectedCategories + listOf(null)
}
onCategorySelected(newSelection)
} }
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
Text(stringResource(R.string.text_none)) Text(
text = stringResource(R.string.label_no_category),
color = MaterialTheme.colorScheme.onSurface
)
} }
}, },
onClick = { onClick = {
if (multipleSelectable) { if (multipleSelectable) {
selectedCategories = if (noneSelected) { val newSelection = if (noneSelected) {
selectedCategories.filterNotNull() state.selectedCategories.filterNotNull()
} else { } else {
selectedCategories + listOf(null) state.selectedCategories + listOf(null)
} }
onCategorySelected(selectedCategories) onCategorySelected(newSelection)
} else { } else {
selectedCategories = listOf(null) onCategorySelected(listOf(null))
onCategorySelected(selectedCategories) onExpand(false)
expanded = false
} }
} }
) )
} }
selectableCategories.forEach { category ->
val isSelected = selectedCategories.contains(category) filteredCategories.forEach { category ->
val isSelected = state.selectedCategories.contains(category)
AppDropdownMenuItem( AppDropdownMenuItem(
text = { text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (multipleSelectable) { if (multipleSelectable) {
AppCheckbox( AppCheckbox(
checked = isSelected, checked = isSelected,
onCheckedChange = { onCheckedChange = { _ ->
selectedCategories = if (isSelected) selectedCategories - category else selectedCategories + category val newSelection = if (isSelected) {
onCategorySelected(selectedCategories) state.selectedCategories - category
} else {
state.selectedCategories + category
}
onCategorySelected(newSelection)
} }
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
Text(category.name) Text(
text = category.name,
color = MaterialTheme.colorScheme.onSurface
)
} }
}, },
onClick = { onClick = {
if (multipleSelectable) { if (multipleSelectable) {
selectedCategories = if (category in selectedCategories) { val newSelection = if (category in state.selectedCategories) {
selectedCategories - category state.selectedCategories - category
} else { } else {
selectedCategories + category state.selectedCategories + category
} }
onCategorySelected(selectedCategories) onCategorySelected(newSelection)
} else { } else {
selectedCategories = listOf(category) onCategorySelected(listOf(category))
onCategorySelected(selectedCategories) onExpand(false)
expanded = false
} }
} }
) )
} }
if(addCategory) { if (enableSearch && state.searchQuery.isNotBlank() && filteredCategories.isEmpty()) {
HorizontalDivider()
// Create new category section
AppDropdownMenuItem( AppDropdownMenuItem(
text = { text = {
Text(stringResource(R.string.label_add_category)) Text(
text = stringResource(R.string.text_no_models_found),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth()
)
}, },
onClick = {}, onClick = {},
modifier = Modifier.padding(4.dp) enabled = false
) )
}
if (addCategory) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
DropdownHeader(text = stringResource(R.string.label_add_category))
AppDropdownMenuItem( AppDropdownMenuItem(
text = { text = {
@@ -181,26 +220,19 @@ fun CategoryDropdown(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
AppOutlinedTextField( AppOutlinedTextField(
value = newCategoryName, value = state.newCategoryName,
onValueChange = { newCategoryName = it }, onValueChange = onNewCategoryNameChange,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
singleLine = true, singleLine = true,
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(4.dp))
IconButton( IconButton(
onClick = { onClick = {
if (newCategoryName.isNotBlank()) { if (state.newCategoryName.isNotBlank()) {
val newList = onAddCategory(state.newCategoryName.trim())
TagCategory(id = 0, name = newCategoryName.trim())
categoryViewModel.createCategory(newList)
newCategoryName = ""
// Optionally, select the new category if single selection
if (!multipleSelectable) {
expanded = false
}
} }
}, },
enabled = newCategoryName.isNotBlank() enabled = state.newCategoryName.isNotBlank()
) { ) {
Icon( Icon(
imageVector = AppIcons.Add, imageVector = AppIcons.Add,
@@ -209,29 +241,191 @@ fun CategoryDropdown(
} }
} }
}, },
onClick = {} // No action on click onClick = {}
) )
} }
if (multipleSelectable) {
Spacer(modifier = Modifier.height(8.dp))
AppButton(
onClick = { expanded = false },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Text(stringResource(R.string.label_done))
}
}
} }
} }
@Preview /**
* Stateful wrapper for CategoryDropdown that manages its own state.
*/
@Composable @Composable
fun CategoryDropdownPreview() { fun CategoryDropdown(
CategoryDropdown( modifier: Modifier = Modifier,
onCategorySelected = {} initialCategoryId: Int? = null,
onCategorySelected: (List<VocabularyCategory?>) -> Unit,
noneSelectable: Boolean? = true,
multipleSelectable: Boolean = false,
onlyLists: Boolean = false,
addCategory: Boolean = false,
enableSearch: Boolean = false,
) {
var expanded by remember { mutableStateOf(false) }
var selectedCategories by remember {
mutableStateOf<List<VocabularyCategory?>>(emptyList())
}
var newCategoryName by remember { mutableStateOf("") }
var searchQuery by remember { mutableStateOf("") }
val activity = LocalContext.current.findActivity()
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
val initialCategory = remember(categories, initialCategoryId) {
categories.find { it.id == initialCategoryId }
}
remember(initialCategory) {
if (initialCategory != null && selectedCategories.isEmpty()) {
selectedCategories = listOf(initialCategory)
}
true
}
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = expanded,
selectedCategories = selectedCategories,
newCategoryName = newCategoryName,
categories = categories,
searchQuery = searchQuery,
),
onExpand = { isExpanded -> expanded = isExpanded },
onCategorySelected = { newSelection ->
selectedCategories = newSelection
onCategorySelected(newSelection)
},
onNewCategoryNameChange = { newCategoryName = it },
onAddCategory = { name ->
val newCategory = TagCategory(id = 0, name = name)
newCategoryName = ""
categoryViewModel.createCategory(newCategory)
//selectedCategories = selectedCategories + newCategory
if (!multipleSelectable) {
expanded = false
}
},
onSearchQueryChange = { searchQuery = it },
noneSelectable = noneSelectable == true,
multipleSelectable = multipleSelectable,
onlyLists = onlyLists,
addCategory = addCategory,
enableSearch = enableSearch,
modifier = modifier,
) )
} }
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownCollapsedPreview() {
Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = false,
selectedCategories = emptyList(),
categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food")),
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
)
}
}
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownExpandedPreview() {
Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = listOf(TagCategory(1, "Animals")),
categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food"), TagCategory(3, "Travel")),
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownMultipleSelectionPreview() {
val categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food"), TagCategory(3, "Travel"))
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory?>>(listOf(categories[0], categories[2])) }
Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = selectedCategories,
categories = categories,
),
onExpand = {},
onCategorySelected = { selectedCategories = it },
onNewCategoryNameChange = {},
onAddCategory = {},
multipleSelectable = true,
noneSelectable = true,
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownWithAddCategoryPreview() {
Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
newCategoryName = "New Cat",
categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food")),
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
addCategory = true,
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownWithSearchPreview() {
Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
TagCategory(4, "Technology"),
TagCategory(5, "Sports")
),
searchQuery = "",
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
enableSearch = true,
)
}
}

View File

@@ -6,40 +6,41 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.VocabularyCategory import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppDialog import eu.gaudian.translator.view.composable.AppDialog
import eu.gaudian.translator.view.composable.DialogButton import eu.gaudian.translator.view.composable.DialogButton
import eu.gaudian.translator.viewmodel.CategoryViewModel
@Composable @Composable
fun CategorySelectionDialog( fun CategorySelectionDialog(
onCategorySelected: (List<VocabularyCategory?>) -> Unit, onCategorySelected: (List<VocabularyCategory?>) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
var selectedCategory by remember { mutableStateOf<List<VocabularyCategory?>>(emptyList()) } val activity = LocalContext.current.findActivity()
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
AppDialog(onDismissRequest = onDismissRequest, title = { AppDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(R.string.text_select_categories)) Text(text = stringResource(R.string.text_select_categories))
}) { }
) {
// Dropdown button and menu
CategoryDropdown( CategoryDropdown(
onCategorySelected = { categories -> onCategorySelected = onCategorySelected,
selectedCategory = categories
},
noneSelectable = false, noneSelectable = false,
multipleSelectable = true, multipleSelectable = true,
onlyLists = true, onlyLists = true,
addCategory = true addCategory = true,
modifier = Modifier.fillMaxWidth(),
) )
Row( Row(
@@ -54,10 +55,11 @@ fun CategorySelectionDialog(
DialogButton( DialogButton(
onClick = { onClick = {
onCategorySelected(selectedCategory) // The selected categories are handled by CategoryDropdown's internal state
// and passed to onCategorySelected callback
onDismissRequest() onDismissRequest()
}, },
enabled = true enabled = true // Always enabled since CategoryDropdown handles validation
) { ) {
Text(stringResource(R.string.label_confirm)) Text(stringResource(R.string.label_confirm))
} }

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.getImportVocabularyHint
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 = { getImportVocabularyHint() },
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

@@ -0,0 +1,168 @@
package eu.gaudian.translator.view.dialogs
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.view.composable.AppDialog
import eu.gaudian.translator.view.composable.AppOutlinedTextField
import eu.gaudian.translator.view.composable.AppSlider
import kotlin.math.roundToInt
@Composable
fun RequestMorePackDialog(
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var topic by remember { mutableStateOf("") }
var langFrom by remember { mutableStateOf("") }
var langTo by remember { mutableStateOf("") }
var amount by remember { mutableFloatStateOf(50f) }
AppDialog(
onDismissRequest = onDismiss,
title = { Text("Request a Pack", fontWeight = FontWeight.Bold) },
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
stringResource(R.string.text_request_pack_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
stringResource(R.string.label_topic),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
AppOutlinedTextField(
value = topic,
onValueChange = { topic = it },
placeholder = { Text("e.g. Travel, Business, Cooking…") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
stringResource(R.string.label_languages),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
Text(
stringResource(R.string.label_optional),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
AppOutlinedTextField(
value = langFrom,
onValueChange = { langFrom = it },
placeholder = { Text(stringResource(R.string.label_from)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
AppOutlinedTextField(
value = langTo,
onValueChange = { langTo = it },
placeholder = { Text(stringResource(R.string.label_to)) },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Text(
"Approx. word count: ~${amount.roundToInt()} words",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
AppSlider(
value = amount,
onValueChange = { amount = it },
valueRange = 10f..200f,
steps = 18,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.label_cancel)) }
TextButton(
enabled = topic.isNotBlank(),
onClick = {
val subject = "Polly Pack Request $topic"
val langPart = buildString {
val from = langFrom.trim()
val to = langTo.trim()
if (from.isNotBlank() || to.isNotBlank()) {
append("Languages: ${from.ifBlank { "?" }} → ${to.ifBlank { "?" }}\n")
}
}
val body = buildString {
appendLine("Hey Jonas,")
appendLine()
appendLine("Please add the following vocabulary pack to Polly:")
appendLine()
appendLine("Topic: $topic")
if (langPart.isNotBlank()) append(langPart)
appendLine("Word count: ~${amount.roundToInt()} words")
appendLine()
appendLine("Thank you!")
}
val intent = android.content.Intent(android.content.Intent.ACTION_SENDTO).apply {
data = "mailto:play@gaudian.eu".toUri()
putExtra(android.content.Intent.EXTRA_EMAIL, arrayOf("play@gaudian.eu"))
putExtra(android.content.Intent.EXTRA_SUBJECT, subject)
putExtra(android.content.Intent.EXTRA_TEXT, body)
}
context.startActivity(intent)
onDismiss()
}
) {
Text(
stringResource(R.string.label_send_request),
fontWeight = FontWeight.Bold
)
}
}
}
}
}
@ThemePreviews
@Composable
fun RequestMorePackDialogPreview() {
RequestMorePackDialog(
onDismiss = {}
)
}

View File

@@ -1,123 +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.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.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 coroutineScope = rememberCoroutineScope()
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()) }
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!! }
}
}
var selectedLanguages by remember { mutableStateOf<List<Language>>(emptyList()) }
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory>>(emptyList()) }
var selectedStages by remember { mutableStateOf<List<VocabularyStage>>(emptyList()) }
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 = { categories ->
selectedCategories = categories.filterIsInstance<VocabularyCategory>()
},
multipleSelectable = true
)
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

@@ -34,7 +34,7 @@ import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCheckbox import eu.gaudian.translator.view.composable.AppCheckbox
import eu.gaudian.translator.view.composable.AppScaffold import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.hints.getVocabularyReviewHint import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
@Composable @Composable
@@ -43,12 +43,13 @@ fun VocabularyReviewScreen(
onCancel: () -> Unit onCancel: () -> Unit
) { ) {
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val vocabularyViewModel : VocabularyViewModel = hiltViewModel(activity) val vocabularyViewModel: VocabularyViewModel = hiltViewModel(activity)
val generatedItems: List<VocabularyItem> by vocabularyViewModel.generatedVocabularyItems.collectAsState() val generatedItems: List<VocabularyItem> by vocabularyViewModel.generatedVocabularyItems.collectAsState()
val selectedItems = remember { mutableStateListOf<VocabularyItem>() } val selectedItems = remember { mutableStateListOf<VocabularyItem>() }
val duplicates = remember { mutableStateListOf<Boolean>() } val duplicates = remember { mutableStateListOf<Boolean>() }
var selectedCategoryId by remember { mutableStateOf<List<Int>>(emptyList()) } var selectedCategories by remember { mutableStateOf<List<VocabularyCategory?>>(emptyList()) }
LocalContext.current
LaunchedEffect(generatedItems) { LaunchedEffect(generatedItems) {
val duplicateResults = vocabularyViewModel.findDuplicates(generatedItems) val duplicateResults = vocabularyViewModel.findDuplicates(generatedItems)
@@ -61,8 +62,8 @@ fun VocabularyReviewScreen(
AppScaffold( AppScaffold(
topBar = { topBar = {
AppTopAppBar( AppTopAppBar(
title = { Text(stringResource(R.string.found_items)) }, title = stringResource(R.string.found_items),
hintContent = { getVocabularyReviewHint() } hint = HintDefinition.REVIEW.hint()
) )
}, },
) { paddingValues -> ) { paddingValues ->
@@ -128,10 +129,14 @@ fun VocabularyReviewScreen(
modifier = Modifier.padding(8.dp) modifier = Modifier.padding(8.dp)
) )
CategoryDropdown( CategoryDropdown(
onCategorySelected = { categories: List<VocabularyCategory?> -> onCategorySelected = { selectedCategories = it },
selectedCategoryId = categories.filterNotNull().map { it.id } noneSelectable = false,
}, multipleSelectable = true,
onlyLists = true onlyLists = true,
addCategory = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) )
Row( Row(
modifier = Modifier modifier = Modifier
@@ -143,9 +148,13 @@ fun VocabularyReviewScreen(
Text(stringResource(R.string.label_cancel)) Text(stringResource(R.string.label_cancel))
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
AppButton(onClick = { AppButton(
onConfirm(selectedItems.toList(), selectedCategoryId) onClick = {
}) { val selectedCategoryIds = selectedCategories.filterNotNull().map { it.id }
onConfirm(selectedItems.toList(), selectedCategoryIds)
},
enabled = selectedItems.isNotEmpty()
) {
Text(stringResource(R.string.label_add_, selectedItems.size)) Text(stringResource(R.string.label_add_, selectedItems.size))
} }
} }

View File

@@ -1,16 +1,11 @@
@file:Suppress("AssignedValueIsNeverRead")
package eu.gaudian.translator.view.dialogs package eu.gaudian.translator.view.dialogs
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -21,15 +16,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.VocabularyStage import eu.gaudian.translator.model.VocabularyStage
import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCheckbox import eu.gaudian.translator.view.composable.AppCheckbox
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppDropdownContainer
import eu.gaudian.translator.view.composable.AppOutlinedButton import eu.gaudian.translator.view.composable.AppDropdownMenuItem
@Composable @Composable
@@ -44,46 +37,38 @@ fun VocabularyStageDropDown(
var selectedStages by remember { mutableStateOf(preselectedStages) } var selectedStages by remember { mutableStateOf(preselectedStages) }
val context = LocalContext.current val context = LocalContext.current
Box( val buttonText = when {
modifier = modifier,
contentAlignment = Alignment.CenterEnd
) {
AppOutlinedButton(
shape = RoundedCornerShape(8.dp),
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth(),
) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(text = when {
selectedStages.isEmpty() -> stringResource(R.string.label_select_stage) selectedStages.isEmpty() -> stringResource(R.string.label_select_stage)
selectedStages.size == 1 -> selectedStages.first()?.toString(context)?:stringResource(R.string.text_none) selectedStages.size == 1 -> selectedStages.first()?.toString(context) ?: stringResource(R.string.text_none)
else -> stringResource(R.string.stages_selected, selectedStages.size) else -> stringResource(R.string.stages_selected, selectedStages.size)
},
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand)
)
}
} }
DropdownMenu( AppDropdownContainer(
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false }, onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth() onExpandRequest = { expanded = true },
buttonText = buttonText,
modifier = modifier,
showDoneButton = multipleSelectable,
onDoneClick = { expanded = false }
) { ) {
if (noneSelectable == true) { if (noneSelectable == true) {
val noneSelected = selectedStages.contains(null) val noneSelected = selectedStages.contains(null)
DropdownMenuItem( AppDropdownMenuItem(
text = { text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (multipleSelectable) { if (multipleSelectable) {
AppCheckbox( AppCheckbox(
checked = noneSelected, checked = noneSelected,
onCheckedChange = { onCheckedChange = {
selectedStages = if (noneSelected) selectedStages.filterNotNull() else selectedStages + listOf(null) selectedStages = if (noneSelected) {
selectedStages.filterNotNull()
} else {
selectedStages + listOf(null)
}
onStageSelected(selectedStages) onStageSelected(selectedStages)
} }
) )
@@ -111,14 +96,21 @@ fun VocabularyStageDropDown(
VocabularyStage.entries.forEach { stage -> VocabularyStage.entries.forEach { stage ->
val isSelected = selectedStages.contains(stage) val isSelected = selectedStages.contains(stage)
DropdownMenuItem( AppDropdownMenuItem(
text = { text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (multipleSelectable) { if (multipleSelectable) {
AppCheckbox( AppCheckbox(
checked = isSelected, checked = isSelected,
onCheckedChange = { onCheckedChange = {
selectedStages = if (isSelected) selectedStages - stage else selectedStages + stage selectedStages = if (isSelected) {
selectedStages - stage
} else {
selectedStages + stage
}
onStageSelected(selectedStages) onStageSelected(selectedStages)
} }
) )
@@ -143,19 +135,6 @@ fun VocabularyStageDropDown(
} }
) )
} }
if (multipleSelectable) {
HorizontalDivider()
AppButton(
onClick = { expanded = false },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Text(stringResource(R.string.label_done))
}
}
}
} }
} }

View File

@@ -10,7 +10,6 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -20,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -32,14 +32,14 @@ import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -60,23 +60,32 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.semanticColors import eu.gaudian.translator.ui.theme.semanticColors
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppOutlinedButton import eu.gaudian.translator.view.composable.AppOutlinedButton
import eu.gaudian.translator.view.composable.AppSwitch import eu.gaudian.translator.view.composable.AppSwitch
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown import eu.gaudian.translator.view.composable.DictionaryLanguageDropDown
import eu.gaudian.translator.view.composable.DropdownDefaults
import eu.gaudian.translator.view.composable.LargeDropdownMenuItem
import eu.gaudian.translator.viewmodel.CorrectionViewModel import eu.gaudian.translator.viewmodel.CorrectionViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// 1. STATEFUL COMPONENT (Connects to ViewModels)
@Composable @Composable
fun CorrectionScreen( fun CorrectionScreen(
correctionViewModel: CorrectionViewModel, navController: NavController
languageViewModel: LanguageViewModel
) { ) {
val activity = LocalContext.current.findActivity()
val correctionViewModel: CorrectionViewModel = hiltViewModel(activity)
val languageViewModel : LanguageViewModel = hiltViewModel(activity)
val textFieldValue by correctionViewModel.textFieldValue.collectAsState() val textFieldValue by correctionViewModel.textFieldValue.collectAsState()
val explanation by correctionViewModel.explanation.collectAsState() val explanation by correctionViewModel.explanation.collectAsState()
val isLoading by correctionViewModel.isLoading.collectAsState() val isLoading by correctionViewModel.isLoading.collectAsState()
@@ -87,6 +96,15 @@ fun CorrectionScreen(
val successColor = MaterialTheme.semanticColors.success val successColor = MaterialTheme.semanticColors.success
Column(){
AppTopAppBar(
title = stringResource(R.string.label_correction),
onNavigateBack = {
navController.popBackStack()
},
)
CorrectionScreenContent( CorrectionScreenContent(
textFieldValue = textFieldValue, textFieldValue = textFieldValue,
explanation = explanation, explanation = explanation,
@@ -111,6 +129,7 @@ fun CorrectionScreen(
) )
} }
) )
}
} }
// 2. STATELESS COMPONENT (Handles UI Layout) // 2. STATELESS COMPONENT (Handles UI Layout)
@@ -302,7 +321,6 @@ fun CorrectionScreenContent(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -415,6 +433,7 @@ fun CorrectionScreenContent(
} }
} }
@Composable @Composable
private fun ToneDropdown( private fun ToneDropdown(
selectedTone: CorrectionViewModel.Tone, selectedTone: CorrectionViewModel.Tone,
@@ -447,20 +466,33 @@ private fun ToneDropdown(
} }
} }
DropdownMenu( if (expanded) {
expanded = expanded, val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = { expanded = false }, onDismissRequest = { expanded = false },
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) sheetState = sheetState,
containerColor = DropdownDefaults.containerColor()
) { ) {
DropdownMenuItem( Column(
text = { Text(text = stringResource(R.string.text_none)) }, modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp) // Gives breathing room at top/bottom of list
) {
// Replaced with LargeDropdownMenuItem
LargeDropdownMenuItem(
text = stringResource(R.string.text_none),
selected = selectedTone == CorrectionViewModel.Tone.NONE,
enabled = enabled,
onClick = { onClick = {
onToneSelected(CorrectionViewModel.Tone.NONE) onToneSelected(CorrectionViewModel.Tone.NONE)
expanded = false expanded = false
}, }
enabled = enabled
) )
HorizontalDivider()
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
val options = listOf( val options = listOf(
CorrectionViewModel.Tone.FORMAL, CorrectionViewModel.Tone.FORMAL,
CorrectionViewModel.Tone.CASUAL, CorrectionViewModel.Tone.CASUAL,
@@ -471,16 +503,23 @@ private fun ToneDropdown(
CorrectionViewModel.Tone.ACADEMIC, CorrectionViewModel.Tone.ACADEMIC,
CorrectionViewModel.Tone.CREATIVE CorrectionViewModel.Tone.CREATIVE
) )
options.forEach { tone -> options.forEach { tone ->
DropdownMenuItem( // Replaced with LargeDropdownMenuItem
text = { Text(text = labelFor(tone)) }, LargeDropdownMenuItem(
text = labelFor(tone),
selected = selectedTone == tone,
enabled = enabled,
onClick = { onClick = {
onToneSelected(tone) onToneSelected(tone)
expanded = false expanded = false
}, }
enabled = enabled
) )
} }
Spacer(modifier = Modifier.navigationBarsPadding().height(16.dp))
}
}
} }
} }
} }

View File

@@ -381,7 +381,7 @@ fun DefinitionPart(part: EntryPart) {
// Fallback for JsonObject or other top-level types // Fallback for JsonObject or other top-level types
else -> contentElement.toString() else -> contentElement.toString()
} }
} catch (e: Exception) { } catch (_: Exception) {
// Ultimate fallback if something else goes wrong during parsing // Ultimate fallback if something else goes wrong during parsing
part.content.toString() part.content.toString()
} }
@@ -466,12 +466,6 @@ fun DefinitionPartPreview() {
DefinitionPart(part = mockPart) DefinitionPart(part = mockPart)
} }
// Data classes for the refactored components
data class EntryData(
val entry: DictionaryEntry,
val language: Language?
)
data class BreadcrumbItem( data class BreadcrumbItem(
val word: String, val word: String,
val entryId: Int 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.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -345,30 +342,12 @@ fun DictionarySimpleTopBar(
languageName: String?, languageName: String?,
onNavigateBack: () -> Unit onNavigateBack: () -> Unit
) { ) {
word?.let {
AppTopAppBar( AppTopAppBar(
title = { title = it,
Column { onNavigateBack = onNavigateBack
Text(
text = word ?: stringResource(R.string.text_loading_3d),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
languageName?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
fontStyle = FontStyle.Italic
) )
} }
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
}
},
actions = {}
)
} }
@Composable @Composable

View File

@@ -1,12 +1,16 @@
package eu.gaudian.translator.view.dictionary package eu.gaudian.translator.view.dictionary
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import eu.gaudian.translator.R
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.viewmodel.DictionaryViewModel import eu.gaudian.translator.viewmodel.DictionaryViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
@@ -20,6 +24,11 @@ fun DictionaryScreen(
val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity) val dictionaryViewModel: DictionaryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
Column {
AppTopAppBar(
title = stringResource(R.string.label_dictionary),
onNavigateBack = { navController.popBackStack() }
)
// Use the new refactored component // Use the new refactored component
DictionaryScreenContent( DictionaryScreenContent(
@@ -29,6 +38,7 @@ fun DictionaryScreen(
languageViewModel = languageViewModel, languageViewModel = languageViewModel,
onNavigateToOptions = onNavigateToOptions onNavigateToOptions = onNavigateToOptions
) )
}
} }
@Preview @Preview

View File

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

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