From 11cd091a3826c646980ff957a0d380b8651230a4 Mon Sep 17 00:00:00 2001 From: ReichiMD Date: Fri, 4 Sep 2026 17:10:17 +0000 Subject: [PATCH] i18n(settings): move SettingsViewModel status messages into string resources Moves the hardcoded English toasts, status lines and inline errors produced by SettingsViewModel into the string resource system and adds the German values. The messages are created outside a composable, so they are not resolved in the ViewModel: SettingsUiState now carries a SettingsMessage (a @StringRes id plus format args, or raw text for platform exception messages) and SettingsScreen resolves it with stringResource. This mirrors PlayerMessage/PluginMessage and keeps the rendered language tied to the app language instead of the system language of the device. No behaviour changes: stored values, ids and API parameters are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QNcHMnhSphhH3y5JNVSwwS --- .../tv/ui/screens/settings/SettingsScreen.kt | 43 +- .../ui/screens/settings/SettingsViewModel.kt | 521 ++++++++++++------ app/src/main/res/values-de/strings.xml | 82 +++ app/src/main/res/values/strings.xml | 94 ++++ 4 files changed, 564 insertions(+), 176 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 424380a5c..2293e8d19 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -314,6 +314,22 @@ private fun openExternalUrl(context: Context, url: String) { } } +/** + * Resolves a [SettingsMessage] against the app language. Nested messages (e.g. the EPG summary + * inside an IPTV status line) are resolved first so they are localized too. + */ +@Composable +private fun SettingsMessage.localizedText(): String = when (this) { + is SettingsMessage.Raw -> text + is SettingsMessage.Res -> { + val args = mutableListOf() + formatArgs.forEach { arg -> + args += if (arg is SettingsMessage) arg.localizedText() else arg + } + stringResource(resourceId, *args.toTypedArray()) + } +} + @Composable private fun formatUserAgentPreview(value: String?, maxLength: Int): String { val safeValue = value.orEmpty() @@ -1695,8 +1711,8 @@ fun SettingsScreen( playlists = uiState.iptvPlaylists, channelCount = uiState.iptvChannelCount, isLoading = uiState.isIptvLoading, - error = uiState.iptvError, - statusMessage = uiState.iptvStatusMessage, + error = uiState.iptvError?.localizedText(), + statusMessage = uiState.iptvStatusMessage?.localizedText(), statusType = uiState.iptvStatusType, progressText = uiState.iptvProgressText, progressPercent = uiState.iptvProgressPercent, @@ -1756,8 +1772,8 @@ fun SettingsScreen( playlists = uiState.iptvPlaylists, channelCount = uiState.iptvChannelCount, isLoading = uiState.isIptvLoading, - error = uiState.iptvError, - statusMessage = uiState.iptvStatusMessage, + error = uiState.iptvError?.localizedText(), + statusMessage = uiState.iptvStatusMessage?.localizedText(), statusType = uiState.iptvStatusType, progressText = uiState.iptvProgressText, progressPercent = uiState.iptvProgressPercent, @@ -1817,7 +1833,7 @@ fun SettingsScreen( connections = uiState.homeServerConnections, isWorking = uiState.isHomeServerConnecting, isPlexWorking = uiState.isPlexHomeServerPolling || uiState.plexHomeServerAuth != null, - error = uiState.homeServerError, + error = uiState.homeServerError?.localizedText(), focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, onConnect = { homeServerUrl = "" @@ -1899,7 +1915,7 @@ fun SettingsScreen( isTraktAuthStarting = uiState.isTraktAuthStarting, isTraktPolling = uiState.isTraktPolling, isForceCloudSyncing = uiState.isForceCloudSyncing, - lastCloudSyncStatus = uiState.lastCloudSyncStatus, + lastCloudSyncStatus = uiState.lastCloudSyncStatus?.localizedText(), diagnosticsSharingEnabled = uiState.diagnosticsSharingEnabled, isSelfUpdateSupported = uiState.isSelfUpdateSupported, updateStatus = uiState.updateStatus, @@ -2202,7 +2218,7 @@ fun SettingsScreen( query = uiState.catalogSearchQuery, results = uiState.catalogSearchResults, isSearching = uiState.isCatalogSearching, - error = uiState.catalogSearchError, + error = uiState.catalogSearchError?.localizedText(), manualUrl = catalogInputUrl, addedCatalogUrls = uiState.catalogs.mapNotNull { it.sourceUrl }.toSet(), onQueryChange = viewModel::setCatalogSearchQuery, @@ -2266,7 +2282,7 @@ fun SettingsScreen( CatalogPackImportDialog( pendingPack = uiState.pendingPackManifest, isLoading = uiState.isPackLoading, - error = uiState.packError, + error = uiState.packError?.localizedText(), onConfirm = { manifest -> viewModel.confirmInstallPack(uiState.pendingPackUrl ?: "") }, @@ -2606,7 +2622,7 @@ fun SettingsScreen( // Toast notification uiState.toastMessage?.let { message -> Toast( - message = message, + message = message.localizedText(), type = when (uiState.toastType) { ToastType.SUCCESS -> ComponentToastType.SUCCESS ToastType.ERROR -> ComponentToastType.ERROR @@ -4741,8 +4757,8 @@ private fun MobileSettingsSubPage( playlists = uiState.iptvPlaylists, channelCount = uiState.iptvChannelCount, isLoading = uiState.isIptvLoading, - error = uiState.iptvError, - statusMessage = uiState.iptvStatusMessage, + error = uiState.iptvError?.localizedText(), + statusMessage = uiState.iptvStatusMessage?.localizedText(), statusType = uiState.iptvStatusType, progressText = uiState.iptvProgressText, progressPercent = uiState.iptvProgressPercent, @@ -4823,7 +4839,7 @@ private fun MobileSettingsSubPage( connections = uiState.homeServerConnections, isWorking = uiState.isHomeServerConnecting, isPlexWorking = uiState.isPlexHomeServerPolling || uiState.plexHomeServerAuth != null, - error = uiState.homeServerError, + error = uiState.homeServerError?.localizedText(), focusedIndex = -1, onConnect = onConnectHomeServerClick, onConnectPlex = onConnectPlexHomeServerClick, @@ -5103,7 +5119,8 @@ private fun MobileCloudAccountSubPage( MobileSettingsRow( icon = Icons.Default.Sync, title = stringResource(R.string.settings_force_sync), - subtitle = uiState.lastCloudSyncStatus ?: stringResource(R.string.settings_cloud_manual_sync_sub), + subtitle = uiState.lastCloudSyncStatus?.localizedText() + ?: stringResource(R.string.settings_cloud_manual_sync_sub), value = if (uiState.isForceCloudSyncing) stringResource(R.string.settings_badge_syncing) else stringResource(R.string.settings_badge_sync), isFocused = false, showDivider = true, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index d6740e152..587e5871e 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -3,6 +3,7 @@ package com.arflix.tv.ui.screens.settings import android.content.Context import android.graphics.Bitmap import coil.Coil +import androidx.annotation.StringRes import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey @@ -104,6 +105,27 @@ internal fun settingsIptvRefreshPolicy(force: Boolean): SettingsIptvRefreshPolic allowNetworkEpgFetch = force, ) +/** + * A user-facing settings message, kept as a resource reference until a composable renders it. + * A ViewModel only has the application context, whose resources follow the SYSTEM language + * instead of the language selected in the app, so resolving here would show the wrong + * language whenever the two differ. Mirrors [com.arflix.tv.ui.screens.player.PlayerMessage]. + */ +sealed interface SettingsMessage { + /** A localizable message. [formatArgs] may itself contain [SettingsMessage] entries. */ + data class Res( + @param:StringRes val resourceId: Int, + val formatArgs: List = emptyList() + ) : SettingsMessage + + /** Text without a resource (e.g. a platform exception message); shown as it is. */ + data class Raw(val text: String) : SettingsMessage +} + +/** Wraps a non-null error text from a repository/exception into a [SettingsMessage]. */ +private fun String?.orMessage(fallback: SettingsMessage): SettingsMessage = + this?.takeIf { it.isNotBlank() }?.let { SettingsMessage.Raw(it) } ?: fallback + data class AiKeyServerState( val isActive: Boolean = false, val serverUrl: String? = null, @@ -154,7 +176,7 @@ data class SettingsUiState( val showCloudEmailPasswordDialog: Boolean = false, val isCloudAuthWorking: Boolean = false, val isForceCloudSyncing: Boolean = false, - val lastCloudSyncStatus: String? = null, + val lastCloudSyncStatus: SettingsMessage? = null, val shouldSwitchProfile: Boolean = false, val watchlistCount: Int = 0, val historyCount: Int = 0, @@ -198,8 +220,8 @@ data class SettingsUiState( val iptvSortOrder: String = "provider", val iptvChannelCount: Int = 0, val isIptvLoading: Boolean = false, - val iptvError: String? = null, - val iptvStatusMessage: String? = null, + val iptvError: SettingsMessage? = null, + val iptvStatusMessage: SettingsMessage? = null, val iptvStatusType: ToastType = ToastType.INFO, val iptvProgressText: String? = null, val iptvProgressPercent: Int = 0, @@ -219,11 +241,11 @@ data class SettingsUiState( val catalogSearchQuery: String = "", val catalogSearchResults: List = emptyList(), val isCatalogSearching: Boolean = false, - val catalogSearchError: String? = null, + val catalogSearchError: SettingsMessage? = null, val pendingPackManifest: CatalogPackManifest? = null, val pendingPackUrl: String? = null, val isPackLoading: Boolean = false, - val packError: String? = null, + val packError: SettingsMessage? = null, // Addons val addons: List = emptyList(), val isRefreshingAddons: Boolean = false, @@ -231,7 +253,7 @@ data class SettingsUiState( val homeServerConnection: HomeServerConnection? = null, val homeServerConnections: List = emptyList(), val isHomeServerConnecting: Boolean = false, - val homeServerError: String? = null, + val homeServerError: SettingsMessage? = null, val plexHomeServerAuth: PlexPinAuthSession? = null, val isPlexHomeServerPolling: Boolean = false, // Content language (TMDB metadata) @@ -249,7 +271,7 @@ data class SettingsUiState( val accentColor: String = "White", val qualityFilterPresetLabel: String = "OFF", // Toast - val toastMessage: String? = null, + val toastMessage: SettingsMessage? = null, val toastType: ToastType = ToastType.INFO, // AI Subtitles val subtitleAiEnabled: Boolean = false, @@ -1075,9 +1097,15 @@ class SettingsViewModel @Inject constructor( lastSyncTime = formatSyncTime(nowIso), toastMessage = if (!silent) { if (failures.isEmpty()) { - "Synced $totalMovies movies and $totalEpisodes episodes" + SettingsMessage.Res( + R.string.settings_sync_summary, + listOf(totalMovies, totalEpisodes) + ) } else { - "Synced $totalMovies movies and $totalEpisodes episodes; ${failures.joinToString("; ")}" + SettingsMessage.Res( + R.string.settings_sync_summary_with_failures, + listOf(totalMovies, totalEpisodes, failures.joinToString("; ")) + ) } } else { _uiState.value.toastMessage @@ -1094,14 +1122,17 @@ class SettingsViewModel @Inject constructor( } else if (!silent && connectedProviders.isEmpty()) { withContext(Dispatchers.Main) { _uiState.value = _uiState.value.copy( - toastMessage = "No tracking provider connected", + toastMessage = SettingsMessage.Res(R.string.settings_sync_no_provider), toastType = ToastType.ERROR ) } } else if (!silent) { withContext(Dispatchers.Main) { _uiState.value = _uiState.value.copy( - toastMessage = context.getString(R.string.sync_failed, failures.joinToString("; ")), + toastMessage = SettingsMessage.Res( + R.string.sync_failed, + listOf(failures.joinToString("; ")) + ), toastType = ToastType.ERROR ) } @@ -1111,7 +1142,10 @@ class SettingsViewModel @Inject constructor( if (!silent) { withContext(Dispatchers.Main) { _uiState.value = _uiState.value.copy( - toastMessage = context.getString(R.string.sync_failed, e.message), + toastMessage = SettingsMessage.Res( + R.string.sync_failed, + listOf(e.message.orEmpty()) + ), toastType = ToastType.ERROR ) } @@ -1904,7 +1938,7 @@ class SettingsViewModel @Inject constructor( // Prevent losing custom filters by cycling into a preset if (currentPreset == QualityFilterPreset.CUSTOM) { _uiState.value = _uiState.value.copy( - toastMessage = "Custom filters detected — use manual editing to modify", + toastMessage = SettingsMessage.Res(R.string.settings_quality_filters_custom), toastType = ToastType.INFO ) return@launch @@ -2016,16 +2050,24 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( addons = currentAddons, toastMessage = if (importedCatalogs > 0) { - "Added ${addon.name} ($importedCatalogs catalogs imported)" + SettingsMessage.Res( + R.string.settings_addon_added_with_catalogs, + listOf(addon.name, importedCatalogs) + ) } else { - "Added ${addon.name} (no catalogs exposed)" + SettingsMessage.Res( + R.string.settings_addon_added_no_catalogs, + listOf(addon.name) + ) }, toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message?.takeIf { it.isNotBlank() } ?: context.getString(R.string.addon_failed_add), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.addon_failed_add) + ), toastType = ToastType.ERROR ) } @@ -2045,7 +2087,7 @@ class SettingsViewModel @Inject constructor( if (restoreResult == CloudRestoreResult.FAILED) { _uiState.value = _uiState.value.copy( isRefreshingAddons = false, - toastMessage = "Cloud restore failed; addons were not changed", + toastMessage = SettingsMessage.Res(R.string.settings_addons_cloud_restore_failed), toastType = ToastType.ERROR ) return@launch @@ -2056,7 +2098,10 @@ class SettingsViewModel @Inject constructor( runCatching { catalogRepository.syncAddonCatalogs(updatedAddons) } - val toast = "${report.refreshed} addons refreshed, ${report.failed} failed" + val toast = SettingsMessage.Res( + R.string.settings_addons_refresh_report, + listOf(report.refreshed, report.failed) + ) _uiState.value = _uiState.value.copy( addons = updatedAddons, isRefreshingAddons = false, @@ -2068,7 +2113,7 @@ class SettingsViewModel @Inject constructor( if (e is kotlinx.coroutines.CancellationException) throw e _uiState.value = _uiState.value.copy( isRefreshingAddons = false, - toastMessage = "Failed to refresh addons", + toastMessage = SettingsMessage.Res(R.string.settings_addons_refresh_failed), toastType = ToastType.ERROR ) } @@ -2209,7 +2254,9 @@ class SettingsViewModel @Inject constructor( }.onFailure { error -> _uiState.value = _uiState.value.copy( isPackLoading = false, - packError = error.message ?: "Failed to load pack manifest", + packError = error.message.orMessage( + SettingsMessage.Res(R.string.settings_pack_load_failed) + ), pendingPackUrl = null ) } @@ -2238,14 +2285,19 @@ class SettingsViewModel @Inject constructor( isPackLoading = false, pendingPackManifest = null, pendingPackUrl = null, - toastMessage = "Installed pack: ${installedManifest.name}", + toastMessage = SettingsMessage.Res( + R.string.settings_pack_installed, + listOf(installedManifest.name.orEmpty()) + ), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( isPackLoading = false, - packError = error.message ?: "Failed to install pack" + packError = error.message.orMessage( + SettingsMessage.Res(R.string.settings_pack_install_failed) + ) ) } } @@ -2256,13 +2308,15 @@ class SettingsViewModel @Inject constructor( val result = catalogRepository.removeCatalogPack(packId) result.onSuccess { _uiState.value = _uiState.value.copy( - toastMessage = "Pack removed", + toastMessage = SettingsMessage.Res(R.string.settings_pack_removed), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: "Failed to remove pack", + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.settings_pack_remove_failed) + ), toastType = ToastType.ERROR ) } @@ -2274,13 +2328,18 @@ class SettingsViewModel @Inject constructor( val result = catalogRepository.addCustomCatalog(url) result.onSuccess { catalog -> _uiState.value = _uiState.value.copy( - toastMessage = "Added ${catalog.title}", + toastMessage = SettingsMessage.Res( + R.string.settings_catalog_added, + listOf(catalog.title) + ), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: context.getString(R.string.catalog_failed_add), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.catalog_failed_add) + ), toastType = ToastType.ERROR ) } @@ -2301,7 +2360,11 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( catalogSearchResults = emptyList(), isCatalogSearching = false, - catalogSearchError = if (normalizedQuery.isBlank()) null else "Type at least 2 characters" + catalogSearchError = if (normalizedQuery.isBlank()) { + null + } else { + SettingsMessage.Res(R.string.settings_catalog_search_min_chars) + } ) return } @@ -2316,13 +2379,19 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( catalogSearchResults = lists, isCatalogSearching = false, - catalogSearchError = if (lists.isEmpty()) "No public Trakt lists found" else null + catalogSearchError = if (lists.isEmpty()) { + SettingsMessage.Res(R.string.settings_catalog_search_no_lists) + } else { + null + } ) }.onFailure { error -> _uiState.value = _uiState.value.copy( catalogSearchResults = emptyList(), isCatalogSearching = false, - catalogSearchError = error.message ?: context.getString(R.string.catalog_failed_search) + catalogSearchError = error.message.orMessage( + SettingsMessage.Res(R.string.catalog_failed_search) + ) ) } } @@ -2344,13 +2413,18 @@ class SettingsViewModel @Inject constructor( val addResult = catalogRepository.addCustomCatalog(result.sourceUrl) addResult.onSuccess { catalog -> _uiState.value = _uiState.value.copy( - toastMessage = "Added ${catalog.title}", + toastMessage = SettingsMessage.Res( + R.string.settings_catalog_added, + listOf(catalog.title) + ), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: context.getString(R.string.catalog_failed_add), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.catalog_failed_add) + ), toastType = ToastType.ERROR ) } @@ -2362,13 +2436,18 @@ class SettingsViewModel @Inject constructor( val result = catalogRepository.updateCustomCatalog(catalogId, url) result.onSuccess { catalog -> _uiState.value = _uiState.value.copy( - toastMessage = "Updated ${catalog.title}", + toastMessage = SettingsMessage.Res( + R.string.settings_catalog_updated, + listOf(catalog.title) + ), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: context.getString(R.string.catalog_failed_update), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.catalog_failed_update) + ), toastType = ToastType.ERROR ) } @@ -2383,13 +2462,15 @@ class SettingsViewModel @Inject constructor( val updatedCatalogs = visibleCatalogs(catalogRepository.getCatalogs()) _uiState.value = _uiState.value.copy( catalogs = updatedCatalogs, - toastMessage = "Catalog removed", + toastMessage = SettingsMessage.Res(R.string.settings_catalog_removed), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: context.getString(R.string.catalog_failed_remove), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.catalog_failed_remove) + ), toastType = ToastType.ERROR ) } @@ -2411,7 +2492,7 @@ class SettingsViewModel @Inject constructor( val visible = visibleCatalogs(updated) _uiState.value = _uiState.value.copy( catalogs = visible, - toastMessage = "Catalog row extracted from pack", + toastMessage = SettingsMessage.Res(R.string.settings_catalog_unpacked), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) @@ -2449,7 +2530,7 @@ class SettingsViewModel @Inject constructor( val trimmedEpg = epgUrl.trim() if (trimmedM3u.isBlank()) { _uiState.value = _uiState.value.copy( - toastMessage = "M3U URL is required", + toastMessage = SettingsMessage.Res(R.string.settings_iptv_m3u_required), toastType = ToastType.ERROR ) return@launch @@ -2475,7 +2556,7 @@ class SettingsViewModel @Inject constructor( val trimmedMac = macAddress.trim().uppercase() if (trimmedUrl.isBlank() || trimmedMac.isBlank()) { _uiState.value = _uiState.value.copy( - toastMessage = "Portal URL and MAC address are required", + toastMessage = SettingsMessage.Res(R.string.settings_iptv_portal_mac_required), toastType = ToastType.ERROR ) return @@ -2483,7 +2564,7 @@ class SettingsViewModel @Inject constructor( val current = _uiState.value.iptvStalkerPortals if (current.size >= MAX_STALKER_PORTALS) { _uiState.value = _uiState.value.copy( - toastMessage = "Maximum number of Stalker portals reached", + toastMessage = SettingsMessage.Res(R.string.settings_iptv_stalker_max_reached), toastType = ToastType.ERROR ) return @@ -2511,7 +2592,7 @@ class SettingsViewModel @Inject constructor( val trimmedMac = macAddress.trim().uppercase() if (trimmedUrl.isBlank() || trimmedMac.isBlank()) { _uiState.value = _uiState.value.copy( - toastMessage = "Portal URL and MAC address are required", + toastMessage = SettingsMessage.Res(R.string.settings_iptv_portal_mac_required), toastType = ToastType.ERROR ) return @@ -2577,7 +2658,7 @@ class SettingsViewModel @Inject constructor( persistStalkerPortals(updated) viewModelScope.launch { _uiState.value = _uiState.value.copy( - toastMessage = "Stalker portal removed", + toastMessage = SettingsMessage.Res(R.string.settings_iptv_stalker_removed), toastType = ToastType.SUCCESS ) } @@ -2619,7 +2700,7 @@ class SettingsViewModel @Inject constructor( val usingXtream = user.isNotBlank() || pass.isNotBlank() if (usingXtream && (user.isBlank() || pass.isBlank())) { _uiState.value = _uiState.value.copy( - toastMessage = "Xtream requires both username and password", + toastMessage = SettingsMessage.Res(R.string.settings_iptv_xtream_credentials_required), toastType = ToastType.ERROR ) return @@ -2700,16 +2781,34 @@ class SettingsViewModel @Inject constructor( ) } val epgMissing = (snapshot.channels.size - epgCovered).coerceAtLeast(0) - val epgStatus = when { - snapshot.channels.isEmpty() -> "" - epgCovered > 0 -> " EPG: $epgCovered matched, $epgMissing missing." - else -> " EPG: no guide data yet." + val epgStatus: SettingsMessage? = when { + snapshot.channels.isEmpty() -> null + epgCovered > 0 -> SettingsMessage.Res( + R.string.settings_iptv_epg_matched, + listOf(epgCovered, epgMissing) + ) + else -> SettingsMessage.Res(R.string.settings_iptv_epg_none) } - val doneMsg = if (configured) { - snapshot.epgWarning ?: "Connected. Loaded ${snapshot.channels.size} channels.$epgStatus" - } else { - snapshot.epgWarning ?: "Refreshed ${snapshot.channels.size} channels.$epgStatus" + val channelCount = snapshot.channels.size + val loadedMsg = when { + configured && epgStatus != null -> SettingsMessage.Res( + R.string.settings_iptv_connected_channels_epg, + listOf(channelCount, epgStatus) + ) + configured -> SettingsMessage.Res( + R.string.settings_iptv_connected_channels, + listOf(channelCount) + ) + epgStatus != null -> SettingsMessage.Res( + R.string.settings_iptv_refreshed_channels_epg, + listOf(channelCount, epgStatus) + ) + else -> SettingsMessage.Res( + R.string.settings_iptv_refreshed_channels, + listOf(channelCount) + ) } + val doneMsg = snapshot.epgWarning?.let { SettingsMessage.Raw(it) } ?: loadedMsg _uiState.value = _uiState.value.copy( isIptvLoading = false, iptvChannelCount = snapshot.channels.size, @@ -2719,7 +2818,14 @@ class SettingsViewModel @Inject constructor( iptvProgressText = context.getString(R.string.done), iptvProgressPercent = 100, toastMessage = if (showToast) { - if (configured) "IPTV configured (${snapshot.channels.size} channels)" else "IPTV refreshed (${snapshot.channels.size} channels)" + SettingsMessage.Res( + if (configured) { + R.string.settings_iptv_configured_toast + } else { + R.string.settings_iptv_refreshed_toast + }, + listOf(channelCount) + ) } else _uiState.value.toastMessage, toastType = if (showToast) ToastType.SUCCESS else _uiState.value.toastType ) @@ -2730,11 +2836,18 @@ class SettingsViewModel @Inject constructor( if (error is CancellationException) { return@onFailure } - val failMessage = if (configured) "Failed to load IPTV playlist" else "Failed to refresh IPTV" + val failMessage = SettingsMessage.Res( + if (configured) { + R.string.settings_iptv_load_failed + } else { + R.string.settings_iptv_refresh_failed + } + ) + val failDetail = error.message.orMessage(failMessage) _uiState.value = _uiState.value.copy( isIptvLoading = false, - iptvError = error.message ?: failMessage, - iptvStatusMessage = error.message ?: failMessage, + iptvError = failDetail, + iptvStatusMessage = failDetail, iptvStatusType = ToastType.ERROR, iptvProgressText = null, iptvProgressPercent = 0, @@ -2769,11 +2882,11 @@ class SettingsViewModel @Inject constructor( isIptvLoading = false, iptvChannelCount = 0, iptvError = null, - iptvStatusMessage = "IPTV configuration removed", + iptvStatusMessage = SettingsMessage.Res(R.string.settings_iptv_config_removed), iptvStatusType = ToastType.SUCCESS, iptvProgressText = null, iptvProgressPercent = 0, - toastMessage = "IPTV configuration removed", + toastMessage = SettingsMessage.Res(R.string.settings_iptv_config_removed), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) @@ -2815,7 +2928,9 @@ class SettingsViewModel @Inject constructor( clearCloudAuthSession() _uiState.value = _uiState.value.copy( isCloudAuthWorking = false, - toastMessage = error.message ?: context.getString(R.string.cloud_login_failed_start), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.cloud_login_failed_start) + ), toastType = ToastType.ERROR ) } @@ -2854,7 +2969,9 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( showCloudEmailPasswordDialog = false, isCloudAuthWorking = false, - toastMessage = error.message ?: context.getString(R.string.cloud_signin_failed_start), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.cloud_signin_failed_start) + ), toastType = ToastType.ERROR ) } @@ -2872,16 +2989,15 @@ class SettingsViewModel @Inject constructor( ) { val trimmedEmail = AuthEmailValidator.normalize(email) AuthEmailValidator.validate(trimmedEmail, rejectDisposable = createAccount)?.let { messageRes -> - val message = context.getString(messageRes) _uiState.value = _uiState.value.copy( - toastMessage = message, + toastMessage = SettingsMessage.Res(messageRes), toastType = ToastType.ERROR ) return } if (password.isBlank()) { _uiState.value = _uiState.value.copy( - toastMessage = "Password is required", + toastMessage = SettingsMessage.Res(R.string.settings_cloud_password_required), toastType = ToastType.ERROR ) return @@ -2893,7 +3009,9 @@ class SettingsViewModel @Inject constructor( if (sessionReady.isFailure) { clearCloudAuthSession() _uiState.value = _uiState.value.copy( - toastMessage = sessionReady.exceptionOrNull()?.message ?: context.getString(R.string.cloud_signin_could_not_start), + toastMessage = sessionReady.exceptionOrNull()?.message.orMessage( + SettingsMessage.Res(R.string.cloud_signin_could_not_start) + ), toastType = ToastType.ERROR, isCloudAuthWorking = false ) @@ -2904,7 +3022,7 @@ class SettingsViewModel @Inject constructor( if (userCode.isNullOrBlank()) { clearCloudAuthSession() _uiState.value = _uiState.value.copy( - toastMessage = "Cloud sign-in session was unavailable. Try again.", + toastMessage = SettingsMessage.Res(R.string.settings_cloud_session_unavailable), toastType = ToastType.ERROR, isCloudAuthWorking = false ) @@ -2918,7 +3036,7 @@ class SettingsViewModel @Inject constructor( intent = if (createAccount) "signup" else "signin" ).onSuccess { _uiState.value = _uiState.value.copy( - toastMessage = "Waiting for approval...", + toastMessage = SettingsMessage.Res(R.string.settings_waiting_for_approval), toastType = ToastType.INFO, showCloudEmailPasswordDialog = false, isCloudAuthWorking = true @@ -2926,7 +3044,9 @@ class SettingsViewModel @Inject constructor( startCloudPolling() }.onFailure { error -> _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: context.getString(R.string.tv_link_failed), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.tv_link_failed) + ), toastType = ToastType.ERROR, isCloudAuthWorking = false ) @@ -2958,7 +3078,9 @@ class SettingsViewModel @Inject constructor( if (access.isNullOrBlank() || refresh.isNullOrBlank()) { _uiState.value = _uiState.value.copy( isCloudAuthWorking = false, - toastMessage = status.message ?: context.getString(R.string.tv_link_approved_no_tokens), + toastMessage = status.message.orMessage( + SettingsMessage.Res(R.string.tv_link_approved_no_tokens) + ), toastType = ToastType.ERROR ) return@launch @@ -2996,11 +3118,13 @@ class SettingsViewModel @Inject constructor( cloudUserCode = null, cloudVerificationUrl = null, shouldSwitchProfile = true, - toastMessage = when (restoreResult) { - CloudRestoreResult.RESTORED -> "Signed in and restored from cloud" - CloudRestoreResult.NO_BACKUP -> "Signed in successfully" - CloudRestoreResult.FAILED -> "Signed in, but cloud restore failed" - }, + toastMessage = SettingsMessage.Res( + when (restoreResult) { + CloudRestoreResult.RESTORED -> R.string.settings_cloud_signed_in_restored + CloudRestoreResult.NO_BACKUP -> R.string.settings_cloud_signed_in + CloudRestoreResult.FAILED -> R.string.settings_cloud_signed_in_restore_failed + } + ), toastType = when (restoreResult) { CloudRestoreResult.FAILED -> ToastType.ERROR else -> ToastType.SUCCESS @@ -3010,7 +3134,9 @@ class SettingsViewModel @Inject constructor( } else { _uiState.value = _uiState.value.copy( isCloudAuthWorking = false, - toastMessage = tokenImport.exceptionOrNull()?.message ?: context.getString(R.string.cloud_failed_import_tokens), + toastMessage = tokenImport.exceptionOrNull()?.message.orMessage( + SettingsMessage.Res(R.string.cloud_failed_import_tokens) + ), toastType = ToastType.ERROR ) return@launch @@ -3023,7 +3149,9 @@ class SettingsViewModel @Inject constructor( showCloudEmailPasswordDialog = false, cloudUserCode = null, cloudVerificationUrl = null, - toastMessage = status.message ?: context.getString(R.string.cloud_signin_expired), + toastMessage = status.message.orMessage( + SettingsMessage.Res(R.string.cloud_signin_expired) + ), toastType = ToastType.ERROR ) clearCloudAuthSession(cancelPolling = false) @@ -3032,7 +3160,9 @@ class SettingsViewModel @Inject constructor( TvDeviceAuthStatusType.ERROR -> { _uiState.value = _uiState.value.copy( isCloudAuthWorking = false, - toastMessage = status.message ?: context.getString(R.string.cloud_signin_failed), + toastMessage = status.message.orMessage( + SettingsMessage.Res(R.string.cloud_signin_failed) + ), toastType = ToastType.ERROR ) return@launch @@ -3044,7 +3174,7 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isCloudAuthWorking = false, - toastMessage = "Sign-in did not complete. Try again.", + toastMessage = SettingsMessage.Res(R.string.settings_cloud_signin_incomplete), toastType = ToastType.ERROR ) clearCloudAuthSession(cancelPolling = false) @@ -3101,7 +3231,7 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isHomeServerConnecting = true, homeServerError = null, - toastMessage = "Connecting Home Server...", + toastMessage = SettingsMessage.Res(R.string.settings_homeserver_connecting), toastType = ToastType.INFO ) val result = homeServerRepository.connect(serverUrl, username, password, displayName) @@ -3113,15 +3243,19 @@ class SettingsViewModel @Inject constructor( homeServerConnection = connection, homeServerConnections = connections, homeServerError = null, - toastMessage = "Home Server connected", + toastMessage = SettingsMessage.Res(R.string.settings_homeserver_connected), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( isHomeServerConnecting = false, - homeServerError = error.message ?: context.getString(R.string.homeserver_connection_failed), - toastMessage = error.message ?: context.getString(R.string.homeserver_connection_failed), + homeServerError = error.message.orMessage( + SettingsMessage.Res(R.string.homeserver_connection_failed) + ), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.homeserver_connection_failed) + ), toastType = ToastType.ERROR ) } @@ -3141,7 +3275,7 @@ class SettingsViewModel @Inject constructor( homeServerError = null, plexHomeServerAuth = null, isPlexHomeServerPolling = false, - toastMessage = "Starting code sign in...", + toastMessage = SettingsMessage.Res(R.string.settings_homeserver_code_starting), toastType = ToastType.INFO ) val result = homeServerRepository.startHomeServerCodeAuth(trimmedUrl) @@ -3151,7 +3285,7 @@ class SettingsViewModel @Inject constructor( plexHomeServerAuth = session, isPlexHomeServerPolling = true, homeServerError = null, - toastMessage = "Enter the code to connect", + toastMessage = SettingsMessage.Res(R.string.settings_homeserver_enter_code), toastType = ToastType.INFO ) startPlexHomeServerPolling(trimmedUrl, session) @@ -3162,8 +3296,12 @@ class SettingsViewModel @Inject constructor( isHomeServerConnecting = false, plexHomeServerAuth = null, isPlexHomeServerPolling = false, - homeServerError = error.message ?: context.getString(R.string.homeserver_code_signin_failed), - toastMessage = error.message ?: context.getString(R.string.homeserver_code_signin_failed), + homeServerError = error.message.orMessage( + SettingsMessage.Res(R.string.homeserver_code_signin_failed) + ), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.homeserver_code_signin_failed) + ), toastType = ToastType.ERROR ) } @@ -3192,7 +3330,7 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isHomeServerConnecting = true, - toastMessage = "Connecting server...", + toastMessage = SettingsMessage.Res(R.string.settings_homeserver_connecting_server), toastType = ToastType.INFO ) runCatching { @@ -3207,7 +3345,7 @@ class SettingsViewModel @Inject constructor( plexHomeServerAuth = null, isPlexHomeServerPolling = false, homeServerError = null, - toastMessage = "Server connected", + toastMessage = SettingsMessage.Res(R.string.settings_homeserver_server_connected), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) @@ -3219,8 +3357,12 @@ class SettingsViewModel @Inject constructor( isHomeServerConnecting = false, plexHomeServerAuth = null, isPlexHomeServerPolling = false, - homeServerError = error.message ?: context.getString(R.string.homeserver_server_connection_failed), - toastMessage = error.message ?: context.getString(R.string.homeserver_server_connection_failed), + homeServerError = error.message.orMessage( + SettingsMessage.Res(R.string.homeserver_server_connection_failed) + ), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.homeserver_server_connection_failed) + ), toastType = ToastType.ERROR ) return@launch @@ -3233,8 +3375,12 @@ class SettingsViewModel @Inject constructor( isHomeServerConnecting = false, plexHomeServerAuth = null, isPlexHomeServerPolling = false, - homeServerError = lastFailure ?: "Activation code expired", - toastMessage = lastFailure ?: "Activation code expired", + homeServerError = lastFailure.orMessage( + SettingsMessage.Res(R.string.settings_homeserver_code_expired) + ), + toastMessage = lastFailure.orMessage( + SettingsMessage.Res(R.string.settings_homeserver_code_expired) + ), toastType = ToastType.ERROR ) } @@ -3269,15 +3415,19 @@ class SettingsViewModel @Inject constructor( homeServerConnection = connections.firstOrNull(), homeServerConnections = connections, homeServerError = null, - toastMessage = "Home Server is reachable", + toastMessage = SettingsMessage.Res(R.string.settings_homeserver_reachable), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true) }.onFailure { error -> _uiState.value = _uiState.value.copy( isHomeServerConnecting = false, - homeServerError = error.message ?: context.getString(R.string.homeserver_test_failed), - toastMessage = error.message ?: context.getString(R.string.homeserver_test_failed), + homeServerError = error.message.orMessage( + SettingsMessage.Res(R.string.homeserver_test_failed) + ), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.homeserver_test_failed) + ), toastType = ToastType.ERROR ) } @@ -3295,7 +3445,7 @@ class SettingsViewModel @Inject constructor( plexHomeServerAuth = null, isPlexHomeServerPolling = false, homeServerError = null, - toastMessage = "Home Server disconnected", + toastMessage = SettingsMessage.Res(R.string.settings_homeserver_disconnected), toastType = ToastType.INFO ) syncLocalStateToCloud(silent = true) @@ -3327,12 +3477,14 @@ class SettingsViewModel @Inject constructor( if (!silent && result.isSuccess) { _uiState.value = _uiState.value.copy( - toastMessage = "Cloud sync complete", + toastMessage = SettingsMessage.Res(R.string.settings_cloud_sync_complete), toastType = ToastType.SUCCESS ) } else if (!silent && result.isFailure) { _uiState.value = _uiState.value.copy( - toastMessage = result.exceptionOrNull()?.message ?: context.getString(R.string.cloud_sync_failed), + toastMessage = result.exceptionOrNull()?.message.orMessage( + SettingsMessage.Res(R.string.cloud_sync_failed) + ), toastType = ToastType.ERROR ) } @@ -3352,16 +3504,16 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { _uiState.value = _uiState.value.copy( isForceCloudSyncing = true, - lastCloudSyncStatus = "Starting cloud upload...", - toastMessage = "Forcing cloud sync...", + lastCloudSyncStatus = SettingsMessage.Res(R.string.settings_cloud_upload_starting_status), + toastMessage = SettingsMessage.Res(R.string.settings_cloud_force_sync_toast), toastType = ToastType.INFO ) if (!ensureCloudSyncSession()) { _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, - lastCloudSyncStatus = "Cloud session expired. Reconnect ARVIO Cloud, then sync again.", - toastMessage = "Reconnect ARVIO Cloud to sync", + lastCloudSyncStatus = SettingsMessage.Res(R.string.settings_cloud_session_expired_sync_status), + toastMessage = SettingsMessage.Res(R.string.settings_cloud_session_expired_sync_toast), toastType = ToastType.INFO ) return@launch @@ -3376,8 +3528,8 @@ class SettingsViewModel @Inject constructor( if (pushResult == null) { _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, - lastCloudSyncStatus = "Upload timed out before cloud confirmed it", - toastMessage = "Cloud sync upload timed out - try again", + lastCloudSyncStatus = SettingsMessage.Res(R.string.settings_cloud_upload_timeout_status), + toastMessage = SettingsMessage.Res(R.string.settings_cloud_upload_timeout_toast), toastType = ToastType.ERROR ) return@launch @@ -3389,10 +3541,20 @@ class SettingsViewModel @Inject constructor( } } if (pushResult == null || pushResult.isFailure) { - val uploadError = pushResult?.exceptionOrNull()?.message ?: context.getString(R.string.cloud_sync_failed_upload) + val uploadErrorText = pushResult?.exceptionOrNull()?.message?.takeIf { it.isNotBlank() } + val uploadError = uploadErrorText.orMessage( + SettingsMessage.Res(R.string.cloud_sync_failed_upload) + ) _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, - lastCloudSyncStatus = "Upload failed: ${uploadError.take(120)}", + lastCloudSyncStatus = SettingsMessage.Res( + R.string.settings_cloud_upload_failed_status, + listOf( + uploadErrorText?.take(120).orMessage( + SettingsMessage.Res(R.string.cloud_sync_failed_upload) + ) + ) + ), toastMessage = uploadError, toastType = ToastType.ERROR ) @@ -3419,16 +3581,20 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, - lastCloudSyncStatus = when (restoreResult) { - CloudRestoreResult.RESTORED -> "Cloud sync complete and verified" - CloudRestoreResult.NO_BACKUP -> "Cloud upload complete; no remote restore was needed" - CloudRestoreResult.FAILED -> "Upload complete, but restore failed" - }, - toastMessage = when (restoreResult) { - CloudRestoreResult.RESTORED -> "Cloud sync complete" - CloudRestoreResult.NO_BACKUP -> "Cloud sync complete (no backup to restore)" - CloudRestoreResult.FAILED -> "Upload complete, but restore failed" - }, + lastCloudSyncStatus = SettingsMessage.Res( + when (restoreResult) { + CloudRestoreResult.RESTORED -> R.string.settings_cloud_sync_verified_status + CloudRestoreResult.NO_BACKUP -> R.string.settings_cloud_sync_no_restore_status + CloudRestoreResult.FAILED -> R.string.settings_cloud_sync_restore_failed_status + } + ), + toastMessage = SettingsMessage.Res( + when (restoreResult) { + CloudRestoreResult.RESTORED -> R.string.settings_cloud_sync_complete + CloudRestoreResult.NO_BACKUP -> R.string.settings_cloud_sync_complete_no_backup_toast + CloudRestoreResult.FAILED -> R.string.settings_cloud_sync_restore_failed_status + } + ), toastType = if (restoreResult == CloudRestoreResult.FAILED) { ToastType.ERROR } else { @@ -3444,16 +3610,16 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { _uiState.value = _uiState.value.copy( isForceCloudSyncing = true, - lastCloudSyncStatus = context.getString(R.string.settings_cloud_push_status_uploading), - toastMessage = context.getString(R.string.settings_cloud_push_toast_uploading), + lastCloudSyncStatus = SettingsMessage.Res(R.string.settings_cloud_push_status_uploading), + toastMessage = SettingsMessage.Res(R.string.settings_cloud_push_toast_uploading), toastType = ToastType.INFO ) if (!ensureCloudSyncSession()) { _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, - lastCloudSyncStatus = context.getString(R.string.settings_cloud_session_expired_status), - toastMessage = context.getString(R.string.settings_cloud_session_expired_push_toast), + lastCloudSyncStatus = SettingsMessage.Res(R.string.settings_cloud_session_expired_status), + toastMessage = SettingsMessage.Res(R.string.settings_cloud_session_expired_push_toast), toastType = ToastType.INFO ) return@launch @@ -3465,18 +3631,28 @@ class SettingsViewModel @Inject constructor( } if (pushResult == null || pushResult.isFailure) { - val uploadError = pushResult?.exceptionOrNull()?.message ?: context.getString(R.string.settings_cloud_pull_upload_error_default) + val uploadErrorText = pushResult?.exceptionOrNull()?.message?.takeIf { it.isNotBlank() } + val uploadError = uploadErrorText.orMessage( + SettingsMessage.Res(R.string.settings_cloud_pull_upload_error_default) + ) _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, - lastCloudSyncStatus = context.getString(R.string.settings_cloud_push_failed_status, uploadError.take(120)), + lastCloudSyncStatus = SettingsMessage.Res( + R.string.settings_cloud_push_failed_status, + listOf( + uploadErrorText?.take(120).orMessage( + SettingsMessage.Res(R.string.settings_cloud_pull_upload_error_default) + ) + ) + ), toastMessage = uploadError, toastType = ToastType.ERROR ) } else { _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, - lastCloudSyncStatus = context.getString(R.string.settings_cloud_push_success_status), - toastMessage = context.getString(R.string.settings_cloud_push_success_toast), + lastCloudSyncStatus = SettingsMessage.Res(R.string.settings_cloud_push_success_status), + toastMessage = SettingsMessage.Res(R.string.settings_cloud_push_success_toast), toastType = ToastType.SUCCESS ) } @@ -3489,16 +3665,16 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { _uiState.value = _uiState.value.copy( isForceCloudSyncing = true, - lastCloudSyncStatus = context.getString(R.string.settings_cloud_pull_status_pulling), - toastMessage = context.getString(R.string.settings_cloud_pull_toast_pulling), + lastCloudSyncStatus = SettingsMessage.Res(R.string.settings_cloud_pull_status_pulling), + toastMessage = SettingsMessage.Res(R.string.settings_cloud_pull_toast_pulling), toastType = ToastType.INFO ) if (!ensureCloudSyncSession()) { _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, - lastCloudSyncStatus = context.getString(R.string.settings_cloud_session_expired_status), - toastMessage = context.getString(R.string.settings_cloud_session_expired_pull_toast), + lastCloudSyncStatus = SettingsMessage.Res(R.string.settings_cloud_session_expired_status), + toastMessage = SettingsMessage.Res(R.string.settings_cloud_session_expired_pull_toast), toastType = ToastType.INFO ) return@launch @@ -3513,16 +3689,20 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, - lastCloudSyncStatus = when (restoreResult) { - CloudRestoreResult.RESTORED -> context.getString(R.string.settings_cloud_pull_restored_status) - CloudRestoreResult.NO_BACKUP -> context.getString(R.string.settings_cloud_pull_no_backup_status) - CloudRestoreResult.FAILED -> context.getString(R.string.settings_cloud_pull_failed_status) - }, - toastMessage = when (restoreResult) { - CloudRestoreResult.RESTORED -> context.getString(R.string.settings_cloud_pull_restored_toast) - CloudRestoreResult.NO_BACKUP -> context.getString(R.string.settings_cloud_pull_no_backup_toast) - CloudRestoreResult.FAILED -> context.getString(R.string.settings_cloud_pull_failed_status) - }, + lastCloudSyncStatus = SettingsMessage.Res( + when (restoreResult) { + CloudRestoreResult.RESTORED -> R.string.settings_cloud_pull_restored_status + CloudRestoreResult.NO_BACKUP -> R.string.settings_cloud_pull_no_backup_status + CloudRestoreResult.FAILED -> R.string.settings_cloud_pull_failed_status + } + ), + toastMessage = SettingsMessage.Res( + when (restoreResult) { + CloudRestoreResult.RESTORED -> R.string.settings_cloud_pull_restored_toast + CloudRestoreResult.NO_BACKUP -> R.string.settings_cloud_pull_no_backup_toast + CloudRestoreResult.FAILED -> R.string.settings_cloud_pull_failed_status + } + ), toastType = if (restoreResult == CloudRestoreResult.FAILED) ToastType.ERROR else ToastType.SUCCESS ) } @@ -3548,7 +3728,7 @@ class SettingsViewModel @Inject constructor( runCatching { launcherContinueWatchingRepository.refreshForCurrentProfile() } if (!silent) { _uiState.value = _uiState.value.copy( - toastMessage = "Cloud restore complete", + toastMessage = SettingsMessage.Res(R.string.settings_cloud_restore_complete), toastType = ToastType.SUCCESS ) } @@ -3557,7 +3737,7 @@ class SettingsViewModel @Inject constructor( CloudSyncRepository.RestoreResult.NO_BACKUP -> { if (!silent) { _uiState.value = _uiState.value.copy( - toastMessage = "No cloud backup found", + toastMessage = SettingsMessage.Res(R.string.settings_cloud_pull_no_backup_toast), toastType = ToastType.INFO ) } @@ -3566,7 +3746,7 @@ class SettingsViewModel @Inject constructor( CloudSyncRepository.RestoreResult.FAILED -> { if (!silent) { _uiState.value = _uiState.value.copy( - toastMessage = "Cloud restore failed", + toastMessage = SettingsMessage.Res(R.string.settings_cloud_restore_failed), toastType = ToastType.ERROR ) } @@ -3605,7 +3785,7 @@ class SettingsViewModel @Inject constructor( } else { if (showNoUpdateFeedback) { _uiState.value = _uiState.value.copy( - toastMessage = "You already have the latest version", + toastMessage = SettingsMessage.Res(R.string.settings_update_already_latest), toastType = ToastType.INFO ) } @@ -3614,7 +3794,9 @@ class SettingsViewModel @Inject constructor( }.onFailure { error -> if (showNoUpdateFeedback) { _uiState.value = _uiState.value.copy( - toastMessage = error.message ?: context.getString(R.string.update_check_failed), + toastMessage = error.message.orMessage( + SettingsMessage.Res(R.string.update_check_failed) + ), toastType = ToastType.ERROR ) } @@ -3761,9 +3943,14 @@ class SettingsViewModel @Inject constructor( if (e is kotlinx.coroutines.CancellationException) throw e System.err.println("SettingsVM: failed to start Trakt auth: ${e.message}") - val message = when (e) { - is retrofit2.HttpException -> "Trakt activation failed (${e.code()})" - else -> e.message?.takeIf { it.isNotBlank() } ?: "Trakt activation failed" + val message: SettingsMessage = when (e) { + is retrofit2.HttpException -> SettingsMessage.Res( + R.string.settings_trakt_activation_failed_code, + listOf(e.code()) + ) + else -> e.message.orMessage( + SettingsMessage.Res(R.string.settings_trakt_activation_failed) + ) } _uiState.value = _uiState.value.copy( traktCode = null, @@ -3794,7 +3981,7 @@ class SettingsViewModel @Inject constructor( traktPollingJob?.cancel() traktPollingJob = viewModelScope.launch { val expiresAt = System.currentTimeMillis() + (deviceCode.expiresIn * 1000) - var lastFailure: String? = null + var lastFailure: SettingsMessage? = null var pollDelayMs = deviceCode.interval.coerceAtLeast(1) * 1000L while (System.currentTimeMillis() < expiresAt) { @@ -3830,7 +4017,7 @@ class SettingsViewModel @Inject constructor( trackingWatchedReadMode = trackingPreferences.watchedReadMode, trackingWriteToTrakt = trackingPreferences.writeToTrakt == true, trackingWriteToSimkl = trackingPreferences.writeToSimkl == true, - toastMessage = "Trakt connected successfully", + toastMessage = SettingsMessage.Res(R.string.settings_trakt_connected_toast), toastType = ToastType.SUCCESS ) refreshIntegrationUsernames( @@ -3872,12 +4059,17 @@ class SettingsViewModel @Inject constructor( } lastFailure = when (httpError?.code()) { - 404 -> "Trakt activation code is invalid" - 409 -> "Trakt activation code was already used" - 410 -> "Trakt activation code expired" - 418 -> "Trakt authorization was denied" - null -> e.message?.takeIf { it.isNotBlank() } ?: "Trakt authorization failed" - else -> "Trakt authorization failed (${httpError.code()})" + 404 -> SettingsMessage.Res(R.string.settings_trakt_code_invalid) + 409 -> SettingsMessage.Res(R.string.settings_trakt_code_used) + 410 -> SettingsMessage.Res(R.string.settings_trakt_code_expired) + 418 -> SettingsMessage.Res(R.string.settings_trakt_denied) + null -> e.message.orMessage( + SettingsMessage.Res(R.string.settings_trakt_auth_failed) + ) + else -> SettingsMessage.Res( + R.string.settings_trakt_auth_failed_code, + listOf(httpError.code()) + ) } break } @@ -3889,7 +4081,7 @@ class SettingsViewModel @Inject constructor( isTraktAuthStarting = false, isTraktPolling = false, traktUsername = null, - toastMessage = lastFailure ?: "Trakt activation code expired", + toastMessage = lastFailure ?: SettingsMessage.Res(R.string.settings_trakt_code_expired), toastType = ToastType.ERROR ) } @@ -3924,7 +4116,7 @@ class SettingsViewModel @Inject constructor( trackingWatchedReadMode = preferences.watchedReadMode, trackingWriteToTrakt = false, trackingWriteToSimkl = preferences.writeToSimkl == true, - toastMessage = "Trakt disconnected", + toastMessage = SettingsMessage.Res(R.string.settings_trakt_disconnected), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true, force = true) @@ -3943,7 +4135,7 @@ class SettingsViewModel @Inject constructor( if (!valid) { _uiState.value = _uiState.value.copy( mdbListConnecting = false, - toastMessage = context.getString(R.string.mdblist_invalid_key), + toastMessage = SettingsMessage.Res(R.string.mdblist_invalid_key), toastType = ToastType.ERROR ) return@launch @@ -3967,7 +4159,7 @@ class SettingsViewModel @Inject constructor( trackingWatchedReadMode = trackingPreferences.watchedReadMode, trackingWriteToTrakt = trackingPreferences.writeToTrakt == true, trackingWriteToSimkl = trackingPreferences.writeToSimkl == true, - toastMessage = context.getString(R.string.mdblist_connected), + toastMessage = SettingsMessage.Res(R.string.mdblist_connected), toastType = ToastType.SUCCESS ) refreshIntegrationUsernames( @@ -3995,7 +4187,7 @@ class SettingsViewModel @Inject constructor( trackingWatchedReadMode = trackingPreferences.watchedReadMode, trackingWriteToTrakt = trackingPreferences.writeToTrakt == true, trackingWriteToSimkl = trackingPreferences.writeToSimkl == true, - toastMessage = context.getString(R.string.mdblist_disconnected), + toastMessage = SettingsMessage.Res(R.string.mdblist_disconnected), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true, force = true) @@ -4024,7 +4216,10 @@ class SettingsViewModel @Inject constructor( isSimklPolling = false, simklUserCode = null, simklVerificationUrl = null, - toastMessage = "Simkl Auth Error: ${e.message}", + toastMessage = SettingsMessage.Res( + R.string.settings_simkl_auth_error, + listOf(e.message.orEmpty()) + ), toastType = ToastType.ERROR ) } @@ -4058,7 +4253,7 @@ class SettingsViewModel @Inject constructor( trackingWatchedReadMode = trackingPreferences.watchedReadMode, trackingWriteToTrakt = trackingPreferences.writeToTrakt == true, trackingWriteToSimkl = trackingPreferences.writeToSimkl == true, - toastMessage = "Connected to Simkl!", + toastMessage = SettingsMessage.Res(R.string.settings_simkl_connected), toastType = ToastType.SUCCESS ) refreshIntegrationUsernames( @@ -4080,7 +4275,7 @@ class SettingsViewModel @Inject constructor( isSimklPolling = false, simklUserCode = null, simklVerificationUrl = null, - toastMessage = "Simkl authentication timed out", + toastMessage = SettingsMessage.Res(R.string.settings_simkl_timed_out), toastType = ToastType.ERROR ) } @@ -4109,7 +4304,7 @@ class SettingsViewModel @Inject constructor( trackingWatchedReadMode = trackingPreferences.watchedReadMode, trackingWriteToTrakt = trackingPreferences.writeToTrakt == true, trackingWriteToSimkl = trackingPreferences.writeToSimkl == true, - toastMessage = "Connected to Simkl!", + toastMessage = SettingsMessage.Res(R.string.settings_simkl_connected), toastType = ToastType.SUCCESS ) refreshIntegrationUsernames( @@ -4143,7 +4338,7 @@ class SettingsViewModel @Inject constructor( trackingWatchedReadMode = preferences.watchedReadMode, trackingWriteToTrakt = preferences.writeToTrakt == true, trackingWriteToSimkl = false, - toastMessage = "Disconnected from Simkl", + toastMessage = SettingsMessage.Res(R.string.settings_simkl_disconnected), toastType = ToastType.SUCCESS ) syncLocalStateToCloud(silent = true, force = true) @@ -4186,12 +4381,12 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { cancelCloudAuth() _uiState.value = _uiState.value.copy( - toastMessage = "Signing out...", + toastMessage = SettingsMessage.Res(R.string.settings_signing_out), toastType = ToastType.INFO ) authRepository.signOut() _uiState.value = _uiState.value.copy( - toastMessage = "Signed out", + toastMessage = SettingsMessage.Res(R.string.settings_signed_out), toastType = ToastType.SUCCESS ) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c8ce9b197..1f26669a1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1200,6 +1200,88 @@ Cloud-Anmeldung fehlgeschlagen. Erneut versuchen. Cloud-Sync fehlgeschlagen Cloud-Sync beim Hochladen fehlgeschlagen + %1$d Filme und %2$d Episoden synchronisiert + %1$d Filme und %2$d Episoden synchronisiert; %3$s + Kein Tracking-Dienst verbunden + Eigene Filter erkannt — zum Ändern die manuelle Bearbeitung nutzen + %1$s hinzugefügt (%2$d Kataloge importiert) + %1$s hinzugefügt (keine Kataloge vorhanden) + Cloud-Wiederherstellung fehlgeschlagen; Addons wurden nicht geändert + %1$d Addons aktualisiert, %2$d fehlgeschlagen + Addons konnten nicht aktualisiert werden + Paket-Manifest konnte nicht geladen werden + Paket installiert: %1$s + Paket konnte nicht installiert werden + Paket entfernt + Paket konnte nicht entfernt werden + %1$s hinzugefügt + %1$s aktualisiert + Katalog entfernt + Katalogzeile aus dem Paket herausgelöst + Gib mindestens 2 Zeichen ein + Keine öffentlichen Trakt-Listen gefunden + M3U-URL ist erforderlich + Portal-URL und MAC-Adresse sind erforderlich + Maximale Anzahl an Stalker-Portalen erreicht + Stalker-Portal entfernt + Xtream benötigt Benutzername und Passwort + EPG: %1$d zugeordnet, %2$d fehlen. + EPG: noch keine Programmdaten. + Verbunden. %1$d Kanäle geladen. + Verbunden. %1$d Kanäle geladen. %2$s + %1$d Kanäle aktualisiert. + %1$d Kanäle aktualisiert. %2$s + IPTV eingerichtet (%1$d Kanäle) + IPTV aktualisiert (%1$d Kanäle) + IPTV-Playlist konnte nicht geladen werden + IPTV konnte nicht aktualisiert werden + IPTV-Konfiguration entfernt + Passwort ist erforderlich + Cloud-Anmeldesitzung war nicht verfügbar. Erneut versuchen. + Angemeldet und aus der Cloud wiederhergestellt + Erfolgreich angemeldet + Angemeldet, aber die Cloud-Wiederherstellung ist fehlgeschlagen + Anmeldung wurde nicht abgeschlossen. Erneut versuchen. + Cloud-Sync abgeschlossen + Cloud-Upload wird gestartet … + Cloud-Sync wird erzwungen … + Cloud-Sitzung abgelaufen. Verbinde ARVIO Cloud neu und synchronisiere dann erneut. + Für den Sync ARVIO Cloud neu verbinden + Zeitüberschreitung beim Upload, bevor die Cloud bestätigt hat + Zeitüberschreitung beim Cloud-Upload – erneut versuchen + Upload fehlgeschlagen: %1$s + Cloud-Sync abgeschlossen und geprüft + Cloud-Upload abgeschlossen; es war keine Wiederherstellung nötig + Upload abgeschlossen, aber die Wiederherstellung ist fehlgeschlagen + Cloud-Sync abgeschlossen (keine Sicherung zum Wiederherstellen) + Cloud-Wiederherstellung abgeschlossen + Cloud-Wiederherstellung fehlgeschlagen + Du hast bereits die neueste Version + Home-Server wird verbunden … + Home-Server verbunden + Anmeldung per Code wird gestartet … + Gib den Code ein, um zu verbinden + Server wird verbunden … + Server verbunden + Aktivierungscode abgelaufen + Home-Server ist erreichbar + Home-Server getrennt + Trakt-Aktivierung fehlgeschlagen (%1$d) + Trakt-Aktivierung fehlgeschlagen + Trakt erfolgreich verbunden + Trakt-Aktivierungscode ist ungültig + Trakt-Aktivierungscode wurde bereits verwendet + Trakt-Aktivierungscode abgelaufen + Trakt-Autorisierung wurde abgelehnt + Trakt-Autorisierung fehlgeschlagen + Trakt-Autorisierung fehlgeschlagen (%1$d) + Trakt getrennt + Simkl-Anmeldefehler: %1$s + Mit Simkl verbunden! + Zeitüberschreitung bei der Simkl-Anmeldung + Von Simkl getrennt + Abmelden … + Abgemeldet Keine Episoden für Staffel %1$d gefunden Staffel %1$d konnte nicht geladen werden Keine Episode ausgewählt diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b8ef38d2..5e051fc49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1196,6 +1196,100 @@ Cloud sync failed Cloud sync failed while uploading + + Synced %1$d movies and %2$d episodes + Synced %1$d movies and %2$d episodes; %3$s + No tracking provider connected + Custom filters detected — use manual editing to modify + Added %1$s (%2$d catalogs imported) + Added %1$s (no catalogs exposed) + Cloud restore failed; addons were not changed + %1$d addons refreshed, %2$d failed + Failed to refresh addons + Failed to load pack manifest + Installed pack: %1$s + Failed to install pack + Pack removed + Failed to remove pack + + + Added %1$s + Updated %1$s + Catalog removed + Catalog row extracted from pack + Type at least 2 characters + No public Trakt lists found + + + M3U URL is required + Portal URL and MAC address are required + Maximum number of Stalker portals reached + Stalker portal removed + Xtream requires both username and password + EPG: %1$d matched, %2$d missing. + EPG: no guide data yet. + Connected. Loaded %1$d channels. + Connected. Loaded %1$d channels. %2$s + Refreshed %1$d channels. + Refreshed %1$d channels. %2$s + IPTV configured (%1$d channels) + IPTV refreshed (%1$d channels) + Failed to load IPTV playlist + Failed to refresh IPTV + IPTV configuration removed + + + Password is required + Cloud sign-in session was unavailable. Try again. + Signed in and restored from cloud + Signed in successfully + Signed in, but cloud restore failed + Sign-in did not complete. Try again. + Cloud sync complete + Starting cloud upload... + Forcing cloud sync... + Cloud session expired. Reconnect ARVIO Cloud, then sync again. + Reconnect ARVIO Cloud to sync + Upload timed out before cloud confirmed it + Cloud sync upload timed out - try again + Upload failed: %1$s + Cloud sync complete and verified + Cloud upload complete; no remote restore was needed + Upload complete, but restore failed + Cloud sync complete (no backup to restore) + Cloud restore complete + Cloud restore failed + You already have the latest version + + + Connecting Home Server... + Home Server connected + Starting code sign in... + Enter the code to connect + Connecting server... + Server connected + Activation code expired + Home Server is reachable + Home Server disconnected + + + Trakt activation failed (%1$d) + Trakt activation failed + Trakt connected successfully + Trakt activation code is invalid + Trakt activation code was already used + Trakt activation code expired + Trakt authorization was denied + Trakt authorization failed + Trakt authorization failed (%1$d) + Trakt disconnected + Simkl auth error: %1$s + Connected to Simkl! + Simkl authentication timed out + Disconnected from Simkl + Signing out... + Signed out + No episodes found for Season %1$d Failed to load Season %1$d