Merge pull request #229 from ouchadam/feature/fullscreen-images

Fullscreen image viewer
This commit is contained in:
Adam Brown 2022-10-30 18:49:13 +00:00 committed by GitHub
commit 223b5d27ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 155 additions and 19 deletions

View File

@ -6,6 +6,7 @@ import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.media.ExifInterface
import android.net.Uri
import android.os.Build
import android.provider.OpenableColumns
@ -295,9 +296,14 @@ internal class AndroidImageContentReader(private val contentResolver: ContentRes
cursor.getLong(columnIndex)
} ?: throw IllegalArgumentException("Could not process $uri")
val shouldSwapSizes = ExifInterface(contentResolver.openInputStream(androidUri) ?: throw IllegalArgumentException("Could not process $uri")).let {
val orientation = it.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270
}
return ImageContentReader.ImageContent(
height = options.outHeight,
width = options.outWidth,
height = if (shouldSwapSizes) options.outWidth else options.outHeight,
width = if (shouldSwapSizes) options.outHeight else options.outWidth,
size = fileSize,
mimeType = options.outMimeType,
fileName = androidUri.lastPathSegment ?: "file",

View File

@ -47,24 +47,29 @@ sealed interface BubbleModel {
data class Event(val authorId: String, val authorName: String, val edited: Boolean, val time: String)
data class Action(
val onLongClick: (BubbleModel) -> Unit,
val onImageClick: (Image) -> Unit,
)
}
private fun BubbleModel.Reply.isReplyingToSelf() = this.replyingTo.event.authorId == this.reply.event.authorId
@Composable
fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, onLongClick: (BubbleModel) -> Unit) {
val itemisedLongClick = { onLongClick.invoke(model) }
fun MessageBubble(bubble: BubbleMeta, model: BubbleModel, status: @Composable () -> Unit, actions: BubbleModel.Action) {
val itemisedLongClick = { actions.onLongClick.invoke(model) }
when (model) {
is BubbleModel.Text -> TextBubble(bubble, model, status, itemisedLongClick)
is BubbleModel.Encrypted -> EncryptedBubble(bubble, model, status, itemisedLongClick)
is BubbleModel.Image -> ImageBubble(bubble, model, status, itemisedLongClick)
is BubbleModel.Image -> ImageBubble(bubble, model, status, onItemClick = { actions.onImageClick(model) }, itemisedLongClick)
is BubbleModel.Reply -> ReplyBubble(bubble, model, status, itemisedLongClick)
}
}
@Composable
private fun TextBubble(bubble: BubbleMeta, model: BubbleModel.Text, status: @Composable () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) {
Bubble(bubble, onItemClick = {}, onLongClick) {
if (bubble.isNotSelf()) {
AuthorName(model.event, bubble)
}
@ -79,8 +84,8 @@ private fun EncryptedBubble(bubble: BubbleMeta, model: BubbleModel.Encrypted, st
}
@Composable
private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) {
private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @Composable () -> Unit, onItemClick: () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onItemClick, onLongClick) {
if (bubble.isNotSelf()) {
AuthorName(model.event, bubble)
}
@ -97,7 +102,7 @@ private fun ImageBubble(bubble: BubbleMeta, model: BubbleModel.Image, status: @C
@Composable
private fun ReplyBubble(bubble: BubbleMeta, model: BubbleModel.Reply, status: @Composable () -> Unit, onLongClick: () -> Unit) {
Bubble(bubble, onLongClick) {
Bubble(bubble, onItemClick = {}, onLongClick) {
Column(
Modifier
.fillMaxWidth()
@ -204,7 +209,7 @@ private fun Int.scalerFor(max: Float): Float {
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Composable () -> Unit) {
private fun Bubble(bubble: BubbleMeta, onItemClick: () -> Unit, onLongClick: () -> Unit, content: @Composable () -> Unit) {
Box(modifier = Modifier.padding(start = 6.dp)) {
Box(
Modifier
@ -212,7 +217,7 @@ private fun Bubble(bubble: BubbleMeta, onLongClick: () -> Unit, content: @Compos
.clip(bubble.shape)
.background(bubble.background)
.height(IntrinsicSize.Max)
.combinedClickable(onLongClick = onLongClick, onClick = {}),
.combinedClickable(onLongClick = onLongClick, onClick = onItemClick),
) {
Column(
Modifier

View File

@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
@ -16,13 +17,14 @@ fun Toolbar(
onNavigate: (() -> Unit)? = null,
title: String? = null,
offset: (Density.() -> IntOffset)? = null,
color: Color = MaterialTheme.colorScheme.background,
actions: @Composable RowScope.() -> Unit = {}
) {
val navigationIcon = foo(onNavigate)
TopAppBar(
modifier = offset?.let { Modifier.offset(it) } ?: Modifier,
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.background
containerColor = color,
),
navigationIcon = navigationIcon,
title = title?.let { { Text(it, maxLines = 2) } } ?: {},

View File

@ -1,6 +1,7 @@
package app.dapk.st.messenger
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
@ -8,6 +9,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
@ -24,7 +26,11 @@ import androidx.compose.runtime.*
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.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@ -51,6 +57,8 @@ import app.dapk.st.navigator.Navigator
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import kotlinx.coroutines.launch
import kotlin.math.min
import kotlin.math.roundToInt
@Composable
internal fun MessengerScreen(
@ -76,7 +84,8 @@ internal fun MessengerScreen(
val messageActions = MessageActions(
onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) },
onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) },
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) }
onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) },
onImageClick = { viewModel.selectImage(it) }
)
Column {
@ -85,6 +94,7 @@ internal fun MessengerScreen(
// DropdownMenuItem(text = { Text("Settings", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = {})
// }
})
when (state.composerState) {
is ComposerState.Text -> {
Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) })
@ -106,6 +116,86 @@ internal fun MessengerScreen(
}
}
}
when (state.viewerState) {
null -> {
// do nothing
}
else -> {
Box(Modifier.fillMaxSize().background(Color.Black)) {
BackHandler(onBack = { viewModel.unselectImage() })
ZoomableImage(state.viewerState)
Toolbar(
onNavigate = { viewModel.unselectImage() },
title = state.viewerState.event.event.authorName,
color = Color.Black.copy(alpha = 0.4f),
)
}
}
}
}
@Composable
private fun ZoomableImage(viewerState: ViewerState) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val angle by remember { mutableStateOf(0f) }
var zoom by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
val screenWidth = constraints.maxWidth
val screenHeight = constraints.maxHeight
val renderedSize = remember {
val imageContent = viewerState.event.imageContent
val imageHeight = imageContent.height ?: 120
val heightScaleFactor = screenHeight.toFloat() / imageHeight.toFloat()
val imageWidth = imageContent.width ?: 120
val widthScaleFactor = screenWidth.toFloat() / imageWidth.toFloat()
val scaler = min(heightScaleFactor, widthScaleFactor)
IntSize((imageWidth * scaler).roundToInt(), (imageHeight * scaler).roundToInt())
}
Image(
painter = rememberAsyncImagePainter(model = viewerState.event.imageRequest),
contentDescription = "",
contentScale = ContentScale.Fit,
modifier = Modifier
.graphicsLayer {
scaleX = zoom
scaleY = zoom
rotationZ = angle
translationX = offsetX
translationY = offsetY
}
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(1F..4F)
if (zoom > 1) {
val x = (pan.x * zoom)
val y = (pan.y * zoom)
if (renderedSize.width * zoom > screenWidth) {
val maxZoomedWidthOffset = ((renderedSize.width * zoom) - screenWidth) / 2
offsetX = (offsetX + x).coerceIn(-maxZoomedWidthOffset..maxZoomedWidthOffset)
}
if (renderedSize.height * zoom > screenHeight) {
val maxZoomedHeightOffset = ((renderedSize.height * zoom) - screenHeight) / 2
offsetY = (offsetY + y).coerceIn(-maxZoomedHeightOffset..maxZoomedHeightOffset)
}
} else {
offsetX = 0F
offsetY = 0F
}
}
)
}
.fillMaxSize()
)
}
}
@Composable
@ -180,6 +270,11 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
}
}
val bubbleActions = BubbleModel.Action(
onLongClick = { messageActions.onLongClick(it) },
onImageClick = { messageActions.onImageClick(it) }
)
LazyColumn(
modifier = Modifier
.fillMaxWidth()
@ -201,7 +296,7 @@ private fun ColumnScope.RoomContent(self: UserId, state: RoomState, messageActio
onReply = { messageActions.onReply(item) },
) {
val status = @Composable { SendStatus(item) }
MessageBubble(this, item.toModel(), status, onLongClick = messageActions.onLongClick)
MessageBubble(this, item.toModel(), status, bubbleActions)
}
}
}
@ -283,7 +378,13 @@ private fun SendStatus(message: RoomEvent) {
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Unit, onSend: () -> Unit, onAttach: () -> Unit, messageActions: MessageActions) {
private fun TextComposer(
state: ComposerState.Text,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
onAttach: () -> Unit,
messageActions: MessageActions
) {
Row(
Modifier
.fillMaxWidth()
@ -366,6 +467,7 @@ private fun TextComposer(state: ComposerState.Text, onTextChange: (String) -> Un
modifier = Modifier.clickable { onAttach() }.wrapContentWidth().align(Alignment.Bottom),
imageVector = Icons.Filled.Image,
contentDescription = "",
tint = SmallTalkTheme.extendedColors.onOthersBubble.copy(alpha = 0.5f),
)
}
}
@ -447,4 +549,5 @@ class MessageActions(
val onReply: (RoomEvent) -> Unit,
val onDismiss: () -> Unit,
val onLongClick: (BubbleModel) -> Unit,
val onImageClick: (BubbleModel.Image) -> Unit,
)

View File

@ -1,6 +1,7 @@
package app.dapk.st.messenger
import app.dapk.st.core.Lce
import app.dapk.st.design.components.BubbleModel
import app.dapk.st.engine.MessengerState
import app.dapk.st.engine.RoomEvent
import app.dapk.st.matrix.common.RoomId
@ -10,6 +11,11 @@ data class MessengerScreenState(
val roomId: RoomId?,
val roomState: Lce<MessengerState>,
val composerState: ComposerState,
val viewerState: ViewerState?
)
data class ViewerState(
val event: BubbleModel.Image,
)
sealed interface MessengerEvent {

View File

@ -32,7 +32,8 @@ internal class MessengerViewModel(
initialState = MessengerScreenState(
roomId = null,
roomState = Lce.Loading(),
composerState = ComposerState.Text(value = "", reply = null)
composerState = ComposerState.Text(value = "", reply = null),
viewerState = null,
),
factory = factory,
) {
@ -157,6 +158,18 @@ internal class MessengerViewModel(
}
}
fun selectImage(image: BubbleModel.Image) {
updateState {
copy(viewerState = ViewerState(image))
}
}
fun unselectImage() {
updateState {
copy(viewerState = null)
}
}
}
private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) {

View File

@ -48,7 +48,8 @@ class MessengerViewModelTest {
MessengerScreenState(
roomId = null,
roomState = Lce.Loading(),
composerState = ComposerState.Text(value = "", reply = null)
composerState = ComposerState.Text(value = "", reply = null),
viewerState = null,
)
)
}
@ -114,7 +115,8 @@ class MessengerViewModelTest {
fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState(
roomId = roomId,
roomState = Lce.Content(roomState),
composerState = ComposerState.Text(value = messageContent ?: "", reply = null)
composerState = ComposerState.Text(value = messageContent ?: "", reply = null),
viewerState = null,
)
class FakeCopyToClipboard {

View File

@ -60,7 +60,6 @@ internal class SendMessageUseCase(
private suspend fun imageMessageRequest(message: Message.ImageMessage): HttpRequest<ApiSendResponse> {
val imageMeta = message.content.meta
return when (message.sendEncrypted) {
true -> {
val result = mediaEncrypter.encrypt(imageContentReader.inputStream(message.content.uri))