From fd08dc159466cbbb27d8cf3852ada837b7a45af8 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sun, 9 Aug 2026 18:21:05 +0530 Subject: [PATCH] feat(settings): add tracking integrations sub-page with stats grid & brand logos --- .../kotlin/com/arflix/tv/data/api/TraktApi.kt | 17 + .../tv/data/repository/MdbListRepository.kt | 15 + .../tv/data/repository/TraktRepository.kt | 16 + .../tv/ui/screens/settings/SettingsScreen.kt | 474 +++++++++++++++++- .../ui/screens/settings/SettingsViewModel.kt | 69 ++- app/src/main/res/drawable/ic_mdblist.xml | 9 + app/src/main/res/drawable/ic_simkl.xml | 9 + app/src/main/res/drawable/ic_trakt.xml | 9 + app/src/main/res/values/strings.xml | 16 + 9 files changed, 615 insertions(+), 19 deletions(-) create mode 100644 app/src/main/res/drawable/ic_mdblist.xml create mode 100644 app/src/main/res/drawable/ic_simkl.xml create mode 100644 app/src/main/res/drawable/ic_trakt.xml diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/TraktApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/TraktApi.kt index 88cc97670..7ff15e400 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/api/TraktApi.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/api/TraktApi.kt @@ -438,6 +438,16 @@ interface TraktApi { @Query("page") page: Int = 1, @Query("limit") limit: Int = 100 ): List + + // ========== User Profile ========== + + @GET("users/me") + @retrofit2.http.Headers("Content-Type: application/json") + suspend fun getMe( + @Header("Authorization") auth: String, + @Header("trakt-api-key") clientId: String, + @Header("trakt-api-version") version: String = "2" + ): TraktUserProfile } // ========== Request Bodies ========== @@ -925,3 +935,10 @@ data class TraktBulkEpisodeItem( val number: Int, @SerializedName("watched_at") val watchedAt: String? = null ) + +// ========== User Profile ========== + +data class TraktUserProfile( + val username: String? = null, + val name: String? = null +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt index 62517a8e6..8adae6483 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt @@ -71,6 +71,21 @@ class MdbListRepository @Inject constructor( suspend fun isConnected(): Boolean = key() != null + /** + * Fetches the MDBList username for the currently connected API key. + * Returns null gracefully if not connected or if the request fails. + */ + suspend fun fetchUsername(): String? = withContext(Dispatchers.IO) { + try { + val k = key() ?: return@withContext null + api.getUser(k).username + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + null + } + } + // ===== Watchlist ===== suspend fun addToWatchlist(mediaType: MediaType, tmdbId: Int): Boolean = diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt index 63f0411d4..2b321389f 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktRepository.kt @@ -491,6 +491,22 @@ class TraktRepository @Inject constructor( return "Bearer $token" } + /** + * Fetches the Trakt username for the currently authenticated profile. + * Returns null gracefully if not authenticated or if the request fails — a + * failed username fetch must never break the auth or sync flow. + */ + suspend fun fetchUsername(): String? = withContext(Dispatchers.IO) { + try { + val auth = getAuthHeader() ?: return@withContext null + traktApi.getMe(auth = auth, clientId = clientId).username + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + null + } + } + private suspend fun hasStoredTraktTokenForCurrentProfile(): Boolean { ensureProfileCacheScope() val prefs = context.traktDataStore.data.first() 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 c25a49ffc..d18488f86 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 @@ -9,7 +9,9 @@ import android.os.SystemClock import android.view.inputmethod.InputMethodManager import android.widget.EditText import androidx.compose.foundation.clickable +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.layout.ContentScale import coil.compose.AsyncImage import com.arflix.tv.BuildConfig @@ -27,6 +29,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -3543,7 +3546,11 @@ private fun MobileSettingsLayout( onConnectHomeServerClick = onConnectHomeServerClick, onConnectPlexHomeServerClick = onConnectPlexHomeServerClick, onAddCustomAddonClick = onAddCustomAddonClick, - openCustomUserAgentDialog = openCustomUserAgentDialog + openCustomUserAgentDialog = openCustomUserAgentDialog, + onConnectTrakt = { viewModel.startTraktAuth() }, + onDisconnectTrakt = onDisconnectTrakt, + onConnectMdbList = { /* dialog opened inside TrackingIntegrationsPage */ }, + onDisconnectMdbList = { viewModel.disconnectMdbList() } ) } } @@ -3563,9 +3570,11 @@ private fun mobileCategoryTitle(page: String): String = when (page) { "Catalogs" -> stringResource(R.string.catalogs) "TV" -> stringResource(R.string.iptv) "Home Server" -> stringResource(R.string.settings_home_server) + "Tracking Integrations" -> stringResource(R.string.settings_tracking_integrations) else -> page } + @Composable private fun MobileSettingsMainPage( uiState: SettingsUiState, @@ -3579,6 +3588,7 @@ private fun MobileSettingsMainPage( onNavigateToTelegram: () -> Unit = {}, onDisconnectTrakt: () -> Unit = {} ) { + var showMdbListConnect by remember { mutableStateOf(false) } var showMdbListDisconnectConfirm by remember { mutableStateOf(false) } if (showMdbListConnect) { @@ -3710,17 +3720,14 @@ private fun MobileSettingsMainPage( } MobileSettingsRow( icon = Icons.Default.Movie, - title = stringResource(R.string.trakt_account), - value = if (uiState.isTraktAuthenticated) stringResource(R.string.settings_disconnect) else stringResource(R.string.connect), - isFocused = false, - onClick = { if (uiState.isTraktAuthenticated) onDisconnectTrakt() else viewModel.startTraktAuth() } - ) - MobileSettingsRow( - icon = Icons.Default.Movie, - title = stringResource(R.string.mdblist_account), - value = if (uiState.isMdbListConnected) stringResource(R.string.settings_disconnect) else stringResource(R.string.connect), + title = stringResource(R.string.settings_tracking_integrations), + value = when { + uiState.isTraktAuthenticated -> "Trakt" + uiState.isMdbListConnected -> "MDBList" + else -> "" + }, isFocused = false, - onClick = { if (uiState.isMdbListConnected) showMdbListDisconnectConfirm = true else showMdbListConnect = true } + onClick = { onNavigate("Tracking Integrations") } ) MobileSettingsRow( icon = Icons.Default.QrCode, @@ -3765,8 +3772,14 @@ private fun MobileSettingsSubPage( onConnectHomeServerClick: () -> Unit, onConnectPlexHomeServerClick: () -> Unit, onAddCustomAddonClick: () -> Unit, - openCustomUserAgentDialog: () -> Unit = {} + openCustomUserAgentDialog: () -> Unit = {}, + // Tracking integrations + onConnectTrakt: () -> Unit = {}, + onDisconnectTrakt: () -> Unit = {}, + onConnectMdbList: () -> Unit = {}, + onDisconnectMdbList: () -> Unit = {} ) { + val scrollState = rememberScrollState() Column( modifier = Modifier @@ -4216,6 +4229,142 @@ private fun MobileSettingsSubPage( onDisconnect = { viewModel.disconnectHomeServer() } ) } + "Tracking Integrations" -> { + TrackingIntegrationsPage( + uiState = uiState, + onConnectTrakt = onConnectTrakt, + onDisconnectTrakt = onDisconnectTrakt, + onConnectMdbList = onConnectMdbList, + onDisconnectMdbList = onDisconnectMdbList + ) + } + } + } +} + +@Composable +private fun MobileSettingsCategory( + title: String, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = title, + style = ArflixTypography.caption.copy(fontSize = 12.sp, letterSpacing = 1.sp), + color = TextSecondary, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) + ) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(BackgroundElevated) + ) { + content() + } + } +} + +@Composable +private fun MobileSettingsRow( + icon: ImageVector? = null, + @androidx.annotation.DrawableRes iconRes: Int? = null, + title: String, + subtitle: String = "", + value: String?, + isFocused: Boolean = false, + isToggle: Boolean = (value == "On" || value == "Off"), + showDivider: Boolean = true, + onClick: () -> Unit +) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + if (iconRes != null) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(24.dp) + ) + } else if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(24.dp) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = title, + style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + style = ArflixTypography.caption.copy(fontSize = 13.sp), + color = TextSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + } + val safeValue = value.orEmpty() + if (safeValue.isNotEmpty()) { + Spacer(modifier = Modifier.width(16.dp)) + if (isToggle && (safeValue == "On" || safeValue == "Off")) { + val isChecked = safeValue == "On" + Box( + modifier = Modifier + .width(44.dp) + .height(24.dp) + .background( + color = if (isChecked) SuccessGreen else Color.White.copy(alpha = 0.2f), + shape = RoundedCornerShape(13.dp) + ) + .padding(3.dp), + contentAlignment = if (isChecked) Alignment.CenterEnd else Alignment.CenterStart + ) { + Box( + modifier = Modifier + .size(18.dp) + .background( + color = Color.White, + shape = RoundedCornerShape(10.dp) + ) + ) + } + } else { + Text( + text = localizeSettingValue(safeValue), + style = ArflixTypography.caption.copy( + fontSize = 13.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Medium + ), + color = TextSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + if (showDivider) { + Box(modifier = Modifier.fillMaxWidth().height(1.dp).padding(horizontal = 16.dp).background(Color.White.copy(alpha = 0.05f))) } } } @@ -8232,9 +8381,310 @@ private fun AccountDisconnectConfirmDialog( } } +// ========== Tracking Integrations Mobile Sub-Page ========== + +/** + * Full Tracking Integrations sub-page rendered inside MobileSettingsSubPage. + * Owns all connect/disconnect dialog state for the three services. + */ +// ========== Tracking Integrations Mobile Sub-Page ========== + +/** + * Full Tracking Integrations sub-page rendered inside MobileSettingsSubPage. + * Uses standard MobileSettingsCategory blocks matching the rest of the settings UI. + */ +@Composable +private fun TrackingIntegrationsPage( + uiState: SettingsUiState, + onConnectTrakt: () -> Unit, + onDisconnectTrakt: () -> Unit, + onConnectMdbList: () -> Unit, + onDisconnectMdbList: () -> Unit +) { + val viewModel = androidx.lifecycle.viewmodel.compose.viewModel() + + var showMdbListConnect by remember { mutableStateOf(false) } + var showMdbListDisconnectConfirm by remember { mutableStateOf(false) } + var showTraktDisconnectConfirm by remember { mutableStateOf(false) } + + if (showMdbListConnect) { + MdbListConnectDialog( + connecting = uiState.mdbListConnecting, + onConnect = { key -> + showMdbListConnect = false + viewModel.connectMdbList(key) + }, + onDismiss = { showMdbListConnect = false } + ) + } + if (showMdbListDisconnectConfirm) { + AccountDisconnectConfirmDialog( + title = stringResource(R.string.mdblist_disconnect_confirm_title), + description = stringResource(R.string.mdblist_disconnect_confirm_desc), + onConfirm = { + showMdbListDisconnectConfirm = false + onDisconnectMdbList() + }, + onDismiss = { showMdbListDisconnectConfirm = false } + ) + } + if (showTraktDisconnectConfirm) { + AccountDisconnectConfirmDialog( + title = stringResource(R.string.settings_trakt_disconnect_confirm_title), + description = stringResource(R.string.settings_trakt_disconnect_confirm_desc), + onConfirm = { + showTraktDisconnectConfirm = false + onDisconnectTrakt() + }, + onDismiss = { showTraktDisconnectConfirm = false } + ) + } + + val activeProviders = buildList { + if (uiState.isTraktAuthenticated) add("Trakt") + if (uiState.isMdbListConnected) add("MDBList") + } + val activeProvider = if (activeProviders.isNotEmpty()) { + activeProviders.joinToString(", ") + } else { + stringResource(R.string.settings_tracking_none) + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Section 1: SYNC STATS (2x2 grid with BackgroundElevated chips) + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.settings_tracking_section_stats), + style = ArflixTypography.caption.copy(fontSize = 12.sp, letterSpacing = 1.sp), + color = TextSecondary, + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp) + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + TrackingStatChip( + label = stringResource(R.string.settings_tracking_movies), + value = if (uiState.syncedMovies > 0) uiState.syncedMovies.toString() else "—", + modifier = Modifier.weight(1f) + ) + TrackingStatChip( + label = stringResource(R.string.settings_tracking_episodes), + value = if (uiState.syncedEpisodes > 0) uiState.syncedEpisodes.toString() else "—", + modifier = Modifier.weight(1f) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + TrackingStatChip( + label = stringResource(R.string.settings_tracking_provider), + value = activeProvider, + modifier = Modifier.weight(1f) + ) + TrackingStatChip( + label = stringResource(R.string.settings_tracking_last_sync), + value = uiState.lastSyncTime ?: "—", + modifier = Modifier.weight(1f) + ) + } + } + } + + // Section 2: SERVICES + MobileSettingsCategory(title = stringResource(R.string.settings_tracking_section_services)) { + // Trakt + TrackingServiceRow( + iconRes = R.drawable.ic_trakt, + title = "Trakt", + tagline = stringResource(R.string.settings_trakt_tagline), + isConnected = uiState.isTraktAuthenticated, + isWorking = uiState.isTraktPolling || uiState.isTraktAuthStarting, + connectedAs = uiState.traktUsername, + showDivider = true, + onConnect = onConnectTrakt, + onDisconnect = { showTraktDisconnectConfirm = true } + ) + + // MDBList + TrackingServiceRow( + iconRes = R.drawable.ic_mdblist, + title = "MDBList", + tagline = stringResource(R.string.settings_mdblist_tagline), + isConnected = uiState.isMdbListConnected, + isWorking = uiState.mdbListConnecting, + connectedAs = uiState.mdbListUsername, + showDivider = true, + onConnect = { showMdbListConnect = true }, + onDisconnect = { showMdbListDisconnectConfirm = true } + ) + + // Simkl — Coming Soon + TrackingServiceRow( + iconRes = R.drawable.ic_simkl, + title = "Simkl", + tagline = stringResource(R.string.settings_simkl_tagline), + isConnected = false, + isWorking = false, + connectedAs = null, + comingSoon = true, + showDivider = false, + onConnect = {}, + onDisconnect = {} + ) + } + } +} + +@Composable +private fun TrackingStatChip( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(BackgroundElevated) + .padding(horizontal = 16.dp, vertical = 14.dp) + ) { + Text( + text = value, + style = ArflixTypography.cardTitle.copy(fontSize = 18.sp), + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = label, + style = ArflixTypography.caption.copy(fontSize = 11.sp), + color = TextSecondary + ) + } +} + +@Composable +private fun TrackingServiceRow( + @androidx.annotation.DrawableRes iconRes: Int, + title: String, + tagline: String, + isConnected: Boolean, + isWorking: Boolean, + connectedAs: String?, + comingSoon: Boolean = false, + showDivider: Boolean = true, + onConnect: () -> Unit, + onDisconnect: () -> Unit +) { + val rowAlpha = if (comingSoon) 0.5f else 1f + + Column(modifier = Modifier.alpha(rowAlpha)) { + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (!comingSoon && !isWorking) { + Modifier.clickable { if (isConnected) onDisconnect() else onConnect() } + } else Modifier + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = title, + style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), + color = TextPrimary + ) + Text( + text = tagline, + style = ArflixTypography.caption.copy(fontSize = 13.sp), + color = TextSecondary + ) + if (isConnected && !connectedAs.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.settings_connected_as, "@$connectedAs"), + style = ArflixTypography.caption.copy(fontSize = 12.sp), + color = TextSecondary + ) + } + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Status pill + val (pillText, pillBg, pillText2) = when { + comingSoon -> Triple( + stringResource(R.string.settings_coming_soon), + Color.White.copy(alpha = 0.10f), + TextSecondary + ) + isWorking -> Triple( + stringResource(R.string.settings_connecting), + Color.White.copy(alpha = 0.10f), + TextSecondary + ) + isConnected -> Triple( + stringResource(R.string.connected), + Color(0xFF1CE783).copy(alpha = 0.15f), + Color(0xFF1CE783) + ) + else -> Triple( + stringResource(R.string.connect), + Pink.copy(alpha = 0.15f), + Pink + ) + } + Box( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(pillBg) + .padding(horizontal = 12.dp, vertical = 5.dp) + ) { + Text( + text = pillText, + style = ArflixTypography.caption.copy(fontSize = 12.sp), + color = pillText2 + ) + } + } + + if (showDivider) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .padding(horizontal = 16.dp) + .background(Color.White.copy(alpha = 0.05f)) + ) + } + } +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun AccountRow( + name: String, description: String, isConnected: Boolean, 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 cbac656b9..0bc88ccd2 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 @@ -136,9 +136,11 @@ data class SettingsUiState( val isTraktAuthStarting: Boolean = false, val isTraktPolling: Boolean = false, val traktExpiration: String? = null, + val traktUsername: String? = null, // MDBList (alternative remote sync provider) val isMdbListConnected: Boolean = false, val mdbListConnecting: Boolean = false, + val mdbListUsername: String? = null, // Trakt Sync val isSyncing: Boolean = false, val syncProgress: SyncProgress = SyncProgress(), @@ -315,6 +317,7 @@ class SettingsViewModel @Inject constructor( private var traktPollingJob: Job? = null private var traktStartupJob: Job? = null + private var loadSettingsJob: Job? = null private var plexHomeServerPollingJob: Job? = null private var plexHomeServerUrl: String? = null private var plexHomeServerDisplayName: String? = null @@ -434,7 +437,9 @@ class SettingsViewModel @Inject constructor( } private fun loadSettings() { - viewModelScope.launch { + loadSettingsJob?.cancel() + loadSettingsJob = viewModelScope.launch { + val loadProfileId = profileManager.getProfileIdSync() // Load local preferences first val prefs = context.settingsDataStore.data.first() var defaultSub = prefs[defaultSubtitleKey()] ?: "Off" @@ -525,6 +530,29 @@ class SettingsViewModel @Inject constructor( val isTrakt = traktRepository.hasTrakt() val isMdbList = mdbListRepository.isConnected() + // Fetch usernames for connected integrations (best-effort, non-blocking) + val traktUsername = if (isTrakt) { + try { + traktRepository.fetchUsername() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + null + } + } else null + + val mdbListUsername = if (isMdbList) { + try { + mdbListRepository.fetchUsername() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + null + } + } else null + + if (profileManager.getProfileIdSync() != loadProfileId) return@launch + // Get Trakt expiration if authenticated var traktExpiration: String? = null if (isTrakt) { @@ -572,7 +600,9 @@ class SettingsViewModel @Inject constructor( accountEmail = accountEmail, isTraktAuthenticated = isTrakt, traktExpiration = traktExpiration, + traktUsername = traktUsername, isMdbListConnected = isMdbList, + mdbListUsername = mdbListUsername, catalogs = existingCatalogs, contentLanguage = contentLang, deviceModeOverride = deviceModeOverride, @@ -3158,16 +3188,14 @@ class SettingsViewModel @Inject constructor( // ========== Trakt Authentication ========== fun startTraktAuth() { - val current = _uiState.value - if (current.isTraktAuthStarting || current.isTraktPolling) return - - traktStartupJob?.cancel() - traktStartupJob = viewModelScope.launch { + viewModelScope.launch { + cancelTraktAuth() traktPollingJob?.cancel() _uiState.value = _uiState.value.copy( traktCode = null, isTraktAuthStarting = true, isTraktPolling = false, + traktUsername = null, toastMessage = null ) @@ -3180,6 +3208,7 @@ class SettingsViewModel @Inject constructor( traktCode = deviceCode, isTraktAuthStarting = false, isTraktAuthenticated = false, + traktUsername = null, isTraktPolling = true ) @@ -3197,6 +3226,7 @@ class SettingsViewModel @Inject constructor( traktCode = null, isTraktAuthStarting = false, isTraktPolling = false, + traktUsername = null, toastMessage = message, toastType = ToastType.ERROR ) @@ -3210,6 +3240,7 @@ class SettingsViewModel @Inject constructor( traktRepository.logout() _uiState.value = _uiState.value.copy( isTraktAuthenticated = false, + traktUsername = null, traktExpiration = null ) startTraktAuth() @@ -3236,9 +3267,19 @@ class SettingsViewModel @Inject constructor( // Mutual exclusion: selecting Trakt disconnects MDBList for this profile. syncProviderStore.setProvider(com.arflix.tv.data.repository.sync.SyncProvider.TRAKT) syncProviderStore.setMdbListApiKey(null) + // Fetch username best-effort (don't fail the auth flow if it errors) + val username = try { + traktRepository.fetchUsername() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + null + } _uiState.value = _uiState.value.copy( isTraktAuthenticated = true, + traktUsername = username, isMdbListConnected = false, + mdbListUsername = null, traktCode = null, isTraktAuthStarting = false, isTraktPolling = false, @@ -3295,6 +3336,7 @@ class SettingsViewModel @Inject constructor( traktCode = null, isTraktAuthStarting = false, isTraktPolling = false, + traktUsername = null, toastMessage = lastFailure ?: "Trakt activation code expired", toastType = ToastType.ERROR ) @@ -3307,7 +3349,8 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( traktCode = null, isTraktAuthStarting = false, - isTraktPolling = false + isTraktPolling = false, + traktUsername = null ) } @@ -3318,6 +3361,7 @@ class SettingsViewModel @Inject constructor( syncProviderStore.setProvider(com.arflix.tv.data.repository.sync.SyncProvider.NONE) _uiState.value = _uiState.value.copy( isTraktAuthenticated = false, + traktUsername = null, traktExpiration = null, toastMessage = "Trakt disconnected", toastType = ToastType.SUCCESS @@ -3352,10 +3396,20 @@ class SettingsViewModel @Inject constructor( runCatching { traktRepository.logout() } syncProviderStore.setMdbListApiKey(trimmed) syncProviderStore.setProvider(com.arflix.tv.data.repository.sync.SyncProvider.MDBLIST) + // Fetch username from the /user endpoint we already called during validateKey + val username = try { + mdbListRepository.fetchUsername() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (_: Exception) { + null + } _uiState.value = _uiState.value.copy( mdbListConnecting = false, isMdbListConnected = true, + mdbListUsername = username, isTraktAuthenticated = false, + traktUsername = null, traktExpiration = null, toastMessage = context.getString(R.string.mdblist_connected), toastType = ToastType.SUCCESS @@ -3372,6 +3426,7 @@ class SettingsViewModel @Inject constructor( syncProviderStore.setProvider(com.arflix.tv.data.repository.sync.SyncProvider.NONE) _uiState.value = _uiState.value.copy( isMdbListConnected = false, + mdbListUsername = null, toastMessage = context.getString(R.string.mdblist_disconnected), toastType = ToastType.SUCCESS ) diff --git a/app/src/main/res/drawable/ic_mdblist.xml b/app/src/main/res/drawable/ic_mdblist.xml new file mode 100644 index 000000000..f0c423277 --- /dev/null +++ b/app/src/main/res/drawable/ic_mdblist.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_simkl.xml b/app/src/main/res/drawable/ic_simkl.xml new file mode 100644 index 000000000..25f82b143 --- /dev/null +++ b/app/src/main/res/drawable/ic_simkl.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trakt.xml b/app/src/main/res/drawable/ic_trakt.xml new file mode 100644 index 000000000..fb1ffcbdc --- /dev/null +++ b/app/src/main/res/drawable/ic_trakt.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e6236032..293d79da7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -440,6 +440,22 @@ Connect MDBList Disconnect MDBList? Your watch history and lists will no longer sync. + + Tracking Integrations + Connected as %1$s + Movies synced + Episodes synced + Active provider + Last sync + Coming Soon + Connecting… + Scrobble history, ratings & watchlists + Ratings, lists & watch tracking + Full scrobble & anime tracking + SYNC STATS + SERVICES + None + App language Default Secondary