Compare commits

...

12 Commits

Author SHA1 Message Date
jonasgaudian
2d0bf4cb1c implement glassmorphism design across UI components 2026-02-16 11:13:08 +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
75 changed files with 2112 additions and 2675 deletions

View File

@@ -4,7 +4,7 @@
<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-15T19:51:37.987601800Z">
<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\Medium_Phone_28.avd" />

View File

@@ -22,8 +22,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"
} }
@@ -130,6 +130,7 @@ dependencies {
implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime implementation(libs.androidx.room.runtime) // ADDED: Explicitly add runtime
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
implementation(libs.core.ktx) implementation(libs.core.ktx)
implementation(libs.androidx.compose.foundation.layout)
ksp(libs.room.compiler) // CHANGED: Use ksp instead of implementation ksp(libs.room.compiler) // CHANGED: Use ksp instead of implementation
// Networking // Networking

View File

@@ -1,87 +0,0 @@
# How to Scan for AI Models
# TODO REWRITE
This guide explains how to use the **Scan** feature to discover and add AI models to your app.
## How Scanning Works
The scan feature searches for available AI models on your device or network.
> **Note:** Results depend on your API key permissions.
### Key Points
- Only public models are shown by default
- Private models require additional setup
- Try again if no models are found
## Why Some Models Are Missing
Some models may not appear in the scan results due to:
| Reason | Description | Icon |
|--------|-------------|------|
| Restricted access | Model requires special permissions | 🔒 |
| Not suitable | Model type not supported | ⚠️ |
| Text only | Only text-based models are supported | ✓ |
### Model Tiers
We recommend these tiers for optimal performance:
- **Nano** - Fastest, for simple tasks
- **Mini** - Balanced speed and capability
- **Small** - Good for most tasks
- **Medium** - More capable, slower
- **Large** - Most capable, paid only
## Tips for Success
1. **Verify your API key** is active and has correct permissions
2. **Select the correct organization** from your account
3. **Type model names manually** if scanning doesn't find them
4. **Prefer instruct or chat models** for text generation
```kotlin
// Example: Manual model addition
val model = Model(
name = "llama3.2",
type = ModelType.TEXT,
provider = "ollama"
)
```
## Visual Guide
### Step 1: Initiate Scan
Click the scan button to search for available models.
### Step 2: Select Model Type
Choose between different model categories:
- **Text Chat** - For conversational AI
- **Instruct** - For direct instructions
- **Complete** - For text completion
### Step 3: Add & Validate
Add the selected model and validate it works 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
> **Pro Tip:** You can always add models manually by clicking the "+" button in the models screen.
---
*Last updated: 2024-01-15*
*For more help, visit our documentation website.*

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

@@ -1,26 +1,23 @@
# Import Vocabulary with AI
# TODO REWRITE
Generate vocabulary lists automatically using AI assistance. Generate vocabulary lists automatically using AI assistance.
## Getting Started ## Getting Started
Use AI to quickly create vocabulary lists from your learning goals. Use AI to quickly create vocabulary lists for a certain topic.
## Step-by-Step Guide
### Step 1: Enter Search Term ### Step 1: Enter Search Term
Type a topic, theme, or concept for your vocabulary list: Type a topic, theme, or concept for your vocabulary list:
- Be specific for better results - Be specific for better results
- Example: "German food and restaurant phrases" - Example: "German food and restaurant phrases"
- Example: "Business vocabulary for meetings" - Example: "Things to do in Paris"
- Example: "Difficult verbs that are confusing"
### Step 2: Select Languages ### Step 2: Select Languages
Choose source and target languages: Choose source and target languages:
- **Source language** - The language you're learning from - **Source language** - The first language of the flashcard
- **Target language** - Your native language - **Target language** - The second language of the flashcard
### Step 3: Set Amount ### Step 3: Set Amount
@@ -33,22 +30,17 @@ Choose how many words to generate:
Tap the generate button: Tap the generate button:
- AI creates the vocabulary list - AI creates the vocabulary list
- Review each entry before saving
- Edit any translations if needed
## After Generation ## After Generation
Once generated, you can: Once generated, you can:
- **Review** - Check each word-translation pair - Choose which terms to keep
- **Edit** - Correct any mistakes - Optionally, add it to a category
- **Delete** - Remove unwanted entries
- **Import All** - Add all to your vocabulary
## Tips ## Tips
> **Pro Tip:** Start with 10 words per import to get familiar with the feature. - 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
*Need help? Check our vocabulary management guide.*

View File

@@ -1,11 +1,4 @@
# Sorting Vocabulary After you imported vocabulary, you can sort vocabulary
# TODO REWRITE
Learn how to efficiently sort and organize new vocabulary as you add them.
## The Sorting Screen
When you import vocabulary, you'll see the sorting screen where you can:
- Review each word-translation pair - Review each word-translation pair
- Decide the next action for each item - Decide the next action for each item
@@ -13,19 +6,17 @@ When you import vocabulary, you'll see the sorting screen where you can:
## Actions ## Actions
### Mark as Learned ### Mark as Learned
Move the word directly to Stage 1: If you already know the word, move the word directly to Stage "Learned". This prevents the word from reappearing in your exercises.
- The word enters your learning queue
- You'll review it according to the learning schedule
### 🗑️ Delete ### Delete
Remove the word entirely: Remove the word entirely:
- Use for duplicates or unwanted entries - Use for duplicates or unwanted entries
- This action is permanent - This action is permanent
### 📝 Edit ### Edit
Tap on any word or translation to edit: Tap on any word or translation to edit:
- Correct typos - Correct typos
@@ -34,18 +25,12 @@ Tap on any word or translation to edit:
## Duplicate Handling ## Duplicate Handling
When duplicates are detected: When duplicates are detected, you can choose how to handle them:
| Icon | Meaning |
|------|---------|
| ⚠️ | Duplicate detected |
| ✅ | Original entry |
| ❌ | Duplicate entry |
**Options for duplicates:** **Options for duplicates:**
- Keep only the original - Keep only the original
- Keep the newer entry - Keep the newer entry
- Keep both (merge) - Keep both (merge): the newer entry will get deleted but all its information (categories) will be added the old item.
- Delete the duplicate - Delete the duplicate
## Helper Features ## Helper Features
@@ -57,17 +42,7 @@ Toggle to automatically strip articles from words:
- "the dog" → "dog" - "the dog" → "dog"
- Useful for cleaner vocabulary lists - Useful for cleaner vocabulary lists
### Quick Actions
Use quick action buttons for bulk operations:
- **Skip All** - Review later
- **Learn All** - Add all to Stage 1
- **Delete Duplicates** - Auto-remove duplicates
## Tips ## Tips
> **Pro Tip:** Review carefully before sorting. Once sorted, you can still edit words in the vocabulary list. You can edit your flashcards at any point in the flashcard itself
---
*For more tips, check our vocabulary management guide.*

View File

@@ -1,79 +1,7 @@
# Vocabulary Progress Tracking
# TODO REWRITE
Monitor your vocabulary learning journey with detailed progress statistics. Monitor your vocabulary learning journey with detailed progress statistics.
## Progress Overview ## Progress Overview
Track your learning with these key metrics: Track your learning with these key metrics:
### Words Learned TODO Rewrite
- Total words added to your vocabulary
- Words currently in each learning stage
- Words marked as fully learned
### Learning Streak
- Days since you started learning
- Current streak count
- Best streak achieved
### Review Statistics
- Words reviewed today
- Accuracy rate per session
- Words due for review
## Progress Tracking Features
### 📊 Dashboard
View your overall progress at a glance:
- Total vocabulary count
- Mastery percentage
- Recent activity summary
### 📈 Statistics
Detailed analytics include:
- Learning rate over time
- Stage distribution
- Accuracy trends
- Time spent studying
### 🎯 Goals
Set and track learning goals:
- Daily word targets
- Weekly review quotas
- Mastery milestones
## Learning Stages Summary
| Stage | Count | Percentage |
|-------|-------|------------|
| New | X | X% |
| Learning | X | X% |
| Mastered | X | X% |
## Review System
The review system helps you:
1. **Prioritize** - Shows words due for review first
2. **Space** - Optimizes review timing for retention
3. **Track** - Records your performance over time
## Customization
Customize your progress tracking:
- **Select metrics** to display on dashboard
- **Set goals** for personalized targets
- **Export data** for external analysis
- **Reset progress** if starting fresh
---
*Keep practicing consistently to see your progress grow!*

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

@@ -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

@@ -9,6 +9,7 @@ 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.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.ForestTheme import eu.gaudian.translator.ui.theme.themes.ForestTheme
import eu.gaudian.translator.ui.theme.themes.NordTheme import eu.gaudian.translator.ui.theme.themes.NordTheme
@@ -113,6 +114,7 @@ val AllThemes = listOf(
SpaceTheme, SpaceTheme,
CyberpunkTheme, CyberpunkTheme,
SynthwaveTheme, SynthwaveTheme,
DebugTheme,
) )

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,116 @@
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),
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),
// 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),
// 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,31 +22,59 @@ 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)
} }
} }
/**
* @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)"))
@Suppress("unused") @Suppress("unused")
fun showSimpleMessage(text: String, type: MessageDisplayType = MessageDisplayType.INFO) { fun showSimpleMessage(text: String, type: MessageDisplayType = MessageDisplayType.INFO) {
scope.launch { scope.launch {
@@ -52,6 +82,10 @@ object StatusMessageService {
} }
} }
/**
* @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 +96,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 +109,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 +122,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 +135,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

@@ -117,6 +117,7 @@ class TranslationService(private val context: Context) {
} }
suspend fun translateSentence(sentence: String): Result<TranslationHistoryItem> = withContext(Dispatchers.IO) { suspend fun translateSentence(sentence: String): Result<TranslationHistoryItem> = withContext(Dispatchers.IO) {
val statusMessageService = StatusMessageService
val additionalInstructions = settingsRepository.customPromptTranslation.flow.first() val additionalInstructions = settingsRepository.customPromptTranslation.flow.first()
val selectedSource = languageRepository.loadSelectedSourceLanguage().first() val selectedSource = languageRepository.loadSelectedSourceLanguage().first()
val sourceLangName = selectedSource?.englishName ?: "Auto" val sourceLangName = selectedSource?.englishName ?: "Auto"

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,81 +40,88 @@ 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(
IntroPageData( IntroPageData(
title = stringResource(R.string.intro_title_welcome), title = stringResource(R.string.intro_title_welcome),
description = stringResource(R.string.intro_desc_welcome), description = stringResource(R.string.intro_desc_welcome),
content = { IconContent(iconRes = R.drawable.ic_intro_welcome) } content = { IconContent(iconRes = R.drawable.ic_intro_welcome) }
), ),
IntroPageData( IntroPageData(
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(
IconContent(iconRes = R.drawable.ic_intro_ai_agents) horizontalAlignment = Alignment.CenterHorizontally,
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) { verticalArrangement = Arrangement.spacedBy(4.dp)
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_mistral)) }) ) {
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_your_own_ai)) }) IconContent(iconRes = R.drawable.ic_intro_ai_agents)
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_openai)) }) FlowRow(
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_claude)) }) horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_gemini)) }) verticalArrangement = Arrangement.spacedBy(2.dp),
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_deepseek)) }) modifier = Modifier.fillMaxWidth()
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_openrouter)) }) ) {
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_and_many_more)) }) 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_openai)) })
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_claude)) })
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_gemini)) })
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_deepseek)) })
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_openrouter)) })
SuggestionChip(onClick = { }, label = { Text(stringResource(R.string.text_and_many_more)) })
} }
} }
} }
), ),
IntroPageData( IntroPageData(
title = stringResource(R.string.intro_title_dictionary_translator), title = stringResource(R.string.intro_title_dictionary_translator),
description = stringResource(R.string.intro_desc_dictionary_translator), description = stringResource(R.string.intro_desc_dictionary_translator),
content = { IconContent(iconRes = R.drawable.ic_intro_lookup) } content = { IconContent(iconRes = R.drawable.ic_intro_lookup) }
), ),
IntroPageData( IntroPageData(
title = stringResource(R.string.intro_title_flashcards), title = stringResource(R.string.intro_title_flashcards),
description = stringResource(R.string.intro_desc_flashcards), description = stringResource(R.string.intro_desc_flashcards),
content = { FlashcardTopicsPreview() } content = { FlashcardTopicsPreview() }
), ),
IntroPageData( IntroPageData(
title = stringResource(R.string.intro_title_practice), title = stringResource(R.string.intro_title_practice),
description = stringResource(R.string.intro_desc_practice), description = stringResource(R.string.intro_desc_practice),
content = { IconContent(iconRes = R.drawable.ic_inro_practice) } content = { IconContent(iconRes = R.drawable.ic_inro_practice) }
), ),
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),
description = stringResource(R.string.intro_desc_categories), description = stringResource(R.string.intro_desc_categories),
content = { IconContent(iconRes = R.drawable.ic_intro_categories) } content = { IconContent(iconRes = R.drawable.ic_intro_categories) }
), ),
IntroPageData( IntroPageData(
title = stringResource(R.string.intro_title_progress), title = stringResource(R.string.intro_title_progress),
description = stringResource(R.string.intro_desc_progress), description = stringResource(R.string.intro_desc_progress),
content = { IconContent(iconRes = R.drawable.ic_intro_track_progress) } content = { IconContent(iconRes = R.drawable.ic_intro_track_progress) }
), ),
IntroPageData( IntroPageData(
title = stringResource(R.string.intro_need_help), title = stringResource(R.string.intro_need_help),
description = stringResource(R.string.intro_if_you_need_help_you), description = stringResource(R.string.intro_if_you_need_help_you),
content = { IconContent(iconRes = R.drawable.ic_intro_help) } content = { IconContent(iconRes = R.drawable.ic_intro_help) }
), ),
IntroPageData( IntroPageData(
title = stringResource(R.string.intro_title_beta), title = stringResource(R.string.intro_title_beta),
description = stringResource(R.string.intro_desc_beta), description = stringResource(R.string.intro_desc_beta),
content = { IconContent(iconRes = R.drawable.ic_icon_construction) } content = { IconContent(iconRes = R.drawable.ic_icon_construction) }
), ),
IntroPageData( IntroPageData(
title = stringResource(R.string.intro_title_all_set), title = stringResource(R.string.intro_title_all_set),
description = stringResource(R.string.intro_desc_all_set), description = stringResource(R.string.intro_desc_all_set),
content = { IconContent(iconRes = R.drawable.ic_intro_robot) } content = { IconContent(iconRes = R.drawable.ic_intro_robot) }
)
) )
)
val pagerState = rememberPagerState(pageCount = { pages.size }) val pagerState = rememberPagerState(pageCount = { pages.size })
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -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(
@@ -234,15 +240,14 @@ private fun PagerIndicator(pageCount: Int, currentPage: Int) {
@Composable @Composable
private fun IconContent(iconRes: Int) { private fun IconContent(iconRes: Int) {
Box(modifier = Modifier.clip(RoundedCornerShape(16.dp))) { Box(modifier = Modifier.clip(RoundedCornerShape(16.dp))) {
Icon( Icon(
painter = painterResource(id = iconRes), painter = painterResource(id = iconRes),
contentDescription = null, contentDescription = null,
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
@@ -149,9 +155,7 @@ fun TranslatorApp(
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()
@@ -303,7 +307,7 @@ 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()
) )
@@ -357,9 +361,12 @@ 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 // We must keep this for older Android version!!!
//window.navigationBarColor = android.graphics.Color.TRANSPARENT @Suppress("DEPRECATION")
//TODO remove eventually window.statusBarColor = colorScheme.surface.toArgb()
//Elevation must be the same as BottomNavigationBar
@Suppress("DEPRECATION")
window.navigationBarColor = colorScheme.surfaceColorAtElevation(8.dp).toArgb()
windowInsetsController.isAppearanceLightStatusBars = !useDarkTheme windowInsetsController.isAppearanceLightStatusBars = !useDarkTheme
windowInsetsController.isAppearanceLightNavigationBars = !useDarkTheme windowInsetsController.isAppearanceLightNavigationBars = !useDarkTheme
@@ -400,8 +407,10 @@ private fun AppTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = dynamicTypography, typography = dynamicTypography,
) { ) {
eu.gaudian.translator.ui.theme.ProvideSemanticColors { ProvideSemanticColors {
content() content()
} }
} }
} }

View File

@@ -1,22 +1,19 @@
package eu.gaudian.translator.view.composable package eu.gaudian.translator.view.composable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.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.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
import androidx.compose.material3.DropdownMenu import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -24,8 +21,6 @@ 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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -42,7 +37,6 @@ fun ApiModelDropDown(
onModelSelected: (LanguageModel?) -> Unit, onModelSelected: (LanguageModel?) -> Unit,
enabled: Boolean = true enabled: Boolean = true
) { ) {
LocalContext.current
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
@@ -65,92 +59,58 @@ fun ApiModelDropDown(
} }
} }
Box { // Custom button content showing selected model and provider
AppOutlinedButton( val buttonContent: @Composable () -> Unit = {
onClick = { expanded = true }, Row(
modifier = Modifier.align(Alignment.Center), verticalAlignment = Alignment.CenterVertically,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth()
enabled = enabled
) { ) {
Row( Column(modifier = Modifier.weight(1f)) {
verticalAlignment = Alignment.CenterVertically, Text(
modifier = Modifier.fillMaxWidth() text = selectedModel?.displayName ?: stringResource(R.string.text_select_model),
) { style = MaterialTheme.typography.bodyMedium,
Column(modifier = Modifier.weight(1f)) { maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (selectedModel != null) {
Text( Text(
text = selectedModel?.displayName ?: stringResource(R.string.text_select_model), text = providerNames[selectedModel.providerKey] ?: selectedModel.providerKey,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis 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)
)
} }
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)
)
} }
}
DropdownMenu( 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 modifier = Modifier
.fillMaxWidth(), .heightIn(max = 400.dp)
expanded = expanded, .verticalScroll(rememberScrollState())
onDismissRequest = { expanded = false }
) { ) {
// Search bar
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
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 = { searchQuery = it },
placeholder = { Text(stringResource(R.string.label_search_models)) },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
modifier = Modifier.weight(1f)
)
if (searchQuery.isNotBlank()) {
IconButton(
onClick = { searchQuery = "" },
modifier = Modifier.size(24.dp)
) {
Icon(
AppIcons.Close,
contentDescription = stringResource(R.string.cd_clear_search),
modifier = Modifier.size(16.dp)
)
}
}
}
HorizontalDivider()
}
if (filteredGroupedModels.isNotEmpty()) { if (filteredGroupedModels.isNotEmpty()) {
filteredGroupedModels.entries.forEachIndexed { index, entry -> filteredGroupedModels.entries.forEachIndexed { index, entry ->
val providerKey = entry.key val providerKey = entry.key
@@ -158,7 +118,7 @@ fun ApiModelDropDown(
val isActive = providerStatuses[providerKey] == true val isActive = providerStatuses[providerKey] == true
val providerName = providerNames[providerKey] ?: providerKey val providerName = providerNames[providerKey] ?: providerKey
if (index > 0) HorizontalDivider() if (index > 0) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Provider header // Provider header
AppDropdownMenuItem( AppDropdownMenuItem(

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
) )
} }
@@ -156,7 +157,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
) { ) {
@@ -327,7 +328,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 +378,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")

View File

@@ -2,11 +2,14 @@
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.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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -14,22 +17,30 @@ 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.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
@@ -48,6 +59,8 @@ 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.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
@@ -56,100 +69,362 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import eu.gaudian.translator.R import eu.gaudian.translator.R
/** // =========================================
* A modern, custom dropdown menu composable that provides a styled text field with a dropdown list of options. // UNIFIED DROPDOWN STYLES & CONSTANTS
* This implementation uses a custom dropdown for a more tailored look compared to the stock menu, behaving like a normal ExposedDropdownMenu. // =========================================
* 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.
*/
@Composable
fun AppDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
placeholder: @Composable (() -> Unit)? = null,
selectedText: String = "",
onExpandRequest: () -> Unit = {},
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit,
) {
var textFieldSize by remember { mutableStateOf(Size.Zero) }
val interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
Column(modifier = modifier) { object DropdownDefaults {
OutlinedTextField( val shape = RoundedCornerShape(8.dp)
value = selectedText, val itemPaddingHorizontal = 8.dp
onValueChange = {}, val itemPaddingVertical = 2.dp
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates ->
textFieldSize = coordinates.size.toSize()
}
.clickable(
enabled = enabled,
onClick = onExpandRequest,
interactionSource = interactionSource,
indication = null
),
readOnly = true,
label = label,
placeholder = placeholder,
trailingIcon = {
val icon = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown
Icon(
imageVector = icon,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
shape = ComponentDefaults.DefaultShape,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_LOW),
focusedLabelColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
disabledBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_LOW),
disabledLabelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ComponentDefaults.ALPHA_MEDIUM)
),
enabled = enabled,
interactionSource = interactionSource
)
DropdownMenu( @Composable
expanded = expanded, fun containerColor(): Color = MaterialTheme.colorScheme.surface
onDismissRequest = onDismissRequest,
modifier = Modifier @Composable
.width(with(LocalDensity.current) { textFieldSize.width.toDp() }) fun itemBackground(selected: Boolean): Color {
// Give the menu itself a bit of breathing room return if (selected) {
.padding(vertical = 4.dp), MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
offset = DpOffset(0.dp, 4.dp), // Slight detachment from the anchor } else {
scrollState = rememberScrollState(), Color.Transparent
properties = PopupProperties(focusable = true), }
shape = RoundedCornerShape(12.dp), }
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp, @Composable
shadowElevation = 8.dp, fun itemContentColor(selected: Boolean, enabled: Boolean): Color {
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) return when {
) { !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
content() selected -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
} }
} }
} }
/** /**
* A modern and stylish composable for individual dropdown items, featuring enhanced visual design * A drop-in replacement for [androidx.compose.material3.DropdownMenu] that opens
* with subtle shadows, rounded corners, and smooth interactions. * as a BottomSheet. Compatible with the standard M3 signature.
*/ */
@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 @Composable
fun AppDropdownMenuItem( fun AppDropdownMenuItem(
text: @Composable () -> Unit, text: @Composable () -> Unit,
@@ -160,23 +435,25 @@ fun AppDropdownMenuItem(
trailingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null,
selected: Boolean = false, selected: Boolean = false,
) { ) {
val contentColor = if (enabled) { val contentColor by animateColorAsState(
if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface targetValue = DropdownDefaults.itemContentColor(selected, enabled),
} else { label = "contentColor"
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) )
} val backgroundColor by animateColorAsState(
targetValue = DropdownDefaults.itemBackground(selected),
// Modern "floating" highlight background label = "backgroundColor"
val backgroundColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) else Color.Transparent )
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp) // Outer padding creates the floating shape .padding(
.clip(RoundedCornerShape(8.dp)) horizontal = DropdownDefaults.itemPaddingHorizontal,
vertical = DropdownDefaults.itemPaddingVertical
)
.clip(DropdownDefaults.shape)
.background(backgroundColor) .background(backgroundColor)
.clickable(enabled = enabled) { onClick() } .clickable(enabled = enabled) { onClick() }
//.padding(horizontal = 12.dp, vertical = 10.dp) // Inner padding keeps content comfortable
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -199,7 +476,112 @@ fun AppDropdownMenuItem(
} }
} }
// ... [Previews remain exactly the same as your original file] ... /**
* A lightweight, modern dropdown menu composable with a clean text field and dropdown list.
*/
@Suppress("unused", "HardCodedStringLiteral")
@Composable
fun AppDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null,
enabled: Boolean = true,
placeholder: @Composable (() -> Unit)? = null,
selectedText: String = "",
onExpandRequest: () -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
var textFieldSize by remember { mutableStateOf(Size.Zero) }
val interactionSource = remember { MutableInteractionSource() }
Column(modifier = modifier) {
OutlinedTextField(
value = selectedText,
onValueChange = {},
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates ->
textFieldSize = coordinates.size.toSize()
}
.clickable(
enabled = enabled,
onClick = onExpandRequest,
interactionSource = interactionSource,
indication = null
),
readOnly = true,
label = label,
placeholder = placeholder,
trailingIcon = {
Icon(
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
shape = DropdownDefaults.shape,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.6f),
focusedLabelColor = MaterialTheme.colorScheme.primary,
cursorColor = MaterialTheme.colorScheme.primary,
disabledBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
disabledLabelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
),
enabled = enabled,
interactionSource = interactionSource
)
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = Modifier.width(with(LocalDensity.current) { textFieldSize.width.toDp() }),
offset = DpOffset(0.dp, 2.dp),
properties = PopupProperties(focusable = true),
shape = DropdownDefaults.shape,
containerColor = DropdownDefaults.containerColor()
) {
content()
}
}
}
// =========================================
// LEGACY COMPONENTS (UPDATED TO USE UNIFIED STYLES)
// =========================================
@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) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = DropdownDefaults.itemPaddingHorizontal,
vertical = DropdownDefaults.itemPaddingVertical
)
.clip(DropdownDefaults.shape)
.background(backgroundColor)
.clickable(enabled) { onClick() }
.padding(horizontal = 16.dp, vertical = 14.dp)
) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = fontWeight),
)
}
}
}
@Composable @Composable
fun <T> LargeDropdownMenu( fun <T> LargeDropdownMenu(
@@ -210,12 +592,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,
) )
}, },
@@ -247,13 +629,10 @@ fun <T> LargeDropdownMenu(
} }
if (expanded) { if (expanded) {
Dialog( Dialog(onDismissRequest = { expanded = false }) {
onDismissRequest = { expanded = false }, // Fixed bug from original code
) {
Surface( Surface(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp,
tonalElevation = 6.dp tonalElevation = 6.dp
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -263,7 +642,6 @@ fun <T> LargeDropdownMenu(
} }
} }
// Added vertical padding to the list instead of hard dividers
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
state = listState, state = listState,
@@ -279,7 +657,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,
@@ -296,39 +674,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 = 0.38f)
selected -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
}
val backgroundColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f) else Color.Transparent
val fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
CompositionLocalProvider(LocalContentColor provides contentColor) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp) // Outer padding for floating shape
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.clickable(enabled) { onClick() }
.padding(horizontal = 16.dp, vertical = 14.dp) // Inner padding
) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = fontWeight),
)
}
}
}
@Suppress("HardCodedStringLiteral") @Suppress("HardCodedStringLiteral")
@Preview(showBackground = true) @Preview(showBackground = true)
@@ -354,6 +700,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
@@ -365,53 +735,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

