From 380c2e67ccfc43c87ff17cebbc0c4d2e255b690d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 13 Jan 2023 20:16:57 -0500 Subject: [PATCH] Adds a click to zoom image. --- .../com/vitorpamplona/amethyst/model/Note.kt | 14 +++- .../amethyst/ui/actions/NewPostView.kt | 4 +- .../ui/components/ExtendedImageView.kt | 81 +++++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 24 ++---- .../amethyst/ui/components/UrlPreview.kt | 6 ++ .../ui/components/ZoomableAsyncImage.kt | 60 ++++++++++++++ .../vitorpamplona/amethyst/ui/note/Note.kt | 2 +- .../amethyst/ui/note/ReactionsRow.kt | 3 +- .../amethyst/ui/screen/ThreadFeedView.kt | 2 +- 9 files changed, 171 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExtendedImageView.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 02ff4f47d..6bc1083a8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -9,6 +9,10 @@ import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Collections +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import nostr.postr.events.Event class Note(val idHex: String) { @@ -97,18 +101,24 @@ class Note(val idHex: String) { } class NoteLiveData(val note: Note): LiveData(NoteState(note)) { + val scope = CoroutineScope(Job() + Dispatchers.Main) + fun refresh() { postValue(NoteState(note)) } override fun onActive() { super.onActive() - NostrSingleEventDataSource.add(note.idHex) + scope.launch { + NostrSingleEventDataSource.add(note.idHex) + } } override fun onInactive() { super.onInactive() - NostrSingleEventDataSource.remove(note.idHex) + scope.launch { + NostrSingleEventDataSource.remove(note.idHex) + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index d1d4892c1..291e57819 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -191,7 +191,7 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account) } @Composable -private fun CloseButton(onCancel: () -> Unit) { +fun CloseButton(onCancel: () -> Unit) { Button( onClick = { onCancel() @@ -207,7 +207,7 @@ private fun CloseButton(onCancel: () -> Unit) { } @Composable -private fun PostButton(onPost: () -> Unit = {}) { +fun PostButton(onPost: () -> Unit = {}) { Button( onClick = { onPost() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExtendedImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExtendedImageView.kt new file mode 100644 index 000000000..dd1f23df4 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExtendedImageView.kt @@ -0,0 +1,81 @@ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.note.ReactionsRowState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +fun ExtendedImageView(word: String) { + // store the dialog open or close state + var dialogOpen by remember { + mutableStateOf(false) + } + + AsyncImage( + model = word, + contentDescription = word, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) + .clickable( + onClick = { dialogOpen = true } + ) + ) + + if (dialogOpen) { + Dialog( + onDismissRequest = { dialogOpen = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { + Column( + modifier = Modifier.padding(10.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = { + dialogOpen = false + }) + } + + ZoomableAsyncImage(word) + } + } + } + } +} \ No newline at end of file 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 2f4c0e39f..d775b4fd6 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 @@ -1,23 +1,19 @@ package com.vitorpamplona.amethyst.ui.components import android.util.Patterns -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +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.unit.dp -import coil.compose.AsyncImage import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import java.net.MalformedURLException import java.net.URISyntaxException import java.net.URL @@ -43,8 +39,9 @@ fun isValidURL(url: String?): Boolean { } } +@OptIn(ExperimentalComposeUiApi::class) @Composable -fun RichTextViewer(content: String, tags: List>?) { +fun RichTextViewer(content: String, tags: List>?, note: Note, accountViewModel: AccountViewModel) { Column(modifier = Modifier.padding(top = 5.dp)) { // FlowRow doesn't work well with paragraphs. So we need to split them content.split('\n').forEach { paragraph -> @@ -58,16 +55,7 @@ fun RichTextViewer(content: String, tags: List>?) { } else if (isValidURL(word)) { val removedParamsFromUrl = word.split("?")[0].toLowerCase() if (imageExtension.matcher(removedParamsFromUrl).matches()) { - AsyncImage( - model = word, - contentDescription = word, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .padding(top = 4.dp) - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) - ) + ExtendedImageView(word) } else if (videoExtension.matcher(removedParamsFromUrl).matches()) { VideoView(word) } else { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt index b8a1e229f..83ed41abf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape @@ -12,6 +13,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -19,6 +21,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -27,6 +30,8 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import com.baha.url.preview.BahaUrlPreview import com.baha.url.preview.IUrlPreviewCallback @@ -34,6 +39,7 @@ import com.baha.url.preview.UrlInfoItem import com.vitorpamplona.amethyst.model.UrlCachedPreviewer +@OptIn(ExperimentalComposeUiApi::class) @Composable fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) { var urlPreviewState by remember { mutableStateOf(UrlPreviewState.Loading) } 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 new file mode 100644 index 000000000..c2151c178 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableAsyncImage.kt @@ -0,0 +1,60 @@ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.forEachGesture +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 coil.compose.AsyncImage + +@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) { + forEachGesture { + awaitPointerEventScope { + 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 = "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/note/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Note.kt index 63cc90d9d..e6c241e8e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/Note.kt @@ -145,7 +145,7 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool } else { val eventContent = note.event?.content if (eventContent != null) - RichTextViewer(eventContent, note.event?.tags) + RichTextViewer(eventContent, note.event?.tags, note, accountViewModel) ReactionsRowState(note, accountViewModel) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a77974532..d0368a013 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -35,7 +36,7 @@ fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: ( NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, account) Row(modifier = Modifier.padding(top = 8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { IconButton( modifier = Modifier.then(Modifier.size(24.dp)), onClick = { if (account.isWriteable()) wantsToReplyTo = note } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index f75898db8..f5c103149 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -213,7 +213,7 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController Column() { val eventContent = note.event?.content if (eventContent != null) - RichTextViewer(eventContent, note.event?.tags) + RichTextViewer(eventContent, note.event?.tags, note, accountViewModel) ReactionsRowState(note, accountViewModel)