diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index cc326794..8f585ada 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -1019,6 +1019,7 @@
Mark previous as watched
Mark %1$s as unwatched
Mark %1$s as watched
+ Mark previous seasons as watched
Mark as unwatched
Mark as watched
Up next
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
index d8bfbf27..899747c7 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
@@ -75,6 +75,7 @@ import com.nuvio.app.features.details.components.DetailProductionSection
import com.nuvio.app.features.details.components.DetailSeriesContent
import com.nuvio.app.features.details.components.DetailTrailersSection
import com.nuvio.app.features.details.components.EpisodeWatchedActionSheet
+import com.nuvio.app.features.details.components.SeasonWatchedActionSheet
import com.nuvio.app.features.details.components.TrailerPlayerPopup
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryRepository
@@ -92,6 +93,7 @@ import com.nuvio.app.features.trailer.TrailerPlaybackResolver
import com.nuvio.app.features.trailer.TrailerPlaybackSource
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.previousReleasedEpisodesBefore
+import com.nuvio.app.features.watched.releasedPlayableEpisodes
import com.nuvio.app.features.watched.releasedEpisodesForSeason
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.WatchProgressEntry
@@ -151,6 +153,7 @@ fun MetaDetailsScreen(
var autoLoadAttempted by remember(type, id) { mutableStateOf(false) }
var observedOfflineState by remember(type, id) { mutableStateOf(false) }
var selectedEpisodeForActions by remember(type, id) { mutableStateOf(null) }
+ var selectedSeasonForActions by remember(type, id) { mutableStateOf(null) }
val commentsEnabled by remember {
TraktCommentsSettings.ensureLoaded()
TraktCommentsSettings.enabled
@@ -337,7 +340,10 @@ fun MetaDetailsScreen(
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
}
}
- val movieProgress = watchProgressUiState.byVideoId[meta.id]
+ val progressByVideoId = remember(watchProgressUiState.entries) {
+ watchProgressUiState.byVideoId
+ }
+ val movieProgress = progressByVideoId[meta.id]
?.takeUnless { it.isCompleted }
val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) {
@@ -715,11 +721,12 @@ fun MetaDetailsScreen(
},
onCommentClick = { review -> selectedComment = review },
onTrailerClick = resolveTrailer,
- progressByVideoId = watchProgressUiState.byVideoId,
+ progressByVideoId = progressByVideoId,
watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
+ onSeasonLongPress = { season -> selectedSeasonForActions = season },
onOpenMeta = onOpenMeta,
onCastClick = onCastClick,
onCompanyClick = onCompanyClick,
@@ -776,12 +783,12 @@ fun MetaDetailsScreen(
)
selectedEpisodeForActions?.let { selectedEpisode ->
- val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) {
- WatchingState.isEpisodeWatched(
- watchedKeys = watchedUiState.watchedKeys,
- metaType = meta.type,
- metaId = meta.id,
+ val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys, progressByVideoId) {
+ isEpisodeWatchedForActions(
+ meta = meta,
episode = selectedEpisode,
+ watchedKeys = watchedUiState.watchedKeys,
+ progressByVideoId = progressByVideoId,
)
}
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
@@ -796,20 +803,20 @@ fun MetaDetailsScreen(
todayIsoDate = todayIsoDate,
)
}
- val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) {
- WatchingState.areEpisodesWatched(
- watchedKeys = watchedUiState.watchedKeys,
- metaType = meta.type,
- metaId = meta.id,
+ val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
+ areEpisodesWatchedForActions(
+ meta = meta,
episodes = previousEpisodes,
+ watchedKeys = watchedUiState.watchedKeys,
+ progressByVideoId = progressByVideoId,
)
}
- val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) {
- WatchingState.areEpisodesWatched(
- watchedKeys = watchedUiState.watchedKeys,
- metaType = meta.type,
- metaId = meta.id,
+ val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
+ areEpisodesWatchedForActions(
+ meta = meta,
episodes = seasonEpisodes,
+ watchedKeys = watchedUiState.watchedKeys,
+ progressByVideoId = progressByVideoId,
)
}
EpisodeWatchedActionSheet(
@@ -850,6 +857,62 @@ fun MetaDetailsScreen(
)
}
+ selectedSeasonForActions?.let { selectedSeason ->
+ val seasonLabel = selectedSeasonLabel(selectedSeason)
+ val seasonEpisodes = remember(meta, selectedSeason, todayIsoDate) {
+ meta.releasedEpisodesForSeason(
+ seasonNumber = selectedSeason,
+ todayIsoDate = todayIsoDate,
+ )
+ }
+ val previousSeasonEpisodes = remember(meta, selectedSeason, todayIsoDate) {
+ val normalizedSelectedSeason = selectedSeason.coerceAtLeast(0)
+ meta.releasedPlayableEpisodes(todayIsoDate)
+ .filter { episode ->
+ val season = episode.season?.coerceAtLeast(0) ?: 0
+ season > 0 && season < normalizedSelectedSeason
+ }
+ }
+ val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
+ areEpisodesWatchedForActions(
+ meta = meta,
+ episodes = seasonEpisodes,
+ watchedKeys = watchedUiState.watchedKeys,
+ progressByVideoId = progressByVideoId,
+ )
+ }
+ val canMarkPreviousSeasons = remember(previousSeasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
+ previousSeasonEpisodes.any { episode ->
+ !isEpisodeWatchedForActions(
+ meta = meta,
+ episode = episode,
+ watchedKeys = watchedUiState.watchedKeys,
+ progressByVideoId = progressByVideoId,
+ )
+ }
+ }
+ SeasonWatchedActionSheet(
+ seasonLabel = seasonLabel,
+ isSeasonWatched = isSeasonWatched,
+ canMarkPreviousSeasons = canMarkPreviousSeasons,
+ onDismiss = { selectedSeasonForActions = null },
+ onToggleSeasonWatched = {
+ WatchingActions.toggleSeasonWatched(
+ meta = meta,
+ episodes = seasonEpisodes,
+ areCurrentlyWatched = isSeasonWatched,
+ )
+ },
+ onMarkPreviousSeasonsWatched = {
+ WatchingActions.togglePreviousEpisodesWatched(
+ meta = meta,
+ episodes = previousSeasonEpisodes,
+ areCurrentlyWatched = false,
+ )
+ },
+ )
+ }
+
if (inAppTrailerPlaybackEnabled) {
TrailerPlayerPopup(
visible = selectedTrailer != null,
@@ -970,6 +1033,49 @@ private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
}
+@Composable
+private fun selectedSeasonLabel(season: Int): String =
+ if (season == 0) {
+ stringResource(Res.string.episodes_specials)
+ } else {
+ stringResource(Res.string.episodes_season, season)
+ }
+
+private fun isEpisodeWatchedForActions(
+ meta: MetaDetails,
+ episode: MetaVideo,
+ watchedKeys: Set,
+ progressByVideoId: Map,
+): Boolean {
+ val episodeVideoId = buildPlaybackVideoId(
+ parentMetaId = meta.id,
+ seasonNumber = episode.season,
+ episodeNumber = episode.episode,
+ fallbackVideoId = episode.id,
+ )
+ return progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
+ WatchingState.isEpisodeWatched(
+ watchedKeys = watchedKeys,
+ metaType = meta.type,
+ metaId = meta.id,
+ episode = episode,
+ )
+}
+
+private fun areEpisodesWatchedForActions(
+ meta: MetaDetails,
+ episodes: Collection,
+ watchedKeys: Set,
+ progressByVideoId: Map,
+): Boolean = episodes.isNotEmpty() && episodes.all { episode ->
+ isEpisodeWatchedForActions(
+ meta = meta,
+ episode = episode,
+ watchedKeys = watchedKeys,
+ progressByVideoId = progressByVideoId,
+ )
+}
+
private fun extractImdbId(value: String?): String? =
value
?.trim()
@@ -1026,6 +1132,7 @@ private fun ConfiguredMetaSections(
blurUnwatchedEpisodes: Boolean,
onEpisodeClick: (MetaVideo) -> Unit,
onEpisodeLongPress: (MetaVideo) -> Unit,
+ onSeasonLongPress: (Int) -> Unit,
onOpenMeta: ((MetaPreview) -> Unit)?,
onCastClick: ((MetaPerson, String?) -> Unit)?,
onCompanyClick: ((MetaCompany, String) -> Unit)?,
@@ -1120,6 +1227,7 @@ private fun ConfiguredMetaSections(
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
+ onSeasonLongPress = onSeasonLongPress,
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt
index e5140b74..1c598f66 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt
@@ -100,6 +100,7 @@ fun DetailSeriesContent(
blurUnwatchedEpisodes: Boolean = false,
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
+ onSeasonLongPress: ((Int) -> Unit)? = null,
) {
val hasVideos = meta.videos.isNotEmpty()
if (meta.type != "series" && !hasVideos) return
@@ -230,12 +231,14 @@ fun DetailSeriesContent(
currentSeason = currentSeason,
sizing = sizing,
onSelect = { selectedSeasonOverride = it },
+ onLongPress = onSeasonLongPress,
)
SeasonViewMode.Text -> SeasonTextChipScrollRow(
seasons = seasons,
currentSeason = currentSeason,
sizing = sizing,
onSelect = { selectedSeasonOverride = it },
+ onLongPress = onSeasonLongPress,
)
}
}
@@ -245,6 +248,7 @@ fun DetailSeriesContent(
currentSeason = currentSeason,
sizing = sizing,
onSelect = { selectedSeasonOverride = it },
+ onLongPress = onSeasonLongPress,
)
}
}
@@ -372,12 +376,14 @@ private fun SeasonViewModeToggle(
}
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SeasonTextChipScrollRow(
seasons: List,
currentSeason: Int,
sizing: SeriesContentSizing,
onSelect: (Int) -> Unit,
+ onLongPress: ((Int) -> Unit)?,
) {
val seasonListState = rememberLazyListState()
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
@@ -411,7 +417,10 @@ private fun SeasonTextChipScrollRow(
Color.Transparent
},
)
- .clickable { onSelect(season) }
+ .combinedClickable(
+ onClick = { onSelect(season) },
+ onLongClick = onLongPress?.let { handler -> { handler(season) } },
+ )
.padding(
horizontal = sizing.seasonChipHorizontalPadding,
vertical = sizing.seasonChipVerticalPadding,
@@ -443,6 +452,7 @@ private fun SeasonPosterScrollRow(
currentSeason: Int,
sizing: SeriesContentSizing,
onSelect: (Int) -> Unit,
+ onLongPress: ((Int) -> Unit)?,
) {
val seasonListState = rememberLazyListState()
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
@@ -475,11 +485,13 @@ private fun SeasonPosterScrollRow(
isSelected = season == currentSeason,
sizing = sizing,
onClick = { onSelect(season) },
+ onLongClick = onLongPress?.let { handler -> { handler(season) } },
)
}
}
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SeasonPosterButton(
label: String,
@@ -487,11 +499,15 @@ private fun SeasonPosterButton(
isSelected: Boolean,
sizing: SeriesContentSizing,
onClick: () -> Unit,
+ onLongClick: (() -> Unit)?,
) {
Column(
modifier = Modifier
.width(sizing.seasonPosterWidth)
- .clickable(onClick = onClick),
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = onLongClick,
+ ),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt
index 44c02bba..00dd40f2 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt
@@ -1,14 +1,9 @@
package com.nuvio.app.features.details.components
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DoneAll
@@ -135,6 +130,73 @@ fun EpisodeWatchedActionSheet(
}
}
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SeasonWatchedActionSheet(
+ seasonLabel: String,
+ isSeasonWatched: Boolean,
+ canMarkPreviousSeasons: Boolean,
+ onDismiss: () -> Unit,
+ onToggleSeasonWatched: () -> Unit,
+ onMarkPreviousSeasonsWatched: () -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val coroutineScope = rememberCoroutineScope()
+
+ NuvioModalBottomSheet(
+ onDismissRequest = {
+ coroutineScope.launch {
+ dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
+ }
+ },
+ sheetState = sheetState,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = nuvioSafeBottomPadding(16.dp)),
+ ) {
+ Text(
+ text = seasonLabel,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
+ )
+ NuvioBottomSheetDivider()
+ NuvioBottomSheetActionRow(
+ icon = Icons.Default.PlaylistAddCheckCircle,
+ title = if (isSeasonWatched) {
+ stringResource(Res.string.episode_mark_season_unwatched, seasonLabel)
+ } else {
+ stringResource(Res.string.episode_mark_season_watched, seasonLabel)
+ },
+ onClick = {
+ onToggleSeasonWatched()
+ coroutineScope.launch {
+ dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
+ }
+ },
+ )
+ if (canMarkPreviousSeasons) {
+ NuvioBottomSheetDivider()
+ NuvioBottomSheetActionRow(
+ icon = Icons.Default.DoneAll,
+ title = stringResource(Res.string.episode_mark_previous_seasons_watched),
+ onClick = {
+ onMarkPreviousSeasonsWatched()
+ coroutineScope.launch {
+ dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun EpisodeActionSheetHeader(
episode: MetaVideo,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt
index c2ae8997..a208fe94 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt
@@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json
@Serializable
private data class StoredWatchedPayload(
val items: List = emptyList(),
+ val lastSuccessfulPushEpochMs: Long = 0L,
)
object WatchedRepository {
@@ -43,6 +44,7 @@ object WatchedRepository {
private var hasLoaded = false
private var currentProfileId: Int = 1
private var itemsByKey: MutableMap = mutableMapOf()
+ private var lastSuccessfulPushEpochMs: Long = 0L
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
private fun activePullSyncAdapter(): WatchedSyncAdapter =
@@ -62,6 +64,7 @@ object WatchedRepository {
hasLoaded = false
currentProfileId = 1
itemsByKey.clear()
+ lastSuccessfulPushEpochMs = 0L
_uiState.value = WatchedUiState()
}
@@ -72,13 +75,16 @@ object WatchedRepository {
val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim()
if (payload.isNotEmpty()) {
- val items = runCatching {
- json.decodeFromString(payload).items
- }.getOrDefault(emptyList())
- itemsByKey = items
+ val storedPayload = runCatching {
+ json.decodeFromString(payload)
+ }.getOrDefault(StoredWatchedPayload())
+ lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs
+ itemsByKey = storedPayload.items
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
+ } else {
+ lastSuccessfulPushEpochMs = 0L
}
publish()
@@ -88,16 +94,23 @@ object WatchedRepository {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
currentProfileId = profileId
+ val pullStartedEpochMs = WatchedClock.nowEpochMs()
+ val localBeforePull = itemsByKey.values
+ .map(WatchedItem::normalizedMarkedAt)
+ .toList()
+ val lastPushEpochMs = lastSuccessfulPushEpochMs
runCatching {
val serverItems = activePullSyncAdapter().pull(
profileId = profileId,
pageSize = watchedItemsPageSize,
)
- itemsByKey = serverItems
- .map(WatchedItem::normalizedMarkedAt)
- .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
- .toMutableMap()
+ itemsByKey = mergeWatchedItemsPreservingUnsynced(
+ serverItems = serverItems,
+ localItems = localBeforePull,
+ lastSuccessfulPushEpochMs = lastPushEpochMs,
+ pullStartedEpochMs = pullStartedEpochMs,
+ ).toMutableMap()
hasLoaded = true
publish()
persist()
@@ -213,6 +226,7 @@ object WatchedRepository {
if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId
pushToActiveTargets(profileId = profileId, items = items)
+ recordSuccessfulPush(profileId = profileId, items = items)
}.onFailure { e ->
log.e(e) { "Failed to push watched items" }
}
@@ -252,11 +266,24 @@ object WatchedRepository {
items = itemsByKey.values
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs },
+ lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs,
),
),
)
}
+ private fun recordSuccessfulPush(profileId: Int, items: Collection) {
+ if (profileId != currentProfileId) return
+ val latestPushed = items
+ .asSequence()
+ .map { item -> normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs) }
+ .maxOrNull()
+ ?: return
+ if (latestPushed <= lastSuccessfulPushEpochMs) return
+ lastSuccessfulPushEpochMs = latestPushed
+ persist()
+ }
+
private fun shouldUseTraktWatchedSync(): Boolean =
shouldUseTraktWatchedSync(
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
@@ -294,6 +321,33 @@ object WatchedRepository {
}
}
+internal fun mergeWatchedItemsPreservingUnsynced(
+ serverItems: Collection,
+ localItems: Collection,
+ lastSuccessfulPushEpochMs: Long,
+ pullStartedEpochMs: Long,
+): Map {
+ val merged = serverItems
+ .map(WatchedItem::normalizedMarkedAt)
+ .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
+ .toMutableMap()
+
+ localItems
+ .map(WatchedItem::normalizedMarkedAt)
+ .forEach { localItem ->
+ val key = watchedItemKey(localItem.type, localItem.id, localItem.season, localItem.episode)
+ if (key in merged) return@forEach
+ val markedAt = localItem.markedAtEpochMs
+ val wasMarkedAfterLastPush = lastSuccessfulPushEpochMs > 0L && markedAt > lastSuccessfulPushEpochMs
+ val wasMarkedDuringPull = pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs
+ if (wasMarkedAfterLastPush || wasMarkedDuringPull) {
+ merged[key] = localItem
+ }
+ }
+
+ return merged
+}
+
internal fun shouldUseTraktWatchedSync(
isAuthenticated: Boolean,
source: WatchProgressSource,
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt
index ca489fe2..43356837 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt
@@ -44,5 +44,57 @@ class WatchedRepositoryTest {
assertTrue(result)
}
-}
+ @Test
+ fun mergeWatchedItemsPreservingUnsynced_keeps_local_items_marked_after_last_push() {
+ val serverItem = WatchedItem(
+ id = "show",
+ type = "series",
+ name = "Episode 1",
+ season = 1,
+ episode = 1,
+ markedAtEpochMs = 1_000L,
+ )
+ val unsyncedLocalItem = WatchedItem(
+ id = "show",
+ type = "series",
+ name = "Episode 2",
+ season = 1,
+ episode = 2,
+ markedAtEpochMs = 3_000L,
+ )
+
+ val merged = mergeWatchedItemsPreservingUnsynced(
+ serverItems = listOf(serverItem),
+ localItems = listOf(serverItem, unsyncedLocalItem),
+ lastSuccessfulPushEpochMs = 2_000L,
+ pullStartedEpochMs = 4_000L,
+ )
+
+ assertEquals(
+ setOf("series:show:1:1", "series:show:1:2"),
+ merged.keys,
+ )
+ }
+
+ @Test
+ fun mergeWatchedItemsPreservingUnsynced_drops_old_local_items_missing_from_server() {
+ val oldLocalItem = WatchedItem(
+ id = "show",
+ type = "series",
+ name = "Episode 1",
+ season = 1,
+ episode = 1,
+ markedAtEpochMs = 1_000L,
+ )
+
+ val merged = mergeWatchedItemsPreservingUnsynced(
+ serverItems = emptyList(),
+ localItems = listOf(oldLocalItem),
+ lastSuccessfulPushEpochMs = 2_000L,
+ pullStartedEpochMs = 4_000L,
+ )
+
+ assertTrue(merged.isEmpty())
+ }
+}