Compare commits

..

4 Commits

27 changed files with 652 additions and 382 deletions

View File

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

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

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

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

@@ -62,6 +62,8 @@ import eu.gaudian.translator.ui.theme.AllThemes
import eu.gaudian.translator.ui.theme.ProvideSemanticColors 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
@@ -153,6 +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()
@@ -304,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()
) )
@@ -408,4 +411,6 @@ private fun AppTheme(
content() content()
} }
} }
} }

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,14 +43,21 @@ 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) }
Surface(
modifier = modifier.glassmorphic(shape = RectangleShape, alpha = 0.2f),
color = Color.Transparent
) {
TopAppBar( TopAppBar(
modifier = modifier.height(56.dp), modifier = Modifier.height(56.dp),
windowInsets = WindowInsets(0.dp), windowInsets = WindowInsets(0.dp),
colors = colors, colors = colors,
title = { title = {
@@ -104,6 +113,7 @@ fun AppTopAppBar(
}, },
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

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

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

@@ -70,15 +70,24 @@ 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)
) { ) {
item {
AppCard { AppCard {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
AppCard(
title = stringResource(R.string.text_select_languages),
text = stringResource(R.string.text_language_settings_description),
expandable = true,
initiallyExpanded = false
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -109,7 +118,10 @@ fun LanguageOptionsScreen(
} }
} }
} }
}
}
item {
PrimaryButton( PrimaryButton(
onClick = { showAddLanguageDialog = true }, onClick = { showAddLanguageDialog = true },
text = stringResource(R.string.text_add_custom_language), text = stringResource(R.string.text_add_custom_language),
@@ -117,9 +129,9 @@ fun LanguageOptionsScreen(
) )
} }
} }
}
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

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

@@ -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,16 +94,66 @@ 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.
@@ -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

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

View File

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

View File

@@ -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>
@@ -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>
@@ -1042,4 +1042,76 @@
<string name="label_no_category">None</string> <string name="label_no_category">None</string>
<string name="text_select">Select</string> <string name="text_select">Select</string>
<string name="text_search">Search</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" }