From bbf33df04c4bf34ca40ff66071d8ca35c9714121 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 24 Mar 2023 12:33:16 -0400 Subject: [PATCH] Adds an Image Carousel when there are more than 1 image in the post. --- app/build.gradle | 2 + .../amethyst/ui/components/RichTextViewer.kt | 23 +++- .../amethyst/ui/components/SlidingCarousel.kt | 104 ++++++++++++++++++ .../ui/components/ZoomableAsyncImage.kt | 61 ---------- .../ui/components/ZoomableImageView.kt | 52 +++++++-- .../ui/dal/HomeConversationsFeedFilter.kt | 1 - 6 files changed, 169 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt delete mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt diff --git a/app/build.gradle b/app/build.gradle index 82b9d26f0..d2dc83ba0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -104,6 +104,8 @@ dependencies { implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version" + implementation "net.engawapg.lib:zoomable:1.4.0" + // Biometrics implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index a184d420c..08e906e78 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -131,6 +131,20 @@ fun RichTextViewer( ) } } else { + val imagesForPager = mutableListOf() + + content.split('\n').forEach { paragraph -> + paragraph.split(' ').forEach { word: String -> + // sequence of images will render in a slideview + if (isValidURL(word)) { + val removedParamsFromUrl = word.split("?")[0].lowercase() + if (imageExtension.matcher(removedParamsFromUrl).matches()) { + imagesForPager.add(word) + } + } + } + } + // FlowRow doesn't work well with paragraphs. So we need to split them content.split('\n').forEach { paragraph -> FlowRow() { @@ -140,17 +154,18 @@ fun RichTextViewer( // Explicit URL val lnInvoice = LnInvoiceUtil.findInvoice(word) val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word) - if (lnInvoice != null) { - InvoicePreview(lnInvoice) - } else if (isValidURL(word)) { + + if (isValidURL(word)) { val removedParamsFromUrl = word.split("?")[0].lowercase() if (imageExtension.matcher(removedParamsFromUrl).matches()) { - ZoomableImageView(word) + ZoomableImageView(word, imagesForPager) } else if (videoExtension.matcher(removedParamsFromUrl).matches()) { VideoView(word) } else { UrlPreview(word, "$word ") } + } else if (lnInvoice != null) { + InvoicePreview(lnInvoice) } else if (lnWithdrawal != null) { ClickableWithdrawal(withdrawalString = lnWithdrawal) } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt new file mode 100644 index 000000000..03636135b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt @@ -0,0 +1,104 @@ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.HorizontalPager +import com.google.accompanist.pager.PagerState + +@OptIn(ExperimentalPagerApi::class) +@Composable +fun SlidingCarousel( + modifier: Modifier = Modifier, + pagerState: PagerState = remember { PagerState() }, + itemsCount: Int, + itemContent: @Composable (index: Int) -> Unit +) { + val isDragged by pagerState.interactionSource.collectIsDraggedAsState() + + Box( + modifier = modifier.fillMaxWidth() + ) { + HorizontalPager(count = itemsCount, state = pagerState) { page -> + itemContent(page) + } + + // you can remove the surface in case you don't want + // the transparant bacground + Surface( + modifier = Modifier + .padding(bottom = 8.dp) + .align(Alignment.BottomCenter), + shape = CircleShape, + color = Color.Black.copy(alpha = 0.5f) + ) { + DotsIndicator( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + totalDots = itemsCount, + selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage, + dotSize = 8.dp + ) + } + } +} + +@Composable +fun DotsIndicator( + modifier: Modifier = Modifier, + totalDots: Int, + selectedIndex: Int, + selectedColor: Color = MaterialTheme.colors.primary /* Color.Yellow */, + unSelectedColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) /* Color.Gray */, + dotSize: Dp +) { + LazyRow( + modifier = modifier + .wrapContentWidth() + .wrapContentHeight() + ) { + items(totalDots) { index -> + IndicatorDot( + color = if (index == selectedIndex) selectedColor else unSelectedColor, + size = dotSize + ) + + if (index != totalDots - 1) { + Spacer(modifier = Modifier.padding(horizontal = 2.dp)) + } + } + } +} + +@Composable +fun IndicatorDot( + modifier: Modifier = Modifier, + size: Dp, + color: Color +) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(color) + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt deleted file mode 100644 index a55282ce0..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.vitorpamplona.amethyst.ui.components - -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.calculatePan -import androidx.compose.foundation.gestures.calculateZoom -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource -import coil.compose.AsyncImage -import com.vitorpamplona.amethyst.R - -@Composable -fun ZoomableAsyncImage(imageUrl: String) { - var scale by remember { mutableStateOf(1f) } - var offsetX by remember { mutableStateOf(0f) } - var offsetY by remember { mutableStateOf(0f) } - - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .pointerInput(Unit) { - awaitEachGesture { - awaitFirstDown() - do { - val event = awaitPointerEvent() - scale *= event.calculateZoom() - val offset = event.calculatePan() - offsetX += offset.x - offsetY += offset.y - } while (event.changes.any { it.pressed }) - } - } - ) { - AsyncImage( - model = imageUrl, - contentDescription = stringResource(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, - modifier = Modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offsetX, - translationY = offsetY - ) - ) - } -} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt index b2196de82..9e968f27e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt @@ -18,22 +18,27 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage +import com.google.accompanist.pager.ExperimentalPagerApi +import com.google.accompanist.pager.PagerState +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.SaveToGallery +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable @Composable -@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) -fun ZoomableImageView(word: String) { +@OptIn(ExperimentalFoundationApi::class) +fun ZoomableImageView(word: String, images: List = listOf(word)) { val clipboardManager = LocalClipboardManager.current // store the dialog open or close state @@ -49,7 +54,11 @@ fun ZoomableImageView(word: String) { .padding(top = 4.dp) .fillMaxWidth() .clip(shape = RoundedCornerShape(15.dp)) - .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ) .combinedClickable( onClick = { dialogOpen = true }, onLongClick = { clipboardManager.setText(AnnotatedString(word)) } @@ -57,12 +66,13 @@ fun ZoomableImageView(word: String) { ) if (dialogOpen) { - ZoomableImageDialog(word, onDismiss = { dialogOpen = false }) + ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false }) } } +@OptIn(ExperimentalPagerApi::class) @Composable -fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) { +fun ZoomableImageDialog(imageUrl: String, allImages: List = listOf(imageUrl), onDismiss: () -> Unit) { Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false) @@ -71,6 +81,8 @@ fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) { Column( modifier = Modifier.padding(10.dp) ) { + var pagerState: PagerState = remember { PagerState() } + Row( modifier = Modifier .fillMaxWidth(), @@ -79,10 +91,34 @@ fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) { ) { CloseButton(onCancel = onDismiss) - SaveToGallery(url = imageUrl) + SaveToGallery(url = allImages[pagerState.currentPage]) } - ZoomableAsyncImage(imageUrl) + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + itemsCount = allImages.size, + itemContent = { index -> + AsyncImage( + model = allImages[index], + contentDescription = stringResource(id = R.string.profile_image), + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .zoomable(rememberZoomState()) + ) + } + ) + } else { + AsyncImage( + model = imageUrl, + contentDescription = stringResource(id = R.string.profile_image), + contentScale = ContentScale.FillWidth, + modifier = Modifier + .fillMaxSize() + .zoomable(rememberZoomState()) + ) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index 559c81cab..50385acfd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent object HomeConversationsFeedFilter : FeedFilter() {