diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 6a01bb0c..031b2e37 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -13,6 +13,7 @@ Play Previous Remove + Remove anyway Reorder Reset to Default Resume @@ -1467,6 +1468,11 @@ Failed to update Trakt lists Failed to update tracking lists %1$s placed this title in %2$s instead of %3$s + Remove from %1$s? + Removing “%1$s” from %2$s will also clear its %3$s there. This can’t be undone. + watched history + rating + watched history and rating %1$s • %2$s Update check failed Download failed diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 564002b6..50816838 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -107,6 +107,7 @@ import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder import com.nuvio.app.core.ui.PosterZoomOverlayAction +import com.nuvio.app.core.ui.PosterZoomOverlayExitAnimation import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import com.nuvio.app.core.ui.NuvioStatusModal @@ -162,6 +163,9 @@ import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.LibrarySection import com.nuvio.app.features.library.LibrarySortOption import com.nuvio.app.features.library.LibrarySourceMode +import com.nuvio.app.features.library.PendingTrackingMembershipRemoval +import com.nuvio.app.features.library.TrackingMembershipRemovalConfirmationHost +import com.nuvio.app.features.library.executeTrackingMembershipOperation import com.nuvio.app.features.library.showTrackingMembershipRewriteFeedback import com.nuvio.app.features.library.LibraryScreen import com.nuvio.app.features.library.toLibraryItem @@ -226,6 +230,8 @@ import com.nuvio.app.features.tracking.TrackingScrobbleCoordinator import com.nuvio.app.features.tracking.TrackingScrobbleEvent import com.nuvio.app.features.tracking.buildTrackingMediaReference import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingMembershipApplyResult +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.AppUpdaterPlatform @@ -820,6 +826,8 @@ private fun MainAppContent( var pickerMembership by remember { mutableStateOf>(emptyMap()) } var pickerPending by remember { mutableStateOf(false) } var pickerError by remember { mutableStateOf(null) } + var pendingTrackingRemoval by remember { mutableStateOf(null) } + val trackingListsUpdateFailedMessage = stringResource(Res.string.tracking_lists_update_failed) val addonsUiState by remember { AddonRepository.initialize() AddonRepository.uiState @@ -3300,8 +3308,6 @@ private fun MainAppContent( watchedKeys = watchedUiState.watchedKeys, item = preview, ) - // Remote-library items long-pressed outside the library open the list picker - // instead of removing, so only true removals disintegrate. val removesFromLibrary = isSaved && (posterActionTarget.libraryItem != null || !isRemoteLibrarySource) NuvioPosterZoomActionOverlay( @@ -3324,35 +3330,66 @@ private fun MainAppContent( stringResource(Res.string.hero_add_to_library) }, isDestructive = removesFromLibrary, + exitAnimation = if (removesFromLibrary && !isRemoteLibrarySource) { + PosterZoomOverlayExitAnimation.DISINTEGRATE + } else { + PosterZoomOverlayExitAnimation.COLLAPSE + }, onSelected = { val libraryItem = posterActionTarget.libraryItem ?: preview.toLibraryItem(savedAtEpochMs = 0L) if (posterActionTarget.libraryItem != null) { if (isRemoteLibrarySource) { coroutineScope.launch { - runCatching { - val listKey = posterActionTarget.libraryListKey + val listKey = posterActionTarget.libraryListKey + val removeMembership: suspend (Set) -> + TrackingMembershipApplyResult = { confirmedProviders -> if (listKey.isNullOrBlank()) { val currentMembership = LibraryRepository.getMembershipSnapshot(libraryItem) LibraryRepository.applyMembershipChanges( item = libraryItem, desiredMembership = currentMembership.mapValues { false }, + confirmedRemovalProviders = confirmedProviders, ) } else { - LibraryRepository.removeFromList(libraryItem, listKey) + LibraryRepository.removeFromList( + item = libraryItem, + listKey = listKey, + confirmedRemovalProviders = confirmedProviders, + ) } - }.onFailure { error -> - NuvioToastController.show( - error.message ?: getString(Res.string.tracking_lists_update_failed), - ) } + executeTrackingMembershipOperation( + operation = { removeMembership(emptySet()) }, + onSuccess = { result -> + if (result.requiresRemovalConfirmation) { + pendingTrackingRemoval = PendingTrackingMembershipRemoval( + itemTitle = libraryItem.name, + confirmations = result.requiredRemovalConfirmations, + retry = removeMembership, + onApplied = {}, + onFailure = { error -> + NuvioToastController.show( + error.message + ?: trackingListsUpdateFailedMessage, + ) + }, + ) + } + }, + onFailure = { error -> + NuvioToastController.show( + error.message ?: trackingListsUpdateFailedMessage, + ) + }, + ) } } else { LibraryRepository.remove(libraryItem.id) } } else { if (!isRemoteLibrarySource) { - LibraryRepository.toggleSaved(libraryItem) + LibraryRepository.toggleLocalSaved(libraryItem) } else { pickerItem = libraryItem pickerTitle = preview.name @@ -3519,24 +3556,52 @@ private fun MainAppContent( coroutineScope.launch { pickerPending = true pickerError = null - runCatching { + val desiredMembership = pickerMembership.toMap() + val applyMembership: suspend (Set) -> + TrackingMembershipApplyResult = { confirmedProviders -> LibraryRepository.applyMembershipChanges( item = item, - desiredMembership = pickerMembership, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedProviders, ) - }.onSuccess { result -> + } + val completeMembershipUpdate: suspend (TrackingMembershipApplyResult) -> Unit = { result -> showTrackingMembershipRewriteFeedback(result) showLibraryListPicker = false pickerItem = null pickerError = null - }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed) } + executeTrackingMembershipOperation( + operation = { applyMembership(emptySet()) }, + onSuccess = { result -> + if (result.requiresRemovalConfirmation) { + pendingTrackingRemoval = PendingTrackingMembershipRemoval( + itemTitle = item.name, + confirmations = result.requiredRemovalConfirmations, + retry = applyMembership, + onApplied = completeMembershipUpdate, + onFailure = { error -> + pickerError = error.message ?: trackingListsUpdateFailedMessage + }, + ) + } else { + completeMembershipUpdate(result) + } + }, + onFailure = { error -> + pickerError = error.message ?: trackingListsUpdateFailedMessage + }, + ) pickerPending = false } }, ) + TrackingMembershipRemovalConfirmationHost( + pending = pendingTrackingRemoval, + onPendingChange = { pendingTrackingRemoval = it }, + ) + NuvioStatusModal( title = stringResource(Res.string.app_exit_title), message = stringResource(Res.string.app_exit_message), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt index 9629e7f2..6bcaf17a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt @@ -87,10 +87,20 @@ object PosterZoomAnchorHolder { fun consume(): PosterZoomAnchor? = pending.also { pending = null } } +enum class PosterZoomOverlayExitAnimation { + COLLAPSE, + DISINTEGRATE, +} + class PosterZoomOverlayAction( val icon: ImageVector, val label: String, val isDestructive: Boolean = false, + val exitAnimation: PosterZoomOverlayExitAnimation = if (isDestructive) { + PosterZoomOverlayExitAnimation.DISINTEGRATE + } else { + PosterZoomOverlayExitAnimation.COLLAPSE + }, val onSelected: () -> Unit, ) @@ -181,7 +191,7 @@ fun NuvioPosterZoomActionOverlay( fun select(action: PosterZoomOverlayAction) { if (phase != PosterZoomPhase.Open) return - if (action.isDestructive) { + if (action.exitAnimation == PosterZoomOverlayExitAnimation.DISINTEGRATE) { phase = PosterZoomPhase.Disintegrating hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) action.onSelected() 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 41f45319..946184ff 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 @@ -80,6 +80,7 @@ import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay +import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder import com.nuvio.app.core.ui.PosterZoomOverlayAction @@ -106,6 +107,9 @@ 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 +import com.nuvio.app.features.library.PendingTrackingMembershipRemoval +import com.nuvio.app.features.library.TrackingMembershipRemovalConfirmationHost +import com.nuvio.app.features.library.executeTrackingMembershipOperation import com.nuvio.app.features.library.showTrackingMembershipRewriteFeedback import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.player.PlayerSettingsRepository @@ -118,6 +122,7 @@ import com.nuvio.app.features.trakt.TraktCommentsRepository import com.nuvio.app.features.trakt.TraktCommentsSettings import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.tracking.TrackingLibraryTab +import com.nuvio.app.features.tracking.TrackingMembershipApplyResult import com.nuvio.app.features.tracking.toggleTrackingLibraryMembership import com.nuvio.app.features.tracking.TrackingSettingsRepository import com.nuvio.app.features.tracking.TrackingProviderId @@ -224,6 +229,10 @@ fun MetaDetailsScreen( var pickerMembership by remember(type, id) { mutableStateOf>(emptyMap()) } var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } + var pendingTrackingRemoval by remember(type, id) { + mutableStateOf(null) + } + val trackingListsUpdateFailedMessage = stringResource(Res.string.tracking_lists_update_failed) var episodeImdbRatings by remember(type, id) { mutableStateOf, Double>>(emptyMap()) } var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) } @@ -507,9 +516,44 @@ fun MetaDetailsScreen( Unit } } - val toggleSaved = remember(meta) { + val toggleSaved = remember(meta, trackingListsUpdateFailedMessage) { { - LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L)) + val item = meta.toLibraryItem(savedAtEpochMs = 0L) + detailsScope.launch { + val toggleMembership: suspend (Set) -> + TrackingMembershipApplyResult = { confirmedProviders -> + LibraryRepository.toggleSaved( + item = item, + confirmedRemovalProviders = confirmedProviders, + ) + } + executeTrackingMembershipOperation( + operation = { toggleMembership(emptySet()) }, + onSuccess = { result -> + if (result.requiresRemovalConfirmation) { + pendingTrackingRemoval = PendingTrackingMembershipRemoval( + itemTitle = item.name, + confirmations = result.requiredRemovalConfirmations, + retry = toggleMembership, + onApplied = ::showTrackingMembershipRewriteFeedback, + onFailure = { error -> + NuvioToastController.show( + error.message ?: trackingListsUpdateFailedMessage, + ) + }, + ) + } else { + showTrackingMembershipRewriteFeedback(result) + } + }, + onFailure = { error -> + NuvioToastController.show( + error.message ?: trackingListsUpdateFailedMessage, + ) + }, + ) + } + Unit } } val toggleWatched = remember(metaPreview) { @@ -1305,22 +1349,52 @@ fun MetaDetailsScreen( detailsScope.launch { pickerPending = true pickerError = null - runCatching { + val item = meta.toLibraryItem(savedAtEpochMs = 0L) + val desiredMembership = pickerMembership.toMap() + val applyMembership: suspend (Set) -> + TrackingMembershipApplyResult = { confirmedProviders -> LibraryRepository.applyMembershipChanges( - item = meta.toLibraryItem(savedAtEpochMs = 0L), - desiredMembership = pickerMembership, + item = item, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedProviders, ) - }.onSuccess { result -> + } + val completeMembershipUpdate: suspend (TrackingMembershipApplyResult) -> Unit = { result -> showTrackingMembershipRewriteFeedback(result) showLibraryListPicker = false - }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.tracking_lists_update_failed) } + executeTrackingMembershipOperation( + operation = { applyMembership(emptySet()) }, + onSuccess = { result -> + if (result.requiresRemovalConfirmation) { + pendingTrackingRemoval = PendingTrackingMembershipRemoval( + itemTitle = item.name, + confirmations = result.requiredRemovalConfirmations, + retry = applyMembership, + onApplied = completeMembershipUpdate, + onFailure = { error -> + pickerError = error.message + ?: trackingListsUpdateFailedMessage + }, + ) + } else { + completeMembershipUpdate(result) + } + }, + onFailure = { error -> + pickerError = error.message ?: trackingListsUpdateFailedMessage + }, + ) pickerPending = false } }, ) + TrackingMembershipRemovalConfirmationHost( + pending = pendingTrackingRemoval, + onPendingChange = { pendingTrackingRemoval = it }, + ) + selectedComment?.let { comment -> val commentIndex = comments.indexOfFirst { it.id == comment.id }.coerceAtLeast(0) CommentDetailSheet( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 7cf30609..44c3d896 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -6,7 +6,6 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.core.tracking.ensureTrackingProvidersRegistered -import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.tracking.TrackingLibraryProvider @@ -14,6 +13,7 @@ import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.TrackingLibraryTabKind import com.nuvio.app.features.tracking.TrackingMembershipApplyResult import com.nuvio.app.features.tracking.TrackingMembershipResolution +import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingProviderRegistry import com.nuvio.app.features.tracking.TrackingRefreshIntent import com.nuvio.app.features.tracking.TrackingSettingsRepository @@ -51,7 +51,6 @@ import kotlinx.serialization.json.put import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.library_local_tab_title import nuvio.composeapp.generated.resources.library_other -import nuvio.composeapp.generated.resources.tracking_lists_update_failed import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString @@ -272,30 +271,37 @@ object LibraryRepository { private fun isActiveOperation(token: LibraryProfileToken): Boolean = localState.isCurrent(token) && ProfileRepository.activeProfileId == token.profileId - fun toggleSaved(item: LibraryItem) { + suspend fun toggleSaved( + item: LibraryItem, + confirmedRemovalProviders: Set = emptySet(), + ): TrackingMembershipApplyResult { ensureLoaded() activeLibraryProvider()?.let { provider -> - val profileId = localState.snapshot().token.profileId + val providerMembership = provider.membership(item) + val desiredMembership = provider.toggledDefaultMembership(providerMembership) log.i { "toggleSaved routed to ${provider.providerId.storageId} library source " + - "item=${item.id} type=${item.type} profile=$profileId" + "item=${item.id} type=${item.type} profile=${localState.snapshot().token.profileId}" } - syncScope.launch { - runCatching { provider.toggleDefaultMembership(profileId, item) } - .onFailure { error -> - if (error is CancellationException) throw error - log.e(error) { "Failed to toggle ${provider.providerId.storageId} default library membership" } - NuvioToastController.show( - error.message?.takeIf(String::isNotBlank) - ?: getString(Res.string.tracking_lists_update_failed), - ) - } - publish() - } - return + return applyMembershipChanges( + item = item, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedRemovalProviders, + targetProviderIds = setOf(provider.providerId), + updateLocal = false, + ) } + return toggleLocalSavedInternal(item) + } + + fun toggleLocalSaved(item: LibraryItem) { + ensureLoaded() + toggleLocalSavedInternal(item) + } + + private fun toggleLocalSavedInternal(item: LibraryItem): TrackingMembershipApplyResult { val result = localState.toggle( item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()), ) @@ -313,6 +319,7 @@ object LibraryRepository { persist(result.snapshot) publish() pushToServer(result.snapshot) + return TrackingMembershipApplyResult() } fun save(item: LibraryItem) { @@ -392,17 +399,50 @@ object LibraryRepository { suspend fun applyMembershipChanges( item: LibraryItem, desiredMembership: Map, + confirmedRemovalProviders: Set = emptySet(), + ): TrackingMembershipApplyResult = applyMembershipChanges( + item = item, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedRemovalProviders, + targetProviderIds = null, + updateLocal = true, + ) + + private suspend fun applyMembershipChanges( + item: LibraryItem, + desiredMembership: Map, + confirmedRemovalProviders: Set, + targetProviderIds: Set?, + updateLocal: Boolean, ): TrackingMembershipApplyResult { ensureLoaded() val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true val currentlyInLocal = localState.contains(item.id, item.type) val profileId = localState.snapshot().token.profileId + val providerChanges = TrackingProviderRegistry.connectedLibraryProviders() + .filter { provider -> targetProviderIds == null || provider.providerId in targetProviderIds } + .mapNotNull { provider -> + val providerListKeys = provider.snapshot().tabs.mapTo(mutableSetOf(), TrackingLibraryTab::key) + val providerMembership = desiredMembership.filterKeys(providerListKeys::contains) + providerMembership.takeIf { membership -> membership.isNotEmpty() }?.let { membership -> + provider to membership + } + } + val requiredConfirmations = providerChanges.mapNotNull { (provider, providerMembership) -> + provider.membershipRemovalConfirmation(item, providerMembership) + ?.takeUnless { confirmation -> confirmation.providerId in confirmedRemovalProviders } + } log.i { "Applying library membership item=${item.id} type=${item.type} profile=$profileId " + "localDesired=$localDesired currentlyInLocal=$currentlyInLocal " + "connectedProviders=${TrackingProviderRegistry.connectedProviderIdsSnapshot()}" } - if (localDesired != currentlyInLocal) { + if (requiredConfirmations.isNotEmpty()) { + return TrackingMembershipApplyResult( + requiredRemovalConfirmations = requiredConfirmations, + ) + } + if (updateLocal && localDesired != currentlyInLocal) { if (localDesired) { save(item) } else { @@ -412,22 +452,19 @@ object LibraryRepository { var firstFailure: Throwable? = null val resolutions = mutableListOf() - TrackingProviderRegistry.connectedLibraryProviders().forEach { provider -> - val providerListKeys = provider.snapshot().tabs.mapTo(mutableSetOf(), TrackingLibraryTab::key) - val providerMembership = desiredMembership.filterKeys(providerListKeys::contains) - if (providerMembership.isNotEmpty()) { - try { - provider.applyMembership( - profileId = profileId, - item = item, - desiredMembership = providerMembership, - )?.let(resolutions::add) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - if (firstFailure == null) firstFailure = error - log.e(error) { "Failed to update ${provider.providerId.storageId} library membership" } - } + providerChanges.forEach { (provider, providerMembership) -> + try { + provider.applyMembership( + profileId = profileId, + item = item, + desiredMembership = providerMembership, + destructiveRemovalConfirmed = provider.providerId in confirmedRemovalProviders, + )?.let(resolutions::add) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + if (firstFailure == null) firstFailure = error + log.e(error) { "Failed to update ${provider.providerId.storageId} library membership" } } } publish() @@ -435,12 +472,30 @@ object LibraryRepository { return TrackingMembershipApplyResult(resolutions = resolutions) } - suspend fun removeFromList(item: LibraryItem, listKey: String) { + suspend fun removeFromList( + item: LibraryItem, + listKey: String, + confirmedRemovalProviders: Set = emptySet(), + ): TrackingMembershipApplyResult { + ensureLoaded() + val targetProvider = TrackingProviderRegistry.connectedLibraryProviders() + .firstOrNull { provider -> provider.snapshot().tabs.any { tab -> tab.key == listKey } } + val currentMembership = if (listKey == LOCAL_LIBRARY_LIST_KEY) { + mapOf(LOCAL_LIBRARY_LIST_KEY to localState.contains(item.id, item.type)) + } else { + targetProvider?.membership(item).orEmpty() + } val desiredMembership = libraryMembershipWithRemovedList( - currentMembership = getMembershipSnapshot(item), + currentMembership = currentMembership, listKey = listKey, ) - applyMembershipChanges(item, desiredMembership) + return applyMembershipChanges( + item = item, + desiredMembership = desiredMembership, + confirmedRemovalProviders = confirmedRemovalProviders, + targetProviderIds = targetProvider?.let { provider -> setOf(provider.providerId) }.orEmpty(), + updateLocal = listKey == LOCAL_LIBRARY_LIST_KEY, + ) } private fun pushToServer(snapshot: LibraryLocalSnapshot) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipRemovalConfirmation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipRemovalConfirmation.kt new file mode 100644 index 00000000..b80929b9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/TrackingMembershipRemovalConfirmation.kt @@ -0,0 +1,127 @@ +package com.nuvio.app.features.library + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.nuvio.app.core.ui.NuvioStatusModal +import com.nuvio.app.features.tracking.TrackingMembershipApplyResult +import com.nuvio.app.features.tracking.TrackingMembershipRemovalConfirmation +import com.nuvio.app.features.tracking.TrackingMembershipRemovalImpact +import com.nuvio.app.features.tracking.TrackingProviderId +import com.nuvio.app.features.tracking.TrackingProviderRegistry +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_remove_anyway +import nuvio.composeapp.generated.resources.tracking_remove_confirmation_message +import nuvio.composeapp.generated.resources.tracking_remove_confirmation_title +import nuvio.composeapp.generated.resources.tracking_removal_impact_history +import nuvio.composeapp.generated.resources.tracking_removal_impact_history_and_rating +import nuvio.composeapp.generated.resources.tracking_removal_impact_rating +import org.jetbrains.compose.resources.stringResource + +class PendingTrackingMembershipRemoval( + val itemTitle: String, + val confirmations: List, + val retry: suspend (Set) -> TrackingMembershipApplyResult, + val onApplied: suspend (TrackingMembershipApplyResult) -> Unit, + val onFailure: suspend (Throwable) -> Unit, +) + +suspend fun executeTrackingMembershipOperation( + operation: suspend () -> TrackingMembershipApplyResult, + onSuccess: suspend (TrackingMembershipApplyResult) -> Unit, + onFailure: suspend (Throwable) -> Unit, +) { + try { + onSuccess(operation()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + onFailure(error) + } +} + +@Composable +fun TrackingMembershipRemovalConfirmationHost( + pending: PendingTrackingMembershipRemoval?, + onPendingChange: (PendingTrackingMembershipRemoval?) -> Unit, +) { + var isBusy by remember(pending) { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val confirmations = pending?.confirmations.orEmpty() + val providerNames = confirmations + .map { confirmation -> + TrackingProviderRegistry.authProvider(confirmation.providerId) + ?.descriptor + ?.displayName + ?: confirmation.providerId.storageId.replaceFirstChar(Char::uppercase) + } + .distinct() + .joinToString() + val impacts = confirmations.flatMapTo(linkedSetOf()) { confirmation -> confirmation.impacts } + val impactLabel = when (impacts) { + setOf(TrackingMembershipRemovalImpact.WATCHED_HISTORY) -> + stringResource(Res.string.tracking_removal_impact_history) + + setOf(TrackingMembershipRemovalImpact.RATING) -> + stringResource(Res.string.tracking_removal_impact_rating) + + else -> stringResource(Res.string.tracking_removal_impact_history_and_rating) + } + + NuvioStatusModal( + title = stringResource(Res.string.tracking_remove_confirmation_title, providerNames), + message = stringResource( + Res.string.tracking_remove_confirmation_message, + pending?.itemTitle.orEmpty(), + providerNames, + impactLabel, + ), + isVisible = pending != null, + isBusy = isBusy, + confirmText = stringResource(Res.string.action_remove_anyway), + dismissText = stringResource(Res.string.action_cancel), + onConfirm = { + val request = pending ?: return@NuvioStatusModal + if (isBusy) return@NuvioStatusModal + isBusy = true + scope.launch { + try { + val confirmedProviders = request.confirmations.mapTo(linkedSetOf()) { confirmation -> + confirmation.providerId + } + val result = request.retry(confirmedProviders) + if (result.requiresRemovalConfirmation) { + onPendingChange( + PendingTrackingMembershipRemoval( + itemTitle = request.itemTitle, + confirmations = result.requiredRemovalConfirmations, + retry = request.retry, + onApplied = request.onApplied, + onFailure = request.onFailure, + ), + ) + } else { + onPendingChange(null) + request.onApplied(result) + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + onPendingChange(null) + request.onFailure(error) + } finally { + isBusy = false + } + } + }, + onDismiss = { + if (!isBusy) onPendingChange(null) + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt index 28ace3bb..d10b06ee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/simkl/SimklLibraryAdapter.kt @@ -7,6 +7,8 @@ import com.nuvio.app.features.tracking.TrackingLibraryProvider import com.nuvio.app.features.tracking.TrackingLibrarySnapshot import com.nuvio.app.features.tracking.TrackingLibraryTab import com.nuvio.app.features.tracking.TrackingLibraryTabKind +import com.nuvio.app.features.tracking.TrackingMembershipRemovalConfirmation +import com.nuvio.app.features.tracking.TrackingMembershipRemovalImpact import com.nuvio.app.features.tracking.TrackingMembershipResolution import com.nuvio.app.features.tracking.TrackingProviderId import com.nuvio.app.features.tracking.TrackingRefreshIntent @@ -68,6 +70,7 @@ object SimklLibraryRepository { profileId: Int, item: LibraryItem, desiredMembership: Map, + destructiveRemovalConfirmed: Boolean = false, ): TrackingMembershipResolution? { if (profileId != ProfileRepository.activeProfileId) return null ensureLoaded() @@ -98,7 +101,10 @@ object SimklLibraryRepository { ) currentStatus != null -> { - require(snapshot.canSafelyRemoveFromSimklLibrary(item.id)) { + require( + snapshot.membershipRemovalConfirmation(item.id) == null || + destructiveRemovalConfirmed, + ) { "Removing this item from Simkl would also clear watched history or a rating" } SimklMutationRepository.removeFromList(profileId = profileId, items = listOf(media)) @@ -125,6 +131,20 @@ object SimklLibraryRepository { return resolution } + fun membershipRemovalConfirmation( + item: LibraryItem, + desiredMembership: Map, + ): TrackingMembershipRemovalConfirmation? { + ensureLoaded() + val removesStatus = simklLibraryStatusDefinitions.none { definition -> + desiredMembership[definition.key] == true + } && findItem(item.id, item.type)?.listKeys.orEmpty().any { key -> + simklLibraryStatusDefinition(key) != null + } + if (!removesStatus) return null + return SimklSyncRepository.state.value.snapshot.membershipRemovalConfirmation(item.id) + } + private fun publish(syncState: SimklSyncUiState) { val projection = syncState.snapshot.toSimklLibraryProjection() _uiState.value = SimklLibraryUiState( @@ -191,39 +211,52 @@ object SimklTrackingLibraryProvider : TrackingLibraryProvider { override suspend fun membership(item: LibraryItem): Map = SimklLibraryRepository.statusMembership(item.id, item.type) + override fun toggledDefaultMembership( + currentMembership: Map, + ): Map = currentMembership.mapValues { false }.toMutableMap().apply { + if (currentMembership.values.none { isSelected -> isSelected }) { + this[simklLibraryStatusDefinitions.single { definition -> + definition.status == SimklListStatus.PLAN_TO_WATCH + }.key] = true + } + } + + override fun membershipRemovalConfirmation( + item: LibraryItem, + desiredMembership: Map, + ): TrackingMembershipRemovalConfirmation? = + SimklLibraryRepository.membershipRemovalConfirmation(item, desiredMembership) + override suspend fun applyMembership( profileId: Int, item: LibraryItem, desiredMembership: Map, + destructiveRemovalConfirmed: Boolean, ): TrackingMembershipResolution? = SimklLibraryRepository.applyStatusMembership( profileId = profileId, item = item, desiredMembership = desiredMembership, + destructiveRemovalConfirmed = destructiveRemovalConfirmed, ) - - override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) { - val current = SimklLibraryRepository.statusMembership(item.id, item.type) - val desired = current.mapValues { false }.toMutableMap() - if (current.values.none { isSelected -> isSelected }) { - desired[simklLibraryStatusDefinitions.single { definition -> - definition.status == SimklListStatus.PLAN_TO_WATCH - }.key] = true - } - SimklLibraryRepository.applyStatusMembership( - profileId = profileId, - item = item, - desiredMembership = desired, - ) - } } -private fun SimklSyncSnapshot.canSafelyRemoveFromSimklLibrary(contentId: String): Boolean = +internal fun SimklSyncSnapshot.membershipRemovalConfirmation( + contentId: String, +): TrackingMembershipRemovalConfirmation? = entries.firstOrNull { entry -> entry.media?.canonicalContentId().equals(contentId, ignoreCase = true) } - ?.let { entry -> + ?.takeUnless { entry -> entry.status == SimklListStatus.PLAN_TO_WATCH && entry.lastWatchedAt == null && entry.userRating == null && entry.seasons.none { season -> season.episodes.any { episode -> episode.watchedAt != null } } } - ?: true + ?.let { + TrackingMembershipRemovalConfirmation( + providerId = TrackingProviderId.SIMKL, + impacts = setOf( + TrackingMembershipRemovalImpact.WATCHED_HISTORY, + TrackingMembershipRemovalImpact.RATING, + ), + ) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt index 9d72aec4..b0118574 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingLibraryMembership.kt @@ -9,9 +9,23 @@ data class TrackingMembershipResolution( get() = requestedListKey != resolvedListKey } +enum class TrackingMembershipRemovalImpact { + WATCHED_HISTORY, + RATING, +} + +data class TrackingMembershipRemovalConfirmation( + val providerId: TrackingProviderId, + val impacts: Set, +) + data class TrackingMembershipApplyResult( val resolutions: List = emptyList(), + val requiredRemovalConfirmations: List = emptyList(), ) { val rewrites: List get() = resolutions.filter(TrackingMembershipResolution::wasRewritten) + + val requiresRemovalConfirmation: Boolean + get() = requiredRemovalConfirmations.isNotEmpty() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt index b0d89b66..5cbdab6a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tracking/TrackingReads.kt @@ -89,12 +89,17 @@ interface TrackingLibraryProvider { fun contains(contentId: String, contentType: String? = null): Boolean fun find(contentId: String): LibraryItem? suspend fun membership(item: LibraryItem): Map + fun toggledDefaultMembership(currentMembership: Map): Map + fun membershipRemovalConfirmation( + item: LibraryItem, + desiredMembership: Map, + ): TrackingMembershipRemovalConfirmation? = null suspend fun applyMembership( profileId: Int, item: LibraryItem, desiredMembership: Map, + destructiveRemovalConfirmed: Boolean = false, ): TrackingMembershipResolution? - suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) } /** Provider adapter for watched-history projection and explicit history mutations. */ diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 39f82e9c..1bdd28d4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -197,16 +197,6 @@ object TraktLibraryRepository { return TraktMembershipSnapshot(listMembership = map) } - suspend fun toggleWatchlist(item: LibraryItem) { - ensureLoaded() - val snapshot = getMembershipSnapshot(item) - val currentlyInWatchlist = snapshot.listMembership[WATCHLIST_KEY] == true - val desired = snapshot.listMembership.toMutableMap().apply { - this[WATCHLIST_KEY] = !currentlyInWatchlist - } - applyMembershipChanges(item, TraktMembershipChanges(desiredMembership = desired)) - } - suspend fun applyMembershipChanges(item: LibraryItem, changes: TraktMembershipChanges) { ensureLoaded() val headers = TraktAuthRepository.authorizedHeaders() ?: return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt index d8b2b1aa..37767810 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProvider.kt @@ -66,10 +66,21 @@ object TraktTrackingLibraryProvider : TrackingLibraryProvider { override suspend fun membership(item: LibraryItem): Map = TraktLibraryRepository.getMembershipSnapshot(item).listMembership + override fun toggledDefaultMembership( + currentMembership: Map, + ): Map { + val watchlistKey = snapshot().tabs + .firstOrNull { tab -> tab.kind == TrackingLibraryTabKind.WATCHLIST } + ?.key + ?: return currentMembership + return toggledTraktWatchlistMembership(currentMembership, watchlistKey) + } + override suspend fun applyMembership( profileId: Int, item: LibraryItem, desiredMembership: Map, + destructiveRemovalConfirmed: Boolean, ): TrackingMembershipResolution? { TraktLibraryRepository.applyMembershipChanges( item = item, @@ -77,9 +88,13 @@ object TraktTrackingLibraryProvider : TrackingLibraryProvider { ) return null } +} - override suspend fun toggleDefaultMembership(profileId: Int, item: LibraryItem) = - TraktLibraryRepository.toggleWatchlist(item) +internal fun toggledTraktWatchlistMembership( + currentMembership: Map, + watchlistKey: String, +): Map = currentMembership.toMutableMap().apply { + this[watchlistKey] = currentMembership[watchlistKey] != true } private fun TraktListTab.toTrackingLibraryTab(): TrackingLibraryTab = diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt index 361a6ecc..1fb08daa 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklProjectionsTest.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.simkl import com.nuvio.app.features.tracking.TrackingMediaKind +import com.nuvio.app.features.tracking.TrackingMembershipRemovalImpact import com.nuvio.app.features.watchprogress.WatchProgressSourceSimklPlayback import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -233,6 +234,95 @@ class SimklProjectionsTest { assertEquals(4, reference.episode?.number) } + @Test + fun `clean plan to watch removal needs no destructive confirmation`() { + val plan = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.PLAN_TO_WATCH, + id = 53536, + imdb = "tt0181852", + ) + + val confirmation = SimklSyncSnapshot(entries = listOf(plan)) + .membershipRemovalConfirmation("tt0181852") + + assertNull(confirmation) + } + + @Test + fun `watched or rated Simkl removal requires destructive confirmation`() { + val watchedPlan = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.PLAN_TO_WATCH, + id = 53536, + imdb = "tt0181852", + lastWatchedAt = "2023-11-14T22:13:20Z", + ) + val ratedPlan = entry( + type = SimklMediaType.MOVIES, + status = SimklListStatus.PLAN_TO_WATCH, + id = 53434, + imdb = "tt0068646", + ).copy(userRating = 9) + val episodePlan = entry( + type = SimklMediaType.SHOWS, + status = SimklListStatus.PLAN_TO_WATCH, + id = 2090, + imdb = "tt1520211", + seasons = listOf( + SimklSeason( + number = 1, + episodes = listOf( + SimklEpisode(number = 1, watchedAt = "2023-11-14T22:13:20Z"), + ), + ), + ), + ) + + val confirmation = assertNotNull( + SimklSyncSnapshot(entries = listOf(watchedPlan, ratedPlan, episodePlan)) + .membershipRemovalConfirmation("tt0181852"), + ) + assertNotNull( + SimklSyncSnapshot(entries = listOf(watchedPlan, ratedPlan, episodePlan)) + .membershipRemovalConfirmation("tt0068646"), + ) + assertNotNull( + SimklSyncSnapshot(entries = listOf(watchedPlan, ratedPlan, episodePlan)) + .membershipRemovalConfirmation("tt1520211"), + ) + + assertEquals( + setOf( + TrackingMembershipRemovalImpact.WATCHED_HISTORY, + TrackingMembershipRemovalImpact.RATING, + ), + confirmation.impacts, + ) + } + + @Test + fun `Simkl default membership toggles only its mutually exclusive status`() { + val planKey = simklLibraryStatusDefinitions.single { definition -> + definition.status == SimklListStatus.PLAN_TO_WATCH + }.key + val watchingKey = simklLibraryStatusDefinitions.single { definition -> + definition.status == SimklListStatus.WATCHING + }.key + val emptyMembership = simklLibraryStatusDefinitions.associate { definition -> + definition.key to false + } + + val added = SimklTrackingLibraryProvider.toggledDefaultMembership(emptyMembership) + val removed = SimklTrackingLibraryProvider.toggledDefaultMembership( + emptyMembership + (watchingKey to true), + ) + + assertTrue(added[planKey] == true) + assertTrue(added.filterKeys { key -> key != planKey }.values.none { it }) + assertTrue(removed.values.none { it }) + } + @Test fun `timestamp parser accepts UTC fractions and rejects invalid calendar values`() { assertEquals(0L, parseSimklUtcEpochMs("1970-01-01T00:00:00Z")) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt new file mode 100644 index 00000000..c5bf96a3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktTrackingLibraryProviderTest.kt @@ -0,0 +1,22 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TraktTrackingLibraryProviderTest { + @Test + fun `default toggle changes only watchlist membership`() { + val membership = mapOf( + "trakt:watchlist" to false, + "trakt:list:42" to true, + ) + + val added = toggledTraktWatchlistMembership(membership, "trakt:watchlist") + val removed = toggledTraktWatchlistMembership(added, "trakt:watchlist") + + assertEquals(true, added["trakt:watchlist"]) + assertEquals(true, added["trakt:list:42"]) + assertEquals(false, removed["trakt:watchlist"]) + assertEquals(true, removed["trakt:list:42"]) + } +}