@@ -35,7 +35,7 @@ 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.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
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
@@ -159,14 +159,14 @@ private fun MenuItem(
) { ) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.shadow(elevation = 8.dp, shape = ComponentDefaults.DefaultShape) .glassmorphic(shape = RoundedCornerShape(16.dp), alpha = 0.4f)
.clickable( .clickable(
onClick = onClick, onClick = onClick,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
), ),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainer color = Color.Transparent // Allow glassmorphic modifier to handle color
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
@@ -197,15 +197,3 @@ private fun MenuItem(
} }
} }
} }
@Preview
@Composable
fun MenuItemPreview() {
@Suppress("HardCodedStringLiteral")
MenuItem(
text = "Menu Item",
imageVector = AppIcons.Add,
painter = null,
onClick = {}
)
}

View File

@@ -69,10 +69,8 @@ fun <T : TabItem> AppTabLayout(
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 8.dp) .padding(vertical = 8.dp, horizontal = 8.dp)
.height(56.dp) .height(56.dp)
.background( // Replace background with glassmorphic extension
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), .glassmorphic(shape = ComponentDefaults.CardShape, alpha = 0.3f)
shape = ComponentDefaults.CardShape
)
) { ) {
val tabWidth = maxWidth / tabs.size val tabWidth = maxWidth / tabs.size
@@ -89,7 +87,7 @@ fun <T : TabItem> AppTabLayout(
.fillMaxHeight() .fillMaxHeight()
.padding(4.dp) .padding(4.dp)
.background( .background(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), // Glassy indicator
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) )
) )

View File

@@ -25,6 +25,8 @@ 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.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
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.unit.dp import androidx.compose.ui.unit.dp
@@ -41,16 +43,23 @@ fun AppTopAppBar(
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(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
),
hintContent: Hint? = null hintContent: Hint? = null
) { ) {
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
TopAppBar( Surface(
modifier = modifier.height(56.dp), modifier = modifier.glassmorphic(shape = RectangleShape, alpha = 0.2f),
windowInsets = WindowInsets(0.dp), color = Color.Transparent
colors = colors, ) {
TopAppBar(
modifier = Modifier.height(56.dp),
windowInsets = WindowInsets(0.dp),
colors = colors,
title = { title = {
Box( Box(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
@@ -102,8 +111,9 @@ fun AppTopAppBar(
// No navigation icon // No navigation icon
} }
}, },
actions = actions actions = actions
) )
}
if (showBottomSheet) { if (showBottomSheet) {
HintBottomSheet( HintBottomSheet(

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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.shape.RoundedCornerShape
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
@@ -28,6 +29,7 @@ import androidx.compose.runtime.remember
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.scale import androidx.compose.ui.draw.scale
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
@@ -100,24 +102,25 @@ fun BottomNavigationBar(
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( NavigationBar(
modifier = modifier.height(height), modifier = modifier
containerColor = MaterialTheme.colorScheme.surface, // Cleaner look than surfaceVariant .height(height)
tonalElevation = 8.dp, // Slight elevation for depth // Apply glassmorphism on the top corners
.glassmorphic(shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), alpha = 0.35f),
containerColor = Color.Transparent, // Let the glass shine through
tonalElevation = 0.dp,
) { ) {
screens.forEach { screen -> screens.forEach { screen ->
val isSelected = screen == selectedItem val isSelected = 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,7 +132,7 @@ 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) onItemSelected(screen)
} }
}, },
@@ -145,17 +148,16 @@ 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)
) )
} }
}, },
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
indicatorColor = MaterialTheme.colorScheme.primaryContainer, indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), // Glassy indicator
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
selectedTextColor = MaterialTheme.colorScheme.primary, selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,

View File

