diff --git a/app/src/main/java/com/anytypeio/anytype/ui/media/MediaActivity.kt b/app/src/main/java/com/anytypeio/anytype/ui/media/MediaActivity.kt index f276bea16a..4a98022cd9 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/media/MediaActivity.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/media/MediaActivity.kt @@ -23,14 +23,15 @@ import com.anytypeio.anytype.di.common.componentManager import com.anytypeio.anytype.localization.R import com.anytypeio.anytype.presentation.media.MediaViewModel import com.anytypeio.anytype.presentation.media.MediaViewModel.MediaViewState +import com.anytypeio.anytype.presentation.search.Subscriptions import com.anytypeio.anytype.ui.media.screens.AudioPlayerBox import com.anytypeio.anytype.ui.media.screens.ImageGalleryBox import com.anytypeio.anytype.ui.media.screens.VideoPlayerBox +import com.anytypeio.anytype.ui.widgets.collection.CollectionFragment import java.util.ArrayList import javax.inject.Inject import kotlinx.coroutines.launch import timber.log.Timber - class MediaActivity : ComponentActivity() { @Inject @@ -69,7 +70,14 @@ class MediaActivity : ComponentActivity() { finish() } is MediaViewState.VideoContent -> { - VideoPlayerBox(url = state.url) + val videoId = intent.getStringArrayListExtra(EXTRA_OBJECTS)?.firstOrNull() + VideoPlayerBox( + url = state.url, + isArchived = state.isArchived, + onRestoreClick = { + videoId?.let { vm.onRestoreObjectClicked(it) } + } + ) } is MediaViewState.ImageContent -> { ImageGalleryBox( @@ -87,13 +95,21 @@ class MediaActivity : ComponentActivity() { toast("Space not found") } }, - onDeleteClick = vm::onDeleteObject + onDeleteClick = vm::onDeleteObject, + onRestoreClick = { obj -> + vm.onRestoreObjectClicked(obj) + } ) } is MediaViewState.AudioContent -> { + val audioId = intent.getStringArrayListExtra(EXTRA_OBJECTS)?.firstOrNull() AudioPlayerBox( name = state.name, - url = state.url + url = state.url, + isArchived = state.isArchived, + onRestoreClick = { + audioId?.let { vm.onRestoreObjectClicked(it) } + } ) } } @@ -123,6 +139,9 @@ class MediaActivity : ComponentActivity() { MediaViewModel.Command.ShowToast.MovedToBin -> { toast(getString(R.string.toast_moved_to_bin)) } + MediaViewModel.Command.ShowToast.Restored -> { + toast(getString(R.string.toast_restored)) + } } } } @@ -138,11 +157,21 @@ class MediaActivity : ComponentActivity() { val name = intent.getStringExtra(EXTRA_MEDIA_NAME) val mediaType = intent.getIntExtra(EXTRA_MEDIA_TYPE, TYPE_UNKNOWN) val index = intent.getIntExtra(EXTRA_IMAGE_INDEX, 0) + val givenSpace = space + + if (givenSpace == null) { + Timber.e("Space ID is missing") + toast("Space not found") + finish() + return + } + + val spaceId = SpaceId(givenSpace) when (mediaType) { - TYPE_IMAGE -> vm.processImage(objects, index) - TYPE_VIDEO -> vm.processVideo(objects.firstOrNull().orEmpty()) - TYPE_AUDIO -> vm.processAudio(objects.firstOrNull().orEmpty(), name.orEmpty()) + TYPE_IMAGE -> vm.processImage(objects, index, spaceId) + TYPE_VIDEO -> vm.processVideo(objects.firstOrNull().orEmpty(), spaceId) + TYPE_AUDIO -> vm.processAudio(objects.firstOrNull().orEmpty(), name.orEmpty(), spaceId) else -> { Timber.e("Invalid media type: $mediaType") finish() diff --git a/app/src/main/java/com/anytypeio/anytype/ui/media/screens/MediaScreen.kt b/app/src/main/java/com/anytypeio/anytype/ui/media/screens/MediaScreen.kt index f845089db1..a20686c7c3 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/media/screens/MediaScreen.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/media/screens/MediaScreen.kt @@ -51,6 +51,11 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -62,6 +67,7 @@ import com.anytypeio.anytype.core_models.Id import com.anytypeio.anytype.core_ui.common.DefaultPreviews import com.anytypeio.anytype.core_ui.views.BodyCallout import com.anytypeio.anytype.core_ui.views.Caption1Medium +import com.anytypeio.anytype.core_ui.views.Caption2Medium import com.anytypeio.anytype.presentation.media.MediaViewModel import kotlinx.coroutines.delay import me.saket.telephoto.zoomable.coil3.ZoomableAsyncImage @@ -74,7 +80,8 @@ fun ImageGallery( onBackClick: () -> Unit = {}, onDownloadClick: (Id) -> Unit = {}, onOpenClick: (Id) -> Unit = {}, - onDeleteClick: (Id) -> Unit = {} + onDeleteClick: (Id) -> Unit = {}, + onRestoreClick: (Id) -> Unit = {} ) { val pagerState = rememberPagerState(initialPage = index) { images.size } var chromeVisible by remember { mutableStateOf(true) } @@ -83,6 +90,9 @@ fun ImageGallery( chromeVisible = true } + val currentImage = images.getOrNull(pagerState.settledPage) + val isCurrentImageArchived = currentImage?.isArchived ?: false + Box(Modifier.fillMaxSize()) { HorizontalPager( state = pagerState, @@ -101,6 +111,46 @@ fun ImageGallery( ) } + // Archived banner (top-center) + if (isCurrentImageArchived) { + val fullText = stringResource(R.string.media_object_in_bin) + val restoreText = "Restore it?" + val startIndex = fullText.indexOf(restoreText) + + val annotatedText = buildAnnotatedString { + if (startIndex >= 0) { + // Add text before "Restore it?" + append(fullText.substring(0, startIndex)) + // Add "Restore it?" with underline + withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { + append(restoreText) + } + // Add any text after (shouldn't be any in this case) + if (startIndex + restoreText.length < fullText.length) { + append(fullText.substring(startIndex + restoreText.length)) + } + } else { + append(fullText) + } + } + + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .systemBarsPadding() + .padding(top = 16.dp) + .clickable { + currentImage?.let { onRestoreClick(it.obj) } + } + ) { + Text( + text = annotatedText, + style = BodyCallout, + color = colorResource(R.color.text_secondary) + ) + } + } + // Page counter chip (top-center) if (images.size > 1) { @@ -113,7 +163,7 @@ fun ImageGallery( Box( modifier = Modifier .systemBarsPadding() - .padding(top = 48.dp) + .padding(top = if (isCurrentImageArchived) 48.dp else 48.dp) .background( color = colorResource(R.color.home_screen_toolbar_button), shape = RoundedCornerShape(12.dp) @@ -140,6 +190,7 @@ fun ImageGallery( ) { MediaActionToolbar( modifier = Modifier.padding(bottom = 32.dp), + isArchived = isCurrentImageArchived, onBackClick = onBackClick, onDownloadClick = { onDownloadClick(images[pagerState.settledPage].obj) @@ -220,48 +271,124 @@ private fun ImageViewer( } } +@Composable +fun ImageGalleryBox( + images: List = emptyList(), + index: Int = 0, + onBackClick: () -> Unit = {}, + onDownloadClick: (Id) -> Unit = {}, + onOpenClick: (Id) -> Unit = {}, + onDeleteClick: (Id) -> Unit = {}, + onRestoreClick: (Id) -> Unit = {} +) { + Box(modifier = Modifier.fillMaxSize()) { + ImageGallery( + images = images, + index = index, + onBackClick = onBackClick, + onDownloadClick = onDownloadClick, + onDeleteClick = onDeleteClick, + onOpenClick = onOpenClick, + onRestoreClick = onRestoreClick + ) + } +} + @Composable fun AudioPlayerBox( name: String, - url: String + url: String, + isArchived: Boolean = false, + onRestoreClick: () -> Unit = {} ) { Box(modifier = Modifier.fillMaxSize()) { AudioPlayer( url = url, name = name ) + + // Archived banner (top-center) + if (isArchived) { + val fullText = stringResource(R.string.media_object_in_bin) + val restoreText = "Restore it?" + val startIndex = fullText.indexOf(restoreText) + + val annotatedText = buildAnnotatedString { + if (startIndex >= 0) { + append(fullText.substring(0, startIndex)) + withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { + append(restoreText) + } + if (startIndex + restoreText.length < fullText.length) { + append(fullText.substring(startIndex + restoreText.length)) + } + } else { + append(fullText) + } + } + + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .systemBarsPadding() + .padding(top = 16.dp) + .clickable { onRestoreClick() } + ) { + Text( + text = annotatedText, + style = BodyCallout, + color = colorResource(R.color.text_secondary) + ) + } + } } } @Composable fun VideoPlayerBox( - url: String + url: String, + isArchived: Boolean = false, + onRestoreClick: () -> Unit = {} ) { Box(modifier = Modifier.fillMaxSize()) { VideoPlayer( url = url ) - } -} - -@Composable -fun ImageGalleryBox( - images: List = emptyList(), - index: Int = 0, - onBackClick: () -> Unit = {}, - onDownloadClick: (Id) -> Unit = {}, - onOpenClick: (Id) -> Unit = {}, - onDeleteClick: (Id) -> Unit = {} -) { - Box(modifier = Modifier.fillMaxSize()) { - ImageGallery( - images = images, - index = index, - onBackClick = onBackClick, - onDownloadClick = onDownloadClick, - onDeleteClick = onDeleteClick, - onOpenClick = onOpenClick - ) + + // Archived banner (top-center) + if (isArchived) { + val fullText = stringResource(R.string.media_object_in_bin) + val restoreText = "Restore it?" + val startIndex = fullText.indexOf(restoreText) + + val annotatedText = buildAnnotatedString { + if (startIndex >= 0) { + append(fullText.substring(0, startIndex)) + withStyle(style = SpanStyle(textDecoration = TextDecoration.Underline)) { + append(restoreText) + } + if (startIndex + restoreText.length < fullText.length) { + append(fullText.substring(startIndex + restoreText.length)) + } + } else { + append(fullText) + } + } + + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .systemBarsPadding() + .padding(top = 16.dp) + .clickable { onRestoreClick() } + ) { + Text( + text = annotatedText, + style = BodyCallout, + color = colorResource(R.color.text_secondary) + ) + } + } } } diff --git a/app/src/main/java/com/anytypeio/anytype/ui/media/screens/Toolbars.kt b/app/src/main/java/com/anytypeio/anytype/ui/media/screens/Toolbars.kt index b6793f7d15..73522da65c 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/media/screens/Toolbars.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/media/screens/Toolbars.kt @@ -23,6 +23,7 @@ import com.anytypeio.anytype.core_ui.foundation.noRippleClickable @Composable fun MediaActionToolbar( modifier: Modifier = Modifier, + isArchived: Boolean = false, onBackClick: () -> Unit = {}, onDownloadClick: () -> Unit = {}, onOpenClick: () -> Unit = {}, @@ -35,6 +36,7 @@ fun MediaActionToolbar( color = colorResource(id = R.color.home_screen_toolbar_button), shape = RoundedCornerShape(16.dp) ) + .padding(horizontal = 20.dp) , verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(20.dp) @@ -43,16 +45,16 @@ fun MediaActionToolbar( Image( painter = painterResource(R.drawable.ic_nav_panel_back), contentDescription = null, - modifier = Modifier - .padding(start = 20.dp) - .noRippleClickable { onBackClick() } + modifier = Modifier.noRippleClickable { onBackClick() } ) - Image( - modifier = Modifier.noRippleClickable { onDownloadClick() }, - painter = painterResource(R.drawable.ic_object_action_download), - contentDescription = null - ) + if (!isArchived) { + Image( + modifier = Modifier.noRippleClickable { onDownloadClick() }, + painter = painterResource(R.drawable.ic_object_action_download), + contentDescription = null + ) + } // Image( // modifier = Modifier.clickable { onOpenClick() }, @@ -60,13 +62,13 @@ fun MediaActionToolbar( // contentDescription = null // ) - Image( - painter = painterResource(R.drawable.icon_delete_red), - contentDescription = null, - modifier = Modifier - .padding(end = 20.dp) - .noRippleClickable { onDeleteClick() } - ) + if (!isArchived) { + Image( + painter = painterResource(R.drawable.icon_delete_red), + contentDescription = null, + modifier = Modifier.noRippleClickable { onDeleteClick() } + ) + } } } diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index 4bf0fd996c..81820bb8df 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -2358,5 +2358,7 @@ Please provide specific details of your needs here. Error while downloading object: %1$s Object moved to bin. + This object is in the bin. Restore it? + Object restored. - \ No newline at end of file + diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/media/MediaViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/media/MediaViewModel.kt index 68e576d05c..08d3622ef3 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/media/MediaViewModel.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/media/MediaViewModel.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject import kotlinx.coroutines.flow.SharedFlow import timber.log.Timber +import kotlinx.coroutines.async class MediaViewModel( private val urlBuilder: UrlBuilder, @@ -37,39 +38,72 @@ class MediaViewModel( private val _viewState = MutableStateFlow(MediaViewState.Loading) val viewState = _viewState.asStateFlow() - fun processImage(objects: List, index: Int = 0) { + fun processImage(objects: List, index: Int = 0, space: SpaceId) { viewModelScope.launch { if (objects.isEmpty()) { _viewState.value = MediaViewState.Error("No image object IDs provided") return@launch } - _viewState.value = MediaViewState.ImageContent( - images = objects.map { + // Fetch archived status for all images + val imagesWithArchived = objects.map { id -> + async { + val obj = fetchObject.async( + params = FetchObject.Params( + space = space, + obj = id, + keys = listOf(Relations.ID, Relations.IS_ARCHIVED) + ) + ).getOrNull() + + if (obj == null) { + Timber.w("Image object not found: $id") + } else { + Timber.d("Image object found: $obj") + } + + val isArchived = obj?.let { ObjectWrapper.Basic(it.map).isArchived } ?: true MediaViewState.ImageContent.Image( - obj = it, - url = urlBuilder.large(it) + obj = id, + url = urlBuilder.large(id), + isArchived = isArchived ) - }, + } + }.map { it.await() } + + Timber.d("Images with archived status: $imagesWithArchived") + + _viewState.value = MediaViewState.ImageContent( + images = imagesWithArchived, currentIndex = index ) } } - fun processVideo(obj: Id) { + fun processVideo(obj: Id, space: SpaceId) { viewModelScope.launch { if (obj.isBlank()) { _viewState.value = MediaViewState.Error("No video object ID provided") return@launch } + val fetchedObj = fetchObject.async( + params = FetchObject.Params( + space = space, + obj = obj, + keys = listOf(Relations.ID, Relations.IS_ARCHIVED) + ) + ).getOrNull() + val isArchived = fetchedObj?.let { ObjectWrapper.Basic(it.map).isArchived } ?: false + _viewState.value = MediaViewState.VideoContent( - url = urlBuilder.original(obj) + url = urlBuilder.original(obj), + isArchived = isArchived ) } } - fun processAudio(obj: Id, name: String = "") { + fun processAudio(obj: Id, name: String = "", space: SpaceId) { viewModelScope.launch { val hash = urlBuilder.original(obj) if (hash.isBlank()) { @@ -77,9 +111,19 @@ class MediaViewModel( return@launch } + val fetchedObj = fetchObject.async( + params = FetchObject.Params( + space = space, + obj = obj, + keys = listOf(Relations.ID, Relations.IS_ARCHIVED) + ) + ).getOrNull() + val isArchived = fetchedObj?.let { ObjectWrapper.Basic(it.map).isArchived } ?: false + _viewState.value = MediaViewState.AudioContent( url = hash, - name = name + name = name, + isArchived = isArchived ) } } @@ -105,6 +149,26 @@ class MediaViewModel( } } + fun onRestoreObjectClicked(id: Id) { + viewModelScope.launch { + setObjectListIsArchived.async( + params = SetObjectListIsArchived.Params( + targets = listOf(id), + isArchived = false + ) + ).onFailure { error -> + Timber.e(error, "Error while restoring media object").also { + _commands.emit( + Command.ShowToast.Generic("Error: ${error.message}") + ) + } + }.onSuccess { + _commands.emit(Command.ShowToast.Restored) + _commands.emit(Command.Dismiss) + } + } + } + fun onDownloadObject(id: Id, space: SpaceId) { Timber.d("onDownload: $id, space: $space") viewModelScope.launch { @@ -159,17 +223,22 @@ class MediaViewModel( ) : MediaViewState() { data class Image( val obj: Id, - val url: String + val url: String, + val isArchived: Boolean = false ) + val currentImage: Image? get() = images.getOrNull(currentIndex) + val isCurrentImageArchived: Boolean get() = currentImage?.isArchived ?: false } data class VideoContent( - val url: String + val url: String, + val isArchived: Boolean = false ) : MediaViewState() data class AudioContent( val url: String, - val name: String + val name: String, + val isArchived: Boolean = false ) : MediaViewState() } @@ -179,6 +248,7 @@ class MediaViewModel( data class Generic(val message: String) : Command() data class ErrorWhileDownloadingObject(val exception: String) : Command() data object MovedToBin : Command() + data object Restored : Command() } }