diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index c4124d0d..e899b044 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -35,6 +35,7 @@ import com.nuvio.app.features.settings.ThemeSettingsStorage import com.nuvio.app.features.trakt.TraktAuthStorage import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktLibraryStorage +import com.nuvio.app.features.trakt.TraktSettingsStorage import com.nuvio.app.features.tmdb.TmdbSettingsStorage import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform import com.nuvio.app.core.ui.PosterCardStyleStorage @@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() { TraktAuthStorage.initialize(applicationContext) TraktCommentsStorage.initialize(applicationContext) TraktLibraryStorage.initialize(applicationContext) + TraktSettingsStorage.initialize(applicationContext) ContinueWatchingPreferencesStorage.initialize(applicationContext) ResumePromptStorage.initialize(applicationContext) ContinueWatchingEnrichmentStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt new file mode 100644 index 00000000..35f23eb7 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.features.trakt + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +internal actual object TraktSettingsStorage { + private const val preferencesName = "nuvio_trakt_settings" + private const val payloadKey = "trakt_settings_payload" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(): String? = + preferences?.getString(ProfileScopedKey.of(payloadKey), null) + + actual fun savePayload(payload: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(payloadKey), payload) + ?.apply() + } +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 43f04cb0..04177a28 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -783,6 +783,20 @@ Open Trakt Login Your Save actions can now target Trakt watchlist and personal lists. Sign in with Trakt to enable list-based saving and Trakt library mode. + Watch Progress + Choose which progress source powers resume and continue watching + Watch Progress + Choose whether resume and continue watching should use Trakt or Nuvio Sync while Trakt scrobbling stays active. + Trakt + Nuvio Sync + Watch progress source set to Trakt + Watch progress source set to Nuvio Sync + Continue Watching Window + Trakt history considered for continue watching + Continue Watching Window + Choose how much Trakt activity should appear in continue watching. + All history + %1$d days Audience Score IMDb Letterboxd diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index 96e2a31e..603fce83 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -21,6 +21,7 @@ import com.nuvio.app.features.streams.StreamContextStore import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -47,6 +48,7 @@ internal object LocalAccountDataCleaner { ThemeSettingsRepository.clearLocalState() PosterCardStyleRepository.clearLocalState() TraktAuthRepository.clearLocalState() + TraktSettingsRepository.clearLocalState() PlayerSettingsRepository.clearLocalState() CatalogRepository.clear() StreamsRepository.clear() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index a56aefb8..cdbd477a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -21,6 +21,8 @@ import com.nuvio.app.features.tmdb.TmdbSettingsStorage import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsSettings +import com.nuvio.app.features.trakt.TraktSettingsStorage +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import io.github.jan.supabase.postgrest.postgrest @@ -156,6 +158,7 @@ object ProfileSettingsSync { MdbListSettingsRepository.uiState.map { "mdblist" }, MetaScreenSettingsRepository.uiState.map { "meta" }, ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, + TraktSettingsRepository.uiState.map { "trakt_settings" }, TraktCommentsSettings.enabled.map { "trakt_comments" }, EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }, ) @@ -199,6 +202,7 @@ object ProfileSettingsSync { mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(), + traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(), traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(), notificationsSettings = NotificationsSettingsPayload( episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled, @@ -230,6 +234,9 @@ object ProfileSettingsSync { ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload) ContinueWatchingPreferencesRepository.onProfileChanged() + TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload) + TraktSettingsRepository.onProfileChanged() + TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings) TraktCommentsSettings.onProfileChanged() @@ -244,6 +251,7 @@ object ProfileSettingsSync { MdbListSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded() ContinueWatchingPreferencesRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() TraktCommentsSettings.ensureLoaded() EpisodeReleaseNotificationsRepository.ensureLoaded() } @@ -263,6 +271,7 @@ object ProfileSettingsSync { "mdblist=${MdbListSettingsRepository.uiState.value}", "meta=${MetaScreenSettingsRepository.uiState.value}", "continue=${ContinueWatchingPreferencesRepository.uiState.value}", + "trakt_settings=${TraktSettingsRepository.uiState.value}", "trakt_comments=${TraktCommentsSettings.enabled.value}", "episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}", ).joinToString(separator = "||") @@ -283,6 +292,7 @@ private data class MobileProfileSettingsFeatures( @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "", @SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "", + @SerialName("trakt_settings_payload") val traktSettingsPayload: String = "", @SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()), @SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 82659478..644295bd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -29,6 +29,10 @@ import com.nuvio.app.features.home.components.HomeHeroSection import com.nuvio.app.features.home.components.HomeSkeletonHero import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap +import com.nuvio.app.features.trakt.shouldUseTraktProgress import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem @@ -87,6 +91,10 @@ fun HomeScreen( val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() + val traktSettingsUiState by remember { + TraktSettingsRepository.ensureLoaded() + TraktSettingsRepository.uiState + }.collectAsStateWithLifecycle() val isTraktAuthenticated by remember { TraktAuthRepository.ensureLoaded() TraktAuthRepository.isAuthenticated @@ -114,17 +122,31 @@ fun HomeScreen( } } - val effectiveWatchProgressEntries = remember(watchProgressUiState.entries, isTraktAuthenticated) { - if (!isTraktAuthenticated) { - watchProgressUiState.entries - } else { - val cutoffMs = WatchProgressClock.nowEpochMs() - (TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT.toLong() * 24L * 60L * 60L * 1000L) - watchProgressUiState.entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } - } + val isTraktProgressActive = remember( + isTraktAuthenticated, + traktSettingsUiState.watchProgressSource, + ) { + shouldUseTraktProgress( + isAuthenticated = isTraktAuthenticated, + source = traktSettingsUiState.watchProgressSource, + ) } - val effectiveWatchedItems = remember(watchedUiState.items, isTraktAuthenticated) { - if (isTraktAuthenticated) emptyList() else watchedUiState.items + val effectiveWatchProgressEntries = remember( + watchProgressUiState.entries, + isTraktProgressActive, + traktSettingsUiState.continueWatchingDaysCap, + ) { + filterEntriesForTraktContinueWatchingWindow( + entries = watchProgressUiState.entries, + isTraktProgressActive = isTraktProgressActive, + daysCap = traktSettingsUiState.continueWatchingDaysCap, + nowEpochMs = WatchProgressClock.nowEpochMs(), + ) + } + + val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) { + if (isTraktProgressActive) emptyList() else watchedUiState.items } val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) { @@ -242,7 +264,7 @@ fun HomeScreen( HomeCatalogSettingsRepository.syncCollections(collections) } - LaunchedEffect(completedSeriesCandidates, metaProviderKey) { + LaunchedEffect(completedSeriesCandidates, metaProviderKey, isTraktProgressActive) { if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() return@LaunchedEffect @@ -263,7 +285,7 @@ fun HomeScreen( seasonNumber = completedEntry.seasonNumber, episodeNumber = completedEntry.episodeNumber, todayIsoDate = todayIsoDate, - showUnairedNextUp = isTraktAuthenticated, + showUnairedNextUp = isTraktProgressActive, ) ?: return@withPermit null val item = completedEntry.toContinueWatchingSeed(meta) .toUpNextContinueWatchingItem(nextEpisode) @@ -525,7 +547,21 @@ fun HomeScreen( } private const val HOME_CATALOG_PREVIEW_LIMIT = 18 -private const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT = 60 +private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L + +internal fun filterEntriesForTraktContinueWatchingWindow( + entries: List, + isTraktProgressActive: Boolean, + daysCap: Int, + nowEpochMs: Long, +): List { + if (!isTraktProgressActive) return entries + val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap) + if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return entries + + val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY) + return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } +} private fun heroMobileBelowSectionHeightHint( maxWidthDp: Float, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 07a9d9c6..1fec7c1e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -20,6 +20,7 @@ import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.search.SearchHistoryRepository import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -136,6 +137,7 @@ object ProfileRepository { persist() WatchedRepository.onProfileChanged(profileIndex) LibraryRepository.onProfileChanged(profileIndex) + TraktSettingsRepository.onProfileChanged() WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 6c80adb8..2ed86c15 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -56,6 +56,8 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktCommentsSettings +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.TraktSettingsUiState import com.nuvio.app.features.tmdb.TmdbSettings import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -109,6 +111,10 @@ fun SettingsScreen( TraktCommentsSettings.ensureLoaded() TraktCommentsSettings.enabled }.collectAsStateWithLifecycle() + val traktSettingsUiState by remember { + TraktSettingsRepository.ensureLoaded() + TraktSettingsRepository.uiState + }.collectAsStateWithLifecycle() val addonsUiState by remember { AddonRepository.initialize() AddonRepository.uiState @@ -191,6 +197,7 @@ fun SettingsScreen( mdbListSettings = mdbListSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, + traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, @@ -231,6 +238,7 @@ fun SettingsScreen( mdbListSettings = mdbListSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, + traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, @@ -281,6 +289,7 @@ private fun MobileSettingsScreen( mdbListSettings: MdbListSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, + traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, @@ -409,6 +418,7 @@ private fun MobileSettingsScreen( SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = false, uiState = traktAuthUiState, + settingsUiState = traktSettingsUiState, commentsEnabled = traktCommentsEnabled, onCommentsEnabledChange = TraktCommentsSettings::setEnabled, ) @@ -446,6 +456,7 @@ private fun TabletSettingsScreen( mdbListSettings: MdbListSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, + traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, @@ -645,6 +656,7 @@ private fun TabletSettingsScreen( SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = true, uiState = traktAuthUiState, + settingsUiState = traktSettingsUiState, commentsEnabled = traktCommentsEnabled, onCommentsEnabledChange = TraktCommentsSettings::setEnabled, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index 82130875..76f3aa35 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -1,31 +1,56 @@ package com.nuvio.app.features.settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.TraktSettingsUiState +import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL +import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.trakt.traktBrandPainter import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.settings_playback_dialog_close import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect import nuvio.composeapp.generated.resources.settings_trakt_authentication import nuvio.composeapp.generated.resources.settings_trakt_comments @@ -42,11 +67,26 @@ import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials import nuvio.composeapp.generated.resources.settings_trakt_open_login import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description +import nuvio.composeapp.generated.resources.trakt_all_history +import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle +import nuvio.composeapp.generated.resources.trakt_continue_watching_window +import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle +import nuvio.composeapp.generated.resources.trakt_cw_window_title +import nuvio.composeapp.generated.resources.trakt_days_format +import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle +import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title +import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected +import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio +import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt +import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle +import nuvio.composeapp.generated.resources.trakt_watch_progress_title +import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.traktSettingsContent( isTablet: Boolean, uiState: TraktAuthUiState, + settingsUiState: TraktSettingsUiState, commentsEnabled: Boolean, onCommentsEnabledChange: (Boolean) -> Unit, ) { @@ -77,12 +117,326 @@ internal fun LazyListScope.traktSettingsContent( isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - SettingsSwitchRow( - title = stringResource(Res.string.settings_trakt_comments), - description = stringResource(Res.string.settings_trakt_comments_description), - checked = commentsEnabled, + TraktFeatureRows( isTablet = isTablet, - onCheckedChange = onCommentsEnabledChange, + settingsUiState = settingsUiState, + commentsEnabled = commentsEnabled, + onCommentsEnabledChange = onCommentsEnabledChange, + ) + } + } + } + } +} + +@Composable +private fun TraktFeatureRows( + isTablet: Boolean, + settingsUiState: TraktSettingsUiState, + commentsEnabled: Boolean, + onCommentsEnabledChange: (Boolean) -> Unit, +) { + var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) } + var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } + var statusMessage by rememberSaveable { mutableStateOf(null) } + + val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource) + val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) + val traktSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) + val nuvioSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) + + TraktSettingsActionRow( + title = stringResource(Res.string.trakt_watch_progress_title), + description = stringResource(Res.string.trakt_watch_progress_subtitle), + value = watchProgressValue, + isTablet = isTablet, + onClick = { showWatchProgressDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + TraktSettingsActionRow( + title = stringResource(Res.string.trakt_continue_watching_window), + description = stringResource(Res.string.trakt_continue_watching_subtitle), + value = continueWatchingWindowValue, + isTablet = isTablet, + onClick = { showContinueWatchingWindowDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_trakt_comments), + description = stringResource(Res.string.settings_trakt_comments_description), + checked = commentsEnabled, + isTablet = isTablet, + onCheckedChange = onCommentsEnabledChange, + ) + statusMessage?.takeIf { it.isNotBlank() }?.let { message -> + SettingsGroupDivider(isTablet = isTablet) + TraktInfoRow( + isTablet = isTablet, + text = message, + ) + } + + if (showWatchProgressDialog) { + WatchProgressSourceDialog( + selectedSource = settingsUiState.watchProgressSource, + onSourceSelected = { source -> + TraktSettingsRepository.setWatchProgressSource(source) + statusMessage = if (source == WatchProgressSource.TRAKT) { + traktSelectedMessage + } else { + nuvioSelectedMessage + } + showWatchProgressDialog = false + }, + onDismiss = { showWatchProgressDialog = false }, + ) + } + + if (showContinueWatchingWindowDialog) { + ContinueWatchingWindowDialog( + selectedDaysCap = settingsUiState.continueWatchingDaysCap, + onDaysCapSelected = { days -> + TraktSettingsRepository.setContinueWatchingDaysCap(days) + showContinueWatchingWindowDialog = false + }, + onDismiss = { showContinueWatchingWindowDialog = false }, + ) + } +} + +@Composable +private fun TraktSettingsActionRow( + title: String, + description: String, + value: String, + isTablet: Boolean, + onClick: () -> Unit, +) { + val verticalPadding = if (isTablet) 16.dp else 14.dp + val horizontalPadding = if (isTablet) 20.dp else 16.dp + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 12.dp) + .widthIn(max = if (isTablet) 560.dp else Dp.Unspecified), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun TraktInfoRow( + isTablet: Boolean, + text: String, +) { + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 14.dp else 12.dp + + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun watchProgressSourceLabel(source: WatchProgressSource): String = + when (source) { + WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt) + WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) + } + +@Composable +private fun continueWatchingDaysCapLabel(daysCap: Int): String { + val normalized = normalizeTraktContinueWatchingDaysCap(daysCap) + return if (normalized == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) { + stringResource(Res.string.trakt_all_history) + } else { + stringResource(Res.string.trakt_days_format, normalized) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun WatchProgressSourceDialog( + selectedSource: WatchProgressSource, + onSourceSelected: (WatchProgressSource) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.trakt_watch_progress_dialog_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.trakt_watch_progress_dialog_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf(WatchProgressSource.TRAKT, WatchProgressSource.NUVIO_SYNC).forEach { source -> + TraktDialogOption( + label = watchProgressSourceLabel(source), + selected = source == selectedSource, + onClick = { onSourceSelected(source) }, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ContinueWatchingWindowDialog( + selectedDaysCap: Int, + onDaysCapSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val normalizedSelected = normalizeTraktContinueWatchingDaysCap(selectedDaysCap) + + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.trakt_cw_window_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.trakt_cw_window_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TraktContinueWatchingDaysOptions.forEach { days -> + val normalizedDays = normalizeTraktContinueWatchingDaysCap(days) + TraktDialogOption( + label = continueWatchingDaysCapLabel(days), + selected = normalizedDays == normalizedSelected, + onClick = { onDaysCapSelected(days) }, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun TraktDialogOption( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val containerColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt new file mode 100644 index 00000000..3f6a66c4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt @@ -0,0 +1,135 @@ +package com.nuvio.app.features.trakt + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL = 0 +const val TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60 +const val TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP = 7 +const val TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP = 365 + +val TraktContinueWatchingDaysOptions: List = listOf( + 14, + 30, + TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, + 90, + 180, + TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP, + TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, +) + +@Serializable +enum class WatchProgressSource { + TRAKT, + NUVIO_SYNC; + + companion object { + fun fromStorage(value: String?): WatchProgressSource = + entries.firstOrNull { it.name == value } ?: DEFAULT_WATCH_PROGRESS_SOURCE + } +} + +val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT + +data class TraktSettingsUiState( + val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE, + val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, +) + +@Serializable +private data class StoredTraktSettings( + val watchProgressSource: String? = null, + val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, +) + +object TraktSettingsRepository { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _uiState = MutableStateFlow(TraktSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun clearLocalState() { + hasLoaded = false + _uiState.value = TraktSettingsUiState() + } + + fun setWatchProgressSource(source: WatchProgressSource) { + ensureLoaded() + if (_uiState.value.watchProgressSource == source) return + _uiState.value = _uiState.value.copy(watchProgressSource = source) + persist() + } + + fun setContinueWatchingDaysCap(days: Int) { + ensureLoaded() + val normalized = normalizeTraktContinueWatchingDaysCap(days) + if (_uiState.value.continueWatchingDaysCap == normalized) return + _uiState.value = _uiState.value.copy(continueWatchingDaysCap = normalized) + persist() + } + + private fun loadFromDisk() { + hasLoaded = true + + val payload = TraktSettingsStorage.loadPayload().orEmpty().trim() + if (payload.isEmpty()) { + _uiState.value = TraktSettingsUiState() + return + } + + val stored = runCatching { + json.decodeFromString(payload) + }.getOrNull() + + _uiState.value = if (stored != null) { + TraktSettingsUiState( + watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource), + continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap), + ) + } else { + TraktSettingsUiState() + } + } + + private fun persist() { + TraktSettingsStorage.savePayload( + json.encodeToString( + StoredTraktSettings( + watchProgressSource = _uiState.value.watchProgressSource.name, + continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap, + ), + ), + ) + } +} + +fun normalizeTraktContinueWatchingDaysCap(days: Int): Int = + if (days == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) { + TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL + } else { + days.coerceIn(TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP, TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP) + } + +fun shouldUseTraktProgress( + isAuthenticated: Boolean, + source: WatchProgressSource, +): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt new file mode 100644 index 00000000..f1302794 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.trakt + +internal expect object TraktSettingsStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 55adcebe..d46c40c6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -7,6 +7,8 @@ import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktProgressRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter @@ -37,7 +39,11 @@ object WatchProgressRepository { init { syncScope.launch { TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> - if (authenticated) { + if (shouldUseTraktProgressSource( + isAuthenticated = authenticated, + source = TraktSettingsRepository.uiState.value.watchProgressSource, + ) + ) { runCatching { TraktProgressRepository.refreshNow() } .onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } } } @@ -45,9 +51,23 @@ object WatchProgressRepository { } } + syncScope.launch { + TraktSettingsRepository.uiState.collectLatest { settings -> + if (shouldUseTraktProgressSource( + isAuthenticated = TraktAuthRepository.isAuthenticated.value, + source = settings.watchProgressSource, + ) + ) { + runCatching { TraktProgressRepository.refreshNow() } + .onFailure { error -> log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } } + } + publish() + } + } + syncScope.launch { TraktProgressRepository.uiState.collectLatest { - if (TraktAuthRepository.isAuthenticated.value) { + if (shouldUseTraktProgress()) { publish() } } @@ -56,19 +76,21 @@ object WatchProgressRepository { fun ensureLoaded() { TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() TraktProgressRepository.ensureLoaded() if (hasLoaded) return loadFromDisk(ProfileRepository.activeProfileId) - if (TraktAuthRepository.isAuthenticated.value) { + if (shouldUseTraktProgress()) { TraktProgressRepository.refreshAsync() } } fun onProfileChanged(profileId: Int) { if (profileId == currentProfileId && hasLoaded) return + TraktSettingsRepository.onProfileChanged() loadFromDisk(profileId) TraktProgressRepository.onProfileChanged() - if (TraktAuthRepository.isAuthenticated.value) { + if (shouldUseTraktProgress()) { TraktProgressRepository.refreshAsync() } } @@ -79,6 +101,7 @@ object WatchProgressRepository { currentProfileId = 1 entriesByVideoId.clear() TraktProgressRepository.clearLocalState() + TraktSettingsRepository.clearLocalState() _uiState.value = WatchProgressUiState() } @@ -98,9 +121,12 @@ object WatchProgressRepository { } suspend fun pullFromServer(profileId: Int) { + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + TraktProgressRepository.ensureLoaded() currentProfileId = profileId - if (TraktAuthRepository.isAuthenticated.value) { + if (shouldUseTraktProgress()) { runCatching { TraktProgressRepository.refreshNow() } .onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } } publish() @@ -368,7 +394,6 @@ object WatchProgressRepository { } private fun pushScrobbleToServer(entry: WatchProgressEntry) { - if (shouldUseTraktProgress()) return syncScope.launch { runCatching { val profileId = ProfileRepository.activeProfileId @@ -406,7 +431,11 @@ object WatchProgressRepository { ) } - private fun shouldUseTraktProgress(): Boolean = TraktAuthRepository.isAuthenticated.value + private fun shouldUseTraktProgress(): Boolean = + shouldUseTraktProgressSource( + isAuthenticated = TraktAuthRepository.isAuthenticated.value, + source = TraktSettingsRepository.uiState.value.watchProgressSource, + ) private fun currentEntries(): List { return if (shouldUseTraktProgress()) { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 849211a7..51da33ff 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.home import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import kotlin.test.Test import kotlin.test.assertEquals @@ -60,6 +61,68 @@ class HomeScreenTest { assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle) } + @Test + fun `Trakt continue watching window filters old progress only when Trakt source is active`() { + val oldEntry = progressEntry( + videoId = "old", + title = "Old", + lastUpdatedEpochMs = 1_000L, + seasonNumber = null, + episodeNumber = null, + ) + val recentEntry = progressEntry( + videoId = "recent", + title = "Recent", + lastUpdatedEpochMs = 30L * MILLIS_PER_DAY, + seasonNumber = null, + episodeNumber = null, + ) + val entries = listOf(oldEntry, recentEntry) + + val filtered = filterEntriesForTraktContinueWatchingWindow( + entries = entries, + isTraktProgressActive = true, + daysCap = 60, + nowEpochMs = 90L * MILLIS_PER_DAY, + ) + val nuvioSource = filterEntriesForTraktContinueWatchingWindow( + entries = entries, + isTraktProgressActive = false, + daysCap = 60, + nowEpochMs = 90L * MILLIS_PER_DAY, + ) + + assertEquals(listOf("recent"), filtered.map(WatchProgressEntry::videoId)) + assertEquals(listOf("old", "recent"), nuvioSource.map(WatchProgressEntry::videoId)) + } + + @Test + fun `Trakt all history window keeps old progress`() { + val oldEntry = progressEntry( + videoId = "old", + title = "Old", + lastUpdatedEpochMs = 1_000L, + seasonNumber = null, + episodeNumber = null, + ) + val recentEntry = progressEntry( + videoId = "recent", + title = "Recent", + lastUpdatedEpochMs = 30L * MILLIS_PER_DAY, + seasonNumber = null, + episodeNumber = null, + ) + + val result = filterEntriesForTraktContinueWatchingWindow( + entries = listOf(oldEntry, recentEntry), + isTraktProgressActive = true, + daysCap = TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, + nowEpochMs = 90L * MILLIS_PER_DAY, + ) + + assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId)) + } + private fun progressEntry( videoId: String, title: String, @@ -100,4 +163,8 @@ class HomeScreenTest { durationMs = 0L, progressFraction = 0f, ) -} \ No newline at end of file + + private companion object { + const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt new file mode 100644 index 00000000..32928ef7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TraktSettingsRepositoryTest { + + @Test + fun `watch progress source defaults to Trakt for unset or invalid storage`() { + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage(null)) + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("")) + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("not-a-source")) + } + + @Test + fun `watch progress source restores valid storage values`() { + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("TRAKT")) + assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC")) + } + + @Test + fun `continue watching cap normalizes finite windows and all history`() { + assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0)) + assertEquals(7, normalizeTraktContinueWatchingDaysCap(1)) + assertEquals(60, normalizeTraktContinueWatchingDaysCap(60)) + assertEquals(365, normalizeTraktContinueWatchingDaysCap(999)) + } + + @Test + fun `Trakt progress is active only when authenticated and selected`() { + assertFalse(shouldUseTraktProgress(isAuthenticated = false, source = WatchProgressSource.TRAKT)) + assertFalse(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.NUVIO_SYNC)) + assertTrue(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.TRAKT)) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt new file mode 100644 index 00000000..06c60535 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +internal actual object TraktSettingsStorage { + private const val payloadKey = "trakt_settings_payload" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) + } +}