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/data/repository/TraktSyncService.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt index 05eb2e132..2d6835eff 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt @@ -1137,22 +1137,27 @@ class TraktSyncService @Inject constructor( } /** - * Get last sync time + * Get the persisted sync summary for the active profile. */ - suspend fun getLastSyncTime(): String? = withContext(Dispatchers.IO) { + suspend fun getLastSyncSummary(): TraktSyncSummary? = withContext(Dispatchers.IO) { try { val userId = getUserId() ?: return@withContext null if (getSupabaseAuth() == null) return@withContext null - // PostgREST requires "eq." prefix for equality filtering - val syncStates = executeSupabaseCall("get sync state (last sync)") { auth -> + val syncStates = executeSupabaseCall("get sync state summary") { auth -> supabaseApi.getSyncState( auth, userId = "eq.$userId", profileId = "eq.${activeProfileId()}" ) } - syncStates.firstOrNull()?.lastSyncAt + syncStates.firstOrNull()?.let { state -> + TraktSyncSummary( + lastSyncAt = state.lastSyncAt, + moviesSynced = state.moviesSynced, + episodesSynced = state.episodesSynced + ) + } } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e @@ -1160,6 +1165,8 @@ class TraktSyncService @Inject constructor( } } + suspend fun getLastSyncTime(): String? = getLastSyncSummary()?.lastSyncAt + // ========== Private Helpers ========== private fun buildEpisodeKey( @@ -2039,6 +2046,12 @@ class TraktSyncService @Inject constructor( // ========== Data Classes ========== +data class TraktSyncSummary( + val lastSyncAt: String?, + val moviesSynced: Int, + val episodesSynced: Int +) + data class SyncProgress( val status: SyncStatus = SyncStatus.IDLE, val message: String = "", 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 c44270cb3..ca2a7dcad 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 @@ -3534,8 +3537,7 @@ private fun MobileSettingsLayout( openSecondarySubtitlePicker = openSecondarySubtitlePicker, openAudioLanguagePicker = openAudioLanguagePicker, onSwitchProfile = onSwitchProfile, - onNavigateToTelegram = onNavigateToTelegram, - onDisconnectTrakt = onDisconnectTrakt + onNavigateToTelegram = onNavigateToTelegram ) } else { Row( @@ -3581,7 +3583,11 @@ private fun MobileSettingsLayout( onConnectHomeServerClick = onConnectHomeServerClick, onConnectPlexHomeServerClick = onConnectPlexHomeServerClick, onAddCustomAddonClick = onAddCustomAddonClick, - openCustomUserAgentDialog = openCustomUserAgentDialog + openCustomUserAgentDialog = openCustomUserAgentDialog, + onConnectTrakt = { viewModel.startTraktAuth() }, + onDisconnectTrakt = onDisconnectTrakt, + onConnectMdbList = viewModel::connectMdbList, + onDisconnectMdbList = { viewModel.disconnectMdbList() } ) } } @@ -3601,9 +3607,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, @@ -3614,33 +3622,9 @@ private fun MobileSettingsMainPage( openSecondarySubtitlePicker: () -> Unit = {}, openAudioLanguagePicker: () -> Unit, onSwitchProfile: () -> Unit, - onNavigateToTelegram: () -> Unit = {}, - onDisconnectTrakt: () -> Unit = {} + onNavigateToTelegram: () -> Unit = {} ) { val context = LocalContext.current - var showMdbListConnect by remember { mutableStateOf(false) } - var showMdbListDisconnectConfirm 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 - viewModel.disconnectMdbList() - }, - onDismiss = { showMdbListDisconnectConfirm = false } - ) - } androidx.compose.foundation.lazy.LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), @@ -3749,17 +3733,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( iconRes = R.drawable.ic_telegram, @@ -3828,8 +3809,14 @@ private fun MobileSettingsSubPage( onConnectHomeServerClick: () -> Unit, onConnectPlexHomeServerClick: () -> Unit, onAddCustomAddonClick: () -> Unit, - openCustomUserAgentDialog: () -> Unit = {} + openCustomUserAgentDialog: () -> Unit = {}, + // Tracking integrations + onConnectTrakt: () -> Unit = {}, + onDisconnectTrakt: () -> Unit = {}, + onConnectMdbList: (String) -> Unit = {}, + onDisconnectMdbList: () -> Unit = {} ) { + val scrollState = rememberScrollState() Column( modifier = Modifier @@ -4279,6 +4266,15 @@ private fun MobileSettingsSubPage( onDisconnect = { viewModel.disconnectHomeServer() } ) } + "Tracking Integrations" -> { + TrackingIntegrationsPage( + uiState = uiState, + onConnectTrakt = onConnectTrakt, + onDisconnectTrakt = onDisconnectTrakt, + onConnectMdbList = onConnectMdbList, + onDisconnectMdbList = onDisconnectMdbList + ) + } } } } @@ -8320,9 +8316,302 @@ private fun AccountDisconnectConfirmDialog( } } +// ========== 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: (String) -> Unit, + onDisconnectMdbList: () -> Unit +) { + 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 + onConnectMdbList(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 "\u2014", + modifier = Modifier.weight(1f) + ) + TrackingStatChip( + label = stringResource(R.string.settings_tracking_episodes), + value = if (uiState.syncedEpisodes > 0) uiState.syncedEpisodes.toString() else "\u2014", + 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 ?: "\u2014", + 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 9f6ffd7a5..122b07ea0 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 @@ -138,9 +138,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(), @@ -317,6 +319,9 @@ class SettingsViewModel @Inject constructor( private var traktPollingJob: Job? = null private var traktStartupJob: Job? = null + private var loadSettingsJob: Job? = null + private var integrationMetadataJob: Job? = null + private var syncSummaryJob: Job? = null private var plexHomeServerPollingJob: Job? = null private var plexHomeServerUrl: String? = null private var plexHomeServerDisplayName: String? = null @@ -444,7 +449,11 @@ class SettingsViewModel @Inject constructor( } private fun loadSettings() { - viewModelScope.launch { + loadSettingsJob?.cancel() + integrationMetadataJob?.cancel() + syncSummaryJob?.cancel() + loadSettingsJob = viewModelScope.launch { + val loadProfileId = profileManager.getProfileIdSync() // Load local preferences first val prefs = context.settingsDataStore.data.first() var defaultSub = prefs[defaultSubtitleKey()] ?: "Off" @@ -535,6 +544,8 @@ class SettingsViewModel @Inject constructor( val isTrakt = traktRepository.hasTrakt() val isMdbList = mdbListRepository.isConnected() + if (profileManager.getProfileIdSync() != loadProfileId) return@launch + // Get Trakt expiration if authenticated var traktExpiration: String? = null if (isTrakt) { @@ -582,7 +593,12 @@ class SettingsViewModel @Inject constructor( accountEmail = accountEmail, isTraktAuthenticated = isTrakt, traktExpiration = traktExpiration, + traktUsername = null, isMdbListConnected = isMdbList, + mdbListUsername = null, + lastSyncTime = null, + syncedMovies = 0, + syncedEpisodes = 0, catalogs = existingCatalogs, contentLanguage = contentLang, deviceModeOverride = deviceModeOverride, @@ -602,6 +618,71 @@ class SettingsViewModel @Inject constructor( subtitleRemoveHearingImpaired = subtitleRemoveHearingImpaired, smoothScrolling = smoothScrolling ) + + refreshIntegrationUsernames(loadProfileId, isTrakt, isMdbList) + if (isTrakt) refreshSyncSummary(loadProfileId) + } + } + + private fun refreshIntegrationUsernames( + profileId: String, + isTraktConnected: Boolean, + isMdbListConnected: Boolean + ) { + integrationMetadataJob?.cancel() + integrationMetadataJob = viewModelScope.launch { + if (isTraktConnected) { + launch { + val username = try { + withTimeoutOrNull(5_000L) { traktRepository.fetchUsername() } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + if ( + profileManager.getProfileIdSync() == profileId && + _uiState.value.isTraktAuthenticated + ) { + _uiState.value = _uiState.value.copy(traktUsername = username) + } + } + } + + if (isMdbListConnected) { + launch { + val username = try { + withTimeoutOrNull(5_000L) { mdbListRepository.fetchUsername() } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + if ( + profileManager.getProfileIdSync() == profileId && + _uiState.value.isMdbListConnected + ) { + _uiState.value = _uiState.value.copy(mdbListUsername = username) + } + } + } + } + } + + private fun refreshSyncSummary(profileId: String) { + syncSummaryJob?.cancel() + syncSummaryJob = viewModelScope.launch { + val previousLastSyncTime = _uiState.value.lastSyncTime + val summary = traktSyncService.getLastSyncSummary() + if ( + profileManager.getProfileIdSync() != profileId || + _uiState.value.lastSyncTime != previousLastSyncTime + ) return@launch + _uiState.value = _uiState.value.copy( + lastSyncTime = formatSyncTime(summary?.lastSyncAt), + syncedMovies = summary?.moviesSynced ?: 0, + syncedEpisodes = summary?.episodesSynced ?: 0 + ) } } @@ -688,11 +769,6 @@ class SettingsViewModel @Inject constructor( } } - // Load last sync time - viewModelScope.launch { - val lastSync = traktSyncService.getLastSyncTime() - _uiState.value = _uiState.value.copy(lastSyncTime = formatSyncTime(lastSync)) - } } private fun formatSyncTime(isoTime: String?): String? { @@ -3172,12 +3248,13 @@ class SettingsViewModel @Inject constructor( if (current.isTraktAuthStarting || current.isTraktPolling) return traktStartupJob?.cancel() + traktPollingJob?.cancel() traktStartupJob = viewModelScope.launch { - traktPollingJob?.cancel() _uiState.value = _uiState.value.copy( traktCode = null, isTraktAuthStarting = true, isTraktPolling = false, + traktUsername = null, toastMessage = null ) @@ -3190,6 +3267,7 @@ class SettingsViewModel @Inject constructor( traktCode = deviceCode, isTraktAuthStarting = false, isTraktAuthenticated = false, + traktUsername = null, isTraktPolling = true ) @@ -3207,6 +3285,7 @@ class SettingsViewModel @Inject constructor( traktCode = null, isTraktAuthStarting = false, isTraktPolling = false, + traktUsername = null, toastMessage = message, toastType = ToastType.ERROR ) @@ -3220,6 +3299,7 @@ class SettingsViewModel @Inject constructor( traktRepository.logout() _uiState.value = _uiState.value.copy( isTraktAuthenticated = false, + traktUsername = null, traktExpiration = null ) startTraktAuth() @@ -3248,7 +3328,9 @@ class SettingsViewModel @Inject constructor( syncProviderStore.setMdbListApiKey(null) _uiState.value = _uiState.value.copy( isTraktAuthenticated = true, + traktUsername = null, isMdbListConnected = false, + mdbListUsername = null, traktCode = null, isTraktAuthStarting = false, isTraktPolling = false, @@ -3256,6 +3338,11 @@ class SettingsViewModel @Inject constructor( toastMessage = "Trakt connected successfully", toastType = ToastType.SUCCESS ) + refreshIntegrationUsernames( + profileManager.getProfileIdSync(), + isTraktConnected = true, + isMdbListConnected = false + ) traktRepository.clearContinueWatchingCache() runCatching { traktRepository.getContinueWatching() } performFullSync(silent = true) @@ -3305,6 +3392,7 @@ class SettingsViewModel @Inject constructor( traktCode = null, isTraktAuthStarting = false, isTraktPolling = false, + traktUsername = null, toastMessage = lastFailure ?: "Trakt activation code expired", toastType = ToastType.ERROR ) @@ -3317,7 +3405,8 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( traktCode = null, isTraktAuthStarting = false, - isTraktPolling = false + isTraktPolling = false, + traktUsername = null ) } @@ -3328,7 +3417,11 @@ 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, + lastSyncTime = null, + syncedMovies = 0, + syncedEpisodes = 0, toastMessage = "Trakt disconnected", toastType = ToastType.SUCCESS ) @@ -3365,11 +3458,21 @@ class SettingsViewModel @Inject constructor( _uiState.value = _uiState.value.copy( mdbListConnecting = false, isMdbListConnected = true, + mdbListUsername = null, isTraktAuthenticated = false, + traktUsername = null, traktExpiration = null, + lastSyncTime = null, + syncedMovies = 0, + syncedEpisodes = 0, toastMessage = context.getString(R.string.mdblist_connected), toastType = ToastType.SUCCESS ) + refreshIntegrationUsernames( + profileManager.getProfileIdSync(), + isTraktConnected = false, + isMdbListConnected = true + ) // The MDBList watchlist is pulled when the Watchlist screen next loads. syncLocalStateToCloud(silent = true, force = true) runCatching { launcherContinueWatchingRepository.refreshForCurrentProfile() } @@ -3382,6 +3485,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 7691eae77..91cb81dce 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