From c4ecf856186b805343b50365ea3a98a3bd10420d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 10 Jun 2024 19:51:52 -0400 Subject: [PATCH] Reverting the spotlight on the Save button --- .../amethyst/ui/actions/ImageSaver.kt | 29 +++++ .../amethyst/ui/components/VideoView.kt | 117 ++++++++++++----- .../ui/components/ZoomableContentDialog.kt | 108 +++++++++++++--- .../ui/components/ZoomableContentView.kt | 121 ++++-------------- .../vitorpamplona/amethyst/ui/theme/Shape.kt | 1 + 5 files changed, 237 insertions(+), 139 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt index 997a94608..b734e01ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt @@ -29,6 +29,8 @@ import android.os.Environment import android.provider.MediaStore import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi +import androidx.core.net.toFile +import androidx.core.net.toUri import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.HttpClientManager import kotlinx.coroutines.CancellationException @@ -45,6 +47,33 @@ import java.io.File import java.util.UUID object ImageSaver { + fun saveImage( + videoUri: String?, + mimeType: String?, + localContext: Context, + onSuccess: () -> Any?, + onError: (Throwable) -> Any?, + ) { + if (videoUri != null) { + if (!videoUri.startsWith("file")) { + saveImage( + context = localContext, + url = videoUri, + onSuccess = onSuccess, + onError = onError, + ) + } else { + saveImage( + context = localContext, + localFile = videoUri.toUri().toFile(), + mimeType = mimeType, + onSuccess = onSuccess, + onError = onError, + ) + } + } + } + /** * Saves the image to the gallery. May require a storage permission. * diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 216847028..76cddfb17 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.Manifest import android.content.Context import android.graphics.Rect import android.graphics.drawable.Drawable import android.net.Uri +import android.os.Build import android.util.Log import android.view.View import android.view.ViewGroup @@ -44,6 +46,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -89,12 +92,15 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState import com.linc.audiowaveform.infiniteLinearGradient import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache import com.vitorpamplona.amethyst.commons.compose.produceCachedState -import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.service.playback.PlaybackClientController +import com.vitorpamplona.amethyst.ui.actions.ImageSaver import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.LyricsIcon import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon @@ -103,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.note.MutedIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize import com.vitorpamplona.amethyst.ui.theme.Size110dp +import com.vitorpamplona.amethyst.ui.theme.Size165dp import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier @@ -789,14 +796,13 @@ private fun RenderVideoPlayer( keepPlaying.value = newKeepPlaying } - AnimatedSaveAndShareButton( - videoUri = videoUri, - mimeType = mimeType, - nostrUriCallback = nostrUriCallback, - controllerVisible = controllerVisible, - modifier = Modifier.align(Alignment.TopEnd).padding(end = Size110dp), - accountViewModel = accountViewModel, - ) + AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context -> + saveImage(videoUri, mimeType, context, accountViewModel) + } + + AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle -> + ShareImageAction(popupExpanded, videoUri, nostrUriCallback, toggle) + } } } @@ -1055,31 +1061,23 @@ private fun KeepPlayingButton( } @Composable -fun AnimatedSaveAndShareButton( - videoUri: String, - mimeType: String?, - nostrUriCallback: String?, +fun AnimatedSaveButton( controllerVisible: State, modifier: Modifier, - accountViewModel: AccountViewModel, + onSaveClick: (localContext: Context) -> Unit, ) { - AnimatedSaveAndShareButton(controllerVisible, modifier) { popupExpanded, toggle -> - ShareImageAction(popupExpanded, videoUri, nostrUriCallback, mimeType, toggle, accountViewModel) + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + SaveButton(onSaveClick) } } @Composable -fun SaveAndShareButton( - content: BaseMediaContent, - accountViewModel: AccountViewModel, -) { - SaveAndShareButton { popupExpanded, toggle -> - ShareImageAction(popupExpanded, content, toggle, accountViewModel) - } -} - -@Composable -fun AnimatedSaveAndShareButton( +fun AnimatedShareButton( controllerVisible: State, modifier: Modifier, innerAction: @Composable (MutableState, () -> Unit) -> Unit, @@ -1090,12 +1088,12 @@ fun AnimatedSaveAndShareButton( enter = remember { fadeIn() }, exit = remember { fadeOut() }, ) { - SaveAndShareButton(innerAction) + ShareButton(innerAction) } } @Composable -fun SaveAndShareButton(innerAction: @Composable (MutableState, () -> Unit) -> Unit) { +fun ShareButton(innerAction: @Composable (MutableState, () -> Unit) -> Unit) { Box(modifier = PinBottomIconSize) { Box( Modifier.clip(CircleShape) @@ -1122,3 +1120,64 @@ fun SaveAndShareButton(innerAction: @Composable (MutableState, () -> Un } } } + +@kotlin.OptIn(ExperimentalPermissionsApi::class) +@Composable +fun SaveButton(onSaveClick: (localContext: Context) -> Unit) { + Box(modifier = PinBottomIconSize) { + Box( + Modifier.clip(CircleShape) + .fillMaxSize(0.6f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), + ) + + val localContext = LocalContext.current + + val writeStoragePermissionState = + rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> + if (isGranted) { + onSaveClick(localContext) + } + } + + IconButton( + onClick = { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || + writeStoragePermissionState.status.isGranted + ) { + onSaveClick(localContext) + } else { + writeStoragePermissionState.launchPermissionRequest() + } + }, + modifier = Size50Modifier, + ) { + Icon( + imageVector = Icons.Default.Download, + modifier = Size20Modifier, + contentDescription = stringResource(R.string.save_to_gallery), + ) + } + } +} + +private fun saveImage( + videoUri: String?, + mimeType: String?, + localContext: Context, + accountViewModel: AccountViewModel, +) { + ImageSaver.saveImage( + videoUri = videoUri, + mimeType = mimeType, + localContext = localContext, + onSuccess = { + accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery) + }, + onError = { + accountViewModel.toast(R.string.failed_to_save_the_image, null, it) + }, + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index b1aa270f2..147b86f9b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.Manifest +import android.content.Context import android.os.Build import android.view.View import android.view.WindowInsets @@ -29,11 +31,13 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -43,6 +47,7 @@ import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Share import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon @@ -60,6 +65,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.window.Dialog @@ -67,14 +73,21 @@ import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri import androidx.core.view.ViewCompat import coil.compose.AsyncImage +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.MediaLocalImage import com.vitorpamplona.amethyst.commons.richtext.MediaLocalVideo +import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.ui.actions.ImageSaver import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size15dp import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size5dp import kotlinx.collections.immutable.ImmutableList @@ -156,7 +169,7 @@ fun ZoomableImageDialog( } @Composable -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalPermissionsApi::class) private fun DialogContent( allImages: ImmutableList, imageUrl: BaseMediaContent, @@ -221,7 +234,7 @@ private fun DialogContent( exit = remember { fadeOut() }, ) { Row( - modifier = Modifier.padding(Size10dp).statusBarsPadding().systemBarsPadding().fillMaxWidth(), + modifier = Modifier.padding(horizontal = Size15dp, vertical = Size10dp).statusBarsPadding().systemBarsPadding().fillMaxWidth(), horizontalArrangement = spacedBy(Size10dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -236,27 +249,92 @@ private fun DialogContent( ) } - val popupExpanded = remember { mutableStateOf(false) } + Spacer(modifier = Modifier.weight(1f)) - OutlinedButton( - onClick = { popupExpanded.value = true }, - contentPadding = PaddingValues(horizontal = Size5dp), - colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), - ) { - Icon( - imageVector = Icons.Default.Share, - modifier = Size20Modifier, - contentDescription = stringResource(R.string.share_or_save), - ) + allImages.getOrNull(pagerState.currentPage)?.let { myContent -> + val popupExpanded = remember { mutableStateOf(false) } - allImages.getOrNull(pagerState.currentPage)?.let { myContent -> - ShareImageAction(popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false }, accountViewModel = accountViewModel) + OutlinedButton( + onClick = { popupExpanded.value = true }, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), + ) { + Icon( + imageVector = Icons.Default.Share, + modifier = Size20Modifier, + contentDescription = stringResource(R.string.quick_action_share), + ) + + ShareImageAction(popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false }) + } + + val localContext = LocalContext.current + + val writeStoragePermissionState = + rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> + if (isGranted) { + saveImage(myContent, localContext, accountViewModel) + } + } + + OutlinedButton( + onClick = { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || + writeStoragePermissionState.status.isGranted + ) { + saveImage(myContent, localContext, accountViewModel) + } else { + writeStoragePermissionState.launchPermissionRequest() + } + }, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), + ) { + Icon( + imageVector = Icons.Default.Download, + modifier = Size20Modifier, + contentDescription = stringResource(R.string.save_to_gallery), + ) } } } } } +private fun saveImage( + content: BaseMediaContent, + localContext: Context, + accountViewModel: AccountViewModel, +) { + if (content is MediaUrlContent) { + ImageSaver.saveImage( + content.url, + localContext, + onSuccess = { + accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery) + }, + onError = { + accountViewModel.toast(R.string.failed_to_save_the_image, null, it) + }, + ) + } else if (content is MediaPreloadedContent) { + content.localFile?.let { + ImageSaver.saveImage( + it, + content.mimeType, + localContext, + onSuccess = { + accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery) + }, + onError = { + accountViewModel.toast(R.string.failed_to_save_the_image, null, it) + }, + ) + } + } +} + @Composable @OptIn(ExperimentalFoundationApi::class) fun InlineCarrousel( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 30a71186f..68cbf9944 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -20,16 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.components -import android.Manifest import android.app.Activity import android.content.Context import android.content.ContextWrapper -import android.os.Build import android.util.Log import android.view.Window import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio @@ -73,8 +72,6 @@ import coil.compose.AsyncImagePainter import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImageContent import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent @@ -85,7 +82,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.service.BlurHashRequester -import com.vitorpamplona.amethyst.ui.actions.ImageSaver import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.BlankNote @@ -142,12 +138,12 @@ fun ZoomableContentView( mainImageModifier = mainImageModifier.clickable { dialogOpen = true } } - val controllerVisible = remember { mutableStateOf(true) } - when (content) { is MediaUrlImage -> SensitivityWarning(content.contentWarning != null, accountViewModel) { - UrlImageView(content, mainImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel) + TwoSecondController(content) { controllerVisible -> + UrlImageView(content, mainImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel) + } } is MediaUrlVideo -> SensitivityWarning(content.contentWarning != null, accountViewModel) { @@ -169,7 +165,9 @@ fun ZoomableContentView( } } is MediaLocalImage -> - LocalImageView(content, mainImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel) + TwoSecondController(content) { controllerVisible -> + LocalImageView(content, mainImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel) + } is MediaLocalVideo -> content.localFile?.let { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { @@ -194,6 +192,25 @@ fun ZoomableContentView( } } +@Composable +fun TwoSecondController( + content: BaseMediaContent, + inner: @Composable (controllerVisible: MutableState) -> Unit, +) { + val controllerVisible = remember { mutableStateOf(true) } + + LaunchedEffect(content) { + launch(Dispatchers.Default) { + delay(2000) + withContext(Dispatchers.Main) { + controllerVisible.value = false + } + } + } + + inner(controllerVisible) +} + @Composable fun LocalImageView( content: MediaLocalImage, @@ -600,25 +617,20 @@ fun ShareImageAction( popupExpanded: MutableState, content: BaseMediaContent, onDismiss: () -> Unit, - accountViewModel: AccountViewModel, ) { if (content is MediaUrlContent) { ShareImageAction( popupExpanded = popupExpanded, videoUri = content.url, postNostrUri = content.uri, - mimeType = content.mimeType, onDismiss = onDismiss, - accountViewModel = accountViewModel, ) } else if (content is MediaPreloadedContent) { ShareImageAction( popupExpanded = popupExpanded, videoUri = content.localFile?.toUri().toString(), postNostrUri = content.uri, - mimeType = content.mimeType, onDismiss = onDismiss, - accountViewModel = accountViewModel, ) } } @@ -629,9 +641,7 @@ fun ShareImageAction( popupExpanded: MutableState, videoUri: String?, postNostrUri: String?, - mimeType: String?, onDismiss: () -> Unit, - accountViewModel: AccountViewModel, ) { DropdownMenu( expanded = popupExpanded.value, @@ -658,85 +668,6 @@ fun ShareImageAction( }, ) } - - if (videoUri != null) { - if (!videoUri.startsWith("file")) { - val localContext = LocalContext.current - - fun saveImage() { - ImageSaver.saveImage( - context = localContext, - url = videoUri, - onSuccess = { - accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery) - }, - onError = { - accountViewModel.toast(R.string.failed_to_save_the_image, null, it) - }, - ) - } - - val writeStoragePermissionState = - rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> - if (isGranted) { - saveImage() - } - } - - DropdownMenuItem( - text = { Text(stringResource(R.string.save_to_gallery)) }, - onClick = { - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || - writeStoragePermissionState.status.isGranted - ) { - saveImage() - } else { - writeStoragePermissionState.launchPermissionRequest() - } - onDismiss() - }, - ) - } else { - val localContext = LocalContext.current - - fun saveImage() { - ImageSaver.saveImage( - context = localContext, - localFile = videoUri.toUri().toFile(), - mimeType = mimeType, - onSuccess = { - accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery) - }, - onError = { - accountViewModel.toast(R.string.failed_to_save_the_image, null, it) - }, - ) - } - - val writeStoragePermissionState = - rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> - if (isGranted) { - saveImage() - } - } - - DropdownMenuItem( - text = { Text(stringResource(R.string.save_to_gallery)) }, - onClick = { - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || - writeStoragePermissionState.status.isGranted - ) { - saveImage() - } else { - writeStoragePermissionState.launchPermissionRequest() - } - onDismiss() - }, - ) - } - } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index cb3b0af01..e5aed38b5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -97,6 +97,7 @@ val Size40dp = 40.dp val Size55dp = 55.dp val Size75dp = 75.dp val Size110dp = 110.dp +val Size165dp = 165.dp val HalfEndPadding = Modifier.padding(end = 5.dp) val HalfStartPadding = Modifier.padding(start = 5.dp)