@@ -5,6 +5,8 @@ package eu.gaudian.translator.view.composable
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable 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.Arrangement
@@ -43,6 +45,7 @@ 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.composed
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
@@ -55,49 +58,51 @@ 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 {
// Sizing
val DefaultButtonHeight = 48.dp val DefaultButtonHeight = 48.dp
val CardPadding = 8.dp val CardPadding = 8.dp
// Elevation
val DefaultElevation = 0.dp val DefaultElevation = 0.dp
val NoElevation = 0.dp val NoElevation = 0.dp
// Borders
val DefaultBorderWidth = 1.dp val DefaultBorderWidth = 1.dp
// Shapes
val DefaultCornerRadius = 16.dp val DefaultCornerRadius = 16.dp
val CardClipRadius = 8.dp val CardClipRadius = 16.dp // Increased slightly for softer glass look
val NoRounding = 0.dp val NoRounding = 0.dp
val DefaultShape = RoundedCornerShape(DefaultCornerRadius) val DefaultShape = RoundedCornerShape(DefaultCornerRadius)
val CardClipShape = RoundedCornerShape(CardClipRadius) val CardClipShape = RoundedCornerShape(CardClipRadius)
val CardShape = RoundedCornerShape(DefaultCornerRadius) val CardShape = RoundedCornerShape(DefaultCornerRadius)
val NoShape = RoundedCornerShape(NoRounding) val NoShape = RoundedCornerShape(NoRounding)
// Opacity Levels
const val ALPHA_HIGH = 0.6f const val ALPHA_HIGH = 0.6f
const val ALPHA_MEDIUM = 0.5f const val ALPHA_MEDIUM = 0.4f
const val ALPHA_LOW = 0.3f const val ALPHA_LOW = 0.2f // Adjusted for glass
} }
/** /**
* A styled card container for displaying content with a consistent floating look. * Standard Glassmorphism Modifier
*
* @param modifier The modifier to be applied to the card.
* @param content The content to be displayed inside the card.
*/ */
fun Modifier.glassmorphic(
shape: Shape = ComponentDefaults.DefaultShape,
alpha: Float = ComponentDefaults.ALPHA_LOW,
borderAlpha: Float = 0.15f
): Modifier = composed {
this
.shadow(elevation = 8.dp, shape = shape, spotColor = Color.Black.copy(alpha = 0.05f))
.clip(shape)
.background(MaterialTheme.colorScheme.surface.copy(alpha = alpha))
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = borderAlpha),
shape = shape
)
}
@Composable @Composable
fun AppCard( fun AppCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
title: String? = null, title: String? = null,
icon: ImageVector? = null, // New optional icon parameter icon: ImageVector? = null,
text: String? = null, text: String? = null,
expandable: Boolean = false, expandable: Boolean = false,
initiallyExpanded: Boolean = false, initiallyExpanded: Boolean = false,
@@ -110,25 +115,17 @@ fun AppCard(
label = "Chevron Rotation" 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 hasHeader = title != null || text != null || expandable || icon != null
Surface( Surface(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.shadow( .glassmorphic(shape = ComponentDefaults.CardShape, alpha = 0.25f)
DefaultElevation,
shape = ComponentDefaults.CardShape
)
.clip(ComponentDefaults.CardClipShape)
// Animate height changes when expanding/collapsing
.animateContentSize(), .animateContentSize(),
shape = ComponentDefaults.CardShape, shape = ComponentDefaults.CardShape,
color = MaterialTheme.colorScheme.surfaceContainer color = Color.Transparent // Let glassmorphic handle the background
) { ) {
Column { Column {
// --- Header Row ---
if (hasHeader) { if (hasHeader) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -137,7 +134,6 @@ fun AppCard(
.padding(ComponentDefaults.CardPadding), .padding(ComponentDefaults.CardPadding),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// 1. Optional Icon on the left
if (icon != null) { if (icon != null) {
Icon( Icon(
imageVector = icon, imageVector = icon,
@@ -148,7 +144,6 @@ fun AppCard(
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
} }
// 2. Title and Text Column
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
if (!title.isNullOrBlank()) { if (!title.isNullOrBlank()) {
Text( Text(
@@ -157,12 +152,9 @@ fun AppCard(
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
} }
// Only show spacer if both title and text exist
if (!title.isNullOrBlank() && !text.isNullOrBlank()) { if (!title.isNullOrBlank() && !text.isNullOrBlank()) {
Spacer(Modifier.size(4.dp)) Spacer(Modifier.size(4.dp))
} }
if (!text.isNullOrBlank()) { if (!text.isNullOrBlank()) {
Text( Text(
text = text, text = text,
@@ -172,7 +164,6 @@ fun AppCard(
} }
} }
// 3. Expand Chevron (Far right)
if (expandable) { if (expandable) {
Icon( Icon(
imageVector = AppIcons.ArrowDropDown, imageVector = AppIcons.ArrowDropDown,
@@ -184,15 +175,12 @@ fun AppCard(
} }
} }
// --- Content Area ---
if (!expandable || isExpanded) { if (!expandable || isExpanded) {
Column( Column(
modifier = Modifier.padding( modifier = Modifier.padding(
start = ComponentDefaults.CardPadding, start = ComponentDefaults.CardPadding,
end = ComponentDefaults.CardPadding, end = ComponentDefaults.CardPadding,
bottom = 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 top = if (hasHeader) 0.dp else ComponentDefaults.CardPadding
), ),
content = content content = content
@@ -304,31 +292,27 @@ fun AppButton(
modifier: Modifier? = Modifier, modifier: Modifier? = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
shape: Shape? = null, shape: Shape? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(), colors: ButtonColors = ButtonDefaults.buttonColors(
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(), containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) // Glassy primary
),
elevation: ButtonElevation? = ButtonDefaults.buttonElevation(defaultElevation = 0.dp),
border: BorderStroke? = null, border: BorderStroke? = null,
contentPadding: PaddingValues = ButtonDefaults.ContentPadding, contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
content: @Composable RowScope.() -> Unit content: @Composable RowScope.() -> Unit
) { ) {
val m = modifier ?: Modifier.height(ComponentDefaults.DefaultButtonHeight) val m = modifier ?: Modifier.height(ComponentDefaults.DefaultButtonHeight)
val s = shape ?: ComponentDefaults.DefaultShape val s = shape ?: ComponentDefaults.DefaultShape
Button( Button(
onClick = onClick, onClick = onClick,
modifier = m, modifier = m.border(1.dp, MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.2f), s),
enabled = enabled, enabled = enabled,
shape = s, shape = s,
colors = colors, colors = colors,
elevation = elevation, elevation = elevation,
border = border, border = border,
contentPadding = PaddingValues( contentPadding = PaddingValues(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
start = 8.dp, // More horizontal padding
end = 8.dp,
top = 8.dp, // Default vertical padding
bottom = 8.dp
),
interactionSource = interactionSource interactionSource = interactionSource
) { ) {
content() content()
@@ -368,11 +352,7 @@ fun AppOutlinedButton(
) )
} }
@Preview
@Composable
fun PrimaryButtonWithIconPreview() {
PrimaryButton(onClick = { }, text = stringResource(R.string.primary_with_icon), icon = AppIcons.Add)
}
/** /**
* The secondary button for less prominent actions. * The secondary button for less prominent actions.

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,19 +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.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
@@ -33,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
@@ -82,7 +83,10 @@ 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 },
@@ -104,241 +108,237 @@ fun BaseLanguageDropDown(
} }
} }
DropdownMenu(modifier = modifier.fillMaxWidth(), expanded = expanded, onDismissRequest = { if (expanded) {
expanded = false val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
searchText = ""
tempSelection = emptyList() // Also reset temp selection on dismiss ModalBottomSheet(
}) { onDismissRequest = {
// Helper composable for a single language row in multiple selection mode expanded = false
@Composable searchText = ""
fun MultiSelectItem(language: Language) { tempSelection = emptyList()
val isSelected = tempSelection.contains(language) },
AppDropdownMenuItem( sheetState = sheetState
text = { ) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { @Composable
AppCheckbox( fun MultiSelectItem(language: Language) {
checked = isSelected, val isSelected = tempSelection.contains(language)
onCheckedChange = { AppDropdownMenuItem(
tempSelection = if (isSelected) tempSelection - language else tempSelection + language text = {
@Suppress("AssignedValueIsNeverRead") Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
selectedLanguagesCount = tempSelection.size AppCheckbox(
onLanguagesSelected(tempSelection) checked = isSelected,
} onCheckedChange = {
) tempSelection = if (isSelected) tempSelection - language else tempSelection + language
Spacer(modifier = Modifier.width(8.dp)) selectedLanguagesCount = tempSelection.size
Text(language.name) onLanguagesSelected(tempSelection)
if (language.nativeName != language.name) { }
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "(${language.nativeName})",
style = TextStyle(
fontStyle = FontStyle.Italic,
fontFamily = FontFamily.Default
)
) )
} Spacer(modifier = Modifier.width(8.dp))
} Text(language.name)
},
onClick = {
tempSelection = if (isSelected) tempSelection - language else tempSelection + language
@Suppress("AssignedValueIsNeverRead")
selectedLanguagesCount = tempSelection.size
}
)
}
// Helper composable for a single language row in single selection mode
@Composable
fun SingleSelectItem(language: Language) {
val languageNames = languages.map { it.name }
val duplicateNames = languageNames.groupingBy { it }.eachCount().filter { it.value > 1 }.keys
val isDuplicate = duplicateNames.contains(language.name)
AppDropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column {
Text(text = language.name)
if (language.nativeName != language.name) { if (language.nativeName != language.name) {
Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = language.nativeName, text = "(${language.nativeName})",
style = TextStyle( style = TextStyle(
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
fontSize = 12.sp,
fontFamily = FontFamily.Default fontFamily = FontFamily.Default
) )
) )
} }
} }
if (isDuplicate) { },
Spacer(modifier = Modifier.width(4.dp)) onClick = {
Text(text = "(${language.region})") tempSelection = if (isSelected) tempSelection - language else tempSelection + language
} selectedLanguagesCount = tempSelection.size
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = {
val isCurrentlyFavorite = favoriteLanguages.contains(language)
val updatedFavorites = if (!isCurrentlyFavorite) favoriteLanguages + language else favoriteLanguages - language
languageViewModel.updateFavoriteLanguages(updatedFavorites)
}) {
Icon(
imageVector = if (favoriteLanguages.contains(language)) AppIcons.Favorite else AppIcons.FavoriteOutline,
contentDescription = if (favoriteLanguages.contains(language)) stringResource(
R.string.text_remove_from_favorites
) else stringResource(R.string.text_add_to_favorites)
)
}
} }
}, )
onClick = { }
onLanguageSelected(language)
expanded = false
searchText = ""
}
)
}
@Composable
fun SingleSelectItem(language: Language) {
val languageNames = languages.map { it.name }
val duplicateNames = languageNames.groupingBy { it }.eachCount().filter { it.value > 1 }.keys
val isDuplicate = duplicateNames.contains(language.name)
// --- Main Dropdown Content --- AppDropdownMenuItem(
Column( text = {
modifier = Modifier Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
.heightIn(max = 900.dp) // Constrain the height Column {
) { Text(text = language.name)
// Search bar with a back arrow if (language.nativeName != language.name) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text(
IconButton(onClick = { expanded = false; searchText = "" }) { text = language.nativeName,
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.label_close)) style = TextStyle(
} fontStyle = FontStyle.Italic,
TextField( fontSize = 12.sp,
value = searchText, fontFamily = FontFamily.Default
onValueChange = { searchText = it }, )
singleLine = true, )
placeholder = { Text(stringResource(R.string.text_search_3d)) }, }
trailingIcon = { }
if (searchText.isNotBlank()) { if (isDuplicate) {
IconButton(onClick = { searchText = "" }) { Spacer(modifier = Modifier.width(4.dp))
Icon(imageVector = AppIcons.Close, contentDescription = stringResource(R.string.cd_clear_search)) Text(text = "(${language.region})")
}
Spacer(modifier = Modifier.weight(1f))
IconButton(onClick = {
val isCurrentlyFavorite = favoriteLanguages.contains(language)
val updatedFavorites = if (!isCurrentlyFavorite) favoriteLanguages + language else favoriteLanguages - language
languageViewModel.updateFavoriteLanguages(updatedFavorites)
}) {
Icon(
imageVector = if (favoriteLanguages.contains(language)) AppIcons.Favorite else AppIcons.FavoriteOutline,
contentDescription = if (favoriteLanguages.contains(language)) stringResource(
R.string.text_remove_from_favorites
) else stringResource(R.string.text_add_to_favorites)
)
} }
} }
}, },
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
modifier = Modifier.weight(1f)
)
}
HorizontalDivider()
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
val isSearching = searchText.isNotBlank()
if (isSearching) {
val searchResults = (favoriteLanguages + languageHistory + languages)
.distinctBy { it.nameResId }
.filter { language ->
val matchesName = language.name.contains(searchText, ignoreCase = true)
val matchesNativeName = language.nativeName.contains(searchText, ignoreCase = true)
matchesName || matchesNativeName
}
.sortedBy { it.name }
if (enableMultipleSelection) {
searchResults.forEach { language -> MultiSelectItem(language) }
} else {
searchResults.forEach { language -> SingleSelectItem(language) }
}
} else if (alternateLanguages.isNotEmpty()) {
val sortedAlternate = alternateLanguages.sortedBy { it.name }
if (enableMultipleSelection) {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
sortedAlternate.forEach { language -> MultiSelectItem(language) }
} else {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
sortedAlternate.forEach { language -> SingleSelectItem(language) }
}
} else {
if (enableMultipleSelection) {
if (favoriteLanguages.isNotEmpty()) {
Text(stringResource(R.string.text_favorites), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
favoriteLanguages.forEach { language -> MultiSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val recentHistoryFiltered = languageHistory.filter { it !in favoriteLanguages }.takeLast(5)
if (recentHistoryFiltered.isNotEmpty() && alternateLanguages.isEmpty()) {
Text(stringResource(R.string.text_recent_history), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
recentHistoryFiltered.forEach { language -> MultiSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val remainingLanguages = languages.sortedBy { it.name }
if (remainingLanguages.isNotEmpty()) {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
remainingLanguages.forEach { language -> MultiSelectItem(language) }
}
} else {
// Logic for single selection default view
if (showAutoOption) {
AppDropdownMenuItem(text = { Text(stringResource(R.string.text_select_auto_recognition)) }, onClick = { onAutoSelected(); expanded = false; searchText = "" })
HorizontalDivider()
}
if (showNoneOption) {
AppDropdownMenuItem(text = { Text(stringResource(R.string.text_select_none)) }, onClick = { onNoneSelected(); expanded = false; searchText = "" })
HorizontalDivider()
}
if (favoriteLanguages.any {
@Suppress("HardCodedStringLiteral")
it.code != "none" && it.code != "auto"
}) {
Text(stringResource(R.string.text_favorites), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
favoriteLanguages.filter {
@Suppress("HardCodedStringLiteral")
it.code != "none" && it.code != "auto"
}.forEach { language -> SingleSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val recentHistoryFiltered = languageHistory.filter {
@Suppress("HardCodedStringLiteral")
it !in favoriteLanguages && it.code != "none" && it.code != "auto"
}.takeLast(5)
if (recentHistoryFiltered.isNotEmpty()) {
Text(stringResource(R.string.text_recent_history), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
recentHistoryFiltered.forEach { language -> SingleSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val remainingLanguages = languages.filter {
@Suppress("HardCodedStringLiteral")
it !in favoriteLanguages && it !in recentHistoryFiltered && it.code != "none" && it.code != "auto"
}.sortedBy { it.name }
if (remainingLanguages.isNotEmpty()) {
Text(stringResource(R.string.text_all_languages), style = TextStyle(fontWeight = FontWeight.Bold), modifier = Modifier.padding(8.dp))
remainingLanguages.forEach { language -> SingleSelectItem(language) }
}
}
}
}
// Done button for multiple selection mode
if (enableMultipleSelection) {
HorizontalDivider()
AppButton(
onClick = { onClick = {
onLanguagesSelected(tempSelection) onLanguageSelected(language)
@Suppress("AssignedValueIsNeverRead")
selectedLanguagesCount = tempSelection.size
expanded = false expanded = false
searchText = "" searchText = ""
}, }
)
}
Column(
modifier = Modifier.fillMaxWidth()
) {
DropdownSearchField(
searchQuery = searchText,
onSearchQueryChange = { searchText = it },
placeholder = { Text(stringResource(R.string.text_search_3d)) },
)
HorizontalDivider()
// Replaced height(max = 900.dp) with standard weight logic to allow proper scrolling bounds
Column(
modifier = Modifier modifier = Modifier
.padding(8.dp) .weight(1f, fill = false)
.fillMaxWidth() .verticalScroll(rememberScrollState())
) { ) {
Text(stringResource(R.string.label_done)) val isSearching = searchText.isNotBlank()
if (isSearching) {
val searchResults = (favoriteLanguages + languageHistory + languages)
.distinctBy { it.nameResId }
.filter { language ->
val matchesName = language.name.contains(searchText, ignoreCase = true)
val matchesNativeName = language.nativeName.contains(searchText, ignoreCase = true)
matchesName || matchesNativeName
}
.sortedBy { it.name }
if (enableMultipleSelection) {
searchResults.forEach { language -> MultiSelectItem(language) }
} else {
searchResults.forEach { language -> SingleSelectItem(language) }
}
} else if (alternateLanguages.isNotEmpty()) {
val sortedAlternate = alternateLanguages.sortedBy { it.name }
if (enableMultipleSelection) {
DropdownHeader(text = stringResource(R.string.text_all_languages))
sortedAlternate.forEach { language -> MultiSelectItem(language) }
} else {
DropdownHeader(text = stringResource(R.string.text_all_languages))
sortedAlternate.forEach { language -> SingleSelectItem(language) }
}
} else {
if (enableMultipleSelection) {
if (favoriteLanguages.isNotEmpty()) {
DropdownHeader(text = stringResource(R.string.text_favorites))
favoriteLanguages.forEach { language -> MultiSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val recentHistoryFiltered = languageHistory.filter { it !in favoriteLanguages }.takeLast(5)
if (recentHistoryFiltered.isNotEmpty() && alternateLanguages.isEmpty()) {
DropdownHeader(text = stringResource(R.string.text_recent_history))
recentHistoryFiltered.forEach { language -> MultiSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val remainingLanguages = languages.sortedBy { it.name }
if (remainingLanguages.isNotEmpty()) {
DropdownHeader(text = stringResource(R.string.text_all_languages))
remainingLanguages.forEach { language -> MultiSelectItem(language) }
}
} else {
if (showAutoOption) {
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()
}
if (showNoneOption) {
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()
}
if (favoriteLanguages.any {
it.code != "none" && it.code != "auto"
}) {
DropdownHeader(text = stringResource(R.string.text_favorites))
favoriteLanguages.filter {
it.code != "none" && it.code != "auto"
}.forEach { language -> SingleSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val recentHistoryFiltered = languageHistory.filter {
it !in favoriteLanguages && it.code != "none" && it.code != "auto"
}.takeLast(5)
if (recentHistoryFiltered.isNotEmpty()) {
DropdownHeader(text = stringResource(R.string.text_recent_history))
recentHistoryFiltered.forEach { language -> SingleSelectItem(language) }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}
val remainingLanguages = languages.filter {
it !in favoriteLanguages && it !in recentHistoryFiltered && it.code != "none" && it.code != "auto"
}.sortedBy { it.name }
if (remainingLanguages.isNotEmpty()) {
DropdownHeader(text = stringResource(R.string.text_all_languages))
remainingLanguages.forEach { language -> SingleSelectItem(language) }
}
}
}
} }
if (enableMultipleSelection) {
HorizontalDivider()
AppButton(
onClick = {
onLanguagesSelected(tempSelection)
selectedLanguagesCount = tempSelection.size
expanded = false
searchText = ""
},
modifier = Modifier
.padding(8.dp)
.fillMaxWidth()
) {
Text(stringResource(R.string.label_done))
}
}
// Provides breathing room for system gestures at bottom of sheet
Spacer(modifier = Modifier.navigationBarsPadding().height(16.dp))
} }
} }
} }
@@ -400,7 +400,7 @@ fun TargetLanguageDropdown(
iconEnabled = iconEnabled, iconEnabled = iconEnabled,
noBorder = noBorder, noBorder = noBorder,
) )
} }
@Composable @Composable

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,13 +1,12 @@
@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
@@ -15,29 +14,30 @@ 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.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.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.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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.TagCategory import eu.gaudian.translator.model.TagCategory
import eu.gaudian.translator.model.VocabularyCategory import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyFilter
import eu.gaudian.translator.ui.theme.ThemePreviews import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.utils.findActivity
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
/** /**
@@ -49,22 +49,12 @@ data class CategoryDropdownState(
val selectedCategories: List<VocabularyCategory?> = emptyList(), val selectedCategories: List<VocabularyCategory?> = emptyList(),
val newCategoryName: String = "", val newCategoryName: String = "",
val categories: List<VocabularyCategory> = emptyList(), val categories: List<VocabularyCategory> = emptyList(),
val searchQuery: String = "",
) )
/** /**
* Stateless dropdown content composable for category selection. * Stateless dropdown content composable for category selection.
* This component is fully controlled by its parameters and does not maintain any internal state. * This component is fully controlled by its parameters and does not maintain any internal state.
*
* @param state The current state of the dropdown
* @param onExpand Callback when the dropdown should expand/collapse
* @param onCategorySelected Callback when a category is selected
* @param onNewCategoryNameChange Callback when the new category name changes
* @param onAddCategory Callback when a new category should be added
* @param noneSelectable Whether "None" option is selectable
* @param multipleSelectable Whether multiple categories can be selected
* @param onlyLists Whether to show only list/category types
* @param addCategory Whether to show the "Add Category" option
* @param modifier Modifier for the composable
*/ */
@Composable @Composable
fun CategoryDropdownContent( fun CategoryDropdownContent(
@@ -74,10 +64,12 @@ fun CategoryDropdownContent(
onCategorySelected: (List<VocabularyCategory?>) -> Unit, onCategorySelected: (List<VocabularyCategory?>) -> Unit,
onNewCategoryNameChange: (String) -> Unit, onNewCategoryNameChange: (String) -> Unit,
onAddCategory: (String) -> Unit, onAddCategory: (String) -> Unit,
onSearchQueryChange: (String) -> Unit = {},
noneSelectable: Boolean = true, 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 selectableCategories = if (onlyLists) { val selectableCategories = if (onlyLists) {
state.categories.filterIsInstance<TagCategory>() state.categories.filterIsInstance<TagCategory>()
@@ -85,37 +77,34 @@ fun CategoryDropdownContent(
state.categories state.categories
} }
AppOutlinedButton( // Filter categories by search query if search is enabled
shape = RoundedCornerShape(8.dp), val filteredCategories = if (enableSearch && state.searchQuery.isNotBlank()) {
onClick = { onExpand(true) }, selectableCategories.filter { category ->
modifier = modifier.fillMaxWidth(), category.name.contains(state.searchQuery, ignoreCase = true)
) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = when {
state.selectedCategories.isEmpty() -> stringResource(R.string.text_select_category)
state.selectedCategories.size == 1 -> state.selectedCategories.first()?.name
?: stringResource(R.string.text_none)
else -> stringResource(R.string.text_2d_categories_selected, state.selectedCategories.size)
},
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
Icon(
imageVector = if (state.expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown,
contentDescription = if (state.expanded) {
stringResource(R.string.cd_collapse)
} else {
stringResource(R.string.cd_expand)
}
)
} }
} else {
selectableCategories
} }
DropdownMenu( 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)
}
AppDropdownContainer(
expanded = state.expanded, expanded = state.expanded,
onDismissRequest = { onExpand(false) }, 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) }
) { ) {
if (noneSelectable) { if (noneSelectable) {
val noneSelected = state.selectedCategories.contains(null) val noneSelected = state.selectedCategories.contains(null)
@@ -128,7 +117,7 @@ fun CategoryDropdownContent(
if (multipleSelectable) { if (multipleSelectable) {
AppCheckbox( AppCheckbox(
checked = noneSelected, checked = noneSelected,
onCheckedChange = { isChecked -> onCheckedChange = { _ ->
val newSelection = if (noneSelected) { val newSelection = if (noneSelected) {
state.selectedCategories.filterNotNull() state.selectedCategories.filterNotNull()
} else { } else {
@@ -139,7 +128,10 @@ fun CategoryDropdownContent(
) )
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 = {
@@ -158,7 +150,7 @@ fun CategoryDropdownContent(
) )
} }
selectableCategories.forEach { category -> filteredCategories.forEach { category ->
val isSelected = state.selectedCategories.contains(category) val isSelected = state.selectedCategories.contains(category)
AppDropdownMenuItem( AppDropdownMenuItem(
text = { text = {
@@ -180,7 +172,10 @@ fun CategoryDropdownContent(
) )
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 = {
@@ -199,16 +194,24 @@ fun CategoryDropdownContent(
) )
} }
if (addCategory) { if (enableSearch && state.searchQuery.isNotBlank() && filteredCategories.isEmpty()) {
HorizontalDivider()
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 = {
@@ -222,7 +225,7 @@ fun CategoryDropdownContent(
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 (state.newCategoryName.isNotBlank()) { if (state.newCategoryName.isNotBlank()) {
@@ -241,59 +244,38 @@ fun CategoryDropdownContent(
onClick = {} onClick = {}
) )
} }
if (multipleSelectable) {
Spacer(modifier = Modifier.height(8.dp))
AppButton(
onClick = { onExpand(false) },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Text(stringResource(R.string.label_done))
}
}
} }
} }
/** /**
* Stateful wrapper for CategoryDropdown that manages its own state. * Stateful wrapper for CategoryDropdown that manages its own state.
* This is the main composable that should be used in production code.
*
* @param initialCategoryId The initial category ID to select
* @param onCategorySelected Callback when categories are selected
* @param noneSelectable Whether "None" option is selectable
* @param multipleSelectable Whether multiple categories can be selected
* @param onlyLists Whether to show only list/category types
* @param addCategory Whether to show the "Add Category" option
* @param modifier Modifier for the composable
*/ */
@Composable @Composable
fun CategoryDropdown( fun CategoryDropdown(
modifier: Modifier = Modifier,
initialCategoryId: Int? = null, initialCategoryId: Int? = null,
onCategorySelected: (List<VocabularyCategory?>) -> Unit, onCategorySelected: (List<VocabularyCategory?>) -> Unit,
noneSelectable: Boolean? = true, noneSelectable: Boolean? = true,
multipleSelectable: Boolean = false, multipleSelectable: Boolean = false,
onlyLists: Boolean = false, onlyLists: Boolean = false,
addCategory: Boolean = false, addCategory: Boolean = false,
modifier: Modifier = Modifier, enableSearch: Boolean = false,
) { ) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var selectedCategories by remember { var selectedCategories by remember {
mutableStateOf<List<VocabularyCategory?>>(emptyList()) mutableStateOf<List<VocabularyCategory?>>(emptyList())
} }
var newCategoryName by remember { mutableStateOf("") } var newCategoryName by remember { mutableStateOf("") }
var searchQuery by remember { mutableStateOf("") }
// For production use, this would come from ViewModel val activity = LocalContext.current.findActivity()
// For preview, we'll use empty list or pass via state val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categories by remember { mutableStateOf(emptyList<VocabularyCategory>()) } val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
// Find initial category
val initialCategory = remember(categories, initialCategoryId) { val initialCategory = remember(categories, initialCategoryId) {
categories.find { it.id == initialCategoryId } categories.find { it.id == initialCategoryId }
} }
// Initialize selection with initial category if provided
remember(initialCategory) { remember(initialCategory) {
if (initialCategory != null && selectedCategories.isEmpty()) { if (initialCategory != null && selectedCategories.isEmpty()) {
selectedCategories = listOf(initialCategory) selectedCategories = listOf(initialCategory)
@@ -307,6 +289,7 @@ fun CategoryDropdown(
selectedCategories = selectedCategories, selectedCategories = selectedCategories,
newCategoryName = newCategoryName, newCategoryName = newCategoryName,
categories = categories, categories = categories,
searchQuery = searchQuery,
), ),
onExpand = { isExpanded -> expanded = isExpanded }, onExpand = { isExpanded -> expanded = isExpanded },
onCategorySelected = { newSelection -> onCategorySelected = { newSelection ->
@@ -316,115 +299,35 @@ fun CategoryDropdown(
onNewCategoryNameChange = { newCategoryName = it }, onNewCategoryNameChange = { newCategoryName = it },
onAddCategory = { name -> onAddCategory = { name ->
val newCategory = TagCategory(id = 0, name = name) val newCategory = TagCategory(id = 0, name = name)
// In production, this would call ViewModel.createCategory(newCategory)
newCategoryName = "" newCategoryName = ""
categoryViewModel.createCategory(newCategory)
//selectedCategories = selectedCategories + newCategory
if (!multipleSelectable) { if (!multipleSelectable) {
expanded = false expanded = false
} }
}, },
onSearchQueryChange = { searchQuery = it },
noneSelectable = noneSelectable == true, noneSelectable = noneSelectable == true,
multipleSelectable = multipleSelectable, multipleSelectable = multipleSelectable,
onlyLists = onlyLists, onlyLists = onlyLists,
addCategory = addCategory, addCategory = addCategory,
enableSearch = enableSearch,
modifier = modifier, modifier = modifier,
) )
} }
// ============== PREVIEWS ==============
/**
* Preview provider for CategoryDropdownState
*/
@Suppress("HardCodedStringLiteral")
class CategoryDropdownStateProvider : PreviewParameterProvider<CategoryDropdownState> {
override val values = sequenceOf(
// Collapsed state - nothing selected
CategoryDropdownState(
expanded = false,
selectedCategories = emptyList(),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
VocabularyFilter(3, "Filters", languages = listOf(1, 2)),
)
),
// Collapsed state - one category selected
CategoryDropdownState(
expanded = false,
selectedCategories = listOf(TagCategory(1, "Animals")),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
)
),
// Collapsed state - multiple categories selected
CategoryDropdownState(
expanded = false,
selectedCategories = listOf(
TagCategory(1, "Animals"),
TagCategory(3, "Travel"),
),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
)
),
// Expanded state - nothing selected
CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
)
),
// Expanded state - one selected
CategoryDropdownState(
expanded = true,
selectedCategories = listOf(TagCategory(2, "Food")),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
)
),
// With "None" option selected
CategoryDropdownState(
expanded = true,
selectedCategories = listOf(null),
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
)
),
// With add category option
CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
newCategoryName = "New Cat",
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
)
),
)
}
@ThemePreviews @ThemePreviews
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun CategoryDropdownCollapsedPreview( fun CategoryDropdownCollapsedPreview() {
@PreviewParameter(CategoryDropdownStateProvider::class) state: CategoryDropdownState Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
) {
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent( CategoryDropdownContent(
state = state.copy(expanded = false), state = CategoryDropdownState(
expanded = false,
selectedCategories = emptyList(),
categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food")),
),
onExpand = {}, onExpand = {},
onCategorySelected = {}, onCategorySelected = {},
onNewCategoryNameChange = {}, onNewCategoryNameChange = {},
@@ -436,15 +339,14 @@ fun CategoryDropdownCollapsedPreview(
@ThemePreviews @ThemePreviews
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun CategoryDropdownExpandedPreview( fun CategoryDropdownExpandedPreview() {
@PreviewParameter(CategoryDropdownStateProvider::class) state: CategoryDropdownState Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
) {
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent( CategoryDropdownContent(
state = state.copy(expanded = true), state = CategoryDropdownState(
expanded = true,
selectedCategories = listOf(TagCategory(1, "Animals")),
categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food"), TagCategory(3, "Travel")),
),
onExpand = {}, onExpand = {},
onCategorySelected = {}, onCategorySelected = {},
onNewCategoryNameChange = {}, onNewCategoryNameChange = {},
@@ -458,21 +360,10 @@ fun CategoryDropdownExpandedPreview(
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun CategoryDropdownMultipleSelectionPreview() { fun CategoryDropdownMultipleSelectionPreview() {
val categories = listOf( val categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food"), TagCategory(3, "Travel"))
TagCategory(1, "Animals"), var selectedCategories by remember { mutableStateOf<List<VocabularyCategory?>>(listOf(categories[0], categories[2])) }
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
TagCategory(4, "Business"),
TagCategory(5, "Technology"),
)
var selectedCategories by remember {
mutableStateOf<List<VocabularyCategory?>>(listOf(categories[0], categories[2]))
}
Surface( Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent( CategoryDropdownContent(
state = CategoryDropdownState( state = CategoryDropdownState(
expanded = true, expanded = true,
@@ -494,26 +385,17 @@ fun CategoryDropdownMultipleSelectionPreview() {
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun CategoryDropdownWithAddCategoryPreview() { fun CategoryDropdownWithAddCategoryPreview() {
val categories = listOf( Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
)
var newCategoryName by remember { mutableStateOf("New Category") }
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent( CategoryDropdownContent(
state = CategoryDropdownState( state = CategoryDropdownState(
expanded = true, expanded = true,
selectedCategories = emptyList(), selectedCategories = emptyList(),
newCategoryName = newCategoryName, newCategoryName = "New Cat",
categories = categories, categories = listOf(TagCategory(1, "Animals"), TagCategory(2, "Food")),
), ),
onExpand = {}, onExpand = {},
onCategorySelected = {}, onCategorySelected = {},
onNewCategoryNameChange = { newCategoryName = it }, onNewCategoryNameChange = {},
onAddCategory = {}, onAddCategory = {},
addCategory = true, addCategory = true,
) )
@@ -524,127 +406,8 @@ fun CategoryDropdownWithAddCategoryPreview() {
@ThemePreviews @ThemePreviews
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun CategoryDropdownOnlyListsPreview() { fun CategoryDropdownWithSearchPreview() {
val categories = listOf( Surface(modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.background) {
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
VocabularyFilter(3, "Language Pair EN-DE", languages = listOf(1, 2)),
TagCategory(4, "Travel"),
)
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
categories = categories,
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
onlyLists = true,
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownNoNoneOptionPreview() {
val categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
)
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = true,
selectedCategories = emptyList(),
categories = categories,
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
noneSelectable = false,
)
}
}
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownEmptyPreview() {
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = false,
selectedCategories = emptyList(),
categories = emptyList(),
),
onExpand = {},
onCategorySelected = {},
onNewCategoryNameChange = {},
onAddCategory = {},
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownStatefulPreview() {
var expanded by remember { mutableStateOf(true) }
var selectedCategories by remember {
mutableStateOf<List<VocabularyCategory?>>(listOf(TagCategory(1, "Animals")))
}
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent(
state = CategoryDropdownState(
expanded = expanded,
selectedCategories = selectedCategories,
categories = listOf(
TagCategory(1, "Animals"),
TagCategory(2, "Food"),
TagCategory(3, "Travel"),
),
),
onExpand = { expanded = it },
onCategorySelected = { selectedCategories = it },
onNewCategoryNameChange = {},
onAddCategory = {},
multipleSelectable = true,
noneSelectable = true,
)
}
}
@Suppress("HardCodedStringLiteral")
@ThemePreviews
@Preview(showBackground = true)
@Composable
fun CategoryDropdownFullExpandedPreview() {
Surface(
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
CategoryDropdownContent( CategoryDropdownContent(
state = CategoryDropdownState( state = CategoryDropdownState(
expanded = true, expanded = true,
@@ -653,20 +416,16 @@ fun CategoryDropdownFullExpandedPreview() {
TagCategory(1, "Animals"), TagCategory(1, "Animals"),
TagCategory(2, "Food"), TagCategory(2, "Food"),
TagCategory(3, "Travel"), TagCategory(3, "Travel"),
TagCategory(4, "Business"), TagCategory(4, "Technology"),
TagCategory(5, "Technology"), TagCategory(5, "Sports")
TagCategory(6, "Sports"),
TagCategory(7, "Music"),
TagCategory(8, "Art"),
), ),
searchQuery = "",
), ),
onExpand = {}, onExpand = {},
onCategorySelected = {}, onCategorySelected = {},
onNewCategoryNameChange = {}, onNewCategoryNameChange = {},
onAddCategory = {}, onAddCategory = {},
addCategory = true, enableSearch = true,
multipleSelectable = true,
noneSelectable = true,
) )
} }
} }

View File

@@ -8,9 +8,6 @@ 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
import androidx.compose.runtime.getValue 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.platform.LocalContext
@@ -18,7 +15,6 @@ 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 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.VocabularyCategory import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppDialog import eu.gaudian.translator.view.composable.AppDialog
@@ -34,9 +30,6 @@ fun CategorySelectionDialog(
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity) val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categories by categoryViewModel.categories.collectAsState(initial = emptyList()) val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory?>>(emptyList()) }
var newCategoryName by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
AppDialog( AppDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@@ -45,21 +38,8 @@ fun CategorySelectionDialog(
} }
) { ) {
// Dropdown button and menu // Dropdown button and menu
CategoryDropdownContent( CategoryDropdown(
state = CategoryDropdownState( onCategorySelected = onCategorySelected,
expanded = expanded,
selectedCategories = selectedCategories,
newCategoryName = newCategoryName,
categories = categories,
),
onExpand = { isExpanded -> expanded = isExpanded },
onCategorySelected = { selectedCategories = it },
onNewCategoryNameChange = { newCategoryName = it },
onAddCategory = { name ->
val newCategory = TagCategory(id = 0, name = name.trim())
categoryViewModel.createCategory(newCategory)
newCategoryName = ""
},
noneSelectable = false, noneSelectable = false,
multipleSelectable = true, multipleSelectable = true,
onlyLists = true, onlyLists = true,
@@ -79,10 +59,11 @@ fun CategorySelectionDialog(
DialogButton( DialogButton(
onClick = { onClick = {
onCategorySelected(selectedCategories) // The selected categories are handled by CategoryDropdown's internal state
// and passed to onCategorySelected callback
onDismissRequest() onDismissRequest()
}, },
enabled = selectedCategories.isNotEmpty() enabled = true // Always enabled since CategoryDropdown handles validation
) { ) {
Text(stringResource(R.string.label_confirm)) Text(stringResource(R.string.label_confirm))
} }

View File

@@ -44,7 +44,7 @@ import eu.gaudian.translator.view.composable.DialogButton
import eu.gaudian.translator.view.composable.InspiringSearchField import eu.gaudian.translator.view.composable.InspiringSearchField
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.view.hints.ImportVocabularyHint import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -109,7 +109,7 @@ fun ImportDialogContent(
AppDialog( AppDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(descriptionText) }, title = { Text(descriptionText) },
hintContent = { ImportVocabularyHint() }, hintContent = HintDefinition.IMPORT.hint(),
content = { content = {
Column( Column(
modifier = Modifier modifier = Modifier

View File

@@ -55,9 +55,8 @@ fun StartExerciseDialog(
// Map displayed Language to its DB id (lid) using position mapping from load // Map displayed Language to its DB id (lid) using position mapping from load
var languageIdMap by remember { mutableStateOf<Map<Language, Int>>(emptyMap()) } var languageIdMap by remember { mutableStateOf<Map<Language, Int>>(emptyMap()) }
var selectedLanguages by remember { mutableStateOf<List<Language>>(emptyList()) } var selectedLanguages by remember { mutableStateOf<List<Language>>(emptyList()) }
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory>>(emptyList()) }
var selectedStages by remember { mutableStateOf<List<VocabularyStage>>(emptyList()) } var selectedStages by remember { mutableStateOf<List<VocabularyStage>>(emptyList()) }
var expanded by remember { mutableStateOf(false) } var selectedCategories by remember { mutableStateOf<List<VocabularyCategory>>(emptyList()) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
coroutineScope.launch { coroutineScope.launch {
@@ -87,19 +86,10 @@ fun StartExerciseDialog(
}, },
languages languages
) )
CategoryDropdownContent( CategoryDropdown(
state = CategoryDropdownState(
expanded = expanded,
selectedCategories = selectedCategories,
newCategoryName = "",
categories = categories,
),
onExpand = { isExpanded -> expanded = isExpanded },
onCategorySelected = { cats -> onCategorySelected = { cats ->
selectedCategories = cats.filterIsInstance<VocabularyCategory>() selectedCategories = cats.filterIsInstance<VocabularyCategory>()
}, },
onNewCategoryNameChange = {},
onAddCategory = {},
multipleSelectable = true, multipleSelectable = true,
onlyLists = false, // Show both filters and lists onlyLists = false, // Show both filters and lists
addCategory = false, addCategory = false,

View File

@@ -27,7 +27,6 @@ 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 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.VocabularyCategory import eu.gaudian.translator.model.VocabularyCategory
import eu.gaudian.translator.model.VocabularyItem import eu.gaudian.translator.model.VocabularyItem
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
@@ -35,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.CategoryViewModel import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
@@ -54,8 +53,6 @@ fun VocabularyReviewScreen(
val selectedItems = remember { mutableStateListOf<VocabularyItem>() } val selectedItems = remember { mutableStateListOf<VocabularyItem>() }
val duplicates = remember { mutableStateListOf<Boolean>() } val duplicates = remember { mutableStateListOf<Boolean>() }
var selectedCategories by remember { mutableStateOf<List<VocabularyCategory?>>(emptyList()) } var selectedCategories by remember { mutableStateOf<List<VocabularyCategory?>>(emptyList()) }
var newCategoryName by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(generatedItems) { LaunchedEffect(generatedItems) {
val duplicateResults = vocabularyViewModel.findDuplicates(generatedItems) val duplicateResults = vocabularyViewModel.findDuplicates(generatedItems)
@@ -69,7 +66,7 @@ fun VocabularyReviewScreen(
topBar = { topBar = {
AppTopAppBar( AppTopAppBar(
title = { Text(stringResource(R.string.found_items)) }, title = { Text(stringResource(R.string.found_items)) },
hintContent = getVocabularyReviewHint() hintContent = HintDefinition.REVIEW.hint()
) )
}, },
) { paddingValues -> ) { paddingValues ->
@@ -134,21 +131,8 @@ fun VocabularyReviewScreen(
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(8.dp) modifier = Modifier.padding(8.dp)
) )
CategoryDropdownContent( CategoryDropdown(
state = CategoryDropdownState(
expanded = expanded,
selectedCategories = selectedCategories,
newCategoryName = newCategoryName,
categories = categories,
),
onExpand = { isExpanded -> expanded = isExpanded },
onCategorySelected = { selectedCategories = it }, onCategorySelected = { selectedCategories = it },
onNewCategoryNameChange = { newCategoryName = it },
onAddCategory = { name ->
val newCategory = TagCategory(id = 0, name = name.trim())
categoryViewModel.createCategory(newCategory)
newCategoryName = ""
},
noneSelectable = false, noneSelectable = false,
multipleSelectable = true, multipleSelectable = true,
onlyLists = true, onlyLists = true,

View File

@@ -1,15 +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.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
@@ -20,16 +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.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.AppOutlinedButton
@Composable @Composable
@@ -44,117 +37,103 @@ 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 {
selectedStages.isEmpty() -> stringResource(R.string.label_select_stage)
selectedStages.size == 1 -> selectedStages.first()?.toString(context) ?: stringResource(R.string.text_none)
else -> stringResource(R.string.stages_selected, selectedStages.size)
}
AppDropdownContainer(
expanded = expanded,
onDismissRequest = { expanded = false },
onExpandRequest = { expanded = true },
buttonText = buttonText,
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.CenterEnd showDoneButton = multipleSelectable,
onDoneClick = { expanded = false }
) { ) {
AppOutlinedButton( if (noneSelectable == true) {
shape = RoundedCornerShape(8.dp), val noneSelected = selectedStages.contains(null)
onClick = { expanded = true }, AppDropdownMenuItem(
modifier = Modifier.fillMaxWidth(), text = {
) { Row(
Row(modifier = Modifier.fillMaxWidth()) { modifier = Modifier.fillMaxWidth(),
Text(text = when { verticalAlignment = Alignment.CenterVertically
selectedStages.isEmpty() -> stringResource(R.string.label_select_stage) ) {
selectedStages.size == 1 -> selectedStages.first()?.toString(context)?:stringResource(R.string.text_none) if (multipleSelectable) {
else -> stringResource(R.string.stages_selected, selectedStages.size) AppCheckbox(
checked = noneSelected,
onCheckedChange = {
selectedStages = if (noneSelected) {
selectedStages.filterNotNull()
} else {
selectedStages + listOf(null)
}
onStageSelected(selectedStages)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = stringResource(R.string.text_none))
}
}, },
textAlign = TextAlign.Center, onClick = {
modifier = Modifier.weight(1f) if (multipleSelectable) {
) selectedStages = if (null in selectedStages) {
Icon( selectedStages.filterNotNull()
imageVector = if (expanded) AppIcons.ArrowDropUp else AppIcons.ArrowDropDown, } else {
contentDescription = if (expanded) stringResource(R.string.cd_collapse) else stringResource(R.string.cd_expand) selectedStages + listOf(null)
) }
} onStageSelected(selectedStages)
} else {
selectedStages = listOf(null)
onStageSelected(selectedStages)
expanded = false
}
}
)
} }
DropdownMenu( VocabularyStage.entries.forEach { stage ->
expanded = expanded, val isSelected = selectedStages.contains(stage)
onDismissRequest = { expanded = false }, AppDropdownMenuItem(
modifier = Modifier.fillMaxWidth() text = {
) { Row(
if (noneSelectable == true) { modifier = Modifier.fillMaxWidth(),
val noneSelected = selectedStages.contains(null) verticalAlignment = Alignment.CenterVertically
AppDropdownMenuItem( ) {
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (multipleSelectable) {
AppCheckbox(
checked = noneSelected,
onCheckedChange = {
selectedStages = if (noneSelected) selectedStages.filterNotNull() else selectedStages + listOf(null)
onStageSelected(selectedStages)
}
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(text = stringResource(R.string.text_none))
}
},
onClick = {
if (multipleSelectable) { if (multipleSelectable) {
selectedStages = if (null in selectedStages) { AppCheckbox(
selectedStages.filterNotNull() checked = isSelected,
} else { onCheckedChange = {
selectedStages + listOf(null) selectedStages = if (isSelected) {
} selectedStages - stage
onStageSelected(selectedStages) } else {
} else { selectedStages + stage
selectedStages = listOf(null)
onStageSelected(selectedStages)
expanded = false
}
}
)
}
VocabularyStage.entries.forEach { stage ->
val isSelected = selectedStages.contains(stage)
AppDropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (multipleSelectable) {
AppCheckbox(
checked = isSelected,
onCheckedChange = {
selectedStages = if (isSelected) selectedStages - stage else selectedStages + stage
onStageSelected(selectedStages)
} }
) onStageSelected(selectedStages)
Spacer(modifier = Modifier.width(8.dp)) }
} )
Text(stage.toString(context)) Spacer(modifier = Modifier.width(8.dp))
}
},
onClick = {
if (multipleSelectable) {
selectedStages = if (stage in selectedStages) {
selectedStages - stage
} else {
selectedStages + stage
}
onStageSelected(selectedStages)
} else {
selectedStages = listOf(stage)
onStageSelected(selectedStages)
expanded = false
} }
Text(stage.toString(context))
}
},
onClick = {
if (multipleSelectable) {
selectedStages = if (stage in selectedStages) {
selectedStages - stage
} else {
selectedStages + stage
}
onStageSelected(selectedStages)
} else {
selectedStages = listOf(stage)
onStageSelected(selectedStages)
expanded = false
} }
)
}
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
@@ -67,6 +67,8 @@ 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.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
@@ -415,6 +417,7 @@ fun CorrectionScreenContent(
} }
} }
@Composable @Composable
private fun ToneDropdown( private fun ToneDropdown(
selectedTone: CorrectionViewModel.Tone, selectedTone: CorrectionViewModel.Tone,
@@ -447,39 +450,59 @@ private fun ToneDropdown(
} }
} }
DropdownMenu( if (expanded) {
expanded = expanded, val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
onDismissRequest = { expanded = false },
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)) ModalBottomSheet(
) { onDismissRequest = { expanded = false },
DropdownMenuItem( sheetState = sheetState,
text = { Text(text = stringResource(R.string.text_none)) }, containerColor = DropdownDefaults.containerColor()
onClick = { ) {
onToneSelected(CorrectionViewModel.Tone.NONE) Column(
expanded = false modifier = Modifier
}, .fillMaxWidth()
enabled = enabled .verticalScroll(rememberScrollState())
) .padding(vertical = 8.dp) // Gives breathing room at top/bottom of list
HorizontalDivider() ) {
val options = listOf( // Replaced with LargeDropdownMenuItem
CorrectionViewModel.Tone.FORMAL, LargeDropdownMenuItem(
CorrectionViewModel.Tone.CASUAL, text = stringResource(R.string.text_none),
CorrectionViewModel.Tone.COLLOQUIAL, selected = selectedTone == CorrectionViewModel.Tone.NONE,
CorrectionViewModel.Tone.POLITE, enabled = enabled,
CorrectionViewModel.Tone.PROFESSIONAL, onClick = {
CorrectionViewModel.Tone.FRIENDLY, onToneSelected(CorrectionViewModel.Tone.NONE)
CorrectionViewModel.Tone.ACADEMIC, expanded = false
CorrectionViewModel.Tone.CREATIVE }
) )
options.forEach { tone ->
DropdownMenuItem( HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
text = { Text(text = labelFor(tone)) },
onClick = { val options = listOf(
onToneSelected(tone) CorrectionViewModel.Tone.FORMAL,
expanded = false CorrectionViewModel.Tone.CASUAL,
}, CorrectionViewModel.Tone.COLLOQUIAL,
enabled = enabled CorrectionViewModel.Tone.POLITE,
) CorrectionViewModel.Tone.PROFESSIONAL,
CorrectionViewModel.Tone.FRIENDLY,
CorrectionViewModel.Tone.ACADEMIC,
CorrectionViewModel.Tone.CREATIVE
)
options.forEach { tone ->
// Replaced with LargeDropdownMenuItem
LargeDropdownMenuItem(
text = labelFor(tone),
selected = selectedTone == tone,
enabled = enabled,
onClick = {
onToneSelected(tone)
expanded = false
}
)
}
Spacer(modifier = Modifier.navigationBarsPadding().height(16.dp))
}
} }
} }
} }

View File

@@ -1,30 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import eu.gaudian.translator.R
/**
* Migrated AddModelScanHint using markdown-based format.
* Content is loaded from localized assets/hints based on device locale.
*/
@Composable
fun getAddModelScanHint(): Hint {
return Hint(
titleRes = R.string.hint_scan_hint_title,
elements = listOf(
HintElement.LocalizedMarkdown("example_hint")
)
)
}
@Preview
@Composable
fun AddModelScanHintPreview() {
getAddModelScanHint().Render()
}
@Composable
fun AddModelScanHint() {
getAddModelScanHint().Render()
}

View File

@@ -0,0 +1,55 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.hints
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import eu.gaudian.translator.R
/**
* Hint metadata mapping Markdown filenames to their string resource titles.
* All hint-related operations are available as functions on each enum entry.
*/
@Suppress("HardCodedStringLiteral")
enum class HintDefinition(
val markdownFile: String,
val titleRes: Int
) {
ADD_MODEL_SCAN("find_ai_model", R.string.hint_scan_hint_title),
API_KEY("api_key_hint", R.string.hint_how_to_connect_to_an_ai),
CATEGORY("category_hint", R.string.category_hint_intro),
DICTIONARY_OPTIONS("dictionary_hint", R.string.label_dictionary_options),
EXERCISE("exercise_hint", R.string.label_exercise),
IMPORT("import_hint", R.string.hint_how_to_generate_vocabulary_with_ai),
LEARNING_STAGES("learning_stages_hint", R.string.learning_stages_title),
REVIEW("review_hint", R.string.review_intro),
SORTING("sorting_hint", R.string.sorting_hint_title),
TRANSLATION("translation_hint", R.string.hint_translate_how_it_works),
VOCABULARY_PROGRESS("vocabulary_progress_hint", R.string.hint_vocabulary_progress_hint_title);
/** Creates the Hint data class for this hint definition. */
@Composable
fun hint() = Hint(titleRes = titleRes, elements = listOf(HintElement.LocalizedMarkdown(markdownFile)))
/** Renders this hint's content. */
@Composable
fun Render() = hint().Render()
}
@Composable
fun hint(definition: HintDefinition): Hint = definition.hint()
@Composable fun HintContent(definition: HintDefinition) = definition.Render()
@Composable fun HintScreen(navController: NavController, definition: HintDefinition) = HintScreen(
navController = navController,
title = stringResource(definition.titleRes),
content = { definition.Render() }
)

View File

@@ -1,33 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import eu.gaudian.translator.R
/**
* Migrated API Key hint using markdown-based format.
* Content is loaded from localized assets/hints based on device locale.
*/
@Composable
fun getApiKeyHint() = Hint (
titleRes = R.string.hint_how_to_connect_to_an_ai,
elements = listOf(
HintElement.LocalizedMarkdown("api_key_hint")
)
)
@Composable
fun ApiKeyHint() {
getApiKeyHint().Render()
}
@Preview
@Composable
fun ApiKeyHintPreview() {
MaterialTheme {
ApiKeyHint()
}
}

View File

@@ -1,33 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import eu.gaudian.translator.R
/**
* Migrated Category hint using markdown-based format.
* Content is loaded from localized assets/hints based on device locale.
*/
@Composable
fun getCategoryHint(): Hint {
return Hint(
titleRes = R.string.category_hint_intro,
elements = listOf(
HintElement.LocalizedMarkdown("category_hint")
)
)
}
@Composable
fun CategoryHint() {
getCategoryHint().Render()
}
@Preview
@Composable
fun CategoryHintPreview() {
MaterialTheme {
CategoryHint()
}
}

View File

@@ -1,33 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import eu.gaudian.translator.R
/**
* Migrated Category Hint Screen using markdown-based format.
* Content is loaded from localized assets/hints based on device locale.
*/
@Composable
fun getCategoryHintScreen(): Hint {
return Hint(
titleRes = R.string.category_hint_intro,
elements = listOf(
HintElement.LocalizedMarkdown("category_hint")
)
)
}
@Composable
fun CategoryHintScreen() {
getCategoryHintScreen().Render()
}
@Preview
@Composable
fun CategoryHintScreenPreview() {
MaterialTheme {
CategoryHintScreen()
}
}

View File

@@ -1,25 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import eu.gaudian.translator.R
/**
* Migrated DictionaryOptionsHint using markdown-based format.
* Content is loaded from localized assets/hints based on device locale.
*/
@Composable
fun getDictionaryOptionsHint(): Hint {
return Hint(
titleRes = R.string.label_dictionary_options,
elements = listOf(
HintElement.LocalizedMarkdown("dictionary_hint")
)
)
}
@Preview
@Composable
fun DictionaryOptionsHint() {
getDictionaryOptionsHint().Render()
}

View File

@@ -1,145 +0,0 @@
package eu.gaudian.translator.view.hints
import android.content.Context
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import eu.gaudian.translator.R
/**
* Example of a migrated hint using the new markdown-based approach.
*
* This demonstrates how to migrate from the old LegacyHint format to the new
* markdown-based format.
*
* Benefits:
* - Easier to manage and translate (no code changes needed)
* - Better separation of concerns
* - Consistent styling across all hints
* - Support for rich formatting (tables, code blocks, etc.)
*/
/**
* Example 1: Loading markdown content from string (simple).
*/
@Composable
fun getApiKeyMarkdownHint(): String {
return """
# How to Connect to an AI Model
This guide explains how to connect your app to an AI model using an API key.
## Getting Started
To use AI models in your app, you need to provide a valid API key.
> **Note:** Keep your API key secure and never share it publicly.
## Key Status Indicators
| Status | Icon | Meaning |
|--------|------|---------|
| Active | ✅ | Key is valid and working |
| Missing | ⚠️ | Key is not set |
## Troubleshooting
1. Verify the key is correct
2. Ensure proper permissions
3. Check your quota
```json
{
"api_key": "sk-xxxxxxxxxxxx"
}
```
""".trimIndent()
}
@Composable
fun ApiKeyMarkdownHint() {
val content = getApiKeyMarkdownHint()
MarkdownHint(
markdownContent = content,
title = stringResource(R.string.hint_how_to_connect_to_an_ai)
)
}
/**
* Example 2: Loading markdown from assets file (recommended for production).
*/
@Composable
fun loadMarkdownFromAssets(fileName: String): String {
val context = LocalContext.current
return try {
context.assets.open("hints/$fileName").bufferedReader().use { it.readText() }
} catch (e: Exception) {
"Error loading markdown file: ${e.message}"
}
}
/**
* Data class for programmatic hint loading.
*/
data class MarkdownHintDefinition(
val fileName: String,
val titleRes: Int
) {
fun loadContent(context: Context): String {
return context.assets.open("hints/$fileName").bufferedReader().use { it.readText() }
}
}
/**
* Pre-defined hints ready for migration.
*/
object MarkdownHints {
val API_KEY = MarkdownHintDefinition(
fileName = "api_key_hint.md",
titleRes = R.string.hint_how_to_connect_to_an_ai
)
val CATEGORY = MarkdownHintDefinition(
fileName = "category_hint.md",
titleRes = R.string.category_hint_intro
)
val LEARNING_STAGES = MarkdownHintDefinition(
fileName = "learning_stages_hint.md",
titleRes = R.string.learning_stages_title
)
val SORTING = MarkdownHintDefinition(
fileName = "sorting_hint.md",
titleRes = R.string.sorting_hint_title
)
val VOCABULARY_PROGRESS = MarkdownHintDefinition(
fileName = "vocabulary_progress_hint.md",
titleRes = R.string.hint_vocabulary_progress_hint_title
)
}
/**
* Preview for the migrated API Key hint.
*/
@Preview
@Composable
fun ApiKeyMarkdownHintPreview() {
MaterialTheme {
ApiKeyMarkdownHint()
}
}
/**
* Preview for loading from assets.
*/
@Preview
@Composable
fun LoadFromAssetsPreview() {
MaterialTheme {
val content = loadMarkdownFromAssets("example_hint.md")
MarkdownHint(
markdownContent = content,
title = stringResource(R.string.hint_title_hints_overview)
)
}
}

View File

@@ -1,7 +0,0 @@
package eu.gaudian.translator.view.hints
/**
* This file is kept for reference only.
* All hints are now migrated to markdown-based format.
* See individual hint files for implementations.
*/

View File

@@ -1,3 +1,5 @@
@file:Suppress("HardCodedStringLiteral")
package eu.gaudian.translator.view.hints package eu.gaudian.translator.view.hints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -9,9 +11,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.jeziellago.compose.markdowntext.MarkdownText import dev.jeziellago.compose.markdowntext.MarkdownText
import eu.gaudian.translator.utils.Log
private const val TAG = "MarkdownHint" private const val TAG = "MarkdownHint"
@@ -25,7 +27,7 @@ sealed class HintElement {
data class UIElement(val composable: @Composable () -> Unit) : HintElement() data class UIElement(val composable: @Composable () -> Unit) : HintElement()
/** /**
* A localized markdown file element. * A localized Markdown file element.
* The file is loaded from assets based on the current device locale. * The file is loaded from assets based on the current device locale.
* Follows Android's locale-qualified resource pattern: * Follows Android's locale-qualified resource pattern:
* - assets/hints/ - Default (English) * - assets/hints/ - Default (English)
@@ -56,7 +58,7 @@ fun RenderHintElement(element: HintElement) {
androidx.compose.foundation.layout.Row( androidx.compose.foundation.layout.Row(
modifier = Modifier.padding(top = 4.dp) modifier = Modifier.padding(top = 4.dp)
) { ) {
androidx.compose.material3.Text( Text(
text = "[DEBUG: ${element.fileName}]", text = "[DEBUG: ${element.fileName}]",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
@@ -68,7 +70,7 @@ fun RenderHintElement(element: HintElement) {
} }
/** /**
* Composable to render localized markdown content. * Composable to render localized Markdown content.
* Automatically loads the correct locale version based on device settings. * Automatically loads the correct locale version based on device settings.
* Falls back to English default if localized version is not available. * Falls back to English default if localized version is not available.
*/ */
@@ -85,23 +87,23 @@ fun LocalizedMarkdownContent(
// Try localized version (folder has suffix, filename doesn't) // Try localized version (folder has suffix, filename doesn't)
val localizedPath = "hints$suffix/$fileName.md" val localizedPath = "hints$suffix/$fileName.md"
android.util.Log.d(TAG, "Loading hint: $fileName") Log.d(TAG, "Loading hint: $fileName")
android.util.Log.d(TAG, "Device locale: ${locale.language}_${locale.country}") Log.d(TAG, "Device locale: ${locale.language}_${locale.country}")
android.util.Log.d(TAG, "Localized path: $localizedPath") Log.d(TAG, "Localized path: $localizedPath")
val localized = MarkdownHintLoader.loadFromAssets(context, localizedPath) val localized = MarkdownHintLoader.loadFromAssets(context, localizedPath)
if (localized != null) { if (localized != null) {
android.util.Log.d(TAG, "Found localized version at: $localizedPath") Log.d(TAG, "Found localized version at: $localizedPath")
localized localized
} else { } else {
// Fall back to English default in hints folder // Fall back to English default in hints folder
val defaultPath = "hints/$fileName.md" val defaultPath = "hints/$fileName.md"
android.util.Log.d(TAG, "Localized not found, trying default: $defaultPath") Log.d(TAG, "Localized not found, trying default: $defaultPath")
val default = MarkdownHintLoader.loadFromAssets(context, defaultPath) val default = MarkdownHintLoader.loadFromAssets(context, defaultPath)
if (default != null) { if (default != null) {
android.util.Log.d(TAG, "Found default version at: $defaultPath") Log.d(TAG, "Found default version at: $defaultPath")
} else { } else {
android.util.Log.e(TAG, "No hint found for: $fileName (tried: $localizedPath, $defaultPath)") Log.e(TAG, "No hint found for: $fileName (tried: $localizedPath, $defaultPath)")
} }
default default
} }
@@ -125,22 +127,3 @@ fun LocalizedMarkdownContent(
} }
@Suppress("HardCodedStringLiteral")
@Preview
@Composable
fun UIElementPreview() {
RenderHintElement(
HintElement.UIElement {
Text("Custom UI Element")
}
)
}
@Suppress("HardCodedStringLiteral")
@Preview
@Composable
fun LocalizedMarkdownElementPreview() {
RenderHintElement(
HintElement.LocalizedMarkdown("example_hint")
)
}

View File

@@ -1,123 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import eu.gaudian.translator.R
/**
* Wrapper for Category Hint Screen
*/
@Composable
fun CategoryHintScreenWrapper(navController: NavController) {
HintScreen(
navController = navController,
title = stringResource(R.string.category_hint_intro)
) {
CategoryHint()
}
}
/**
* Dictionary Hint Screen
*/
@Composable
fun DictionaryHintScreen(navController: NavController) {
HintScreen(
navController = navController,
title = stringResource(R.string.label_dictionary_options)
) {
getDictionaryOptionsHint().Render()
}
}
/**
* Import Hint Screen
*/
@Composable
fun ImportHintScreen(navController: NavController) {
HintScreen(
navController = navController,
title = stringResource(R.string.hint_how_to_generate_vocabulary_with_ai)
) {
getImportVocabularyHint()
}
}
/**
* Sorting Hint Screen
*/
@Composable
fun SortingHintScreen(navController: NavController) {
HintScreen(
navController = navController,
title = stringResource(R.string.sorting_hint_title)
) {
SortingScreenHint()
}
}
/**
* Stages Hint Screen
*/
@Composable
fun StagesHintScreen(navController: NavController) {
HintScreen(
navController = navController,
title = stringResource(R.string.learning_stages_title)
) {
LearningStagesHint()
}
}
/**
* Translation Hint Screen
*/
@Composable
fun TranslationHintScreen(navController: NavController) {
HintScreen(
navController = navController,
title = stringResource(R.string.hint_translate_how_it_works)
) {
getTranslationScreenHint().Render()
}
}
/**
* Scan Hint Screen
*/
@Composable
fun ScanHintScreen(navController: NavController) {
HintScreen(
navController = navController,
title = stringResource(R.string.hint_scan_hint_title)
) {
AddModelScanHint()
}
}
/**
* API Hint Screen
*/
@Composable
fun ApiHintScreen(navController: NavController) {
HintScreen(
navController = navController,
title = stringResource(R.string.hint_how_to_connect_to_an_ai)
) {
ApiKeyHint()
}
}
/**
* Vocabulary Progress Hint Screen
*/
@Composable
fun VocabularyProgressHintScreen(navController: NavController) {
HintScreen(
navController = navController,
title = stringResource(R.string.hint_vocabulary_progress_hint_title)
) {
VocabularyProgressHint()
}
}

View File

@@ -18,9 +18,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
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.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.ui.theme.ThemePreviews
import eu.gaudian.translator.view.LocalShowExperimentalFeatures import eu.gaudian.translator.view.LocalShowExperimentalFeatures
import eu.gaudian.translator.view.composable.AppCard import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppIcons
@@ -42,15 +40,16 @@ fun HintsOverviewScreen(
val showExperimental = LocalShowExperimentalFeatures.current val showExperimental = LocalShowExperimentalFeatures.current
// Get hints using the new function-based approach // Get hints using the new function-based approach
val importHint = getImportVocabularyHint() val importHint = HintDefinition.IMPORT.hint()
val addModelScanHint = getAddModelScanHint() val addModelScanHint = HintDefinition.ADD_MODEL_SCAN.hint()
val dictionaryOptionsHint = getDictionaryOptionsHint() val dictionaryOptionsHint = HintDefinition.DICTIONARY_OPTIONS.hint()
val translationScreenHint = getTranslationScreenHint() val translationScreenHint = HintDefinition.TRANSLATION.hint()
val categoryHint = getCategoryHint() val categoryHint = HintDefinition.CATEGORY.hint()
val learningStagesHint = getLearningStagesHint() val learningStagesHint = HintDefinition.LEARNING_STAGES.hint()
val sortingScreenHint = getSortingScreenHint() val sortingScreenHint = HintDefinition.SORTING.hint()
val vocabularyProgressHint = getVocabularyProgressHint() val vocabularyProgressHint = HintDefinition.VOCABULARY_PROGRESS.hint()
val apiKeyHint = getApiKeyHint() val apiKeyHint = HintDefinition.API_KEY.hint()
val hintGroups = remember(showExperimental, importHint, addModelScanHint, dictionaryOptionsHint, translationScreenHint) { val hintGroups = remember(showExperimental, importHint, addModelScanHint, dictionaryOptionsHint, translationScreenHint) {
val allGroups = listOf( val allGroups = listOf(
@@ -133,11 +132,7 @@ fun HintsOverviewScreen(
} }
} }
@ThemePreviews
@Composable
fun HintsOverviewScreenPreview() {
HintsOverviewScreen(navController = rememberNavController())
}
@Composable @Composable
private fun HintHeader( private fun HintHeader(
@@ -176,12 +171,4 @@ private fun HintListItem(
) )
} }
@ThemePreviews
@Composable
fun HintListItemPreview() {
HintListItem(
title = stringResource(R.string.category_hint_intro),
icon = AppIcons.Category,
onClick = {}
)
}

View File

@@ -1,45 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import eu.gaudian.translator.R
/**
* Migrated ImportVocabularyHint using markdown-based format.
* Content is loaded from localized assets/hints based on device locale.
*/
@Composable
fun getImportVocabularyHint(): Hint {
return Hint(
titleRes = R.string.hint_how_to_generate_vocabulary_with_ai,
elements = listOf(
HintElement.LocalizedMarkdown("import_hint")
)
)
}
@Preview
@Composable
fun ImportVocabularyHint() {
getImportVocabularyHint().Render()
}
/**
* Migrated VocabularyReviewHint using markdown-based format.
* Content is loaded from localized assets/hints based on device locale.
*/
@Composable
fun getVocabularyReviewHint(): Hint {
return Hint(
titleRes = R.string.review_intro,
elements = listOf(
HintElement.LocalizedMarkdown("review_hint")
)
)
}
@Composable
fun VocabularyReviewHint() {
getVocabularyReviewHint()
}

View File

@@ -1,33 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import eu.gaudian.translator.R
/**
* Migrated Learning Stages hint using markdown-based format.
* Content is loaded from localized assets/hints based on device locale.
*/
@Composable
fun getLearningStagesHint(): Hint {
return Hint(
titleRes = R.string.learning_stages_title,
elements = listOf(
HintElement.LocalizedMarkdown("learning_stages_hint")
)
)
}
@Composable
fun LearningStagesHint() {
getLearningStagesHint().Render()
}
@Preview
@Composable
fun LearningStagesHintPreview() {
MaterialTheme {
LearningStagesHint()
}
}

View File

@@ -1,147 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.jeziellago.compose.markdowntext.MarkdownText
import eu.gaudian.translator.R
/**
* Markdown-styled hint content using the jeziellago compose-markdown library.
* This provides beautiful, consistent rendering of markdown content with
* support for headings, lists, tables, code blocks, and more.
*
* Usage:
* - Create a .md file in assets/hints/ (e.g., "api_key_hint.md")
* - Load it using context.assets.open() or pass raw markdown string
* - Use MarkdownHint composable for styled rendering
*
* Supported markdown:
* - Headings (# ## ###)
* - Bold (**text**) and italic (*text*)
* - Lists (- item, 1. item)
* - Tables (| col | col |)
* - Code blocks (```code```)
* - Blockquotes (> quote)
* - Links ([text](url))
*/
@Composable
fun MarkdownHint(
markdownContent: String,
modifier: Modifier = Modifier,
title: String? = null
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier
.fillMaxWidth()
.verticalScroll(scrollState)
.padding(16.dp)
) {
// Optional title with icon header
title?.let {
HeaderWithIcon(
title = it,
subtitle = null
)
Spacer(modifier = Modifier.height(16.dp))
}
// Render markdown
MarkdownText(
markdown = markdownContent,
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface
)
)
}
}
/**
* Preview for MarkdownHint with sample content.
*/
@Preview
@Composable
fun MarkdownHintPreview() {
val sampleMarkdown = """
# Welcome to the Hint System
This is a **markdown-based** hint that provides _beautiful_ and consistent styling.
## Features
- **Rich text formatting** - bold, italic, strikethrough
- **Headings** - H1 through H6 levels
- **Lists** - ordered and unordered
- **Code blocks** - with syntax highlighting
- **Tables** - for structured data
- **Links** - styled clickable references
- **Blockquotes** - for highlighted content
## How to Use
1. Create a `.md` file in `assets/hints/`
2. Load it using `context.assets.open("hints/your_hint.md")`
3. Pass the content to `MarkdownHint()`
> **Tip:** You can also embed UI elements by combining markdown with HintElements!
## Code Example
```kotlin
val markdown = loadMarkdownFromAssets("hints/scan_hint.md")
MarkdownHint(
markdownContent = markdown,
title = "Scan Hint"
)
```
## Table Example
| Feature | Status | Priority |
|---------|--------|----------|
| Headings | ✅ | High |
| Lists | ✅ | High |
| Tables | ✅ | Medium |
---
*Last updated: 2024-01-15*
""".trimIndent()
MaterialTheme {
MarkdownHint(
markdownContent = sampleMarkdown,
title = stringResource(R.string.hint_title_hints_overview)
)
}
}
/**
* Data class for loading markdown content from assets.
*/
data class MarkdownHintData(
val fileName: String,
val titleRes: Int
) {
/**
* Load and return the markdown content as a string.
*/
fun loadContent(androidContext: android.content.Context): String {
return androidContext.assets.open("hints/$fileName")
.bufferedReader()
.use { it.readText() }
}
}

View File

@@ -4,7 +4,7 @@ import android.content.Context
import java.util.Locale import java.util.Locale
/** /**
* Internationalization system for markdown hints. * Internationalization system for Markdown hints.
* *
* This follows Android's locale-qualified resource pattern: * This follows Android's locale-qualified resource pattern:
* - assets/hints/ - Default (English) * - assets/hints/ - Default (English)
@@ -18,131 +18,22 @@ import java.util.Locale
*/ */
object MarkdownHintLoader { object MarkdownHintLoader {
/**
* Load markdown content with automatic locale detection.
*
* @param context The context for accessing assets
* @param hintFileName The base filename without locale suffix (e.g., "api_key_hint")
* @return The markdown content as a string, or null if not found
*/
fun loadHint(context: Context, hintFileName: String): String? {
val locale = getCurrentLocale(context)
val suffix = getLocaleSuffix(locale)
// Try localized version (folder has suffix, filename doesn't)
val localizedPath = "hints$suffix/$hintFileName.md"
val localizedContent = loadFromAssets(context, localizedPath)
if (localizedContent != null) {
return localizedContent
}
// Try with just language code (e.g., hints-pt/ instead of hints-pt-rBR/)
val languageSuffix = if (locale.country.isNotEmpty()) "-${locale.language}" else ""
val languageOnlyPath = "hints$languageSuffix/$hintFileName.md"
val languageContent = loadFromAssets(context, languageOnlyPath)
if (languageContent != null) {
return languageContent
}
// Fall back to default (English) in hints folder
val defaultPath = "hints/$hintFileName.md"
val defaultContent = loadFromAssets(context, defaultPath)
if (defaultContent != null) {
return defaultContent
}
return null
}
/**
* Get the localized file path for a hint.
*
* @param hintFileName The base filename (e.g., "api_key_hint")
* @return The full path including locale folder (e.g., "hints-de-rDE/api_key_hint.md")
*/
fun getHintPath(hintFileName: String): String {
val locale = Locale.getDefault()
val localeSuffix = getLocaleSuffix(locale)
return "hints$localeSuffix/$hintFileName.md"
}
/**
* Get the localized file name for a hint.
*
* @param hintFileName The base filename (e.g., "api_key_hint")
* @param locale The target locale
* @return The file name with locale suffix (e.g., "api_key_hint-de-rDE.md")
*/
fun getLocalizedFileName(hintFileName: String, locale: Locale): String {
val localeSuffix = getLocaleSuffix(locale)
return "$hintFileName$localeSuffix.md"
}
/**
* Get the file name with language-only suffix.
*
* @param hintFileName The base filename
* @param locale The target locale
* @return The file name with language suffix (e.g., "api_key_hint-de.md")
*/
private fun getLanguageOnlyFileName(hintFileName: String, locale: Locale): String {
val languageCode = locale.language
return "$hintFileName-$languageCode.md"
}
fun getCurrentLocale(context: Context): Locale { fun getCurrentLocale(context: Context): Locale {
return context.resources.configuration.locale return context.resources.configuration.locale
} }
/**
* Get all supported locales for hints.
*/
fun getSupportedLocales(): List<Locale> {
return listOf(
Locale.ENGLISH, // Default
Locale.GERMAN, // de-rDE
Locale("pt", "BR"), // pt-rBR
Locale.FRENCH, // fr-rFR
Locale("es", "ES"), // es-rES
Locale.ITALIAN, // it-rIT
Locale("nl", "NL"), // nl-rNL
Locale("hr", "HR") // hr-rHR
)
}
/**
* Check if a localized version exists for the given locale.
*/
fun localizedVersionExists(context: Context, hintFileName: String, locale: Locale): Boolean {
val localizedFileName = getLocalizedFileName(hintFileName, locale)
val suffix = getLocaleSuffix(locale)
return assetExists(context, "hints$suffix/$localizedFileName")
}
/** /**
* Load content from assets. * Load content from assets.
*/ */
fun loadFromAssets(context: Context, fileName: String): String? { fun loadFromAssets(context: Context, fileName: String): String? {
return try { return try {
context.assets.open(fileName).bufferedReader().use { it.readText() } context.assets.open(fileName).bufferedReader().use { it.readText() }
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
} }
/**
* Check if an asset file exists.
*/
private fun assetExists(context: Context, path: String): Boolean {
return try {
context.assets.open(path).close()
true
} catch (e: Exception) {
false
}
}
/** /**
* Get the locale suffix string. * Get the locale suffix string.
*/ */
@@ -163,55 +54,3 @@ object MarkdownHintLoader {
} }
} }
/**
* Extension function to get localized hint content.
*/
fun Context.loadLocalizedHint(hintFileName: String): String? {
return MarkdownHintLoader.loadHint(this, hintFileName)
}
/**
* Data class for localized hint information.
*/
data class LocalizedHint(
val fileName: String,
val locale: Locale,
val isDefault: Boolean = false
)
/**
* Hint localization manager that tracks available translations.
*/
object HintLocalizationManager {
/**
* Get all available translations for a hint.
*/
fun getAvailableTranslations(context: Context, baseFileName: String): List<LocalizedHint> {
val available = mutableListOf<LocalizedHint>()
val defaultLocale = MarkdownHintLoader.getCurrentLocale(context)
// Check each supported locale
MarkdownHintLoader.getSupportedLocales().forEach { locale ->
val fileName = MarkdownHintLoader.getLocalizedFileName(baseFileName, locale)
val path = "hints${MarkdownHintLoader.getLocaleSuffix(locale)}/$fileName"
if (MarkdownHintLoader.loadFromAssets(context, path) != null) {
available.add(LocalizedHint(
fileName = fileName,
locale = locale,
isDefault = locale.language == "en" && locale.country.isEmpty()
))
}
}
return available.sortedBy { it.locale.language }
}
/**
* Get the best available translation for current locale.
*/
fun getBestTranslation(context: Context, baseFileName: String): String? {
return MarkdownHintLoader.loadHint(context, baseFileName)
}
}

View File

@@ -1,33 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import eu.gaudian.translator.R
/**
* Migrated Sorting Screen hint using markdown-based format.
* Content is loaded from localized assets/hints based on device locale.
*/
@Composable
fun getSortingScreenHint(): Hint {
return Hint(
titleRes = R.string.sorting_hint_title,
elements = listOf(
HintElement.LocalizedMarkdown("sorting_hint")
)
)
}
@Composable
fun SortingScreenHint() {
getSortingScreenHint()
}
@Preview
@Composable
fun SortingScreenHintPreview() {
MaterialTheme {
SortingScreenHint()
}
}

View File

@@ -1,28 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import eu.gaudian.translator.R
/**
* Migrated TranslationScreenHint using markdown-based format.
* Content is loaded from localized assets/hints based on device locale.
*/
@Composable
fun getTranslationScreenHint() = Hint(
titleRes = R.string.hint_translate_how_it_works,
elements = listOf(
HintElement.LocalizedMarkdown("translation_hint")
)
)
@Composable
fun TranslationScreenHint() {
getTranslationScreenHint().Render()
}
@Preview
@Composable
fun TranslationScreenHintPreview() {
getTranslationScreenHint().Render()
}

View File

@@ -1,33 +0,0 @@
package eu.gaudian.translator.view.hints
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import eu.gaudian.translator.R
/**
* Migrated Vocabulary Progress hint using markdown-based format.
* Content is loaded from localized assets/hints based on device locale.
*/
@Composable
fun getVocabularyProgressHint(): Hint {
return Hint(
titleRes = R.string.hint_vocabulary_progress_hint_title,
elements = listOf(
HintElement.LocalizedMarkdown("vocabulary_progress_hint")
)
)
}
@Composable
fun VocabularyProgressHint() {
getVocabularyProgressHint().Render()
}
@Preview
@Composable
fun VocabularyProgressHintPreview() {
MaterialTheme {
VocabularyProgressHint()
}
}

View File

@@ -44,6 +44,9 @@ import androidx.navigation.NavController
import eu.gaudian.translator.BuildConfig import eu.gaudian.translator.BuildConfig
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.utils.StatusAction
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppCard import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppIcons import eu.gaudian.translator.view.composable.AppIcons
@@ -52,7 +55,6 @@ import eu.gaudian.translator.view.composable.AppSwitch
import eu.gaudian.translator.view.composable.AppTopAppBar import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.SecondaryButton import eu.gaudian.translator.view.composable.SecondaryButton
import eu.gaudian.translator.viewmodel.SettingsViewModel import eu.gaudian.translator.viewmodel.SettingsViewModel
import eu.gaudian.translator.viewmodel.StatusViewModel
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -371,7 +373,7 @@ private fun DeveloperOptions(
val context = LocalContext.current val context = LocalContext.current
val activity = context.findActivity() val activity = context.findActivity()
val statusViewModel: StatusViewModel = hiltViewModel(viewModelStoreOwner = activity) val statusMessageService = StatusMessageService
val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity) val settingsViewModel: SettingsViewModel = hiltViewModel(viewModelStoreOwner = activity)
@@ -420,38 +422,35 @@ private fun DeveloperOptions(
) )
} }
val loadingText = stringResource(R.string.text_loading_3d)
val infoText = stringResource(R.string.text_sentence_this_is_an_info_message)
val successText = stringResource(R.string.text_success_em)
val errorText = stringResource(R.string.text_sentence_oops_something_went_wrong)
SecondaryButton( SecondaryButton(
onClick = { statusViewModel.showLoadingMessage(loadingText) }, onClick = { statusMessageService.showMessageById(StatusMessageId.LOADING_GENERIC) },
text = stringResource(R.string.text_show_loading), text = stringResource(R.string.text_show_loading),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
SecondaryButton( SecondaryButton(
onClick = { statusViewModel.cancelLoadingOperation() }, onClick = { statusMessageService.trigger(StatusAction.CancelLoadingOperation)},
text = stringResource(R.string.text_cancel_loading), text = stringResource(R.string.text_cancel_loading),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
SecondaryButton( SecondaryButton(
onClick = { statusViewModel.showInfoMessage(infoText) }, onClick = { statusMessageService.showInfoById(StatusMessageId.TEST_INFO) },
text = stringResource(R.string.text_show_info_message), text = stringResource(R.string.text_show_info_message),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
SecondaryButton( SecondaryButton(
onClick = { statusViewModel.showSuccessMessage(successText) }, onClick = { statusMessageService.showSuccessById(StatusMessageId.TEST_SUCCESS) },
text = stringResource(R.string.title_show_success_message), text = stringResource(R.string.title_show_success_message),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
SecondaryButton( SecondaryButton(
onClick = { statusViewModel.showErrorMessage(errorText, 2) }, onClick = { statusMessageService.showErrorById(StatusMessageId.TEST_ERROR) },
text = stringResource(R.string.text_show_error_message), text = stringResource(R.string.text_show_error_message),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
SecondaryButton( SecondaryButton(
onClick = { statusViewModel.showApiKeyMissingMessage() }, onClick = { statusMessageService.showErrorById(StatusMessageId.ERROR_API_KEY_MISSING) },
text = stringResource(R.string.show_api_key_missing_message), text = stringResource(R.string.show_api_key_missing_message),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )

View File

@@ -58,8 +58,7 @@ import eu.gaudian.translator.view.composable.AppScaffold
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.AppTopAppBar
import eu.gaudian.translator.view.composable.ModelBadges import eu.gaudian.translator.view.composable.ModelBadges
import eu.gaudian.translator.view.hints.AddModelScanHint import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.view.hints.getAddModelScanHint
import eu.gaudian.translator.viewmodel.ApiViewModel import eu.gaudian.translator.viewmodel.ApiViewModel
@Composable @Composable
@@ -141,7 +140,7 @@ fun AddModelScreen(navController: NavController, providerKey: String) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
} }
}, },
hintContent = getAddModelScanHint() hintContent = HintDefinition.ADD_MODEL_SCAN.hint()
) )
}, },
) { paddingValues -> ) { paddingValues ->

View File

@@ -80,7 +80,7 @@ import eu.gaudian.translator.view.composable.ClickableText
import eu.gaudian.translator.view.composable.PrimaryButton import eu.gaudian.translator.view.composable.PrimaryButton
import eu.gaudian.translator.view.composable.SecondaryButton import eu.gaudian.translator.view.composable.SecondaryButton
import eu.gaudian.translator.view.composable.TabItem import eu.gaudian.translator.view.composable.TabItem
import eu.gaudian.translator.view.hints.getApiKeyHint import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.viewmodel.ApiKeyManagementState import eu.gaudian.translator.viewmodel.ApiKeyManagementState
import eu.gaudian.translator.viewmodel.ApiViewModel import eu.gaudian.translator.viewmodel.ApiViewModel
import eu.gaudian.translator.viewmodel.ProviderState import eu.gaudian.translator.viewmodel.ProviderState
@@ -121,7 +121,7 @@ fun ApiKeyScreen(navController: NavController) {
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
} }
}, },
hintContent = getApiKeyHint() hintContent = HintDefinition.API_KEY.hint()
) )
} }
) { paddingValues -> ) { paddingValues ->

View File

@@ -36,7 +36,7 @@ import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppTopAppBar import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.OptionItemSwitch import eu.gaudian.translator.view.composable.OptionItemSwitch
import eu.gaudian.translator.view.dictionary.DictionaryManagerContent import eu.gaudian.translator.view.dictionary.DictionaryManagerContent
import eu.gaudian.translator.view.hints.getDictionaryOptionsHint import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.viewmodel.ApiViewModel import eu.gaudian.translator.viewmodel.ApiViewModel
import eu.gaudian.translator.viewmodel.DictionaryViewModel import eu.gaudian.translator.viewmodel.DictionaryViewModel
import eu.gaudian.translator.viewmodel.SettingsViewModel import eu.gaudian.translator.viewmodel.SettingsViewModel
@@ -72,7 +72,7 @@ fun DictionaryOptionsScreen(
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
} }
}, },
hintContent = getDictionaryOptionsHint() hintContent = HintDefinition.DICTIONARY_OPTIONS.hint()
) )
} }
) { paddingValues -> ) { paddingValues ->

View File

@@ -70,56 +70,68 @@ fun LanguageOptionsScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
Column( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
AppCard { item {
Column(Modifier.padding(16.dp)) { AppCard {
Row( Column(Modifier.padding(16.dp)) {
modifier = Modifier
.fillMaxWidth()
.clickable { languageViewModel.selectAllLanguages(!isAllSelected) },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(R.string.text_select_all_languages), style = MaterialTheme.typography.titleMedium)
AppSwitch(
checked = isAllSelected,
onCheckedChange = { languageViewModel.selectAllLanguages(it) }
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) AppCard(
title = stringResource(R.string.text_select_languages),
text = stringResource(R.string.text_language_settings_description),
expandable = true,
initiallyExpanded = false
) {
LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) { Row(
items(masterLanguages.sortedBy { it.name }) { language -> modifier = Modifier
val isEnabled = enabledLanguages.any { it.nameResId == language.nameResId } .fillMaxWidth()
LanguageItem( .clickable { languageViewModel.selectAllLanguages(!isAllSelected) },
language = language, verticalAlignment = Alignment.CenterVertically,
isEnabled = isEnabled, horizontalArrangement = Arrangement.SpaceBetween
onLanguageToggled = { lang -> languageViewModel.toggleLanguageSelection(lang) }, ) {
onLanguageDeleted = { lang -> languageViewModel.removeCustomLanguage(lang) }, Text(stringResource(R.string.text_select_all_languages), style = MaterialTheme.typography.titleMedium)
onLanguageEdit = { lang -> editLanguageTarget = lang } AppSwitch(
checked = isAllSelected,
onCheckedChange = { languageViewModel.selectAllLanguages(it) }
) )
} }
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) {
items(masterLanguages.sortedBy { it.name }) { language ->
val isEnabled = enabledLanguages.any { it.nameResId == language.nameResId }
LanguageItem(
language = language,
isEnabled = isEnabled,
onLanguageToggled = { lang -> languageViewModel.toggleLanguageSelection(lang) },
onLanguageDeleted = { lang -> languageViewModel.removeCustomLanguage(lang) },
onLanguageEdit = { lang -> editLanguageTarget = lang }
)
}
}
}
} }
} }
} }
PrimaryButton( item {
onClick = { showAddLanguageDialog = true }, PrimaryButton(
text = stringResource(R.string.text_add_custom_language), onClick = { showAddLanguageDialog = true },
modifier = Modifier.fillMaxWidth() text = stringResource(R.string.text_add_custom_language),
) modifier = Modifier.fillMaxWidth()
)
}
} }
} }
if (showAddLanguageDialog) { if (showAddLanguageDialog) {
@Suppress("KotlinConstantConditions")
AddCustomLanguageDialog( AddCustomLanguageDialog(
showDialog = showAddLanguageDialog, showDialog = showAddLanguageDialog,
onDismiss = { showAddLanguageDialog = false }, onDismiss = { showAddLanguageDialog = false },

View File

@@ -47,7 +47,7 @@ fun MainSettingsScreen(
Setting(R.string.settings_title_voice, AppIcons.TextToSpeech, SettingsRoutes.TTS_OPTIONS), Setting(R.string.settings_title_voice, AppIcons.TextToSpeech, SettingsRoutes.TTS_OPTIONS),
Setting(R.string.label_logs, AppIcons.Log, SettingsRoutes.LOGS), Setting(R.string.label_logs, AppIcons.Log, SettingsRoutes.LOGS),
Setting(R.string.label_languages, AppIcons.Language, SettingsRoutes.LANGUAGE_OPTIONS), Setting(R.string.label_languages, AppIcons.Language, SettingsRoutes.LANGUAGE_OPTIONS),
Setting(R.string.hint_settings_title_hints, AppIcons.Info, SettingsRoutes.HINTS_OVERVIEW) //Setting(R.string.hint_settings_title_help, AppIcons.Info, SettingsRoutes.HINTS_OVERVIEW)
), ),
R.string.settings_header_translator to listOf( R.string.settings_header_translator to listOf(

View File

@@ -7,16 +7,9 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navigation import androidx.navigation.navigation
import eu.gaudian.translator.view.composable.Screen import eu.gaudian.translator.view.composable.Screen
import eu.gaudian.translator.view.hints.ApiHintScreen import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.view.hints.CategoryHintScreenWrapper import eu.gaudian.translator.view.hints.HintScreen
import eu.gaudian.translator.view.hints.DictionaryHintScreen
import eu.gaudian.translator.view.hints.HintsOverviewScreen import eu.gaudian.translator.view.hints.HintsOverviewScreen
import eu.gaudian.translator.view.hints.ImportHintScreen
import eu.gaudian.translator.view.hints.ScanHintScreen
import eu.gaudian.translator.view.hints.SortingHintScreen
import eu.gaudian.translator.view.hints.StagesHintScreen
import eu.gaudian.translator.view.hints.TranslationHintScreen
import eu.gaudian.translator.view.hints.VocabularyProgressHintScreen
// Defines the routes for the settings graph to avoid using raw strings // Defines the routes for the settings graph to avoid using raw strings
object SettingsRoutes { object SettingsRoutes {
@@ -114,31 +107,31 @@ fun NavGraphBuilder.settingsGraph(navController: NavController) {
HintsOverviewScreen(navController = navController) HintsOverviewScreen(navController = navController)
} }
composable(SettingsRoutes.HINTS_CATEGORIES) { composable(SettingsRoutes.HINTS_CATEGORIES) {
CategoryHintScreenWrapper(navController = navController) HintScreen(navController, HintDefinition.CATEGORY)
} }
composable(SettingsRoutes.HINTS_DICTIONARY) { composable(SettingsRoutes.HINTS_DICTIONARY) {
DictionaryHintScreen(navController = navController) HintScreen(navController, HintDefinition.DICTIONARY_OPTIONS)
} }
composable(SettingsRoutes.HINTS_IMPORT) { composable(SettingsRoutes.HINTS_IMPORT) {
ImportHintScreen(navController = navController) HintScreen(navController, HintDefinition.IMPORT)
} }
composable(SettingsRoutes.HINTS_SORTING) { composable(SettingsRoutes.HINTS_SORTING) {
SortingHintScreen(navController = navController) HintScreen(navController, HintDefinition.SORTING)
} }
composable(SettingsRoutes.HINTS_STAGES) { composable(SettingsRoutes.HINTS_STAGES) {
StagesHintScreen(navController = navController) HintScreen(navController, HintDefinition.LEARNING_STAGES)
} }
composable(SettingsRoutes.HINTS_TRANSLATION) { composable(SettingsRoutes.HINTS_TRANSLATION) {
TranslationHintScreen(navController = navController) HintScreen(navController, HintDefinition.TRANSLATION)
} }
composable(SettingsRoutes.HINTS_SCAN) { composable(SettingsRoutes.HINTS_SCAN) {
ScanHintScreen(navController = navController) HintScreen(navController, HintDefinition.ADD_MODEL_SCAN)
} }
composable(SettingsRoutes.HINTS_API) { composable(SettingsRoutes.HINTS_API) {
ApiHintScreen(navController = navController) HintScreen(navController, HintDefinition.API_KEY)
} }
composable(SettingsRoutes.HINTS_VOCABULARY_PROGRESS) { composable(SettingsRoutes.HINTS_VOCABULARY_PROGRESS) {
VocabularyProgressHintScreen(navController = navController) HintScreen(navController, HintDefinition.VOCABULARY_PROGRESS)
} }
} }
} }

View File

@@ -49,8 +49,7 @@ import eu.gaudian.translator.view.composable.AppIcons
import eu.gaudian.translator.view.composable.AppScaffold import eu.gaudian.translator.view.composable.AppScaffold
import eu.gaudian.translator.view.composable.AppSlider import eu.gaudian.translator.view.composable.AppSlider
import eu.gaudian.translator.view.composable.AppTopAppBar import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.hints.VocabularyProgressHint import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.view.hints.getVocabularyProgressHint
import eu.gaudian.translator.viewmodel.SettingsViewModel import eu.gaudian.translator.viewmodel.SettingsViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
import kotlin.math.exp import kotlin.math.exp
@@ -86,7 +85,7 @@ fun VocabularyProgressOptionsScreen(
} }
}, },
// Here is the new hint content // Here is the new hint content
hintContent = getVocabularyProgressHint() hintContent = HintDefinition.VOCABULARY_PROGRESS.hint()
) )
} }
) { paddingValues -> ) { paddingValues ->

View File

@@ -39,6 +39,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import eu.gaudian.translator.R import eu.gaudian.translator.R
import eu.gaudian.translator.model.Language import eu.gaudian.translator.model.Language
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.utils.findActivity import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppButton import eu.gaudian.translator.view.composable.AppButton
import eu.gaudian.translator.view.composable.AppCard import eu.gaudian.translator.view.composable.AppCard
@@ -50,7 +52,6 @@ import eu.gaudian.translator.view.composable.PrimaryButton
import eu.gaudian.translator.view.composable.SecondaryButton import eu.gaudian.translator.view.composable.SecondaryButton
import eu.gaudian.translator.view.composable.SingleLanguageDropDown import eu.gaudian.translator.view.composable.SingleLanguageDropDown
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.StatusViewModel
import eu.gaudian.translator.viewmodel.VocabularyViewModel import eu.gaudian.translator.viewmodel.VocabularyViewModel
@Composable @Composable
@@ -60,7 +61,7 @@ fun VocabularyRepositoryOptionsScreen(
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity) val vocabularyViewModel: VocabularyViewModel = hiltViewModel(viewModelStoreOwner = activity)
val statusViewModel: StatusViewModel = hiltViewModel(viewModelStoreOwner = activity) val statusMessageService = StatusMessageService
val context = LocalContext.current val context = LocalContext.current
@@ -73,7 +74,7 @@ fun VocabularyRepositoryOptionsScreen(
context.contentResolver.openInputStream(it)?.use { inputStream -> context.contentResolver.openInputStream(it)?.use { inputStream ->
val jsonString = inputStream.bufferedReader().use { reader -> reader.readText() } val jsonString = inputStream.bufferedReader().use { reader -> reader.readText() }
vocabularyViewModel.importVocabulary(jsonString) vocabularyViewModel.importVocabulary(jsonString)
statusViewModel.showInfoMessage(repositoryStateImportedFrom + " " +it.path) statusMessageService.showInfoMessage(repositoryStateImportedFrom + " " +it.path)
} }
} }
} }
@@ -145,7 +146,7 @@ fun VocabularyRepositoryOptionsScreen(
row.map { it.trim().trim('"') } row.map { it.trim().trim('"') }
}.filter { r -> r.any { it.isNotBlank() } } }.filter { r -> r.any { it.isNotBlank() } }
} }
val textExcelNotSupportedUseCsv = stringResource(R.string.text_excel_not_supported_use_csv)
val errorParsingTable = stringResource(R.string.error_parsing_table) val errorParsingTable = stringResource(R.string.error_parsing_table)
val errorParsingTableWithReason = stringResource(R.string.error_parsing_table_with_reason) val errorParsingTableWithReason = stringResource(R.string.error_parsing_table_with_reason)
val importTableLauncher = rememberLauncherForActivityResult( val importTableLauncher = rememberLauncherForActivityResult(
@@ -159,7 +160,7 @@ fun VocabularyRepositoryOptionsScreen(
val mime = context.contentResolver.getType(u) val mime = context.contentResolver.getType(u)
val isExcel = mime == "application/vnd.ms-excel" || mime == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" val isExcel = mime == "application/vnd.ms-excel" || mime == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
if (isExcel) { if (isExcel) {
statusViewModel.showInfoMessage(textExcelNotSupportedUseCsv) statusMessageService.showErrorById(StatusMessageId.ERROR_EXCEL_NOT_SUPPORTED)
return@let return@let
} }
context.contentResolver.openInputStream(u)?.use { inputStream -> context.contentResolver.openInputStream(u)?.use { inputStream ->
@@ -173,12 +174,12 @@ fun VocabularyRepositoryOptionsScreen(
parseError = null parseError = null
} else { } else {
parseError = errorParsingTable parseError = errorParsingTable
statusViewModel.showErrorMessage(parseError!!) statusMessageService.showErrorMessage(parseError!!)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
parseError = e.message parseError = e.message
statusViewModel.showErrorMessage( statusMessageService.showErrorMessage(
(errorParsingTableWithReason + " " + e.message) (errorParsingTableWithReason + " " + e.message)
) )
} }
@@ -394,13 +395,13 @@ fun VocabularyRepositoryOptionsScreen(
val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from) val infoImportedItemsFrom = stringResource(R.string.info_imported_items_from)
TextButton(onClick = { TextButton(onClick = {
if (selectedColFirst == selectedColSecond) { if (selectedColFirst == selectedColSecond) {
statusViewModel.showErrorMessage(errorSelectTwoColumns) statusMessageService.showErrorMessage(errorSelectTwoColumns)
return@TextButton return@TextButton
} }
val langA = selectedLangFirst val langA = selectedLangFirst
val langB = selectedLangSecond val langB = selectedLangSecond
if (langA == null || langB == null) { if (langA == null || langB == null) {
statusViewModel.showErrorMessage(errorSelectLanguages) statusMessageService.showErrorMessage(errorSelectLanguages)
return@TextButton return@TextButton
} }
val startIdx = if (skipHeader) 1 else 0 val startIdx = if (skipHeader) 1 else 0
@@ -416,11 +417,11 @@ fun VocabularyRepositoryOptionsScreen(
) )
} }
if (items.isEmpty()) { if (items.isEmpty()) {
statusViewModel.showErrorMessage(errorNoRowsToImport) statusMessageService.showErrorMessage(errorNoRowsToImport)
return@TextButton return@TextButton
} }
vocabularyViewModel.addVocabularyItems(items) vocabularyViewModel.addVocabularyItems(items)
statusViewModel.showSuccessMessage(infoImportedItemsFrom + " " +items.size) statusMessageService.showSuccessMessage(infoImportedItemsFrom + " " +items.size)
showTableImportDialog.value = false showTableImportDialog.value = false
}) { Text(stringResource(R.string.label_import)) } }) { Text(stringResource(R.string.label_import)) }
}, },

View File

@@ -59,7 +59,7 @@ import eu.gaudian.translator.view.NoConnectionScreen
import eu.gaudian.translator.view.composable.AppCard import eu.gaudian.translator.view.composable.AppCard
import eu.gaudian.translator.view.composable.AppOutlinedCard import eu.gaudian.translator.view.composable.AppOutlinedCard
import eu.gaudian.translator.view.dialogs.AddVocabularyDialog import eu.gaudian.translator.view.dialogs.AddVocabularyDialog
import eu.gaudian.translator.view.hints.TranslationScreenHint import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.view.settings.SettingsRoutes import eu.gaudian.translator.view.settings.SettingsRoutes
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
import eu.gaudian.translator.viewmodel.SettingsViewModel import eu.gaudian.translator.viewmodel.SettingsViewModel
@@ -167,7 +167,7 @@ private fun LoadedTranslationContent(
TopBarActions( TopBarActions(
languageViewModel = languageViewModel, languageViewModel = languageViewModel,
onSettingsClick = onSettingsClick, onSettingsClick = onSettingsClick,
hintContent = { TranslationScreenHint() } hintContent = { HintDefinition.TRANSLATION.Render() }
) )
AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) { AppCard(modifier = Modifier.padding(8.dp, end = 8.dp, bottom = 8.dp, top = 0.dp)) {

View File

@@ -75,8 +75,6 @@ enum class VocabularyTab(
Statistics(title = "label_all_vocabulary", icon = AppIcons.BarChart, route = "statistics") Statistics(title = "label_all_vocabulary", icon = AppIcons.BarChart, route = "statistics")
} }
//Used to avoid the warning of unused variables in strings.xml
@Suppress("unused", "HardCodedStringLiteral", "UnusedVariable") @Suppress("unused", "HardCodedStringLiteral", "UnusedVariable")
@Composable @Composable
fun Dummy() { fun Dummy() {
@@ -297,7 +295,18 @@ fun MainVocabularyScreen(
VocabularyTab.entries.find { it.route == currentRoute } ?: VocabularyTab.Dashboard VocabularyTab.entries.find { it.route == currentRoute } ?: VocabularyTab.Dashboard
} }
val showFabText = selectedTab == VocabularyTab.Dashboard && !isScrolling val rawShowFabText = selectedTab == VocabularyTab.Dashboard && !isScrolling
var showFabText by remember { mutableStateOf(rawShowFabText) }
LaunchedEffect(rawShowFabText) {
if (rawShowFabText) {
// Only delay when showing (true), hide immediately
kotlinx.coroutines.delay(2000)
showFabText = true
} else {
showFabText = false
}
}
val repoEmpty = val repoEmpty =
vocabularyViewModel.vocabularyItems.collectAsState(initial = emptyList()).value.isEmpty() vocabularyViewModel.vocabularyItems.collectAsState(initial = emptyList()).value.isEmpty()

View File

@@ -6,7 +6,6 @@ import androidx.compose.foundation.BorderStroke
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
@@ -391,7 +390,6 @@ private fun ExerciseTypeSelector(
onTypeSelected: (VocabularyExerciseType) -> Unit, onTypeSelected: (VocabularyExerciseType) -> Unit,
) { ) {
// Using FlowRow for a more flexible layout that wraps to the next line if needed // Using FlowRow for a more flexible layout that wraps to the next line if needed
@OptIn(ExperimentalLayoutApi::class)
FlowRow( FlowRow(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally),

View File

@@ -238,7 +238,7 @@ fun VocabularyCardHost(
listOf(currentVocabularyItem), listOf(currentVocabularyItem),
it.mapNotNull { category -> category?.id } it.mapNotNull { category -> category?.id }
) )
showCategoryDialog = false //showCategoryDialog = false
}, },
onDismissRequest = { showCategoryDialog = false } onDismissRequest = { showCategoryDialog = false }
) )

View File

@@ -103,7 +103,7 @@ private data class VocabularyFilterState(
val searchQuery: String = "", val searchQuery: String = "",
val selectedStage: VocabularyStage? = null, val selectedStage: VocabularyStage? = null,
val sortOrder: SortOrder = SortOrder.NEWEST_FIRST, val sortOrder: SortOrder = SortOrder.NEWEST_FIRST,
val categoryId: Int? = null, val categoryIds: List<Int> = emptyList(),
val dueTodayOnly: Boolean = false, val dueTodayOnly: Boolean = false,
val selectedLanguageIds: List<Int> = emptyList(), val selectedLanguageIds: List<Int> = emptyList(),
val selectedWordClass: String? = null val selectedWordClass: String? = null
@@ -133,7 +133,7 @@ fun VocabularyListScreen(
var filterState by rememberSaveable { var filterState by rememberSaveable {
mutableStateOf( mutableStateOf(
VocabularyFilterState( VocabularyFilterState(
categoryId = categoryId, categoryIds = categoryId?.let { listOf(it) } ?: emptyList(),
dueTodayOnly = showDueTodayOnly == true, dueTodayOnly = showDueTodayOnly == true,
selectedStage = stage selectedStage = stage
) )
@@ -142,7 +142,7 @@ fun VocabularyListScreen(
val isFilterActive by remember(filterState) { val isFilterActive by remember(filterState) {
derivedStateOf { derivedStateOf {
filterState.selectedStage != null || filterState.selectedStage != null ||
(filterState.categoryId != null && filterState.categoryId != 0) || filterState.categoryIds.isNotEmpty() ||
filterState.dueTodayOnly || filterState.dueTodayOnly ||
filterState.selectedLanguageIds.isNotEmpty() || filterState.selectedLanguageIds.isNotEmpty() ||
!filterState.selectedWordClass.isNullOrBlank() !filterState.selectedWordClass.isNullOrBlank()
@@ -165,7 +165,7 @@ fun VocabularyListScreen(
vocabularyViewModel.filterVocabularyItems( vocabularyViewModel.filterVocabularyItems(
languages = filterState.selectedLanguageIds, languages = filterState.selectedLanguageIds,
query = filterState.searchQuery.takeIf { it.isNotBlank() }, query = filterState.searchQuery.takeIf { it.isNotBlank() },
categoryId = filterState.categoryId, categoryIds = filterState.categoryIds,
stage = filterState.selectedStage, stage = filterState.selectedStage,
wordClass = filterState.selectedWordClass, wordClass = filterState.selectedWordClass,
dueTodayOnly = filterState.dueTodayOnly, dueTodayOnly = filterState.dueTodayOnly,
@@ -179,7 +179,7 @@ fun VocabularyListScreen(
LaunchedEffect(categoryId, showDueTodayOnly, stage) { LaunchedEffect(categoryId, showDueTodayOnly, stage) {
filterState = filterState.copy( filterState = filterState.copy(
categoryId = categoryId, categoryIds = categoryId?.let { listOf(it) } ?: emptyList(),
dueTodayOnly = showDueTodayOnly == true, dueTodayOnly = showDueTodayOnly == true,
selectedStage = stage selectedStage = stage
) )
@@ -374,7 +374,7 @@ fun VocabularyListScreen(
FilterSortBottomSheet( FilterSortBottomSheet(
currentFilterState = filterState, currentFilterState = filterState,
onDismiss = { showFilterSheet = false }, onDismiss = { showFilterSheet = false },
onApplyFilters = { newState -> onApplyFilters = { newState ->
filterState = newState filterState = newState
showFilterSheet = false showFilterSheet = false
scope.launch { lazyListState.scrollToItem(0) } scope.launch { lazyListState.scrollToItem(0) }
@@ -382,7 +382,8 @@ fun VocabularyListScreen(
languageViewModel = languageViewModel, languageViewModel = languageViewModel,
languagesPresent = allLanguages.filter { it.nameResId in languagesPresent }, languagesPresent = allLanguages.filter { it.nameResId in languagesPresent },
hideCategory = categoryId != null && categoryId != 0, hideCategory = categoryId != null && categoryId != 0,
hideStage = stage != null hideStage = stage != null,
categoryViewModel = categoryViewModel
) )
} }
@@ -394,7 +395,7 @@ fun VocabularyListScreen(
selectedItems, selectedItems,
it.mapNotNull { category -> category?.id } it.mapNotNull { category -> category?.id }
) )
showCategoryDialog = false //showCategoryDialog = false
}, },
onDismissRequest = { showCategoryDialog = false } onDismissRequest = { showCategoryDialog = false }
) )
@@ -807,11 +808,12 @@ private fun FilterSortBottomSheet(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onApplyFilters: (VocabularyFilterState) -> Unit, onApplyFilters: (VocabularyFilterState) -> Unit,
hideCategory: Boolean = false, hideCategory: Boolean = false,
hideStage: Boolean = false hideStage: Boolean = false,
categoryViewModel: CategoryViewModel
) { ) {
var selectedStage by rememberSaveable { mutableStateOf(currentFilterState.selectedStage) } var selectedStage by rememberSaveable { mutableStateOf(currentFilterState.selectedStage) }
var dueTodayOnly by rememberSaveable { mutableStateOf(currentFilterState.dueTodayOnly) } var dueTodayOnly by rememberSaveable { mutableStateOf(currentFilterState.dueTodayOnly) }
var selectedCategoryId by rememberSaveable { mutableStateOf(currentFilterState.categoryId) } var selectedCategoryIds by rememberSaveable { mutableStateOf(currentFilterState.categoryIds) }
var selectedLanguageIds by rememberSaveable { mutableStateOf(currentFilterState.selectedLanguageIds) } var selectedLanguageIds by rememberSaveable { mutableStateOf(currentFilterState.selectedLanguageIds) }
var selectedWordClass by rememberSaveable { mutableStateOf(currentFilterState.selectedWordClass) } var selectedWordClass by rememberSaveable { mutableStateOf(currentFilterState.selectedWordClass) }
@@ -840,7 +842,7 @@ private fun FilterSortBottomSheet(
TextButton(onClick = { TextButton(onClick = {
if (!hideStage) selectedStage = null if (!hideStage) selectedStage = null
dueTodayOnly = false dueTodayOnly = false
if (!hideCategory) selectedCategoryId = null if (!hideCategory) selectedCategoryIds = emptyList()
selectedLanguageIds = emptyList() selectedLanguageIds = emptyList()
selectedWordClass = null selectedWordClass = null
}) { }) {
@@ -853,7 +855,7 @@ private fun FilterSortBottomSheet(
currentFilterState.copy( currentFilterState.copy(
selectedStage = selectedStage, selectedStage = selectedStage,
dueTodayOnly = dueTodayOnly, dueTodayOnly = dueTodayOnly,
categoryId = selectedCategoryId, categoryIds = selectedCategoryIds,
selectedLanguageIds = selectedLanguageIds, selectedLanguageIds = selectedLanguageIds,
selectedWordClass = selectedWordClass selectedWordClass = selectedWordClass
) )
@@ -893,16 +895,18 @@ private fun FilterSortBottomSheet(
Text(stringResource(R.string.label_category), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.label_category), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
CategoryDropdown( CategoryDropdown(
initialCategoryId = selectedCategoryId, initialCategoryId = selectedCategoryIds.firstOrNull(),
onCategorySelected = { categories -> onCategorySelected = { categories ->
selectedCategoryId = categories.firstOrNull()?.id selectedCategoryIds = categories.mapNotNull { it?.id }
} },
multipleSelectable = true,
noneSelectable = false
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} }
if (!hideStage) { if (!hideStage) {
Text(stringResource(R.string.filter_by_stage), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.label_filter_by_stage), style = MaterialTheme.typography.titleMedium)
FlowRow( FlowRow(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -955,6 +959,7 @@ private fun FilterSortBottomSheet(
fun FilterSortBottomSheetPreview() { fun FilterSortBottomSheetPreview() {
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
FilterSortBottomSheet( FilterSortBottomSheet(
currentFilterState = VocabularyFilterState(), currentFilterState = VocabularyFilterState(),
languageViewModel = languageViewModel, languageViewModel = languageViewModel,
@@ -962,6 +967,7 @@ fun FilterSortBottomSheetPreview() {
onDismiss = {}, onDismiss = {},
onApplyFilters = {}, onApplyFilters = {},
hideCategory = false, hideCategory = false,
hideStage = false hideStage = false,
categoryViewModel = categoryViewModel
) )
} }

View File

@@ -74,7 +74,7 @@ import eu.gaudian.translator.view.composable.AppTopAppBar
import eu.gaudian.translator.view.composable.SingleLanguageDropDown import eu.gaudian.translator.view.composable.SingleLanguageDropDown
import eu.gaudian.translator.view.dialogs.CategoryDropdown import eu.gaudian.translator.view.dialogs.CategoryDropdown
import eu.gaudian.translator.view.dialogs.CreateCategoryListDialog import eu.gaudian.translator.view.dialogs.CreateCategoryListDialog
import eu.gaudian.translator.view.hints.getSortingScreenHint import eu.gaudian.translator.view.hints.HintDefinition
import eu.gaudian.translator.viewmodel.CategoryViewModel import eu.gaudian.translator.viewmodel.CategoryViewModel
import eu.gaudian.translator.viewmodel.LanguageConfigViewModel import eu.gaudian.translator.viewmodel.LanguageConfigViewModel
import eu.gaudian.translator.viewmodel.LanguageViewModel import eu.gaudian.translator.viewmodel.LanguageViewModel
@@ -236,7 +236,7 @@ fun VocabularySortingScreen(
Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back)) Icon(AppIcons.ArrowBack, contentDescription = stringResource(R.string.cd_back))
} }
}, },
hintContent = getSortingScreenHint() hintContent = HintDefinition.SORTING.hint()
) )
}, },
@@ -299,6 +299,7 @@ fun VocabularySortingItem(
val activity = LocalContext.current.findActivity() val activity = LocalContext.current.findActivity()
val languageConfigViewModel: LanguageConfigViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageConfigViewModel: LanguageConfigViewModel = hiltViewModel(viewModelStoreOwner = activity)
val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity) val languageViewModel: LanguageViewModel = hiltViewModel(viewModelStoreOwner = activity)
val categoryViewModel: CategoryViewModel = hiltViewModel(viewModelStoreOwner = activity)
var wordFirst by remember { mutableStateOf(item.wordFirst) } var wordFirst by remember { mutableStateOf(item.wordFirst) }
var wordSecond by remember { mutableStateOf(item.wordSecond) } var wordSecond by remember { mutableStateOf(item.wordSecond) }
var selectedCategories by remember { mutableStateOf<List<Int>>(emptyList()) } var selectedCategories by remember { mutableStateOf<List<Int>>(emptyList()) }
@@ -313,6 +314,7 @@ fun VocabularySortingItem(
var articlesLangSecond by remember { mutableStateOf(emptySet<String>()) } var articlesLangSecond by remember { mutableStateOf(emptySet<String>()) }
var showDuplicateDialog by remember { mutableStateOf(false) } var showDuplicateDialog by remember { mutableStateOf(false) }
val categories by categoryViewModel.categories.collectAsState(initial = emptyList())
// NEW: Calculate if the item is valid for the "Done" button in faulty mode // NEW: Calculate if the item is valid for the "Done" button in faulty mode
val isItemNowValid by remember(wordFirst, wordSecond, langFirst, langSecond) { val isItemNowValid by remember(wordFirst, wordSecond, langFirst, langSecond) {

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
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 kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -45,6 +46,16 @@ enum class MessageDisplayType(val priority: Int) {
ACTIONABLE_ERROR(5) ACTIONABLE_ERROR(5)
} }
/**
* StatusViewModel is responsible for:
* 1. Collecting status actions from StatusMessageService
* 2. Managing the message queue
* 3. Resolving StatusMessageId to actual strings
* 4. Managing status state
*
* NOTE: All message display requests should go through StatusMessageService.
* This ViewModel should NOT be called directly to display messages.
*/
@HiltViewModel @HiltViewModel
class StatusViewModel @Inject constructor( class StatusViewModel @Inject constructor(
application: Application, application: Application,
@@ -67,9 +78,14 @@ class StatusViewModel @Inject constructor(
} }
} }
/**
* Handles all status actions from StatusMessageService.
* This is the main entry point for all status messages.
*/
private fun handleAction(action: StatusAction) { private fun handleAction(action: StatusAction) {
Log.d("StatusViewModel", "Received action: $action") Log.d("StatusViewModel", "Received action: $action")
when (action) { when (action) {
// Legacy string-based actions (deprecated but still supported for backward compatibility)
is StatusAction.ShowMessage -> showMessageInternal(action.text, action.type, action.timeoutInSeconds) is StatusAction.ShowMessage -> showMessageInternal(action.text, action.type, action.timeoutInSeconds)
is StatusAction.ShowActionableMessage -> showPermanentActionableMessageInternal(action.text, action.type, action.action) is StatusAction.ShowActionableMessage -> showPermanentActionableMessageInternal(action.text, action.type, action.action)
is StatusAction.ShowPermanentMessage -> showPermanentMessageInternal(action.text, action.type) is StatusAction.ShowPermanentMessage -> showPermanentMessageInternal(action.text, action.type)
@@ -78,17 +94,67 @@ class StatusViewModel @Inject constructor(
is StatusAction.CancelPermanentMessage -> cancelPermanentMessageInternal() is StatusAction.CancelPermanentMessage -> cancelPermanentMessageInternal()
is StatusAction.HideMessageBar -> hideMessageBarInternal() is StatusAction.HideMessageBar -> hideMessageBarInternal()
is StatusAction.CancelAllMessages -> cancelAllMessagesInternal() is StatusAction.CancelAllMessages -> cancelAllMessagesInternal()
// New ID-based actions for internationalization
is StatusAction.ShowMessageById -> showMessageByIdInternal(
action.messageId,
action.type,
action.timeoutInSeconds
)
is StatusAction.ShowPermanentMessageById -> showPermanentMessageByIdInternal(
action.messageId,
action.type
)
is StatusAction.ShowActionableMessageById -> showPermanentActionableMessageByIdInternal(
action.messageId,
action.type,
action.action
)
} }
} }
fun showApiKeyMissingMessage() = viewModelScope.launch { /**
statusMessageService.showActionableMessage( * Resolves a StatusMessageId to its actual string text using Android string resources.
text = "API Key is missing or invalid.", */
type = MessageDisplayType.ACTIONABLE_ERROR, private fun resolveMessageText(messageId: StatusMessageId): String {
action = MessageAction.NAVIGATE_TO_API_KEYS return try {
) getApplication<Application>().getString(messageId.stringResId)
} catch (e: Exception) {
Log.e("StatusViewModel", "Failed to resolve message string for ID: $messageId", e)
"Message not available"
}
} }
// --- ID-based internal methods ---
private fun showMessageByIdInternal(
messageId: StatusMessageId,
type: MessageDisplayType,
timeoutInSeconds: Int
) {
val text = resolveMessageText(messageId)
showMessageInternal(text, type, timeoutInSeconds)
}
private fun showPermanentMessageByIdInternal(
messageId: StatusMessageId,
type: MessageDisplayType
) {
val text = resolveMessageText(messageId)
showPermanentMessageInternal(text, type)
}
private fun showPermanentActionableMessageByIdInternal(
messageId: StatusMessageId,
type: MessageDisplayType,
action: MessageAction
) {
val text = resolveMessageText(messageId)
showPermanentActionableMessageInternal(text, type, action)
}
// --- Internal message display methods ---
private fun showPermanentActionableMessageInternal(message: String, type: MessageDisplayType, action: MessageAction) { private fun showPermanentActionableMessageInternal(message: String, type: MessageDisplayType, action: MessageAction) {
cancelAllOperations() // Clear any other messages or loaders. cancelAllOperations() // Clear any other messages or loaders.
_status.value = StatusState.Message(messageIdCounter++, message, type, action) _status.value = StatusState.Message(messageIdCounter++, message, type, action)
@@ -99,54 +165,6 @@ class StatusViewModel @Inject constructor(
_status.value = StatusState.Message(messageIdCounter++, message, type, action = null) _status.value = StatusState.Message(messageIdCounter++, message, type, action = null)
} }
fun showPermanentMessage(message: String, type: MessageDisplayType) = viewModelScope.launch {
statusMessageService.showPermanentMessage(message, type)
}
fun cancelPermanentMessage() = viewModelScope.launch {
statusMessageService.cancelPermanentMessage()
}
fun performLoadingOperation(block: suspend () -> Unit) = viewModelScope.launch {
statusMessageService.trigger(StatusAction.PerformLoadingOperation(block))
}
fun cancelLoadingOperation() = viewModelScope.launch {
statusMessageService.trigger(StatusAction.CancelLoadingOperation)
}
fun showInfoMessage(message: String, timeoutInSeconds: Int = 3) = viewModelScope.launch {
statusMessageService.showInfoMessage(message, timeoutInSeconds)
}
fun showLoadingMessage(message: String, timeoutInSeconds: Int = 0) = viewModelScope.launch { // Default timeout 0 for indefinite
statusMessageService.showLoadingMessage(message, timeoutInSeconds)
}
fun showErrorMessage(message: String, timeoutInSeconds: Int = 5) = viewModelScope.launch { // Default timeout 5 for errors
statusMessageService.showErrorMessage(message, timeoutInSeconds)
}
fun showSuccessMessage(message: String, timeoutInSeconds: Int = 3) = viewModelScope.launch {
statusMessageService.showSuccessMessage(message, timeoutInSeconds)
}
fun hideMessageBar() = viewModelScope.launch {
statusMessageService.hideMessageBar()
}
fun cancelAllMessages() = viewModelScope.launch {
statusMessageService.cancelAllMessages()
}
private fun cancelPermanentMessageInternal() {
if (_status.value is StatusState.Message) {
// This logic can be simplified or adjusted based on desired behavior for permanent messages
_status.value = StatusState.Hidden
processNextMessageInQueue()
}
}
private fun performLoadingOperationInternal(block: suspend () -> Unit) { private fun performLoadingOperationInternal(block: suspend () -> Unit) {
cancelAllOperations() cancelAllOperations()
_status.value = StatusState.Loading _status.value = StatusState.Loading
@@ -159,7 +177,10 @@ class StatusViewModel @Inject constructor(
Log.i("StatusViewModel", "Loading operation was cancelled.") Log.i("StatusViewModel", "Loading operation was cancelled.")
} catch (e: Exception) { } catch (e: Exception) {
Log.e("StatusViewModel", "Loading operation failed.", e) Log.e("StatusViewModel", "Loading operation failed.", e)
showErrorMessage("Operation failed: ${e.localizedMessage ?: "Unknown error"}") // Trigger error message through StatusMessageService
viewModelScope.launch {
statusMessageService.showErrorById(StatusMessageId.ERROR_OPERATION_FAILED)
}
} finally { } finally {
if (activeLoadingJob == this.coroutineContext[Job]) { if (activeLoadingJob == this.coroutineContext[Job]) {
if (_status.value == StatusState.Loading) { if (_status.value == StatusState.Loading) {
@@ -181,7 +202,38 @@ class StatusViewModel @Inject constructor(
} }
} }
// --- REVISED LOGIC --- private fun cancelPermanentMessageInternal() {
if (_status.value is StatusState.Message) {
_status.value = StatusState.Hidden
processNextMessageInQueue()
}
}
private fun hideMessageBarInternal() {
messageDisplayJob?.cancel()
messageDisplayJob = null
if (_status.value is StatusState.Message) {
_status.value = StatusState.Hidden
}
if (activeLoadingJob?.isActive != true) {
processNextMessageInQueue()
}
}
private fun cancelAllMessagesInternal() {
Log.d("StatusViewModel", "Cancelling all messages.")
messageQueue.clear()
messageDisplayJob?.cancel()
messageDisplayJob = null
if (_status.value is StatusState.Message) {
_status.value = StatusState.Hidden
}
}
/**
* Displays a message with priority-based queuing.
* High-priority messages interrupt lower-priority ones.
*/
private fun showMessageInternal(message: String, type: MessageDisplayType, timeoutInSeconds: Int) { private fun showMessageInternal(message: String, type: MessageDisplayType, timeoutInSeconds: Int) {
val currentState = _status.value val currentState = _status.value
val currentPriority = (currentState as? StatusState.Message)?.type?.priority ?: -1 val currentPriority = (currentState as? StatusState.Message)?.type?.priority ?: -1
@@ -204,29 +256,6 @@ class StatusViewModel @Inject constructor(
} }
} }
private fun hideMessageBarInternal() {
messageDisplayJob?.cancel()
messageDisplayJob = null
if (_status.value is StatusState.Message) {
_status.value = StatusState.Hidden
}
if (activeLoadingJob?.isActive != true) {
processNextMessageInQueue()
}
}
private fun cancelAllMessagesInternal() {
Log.d("StatusViewModel", "Cancelling all messages.")
messageQueue.clear()
messageDisplayJob?.cancel()
messageDisplayJob = null
// Do not cancel activeLoadingJob here unless that's the desired behavior.
// Assuming CancelAllMessages is for the message bar only.
if (_status.value is StatusState.Message) {
_status.value = StatusState.Hidden
}
}
private fun cancelAllOperations() { private fun cancelAllOperations() {
messageQueue.clear() messageQueue.clear()
messageDisplayJob?.cancel() messageDisplayJob?.cancel()
@@ -236,7 +265,9 @@ class StatusViewModel @Inject constructor(
_status.value = StatusState.Hidden _status.value = StatusState.Hidden
} }
// --- REVISED LOGIC --- /**
* Processes the next message in the queue.
*/
private fun processNextMessageInQueue(timeoutInSeconds: Int = 3) { private fun processNextMessageInQueue(timeoutInSeconds: Int = 3) {
if (activeLoadingJob?.isActive == true) { if (activeLoadingJob?.isActive == true) {
Log.d("StatusViewModel", "Full-screen loading is active, not processing message queue now.") Log.d("StatusViewModel", "Full-screen loading is active, not processing message queue now.")

View File

@@ -15,6 +15,7 @@ import eu.gaudian.translator.model.repository.dataStore
import eu.gaudian.translator.model.repository.loadObjectList import eu.gaudian.translator.model.repository.loadObjectList
import eu.gaudian.translator.model.repository.saveObjectList import eu.gaudian.translator.model.repository.saveObjectList
import eu.gaudian.translator.utils.Log import eu.gaudian.translator.utils.Log
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.utils.TextToSpeechHelper import eu.gaudian.translator.utils.TextToSpeechHelper
import eu.gaudian.translator.utils.TranslationService import eu.gaudian.translator.utils.TranslationService
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -31,6 +32,9 @@ class TranslationViewModel @Inject constructor(
val languageRepository: LanguageRepository val languageRepository: LanguageRepository
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
private val statusMessageService = StatusMessageService
// For back/forward navigation of history in the UI (like editors) // For back/forward navigation of history in the UI (like editors)
private val _historyCursor = MutableStateFlow(-1) private val _historyCursor = MutableStateFlow(-1)
@@ -112,11 +116,13 @@ class TranslationViewModel @Inject constructor(
fun translateSentence(sentence: String) { fun translateSentence(sentence: String) {
val sentenceToTranslate = sentence.ifEmpty { _inputText.value } val sentenceToTranslate = sentence.ifEmpty { _inputText.value }
if (sentenceToTranslate.isBlank()) { if (sentenceToTranslate.isBlank()) {
statusMessageService.showSimpleMessage("Please enter a sentence to translate.")
return return
} }
if (selectedTranslationModel.value == null) { if (selectedTranslationModel.value == null) {
Log.e("TranslationViewModel", "Cannot translate because no model is selected.") Log.e("TranslationViewModel", "Cannot translate because no model is selected.")
statusMessageService.showSimpleMessage("Cannot translate because no model is selected.")
return return
} }
@@ -151,6 +157,7 @@ class TranslationViewModel @Inject constructor(
} }
.onFailure { exception -> .onFailure { exception ->
Log.e("TranslationViewModel", "Translation failed: ${exception.message}") Log.e("TranslationViewModel", "Translation failed: ${exception.message}")
statusMessageService.showErrorMessage("Translation failed: ${exception.message}")
} }
_isTranslating.value = false _isTranslating.value = false

View File

@@ -26,6 +26,7 @@ import eu.gaudian.translator.model.repository.VocabularyFileSaver
import eu.gaudian.translator.model.repository.VocabularyRepository import eu.gaudian.translator.model.repository.VocabularyRepository
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.utils.StringHelper import eu.gaudian.translator.utils.StringHelper
import eu.gaudian.translator.utils.VocabularyService import eu.gaudian.translator.utils.VocabularyService
@@ -774,7 +775,7 @@ class VocabularyViewModel @Inject constructor(
statusService.hideMessageBar() statusService.hideMessageBar()
if (_cardSet.value == null) { if (_cardSet.value == null) {
statusService.cancelAllMessages() statusService.cancelAllMessages()
statusService.showErrorMessage("No cards found for the specified filter", 3) statusService.showErrorById(StatusMessageId.ERROR_NO_CARDS_FOUND)
} }
} }
} }

View File

@@ -28,7 +28,6 @@
<string name="cd_re_generate_definition">Definition neu erstellen</string> <string name="cd_re_generate_definition">Definition neu erstellen</string>
<string name="cd_clear_search">Suche löschen</string> <string name="cd_clear_search">Suche löschen</string>
<string name="cd_translation_history">Übersetzungsverlauf</string> <string name="cd_translation_history">Übersetzungsverlauf</string>
<string name="label_quit_app">App beenden?</string>
<string name="label_reload">Neu laden</string> <string name="label_reload">Neu laden</string>
<string name="title_single">Einzeln</string> <string name="title_single">Einzeln</string>
<string name="title_widget_streak">Streak</string> <string name="title_widget_streak">Streak</string>
@@ -42,7 +41,7 @@
<string name="title_multiple">Mehrere</string> <string name="title_multiple">Mehrere</string>
<string name="label_translation_settings">Übersetzung</string> <string name="label_translation_settings">Übersetzung</string>
<string name="reset_to_defaults">Auf Standard zurücksetzen</string> <string name="reset_to_defaults">Auf Standard zurücksetzen</string>
<string name="text_excel_not_supported_use_csv">Excel wird nicht unterstützt. Bitte CSV verwenden.</string> <string name="message_error_excel_not_supported">Excel wird nicht unterstützt. Bitte CSV verwenden.</string>
<string name="error_parsing_table">Fehler beim Parsen der Tabelle</string> <string name="error_parsing_table">Fehler beim Parsen der Tabelle</string>
<string name="error_parsing_table_with_reason">Fehler beim Parsen der Tabelle: %1$s</string> <string name="error_parsing_table_with_reason">Fehler beim Parsen der Tabelle: %1$s</string>
<string name="label_import_table_csv_excel">Tabelle importieren (CSV)</string> <string name="label_import_table_csv_excel">Tabelle importieren (CSV)</string>
@@ -210,7 +209,7 @@
<string name="text_favorites">Favoriten</string> <string name="text_favorites">Favoriten</string>
<string name="text_recent_history">Verlauf</string> <string name="text_recent_history">Verlauf</string>
<string name="text_select_auto_recognition">Automatische Erkennung auswählen</string> <string name="text_select_auto_recognition">Automatische Erkennung auswählen</string>
<string name="text_select_none">Keine auswählen</string> <string name="text_select_no_language">Keine auswählen</string>
<string name="text_language_options">Sprachoptionen</string> <string name="text_language_options">Sprachoptionen</string>
<string name="text_select_all_languages">Alle Sprachen auswählen</string> <string name="text_select_all_languages">Alle Sprachen auswählen</string>
<string name="text_delete_custom_language">Eigene Sprache löschen</string> <string name="text_delete_custom_language">Eigene Sprache löschen</string>
@@ -360,7 +359,7 @@
<string name="days_2d">%1$d Tage</string> <string name="days_2d">%1$d Tage</string>
<string name="progress_by_category">Fortschritt nach Kategorie</string> <string name="progress_by_category">Fortschritt nach Kategorie</string>
<string name="label_apply_filters">Filter anwenden</string> <string name="label_apply_filters">Filter anwenden</string>
<string name="filter_by_stage">Nach Stufe filtern</string> <string name="label_filter_by_stage">Nach Stufe filtern</string>
<string name="label_category">Kategorie</string> <string name="label_category">Kategorie</string>
<string name="language">Sprache</string> <string name="language">Sprache</string>
<string name="label_clear_all">Alle löschen</string> <string name="label_clear_all">Alle löschen</string>

View File

@@ -28,7 +28,6 @@
<string name="cd_re_generate_definition">Gerar Definição Novamente</string> <string name="cd_re_generate_definition">Gerar Definição Novamente</string>
<string name="cd_clear_search">Limpar pesquisa</string> <string name="cd_clear_search">Limpar pesquisa</string>
<string name="cd_translation_history">Histórico de Tradução</string> <string name="cd_translation_history">Histórico de Tradução</string>
<string name="label_quit_app">Fechar o aplicativo?</string>
<string name="label_reload">Recarregar</string> <string name="label_reload">Recarregar</string>
<string name="title_single">Único</string> <string name="title_single">Único</string>
<string name="title_widget_streak">Sequência</string> <string name="title_widget_streak">Sequência</string>
@@ -42,7 +41,7 @@
<string name="title_multiple">Múltiplos</string> <string name="title_multiple">Múltiplos</string>
<string name="label_translation_settings">Configurações de Tradução</string> <string name="label_translation_settings">Configurações de Tradução</string>
<string name="reset_to_defaults">Restaurar Padrões</string> <string name="reset_to_defaults">Restaurar Padrões</string>
<string name="text_excel_not_supported_use_csv">Excel não é suportado. Use CSV.</string> <string name="message_error_excel_not_supported">Excel não é suportado. Use CSV.</string>
<string name="error_parsing_table">Erro ao analisar tabela</string> <string name="error_parsing_table">Erro ao analisar tabela</string>
<string name="error_parsing_table_with_reason">Erro ao analisar tabela: %1$s</string> <string name="error_parsing_table_with_reason">Erro ao analisar tabela: %1$s</string>
<string name="label_import_table_csv_excel">Importar Tabela (CSV)</string> <string name="label_import_table_csv_excel">Importar Tabela (CSV)</string>
@@ -207,7 +206,7 @@
<string name="text_favorites">Favoritos</string> <string name="text_favorites">Favoritos</string>
<string name="text_recent_history">Histórico</string> <string name="text_recent_history">Histórico</string>
<string name="text_select_auto_recognition">Selecionar Reconhecimento Automático</string> <string name="text_select_auto_recognition">Selecionar Reconhecimento Automático</string>
<string name="text_select_none">Não selecionar nenhum</string> <string name="text_select_no_language">Não selecionar nenhum</string>
<string name="text_language_options">Opções de Idioma</string> <string name="text_language_options">Opções de Idioma</string>
<string name="text_select_all_languages">Selecionar todos os idiomas</string> <string name="text_select_all_languages">Selecionar todos os idiomas</string>
<string name="text_delete_custom_language">Excluir idioma personalizado</string> <string name="text_delete_custom_language">Excluir idioma personalizado</string>
@@ -358,7 +357,7 @@
<string name="days_2d">%1$d dias</string> <string name="days_2d">%1$d dias</string>
<string name="progress_by_category">Progresso por Categoria</string> <string name="progress_by_category">Progresso por Categoria</string>
<string name="label_apply_filters">Aplicar Filtros</string> <string name="label_apply_filters">Aplicar Filtros</string>
<string name="filter_by_stage">Filtrar por Estágio</string> <string name="label_filter_by_stage">Filtrar por Estágio</string>
<string name="label_category">Categoria</string> <string name="label_category">Categoria</string>
<string name="language">Idioma</string> <string name="language">Idioma</string>
<string name="label_clear_all">Limpar Tudo</string> <string name="label_clear_all">Limpar Tudo</string>
@@ -629,7 +628,7 @@
<string name="text_paste_or_open_a_">Cole ou abra um link do YouTube para ver as legendas aqui.</string> <string name="text_paste_or_open_a_">Cole ou abra um link do YouTube para ver as legendas aqui.</string>
<string name="text_error_2d">Erro: %1$s</string> <string name="text_error_2d">Erro: %1$s</string>
<string name="text_repeat_wrong_guesses">Repetir Respostas Erradas</string> <string name="text_repeat_wrong_guesses">Repetir Respostas Erradas</string>
<string name="label_language_none">Nenhuma</string> <string name="label_language_none">Nenhum</string>
<string name="label_grammar_inflections">Flexões</string> <string name="label_grammar_inflections">Flexões</string>
<string name="label_more">Mais</string> <string name="label_more">Mais</string>
<string name="label_translations">Traduções</string> <string name="label_translations">Traduções</string>

View File

@@ -61,6 +61,8 @@
<string-array name="changelog_entries"> <string-array name="changelog_entries">
<item>Version 0.3.0 \n• Enabled CSV Import for Vocabulary\n• Option to use a translation server for translations instead of AI models for some supported langugaes\n• UI bug fixes \n• Show word frequency \n• Performance optimizations \n• Improved translations (German and Portuguese)</item> <item>Version 0.3.0 \n• Enabled CSV Import for Vocabulary\n• Option to use a translation server for translations instead of AI models for some supported langugaes\n• UI bug fixes \n• Show word frequency \n• Performance optimizations \n• Improved translations (German and Portuguese)</item>
<item>Version 0.4.0 \n• Added dictionary download (beta) \n• UI enhancements \n• Bugfixes \n• Re-designed vocabulary card with improved UI \n• More pre-configured providers \n• Improved performance</item> <item>Version 0.4.0 \n• Added dictionary download (beta) \n• UI enhancements \n• Bugfixes \n• Re-designed vocabulary card with improved UI \n• More pre-configured providers \n• Improved performance</item>
<item>Version 0.5.0 \n• Reworked hints and help content, added more instcructions and help \n• UI changes in the flashcards with a more intuitive design \n• Lots of bugfixes \n• Improved translations for German and Portuguese</item>
<item> </item>
</string-array> </string-array>

View File

@@ -140,7 +140,7 @@
<string name="fetching_grammar_details">Fetching Grammar Details</string> <string name="fetching_grammar_details">Fetching Grammar Details</string>
<string name="filter_and_sort">Filter and Sort</string> <string name="filter_and_sort">Filter and Sort</string>
<string name="filter_by_stage">Filter by Stage</string> <string name="label_filter_by_stage">Filter by Stage</string>
<string name="filter_by_word_type">Filter by Word Type</string> <string name="filter_by_word_type">Filter by Word Type</string>
<string name="find_translations">Find Translations</string> <string name="find_translations">Find Translations</string>
@@ -225,7 +225,7 @@
<string name="label_amount_models">%1$d models</string> <string name="label_amount_models">%1$d models</string>
<string name="label_analyze_grammar">Analyze Grammar</string> <string name="label_analyze_grammar">Analyze Grammar</string>
<string name="label_appearance">Appearance</string> <string name="label_appearance">Appearance</string>
<string name="hint_settings_title_hints">Help</string> <string name="hint_settings_title_help">Help</string>
<string name="label_apply_filters">Apply Filters</string> <string name="label_apply_filters">Apply Filters</string>
<string name="label_article">Article</string> <string name="label_article">Article</string>
<string name="label_backup_and_restore">Backup and Restore</string> <string name="label_backup_and_restore">Backup and Restore</string>
@@ -316,7 +316,7 @@
<string name="label_preview_second">Preview (first 5) for second column: %1$s</string> <string name="label_preview_second">Preview (first 5) for second column: %1$s</string>
<string name="label_pronoun">Pronoun</string> <string name="label_pronoun">Pronoun</string>
<string name="label_providers">Providers</string> <string name="label_providers">Providers</string>
<string name="label_quit_app">Quit app?</string> <string name="label_quit_app">Quit App</string>
<string name="label_quit_exercise_qm">Quit Exercise?</string> <string name="label_quit_exercise_qm">Quit Exercise?</string>
<string name="label_raw_data_2d">Raw Data:</string> <string name="label_raw_data_2d">Raw Data:</string>
<string name="label_related_words">Related Words</string> <string name="label_related_words">Related Words</string>
@@ -788,7 +788,7 @@
<string name="text_error_generating_questions">Error generating questions: %1$s</string> <string name="text_error_generating_questions">Error generating questions: %1$s</string>
<string name="text_error_loading_stored_values">Error loading stored values: %1$s</string> <string name="text_error_loading_stored_values">Error loading stored values: %1$s</string>
<string name="text_error_saving_entry">Error saving entry: %1$s</string> <string name="text_error_saving_entry">Error saving entry: %1$s</string>
<string name="text_excel_not_supported_use_csv">Excel is not supported. Use CSV instead.</string> <string name="message_error_excel_not_supported">Excel is not supported. Use CSV instead.</string>
<string name="text_expand_widget">Expand Widget</string> <string name="text_expand_widget">Expand Widget</string>
<string name="text_explanation">Explanation</string> <string name="text_explanation">Explanation</string>
<string name="text_export_category">Export Category</string> <string name="text_export_category">Export Category</string>
@@ -884,7 +884,7 @@
<string name="text_select_category">Select Category</string> <string name="text_select_category">Select Category</string>
<string name="text_select_languages">Select Languages</string> <string name="text_select_languages">Select Languages</string>
<string name="text_select_model">Select Model</string> <string name="text_select_model">Select Model</string>
<string name="text_select_none">Select None</string> <string name="text_select_no_language">Select None</string>
<string name="text_select_the_content_dictionary">Select the content to be generated for a dictionary entry.</string> <string name="text_select_the_content_dictionary">Select the content to be generated for a dictionary entry.</string>
<string name="text_select_translations_to_add">Select Translations to Add</string> <string name="text_select_translations_to_add">Select Translations to Add</string>
<string name="text_selected">Selected</string> <string name="text_selected">Selected</string>
@@ -1039,4 +1039,79 @@
<string name="duplicate">Duplicate</string> <string name="duplicate">Duplicate</string>
<string name="hint_scan_hint_title">Finding the right AI model</string> <string name="hint_scan_hint_title">Finding the right AI model</string>
<string name="hint_translate_how_it_works">How translation works</string> <string name="hint_translate_how_it_works">How translation works</string>
<string name="label_no_category">None</string>
<string name="text_select">Select</string>
<string name="text_search">Search</string>
<string name="text_language_settings_description">Set what languages you want to use in the app. Languages that are not activated will not appear in this app. You can also add your own language to the list, or change an existing language (region/locale)</string>
<!-- Status Messages (for internationalization) -->
<string name="message_success_generic">Success!</string>
<string name="message_info_generic">Info</string>
<string name="message_error_generic">An error occurred</string>
<string name="message_loading_generic">Loading…</string>
<!-- Language related -->
<string name="message_error_language_not_selected">Source and target languages must be selected.</string>
<string name="message_error_no_words_found">No words found in the provided text.</string>
<string name="message_success_language_replaced">Language ID updated for %1$d items.</string>
<!-- Vocabulary related -->
<string name="message_success_vocabulary_imported">Vocabulary items imported successfully.</string>
<string name="message_error_vocabulary_import_failed">Error importing vocabulary items: %1$s</string>
<string name="message_success_items_merged">Items merged!</string>
<string name="message_success_items_added">Successfully added %1$d new vocabulary items.</string>
<string name="message_error_items_add_failed">Error adding items: %1$s</string>
<string name="message_success_items_deleted">Successfully deleted vocabulary items.</string>
<string name="message_error_items_delete_failed">Error deleting items: %1$s</string>
<string name="message_error_no_cards_found">No cards found for the specified filter.</string>
<string name="message_success_cards_loaded">Successfully loaded card set.</string>
<!-- Grammar related -->
<string name="message_success_grammar_updated">Grammar details updated!</string>
<string name="message_error_grammar_fetch_failed">Could not retrieve grammar details.</string>
<string name="message_loading_grammar_fetch">Fetching grammar for %1$d items…</string>
<!-- File operations -->
<string name="message_success_file_saved">File saved to %1$s</string>
<string name="message_error_file_save_failed">Error saving file: %1$s</string>
<string name="message_error_file_save_cancelled">File save cancelled or failed.</string>
<string name="message_error_file_picker_not_initialized">Save File Launcher not initialized.</string>
<string name="message_success_category_saved">Category saved to %1$s</string>
<!-- API Key related -->
<string name="message_error_api_key_missing">API Key is missing or invalid.</string>
<string name="message_error_api_key_invalid">API Key is missing or invalid.</string>
<!-- Translation related -->
<string name="message_loading_translating">Translating %1$d words…</string>
<string name="message_success_translation_completed">Translation completed.</string>
<string name="message_error_translation_failed">Translation failed: %1$s</string>
<!-- Repository operations -->
<string name="message_success_repository_wiped">All repository data deleted.</string>
<string name="message_error_repository_wipe_failed">Failed to wipe repository: %1$s</string>
<string name="message_loading_card_set">Loading card set</string>
<!-- Stage operations -->
<string name="message_success_stage_updated">Stage updated successfully.</string>
<string name="message_error_stage_update_failed">Error updating stage: %1$s</string>
<!-- Category operations -->
<string name="message_success_category_updated">Category updated successfully.</string>
<string name="message_error_category_update_failed">Error updating category: %1$s</string>
<!-- Article removal -->
<string name="message_success_articles_removed">Articles removed successfully.</string>
<string name="message_error_articles_remove_failed">Error removing articles: %1$s</string>
<!-- Synonyms -->
<string name="message_success_synonyms_generated">Synonyms generated successfully.</string>
<string name="message_error_synonyms_generation_failed">Failed to generate synonyms: %1$s</string>
<!-- Operation status -->
<string name="message_error_operation_failed">Operation failed: %1$s</string>
<string name="message_loading_operation_in_progress">Operation in progress…</string>
<string name="message_test_info">This is a generic info message.</string>
<string name="message_test_success">This is a test success message!</string>
<string name="message_test_error">Oops, something went wrong :(</string>
</resources> </resources>

View File

@@ -43,6 +43,7 @@ truth = "1.4.5"
zstdJni = "1.5.7-7" zstdJni = "1.5.7-7"
composeMarkdown = "0.5.8" composeMarkdown = "0.5.8"
jitpack = "1.0.10" jitpack = "1.0.10"
foundationLayoutVersion = "1.10.3"
[libraries] [libraries]
@@ -103,6 +104,7 @@ hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", ve
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.3.0" } hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.3.0" }
mockk = { module = "io.mockk:mockk", version = "1.14.9" } mockk = { module = "io.mockk:mockk", version = "1.14.9" }
compose-markdown = { module = "com.github.jeziellago:compose-markdown", version.ref = "composeMarkdown" } compose-markdown = { module = "com.github.jeziellago:compose-markdown", version.ref = "composeMarkdown" }
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayoutVersion" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }