implement internationalization for status messages using StatusMessageId enum and refactor StatusMessageService and StatusViewModel to support ID-based message resolution

This commit is contained in:
jonasgaudian
2026-02-16 10:19:46 +01:00
parent 59f5f5e668
commit 2b8b9a84a3
11 changed files with 470 additions and 131 deletions

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.
* Supports both legacy string-based messages and new ID-based messages for internationalization.
*/
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 ShowPermanentMessage(val text: String, val type: MessageDisplayType) : StatusAction()
object CancelPermanentMessage : StatusAction()
@@ -20,31 +22,59 @@ sealed class StatusAction {
object CancelLoadingOperation : StatusAction()
object HideMessageBar : StatusAction()
object CancelAllMessages : 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.
* 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 {
private val _actions = MutableSharedFlow<StatusAction>()
val actions = _actions.asSharedFlow()
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")
_actions.emit(action)
}
fun triggerNonSuspend(action: StatusAction) {
Log.d("StatusMessageService", "Received non-suspend action: $action")
scope.launch {
_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")
fun showSimpleMessage(text: String, type: MessageDisplayType = MessageDisplayType.INFO) {
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) {
scope.launch {
_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) {
scope.launch {
_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) {
scope.launch {
_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) {
scope.launch {
_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) {
scope.launch {
_actions.emit(StatusAction.ShowPermanentMessage(text, type))
}
}
/**
* @deprecated Use StatusAction.CancelPermanentMessage via trigger() if needed.
*/
@Deprecated("Use StatusAction.CancelPermanentMessage via trigger() if needed")
fun cancelPermanentMessage() {
scope.launch {
_actions.emit(StatusAction.CancelPermanentMessage)
}
trigger(StatusAction.CancelPermanentMessage)
}
/**
* @deprecated Use StatusAction.HideMessageBar via trigger() if needed.
*/
@Deprecated("Use StatusAction.HideMessageBar via trigger() if needed")
fun hideMessageBar() {
scope.launch {
_actions.emit(StatusAction.HideMessageBar)
}
trigger(StatusAction.HideMessageBar)
}
/**
* @deprecated Use StatusAction.CancelAllMessages via trigger() if needed.
*/
@Deprecated("Use StatusAction.CancelAllMessages via trigger() if needed")
fun cancelAllMessages() {
scope.launch {
_actions.emit(StatusAction.CancelAllMessages)
}
trigger(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) {
scope.launch {
_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

@@ -62,6 +62,8 @@ 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.utils.Log
import eu.gaudian.translator.utils.StatusAction
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.utils.findActivity
import eu.gaudian.translator.view.composable.AppAlertDialog
import eu.gaudian.translator.view.composable.BottomNavigationBar
@@ -153,6 +155,7 @@ fun TranslatorApp(
val activity = LocalContext.current.findActivity()
val statusViewModel: StatusViewModel = hiltViewModel(activity)
val statusMessageService = StatusMessageService
val navController = rememberNavController()
val statusState by statusViewModel.status.collectAsStateWithLifecycle()
@@ -304,7 +307,7 @@ fun TranslatorApp(
StatusMessageSystem(
statusState = statusState,
navController = navController,
onDismiss = { statusViewModel.hideMessageBar() },
onDismiss = { statusMessageService.trigger(StatusAction.HideMessageBar) },
modifier = Modifier
.fillMaxWidth()
)
@@ -408,4 +411,6 @@ private fun AppTheme(
content()
}
}
}

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.gaudian.translator.utils.Log
import eu.gaudian.translator.utils.StatusAction
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
@@ -45,6 +46,16 @@ enum class MessageDisplayType(val priority: Int) {
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
class StatusViewModel @Inject constructor(
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) {
Log.d("StatusViewModel", "Received action: $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.ShowActionableMessage -> showPermanentActionableMessageInternal(action.text, action.type, action.action)
is StatusAction.ShowPermanentMessage -> showPermanentMessageInternal(action.text, action.type)
@@ -78,16 +94,66 @@ class StatusViewModel @Inject constructor(
is StatusAction.CancelPermanentMessage -> cancelPermanentMessageInternal()
is StatusAction.HideMessageBar -> hideMessageBarInternal()
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(
text = "API Key is missing or invalid.",
type = MessageDisplayType.ACTIONABLE_ERROR,
action = MessageAction.NAVIGATE_TO_API_KEYS
)
/**
* Resolves a StatusMessageId to its actual string text using Android string resources.
*/
private fun resolveMessageText(messageId: StatusMessageId): String {
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) {
cancelAllOperations() // Clear any other messages or loaders.
@@ -99,54 +165,6 @@ class StatusViewModel @Inject constructor(
_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) {
cancelAllOperations()
_status.value = StatusState.Loading
@@ -159,7 +177,10 @@ class StatusViewModel @Inject constructor(
Log.i("StatusViewModel", "Loading operation was cancelled.")
} catch (e: Exception) {
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 {
if (activeLoadingJob == this.coroutineContext[Job]) {
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) {
val currentState = _status.value
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() {
messageQueue.clear()
messageDisplayJob?.cancel()
@@ -236,7 +265,9 @@ class StatusViewModel @Inject constructor(
_status.value = StatusState.Hidden
}
// --- REVISED LOGIC ---
/**
* Processes the next message in the queue.
*/
private fun processNextMessageInQueue(timeoutInSeconds: Int = 3) {
if (activeLoadingJob?.isActive == true) {
Log.d("StatusViewModel", "Full-screen loading is active, not processing message queue now.")

View File

@@ -26,6 +26,7 @@ import eu.gaudian.translator.model.repository.VocabularyFileSaver
import eu.gaudian.translator.model.repository.VocabularyRepository
import eu.gaudian.translator.utils.Log
import eu.gaudian.translator.utils.StatusAction
import eu.gaudian.translator.utils.StatusMessageId
import eu.gaudian.translator.utils.StatusMessageService
import eu.gaudian.translator.utils.StringHelper
import eu.gaudian.translator.utils.VocabularyService
@@ -774,7 +775,7 @@ class VocabularyViewModel @Inject constructor(
statusService.hideMessageBar()
if (_cardSet.value == null) {
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="label_translation_settings">Übersetzung</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_with_reason">Fehler beim Parsen der Tabelle: %1$s</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="label_translation_settings">Configurações de Tradução</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_with_reason">Erro ao analisar tabela: %1$s</string>
<string name="label_import_table_csv_excel">Importar Tabela (CSV)</string>

View File

@@ -788,7 +788,7 @@
<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_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_explanation">Explanation</string>
<string name="text_export_category">Export Category</string>
@@ -1043,4 +1043,75 @@
<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>