Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

In-app image viewer #444

Merged
merged 19 commits into from
Jun 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,5 @@ dependencies {
androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.4.3'
debugImplementation 'androidx.compose.ui:ui-tooling:1.4.3'
debugImplementation 'androidx.compose.ui:ui-test-manifest:1.4.3'
implementation "net.engawapg.lib:zoomable:1.4.3"
}
38 changes: 38 additions & 0 deletions app/src/main/java/com/jerboa/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
package com.jerboa

import android.app.Activity
import android.content.ContentValues
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.os.Environment
import android.provider.MediaStore
import android.util.Log
import android.util.Patterns
Expand Down Expand Up @@ -55,6 +57,7 @@ import com.jerboa.ui.theme.SMALL_PADDING
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.ocpsoft.prettytime.PrettyTime
import java.io.IOException
import java.io.InputStream
import java.net.URL
import java.text.DecimalFormat
Expand Down Expand Up @@ -823,6 +826,41 @@ fun nsfwCheck(postView: PostView): Boolean {
return postView.post.nsfw || postView.community.nsfw
}

@Throws(IOException::class)
fun saveBitmap(
context: Context,
inputStream: InputStream,
mimeType: String?,
displayName: String,
): Uri {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Jerboa")
}

val resolver = context.contentResolver
var uri: Uri? = null

try {
uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: throw IOException("Failed to create new MediaStore record.")

resolver.openOutputStream(uri)?.use {
inputStream.copyTo(it)
} ?: throw IOException("Failed to open output stream.")

return uri
} catch (e: IOException) {
uri?.let { orphanUri ->
// Don't leave an orphan entry in the MediaStore
resolver.delete(orphanUri, null, null)
}

throw e
}
}

@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.onAutofill(vararg autofillType: AutofillType, onFill: (String) -> Unit): Modifier = composed {
val autofillNode = AutofillNode(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.jerboa.ui.components.common

import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import android.widget.Toast
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
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
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.rememberAsyncImagePainter
import com.jerboa.saveBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.net.URL

const val backFadeTime = 300

@Composable
fun ImageViewerDialog(url: String, onBackRequest: () -> Unit) {
@Composable
fun BarIcon(icon: ImageVector, name: String, modifier: Modifier = Modifier, onTap: () -> Unit) {
Box(
Modifier
.size(40.dp)
.clickable(onClick = onTap)
.then(modifier),
) {
Icon(
modifier = Modifier.align(Alignment.Center),
imageVector = icon,
tint = Color.White,
pipe01 marked this conversation as resolved.
Show resolved Hide resolved
contentDescription = name,
)
}
}

val backColor = MaterialTheme.colorScheme.scrim
val backColorTranslucent = MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f)

var showTopBar by remember { mutableStateOf(true) }

val topBarAlpha = animateFloatAsState(
targetValue = if (showTopBar) 1f else 0f,
animationSpec = tween(backFadeTime),
)
val backgroundColor = animateColorAsState(
targetValue = if (showTopBar) backColorTranslucent else backColor,
animationSpec = tween(backFadeTime),
)

val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()

Dialog(
onDismissRequest = onBackRequest,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
) {
Box(Modifier.background(backgroundColor.value)) {
Image(
painter = rememberAsyncImagePainter(url),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.zoomable(
zoomState = rememberZoomState(),
onTap = { showTopBar = !showTopBar },
),
)

Row(
modifier = Modifier
.fillMaxWidth()
.alpha(topBarAlpha.value)
.padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BarIcon(icon = Icons.Filled.ArrowBack, name = "Back") {
onBackRequest()
}

Spacer(Modifier.weight(1f))

BarIcon(icon = Icons.Outlined.Download, name = "Download") {
coroutineScope.launch {
SaveImage(url, context)
}
}
}
}
}
}

suspend fun SaveImage(url: String, context: Context) {
Toast.makeText(context, "Saving image...", Toast.LENGTH_SHORT).show()

val fileName = Uri.parse(url).pathSegments.last()

val extension = MimeTypeMap.getFileExtensionFromUrl(url)
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)

withContext(Dispatchers.IO) {
URL(url).openStream().use {
saveBitmap(context, it, mimeType, fileName)
}
}

Toast.makeText(context, "Saved image", Toast.LENGTH_SHORT).show()
}

