Reverting the spotlight on the Save button

This commit is contained in:
Vitor Pamplona
2024-06-10 19:51:52 -04:00
parent 6ed1f9d369
commit c4ecf85618
5 changed files with 237 additions and 139 deletions

View File

@@ -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.
*

View File

@@ -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<Boolean>,
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<Boolean>,
modifier: Modifier,
innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit,
@@ -1090,12 +1088,12 @@ fun AnimatedSaveAndShareButton(
enter = remember { fadeIn() },
exit = remember { fadeOut() },
) {
SaveAndShareButton(innerAction)
ShareButton(innerAction)
}
}
@Composable
fun SaveAndShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit) {
fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) -> Unit) {
Box(modifier = PinBottomIconSize) {
Box(
Modifier.clip(CircleShape)
@@ -1122,3 +1120,64 @@ fun SaveAndShareButton(innerAction: @Composable (MutableState<Boolean>, () -> 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)
},
)
}

View File

@@ -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<BaseMediaContent>,
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,6 +249,9 @@ private fun DialogContent(
)
}
Spacer(modifier = Modifier.weight(1f))
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
val popupExpanded = remember { mutableStateOf(false) }
OutlinedButton(
@@ -246,13 +262,75 @@ private fun DialogContent(
Icon(
imageVector = Icons.Default.Share,
modifier = Size20Modifier,
contentDescription = stringResource(R.string.share_or_save),
contentDescription = stringResource(R.string.quick_action_share),
)
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
ShareImageAction(popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false }, accountViewModel = accountViewModel)
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)
},
)
}
}
}

View File

@@ -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,13 +138,13 @@ fun ZoomableContentView(
mainImageModifier = mainImageModifier.clickable { dialogOpen = true }
}
val controllerVisible = remember { mutableStateOf(true) }
when (content) {
is MediaUrlImage ->
SensitivityWarning(content.contentWarning != null, accountViewModel) {
TwoSecondController(content) { controllerVisible ->
UrlImageView(content, mainImageModifier, isFiniteHeight, controllerVisible, accountViewModel = accountViewModel)
}
}
is MediaUrlVideo ->
SensitivityWarning(content.contentWarning != null, accountViewModel) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
@@ -169,7 +165,9 @@ fun ZoomableContentView(
}
}
is MediaLocalImage ->
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<Boolean>) -> 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<Boolean>,
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<Boolean>,
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()
},
)
}
}
}
}

View File

@@ -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)