@Composable
@Preview
fun ImageActivityPreview() {
ImageViewerDialog(url = "", onBackRequest = { })
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ fun CircularIcon(
}

@Composable
fun LargerCircularIcon(icon: String) {
fun LargerCircularIcon(modifier: Modifier = Modifier, icon: String) {
CircularIcon(
modifier = modifier,
icon = icon,
size = LARGER_ICON_SIZE,
thumbnailSize = LARGER_ICON_THUMBNAIL_SIZE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

package com.jerboa.ui.components.person

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand Down Expand Up @@ -35,6 +36,7 @@ import com.jerboa.datatypes.samplePersonView
import com.jerboa.personNameShown
import com.jerboa.ui.components.common.DotSpacer
import com.jerboa.ui.components.common.IconAndTextDrawerItem
import com.jerboa.ui.components.common.ImageViewerDialog
import com.jerboa.ui.components.common.LargerCircularIcon
import com.jerboa.ui.components.common.MyMarkdownText
import com.jerboa.ui.components.common.PictrsBannerImage
Expand All @@ -50,6 +52,15 @@ fun PersonProfileTopSection(
personView: PersonViewSafe,
modifier: Modifier = Modifier,
) {
var showImage by remember { mutableStateOf<String?>(null) }

if (showImage != null) {
ImageViewerDialog(
url = showImage!!,
onBackRequest = { showImage = null },
)
}

Column {
Box(
modifier = modifier.fillMaxWidth(),
Expand All @@ -58,12 +69,21 @@ fun PersonProfileTopSection(
personView.person.banner?.also {
PictrsBannerImage(
url = it,
modifier = Modifier.height(PROFILE_BANNER_SIZE),
modifier = Modifier
.height(PROFILE_BANNER_SIZE)
.clickable {
showImage = personView.person.banner
},
)
}
Box(modifier = Modifier.padding(MEDIUM_PADDING)) {
personView.person.avatar?.also {
LargerCircularIcon(icon = it)
LargerCircularIcon(
icon = it,
modifier = Modifier.clickable {
showImage = personView.person.avatar
pipe01 marked this conversation as resolved.
Show resolved Hide resolved
},
)
}
}
}
Expand Down
11 changes: 8 additions & 3 deletions app/src/main/java/com/jerboa/ui/components/post/PostListing.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import com.jerboa.ui.components.common.CircularIcon
import com.jerboa.ui.components.common.CommentOrPostNodeHeader
import com.jerboa.ui.components.common.DotSpacer
import com.jerboa.ui.components.common.IconAndTextDrawerItem
import com.jerboa.ui.components.common.ImageViewerDialog
import com.jerboa.ui.components.common.MyMarkdownText
import com.jerboa.ui.components.common.PictrsThumbnailImage
import com.jerboa.ui.components.common.PictrsUrlImage
Expand Down Expand Up @@ -251,7 +252,6 @@ fun PostTitleBlock(
if (imagePost && expandedImage) {
PostTitleAndImageLink(
postView = postView,
onPostLinkClick = onPostLinkClick,
)
} else {
PostTitleAndThumbnail(
Expand Down Expand Up @@ -288,7 +288,6 @@ fun PostName(
@Composable
fun PostTitleAndImageLink(
postView: PostView,
onPostLinkClick: (url: String) -> Unit,
) {
// This was tested, we know it exists
val url = postView.post.url!!
Expand All @@ -306,8 +305,14 @@ fun PostTitleAndImageLink(
)
}

var showImageDialog by remember { mutableStateOf(false) }

if (showImageDialog) {
ImageViewerDialog(url, onBackRequest = { showImageDialog = false })
}

val postLinkPicMod = Modifier
.clickable { onPostLinkClick(url) }
.clickable { showImageDialog = true }
PictrsUrlImage(
url = url,
nsfw = nsfwCheck(postView),
Expand Down