Merge pull request #229 from ouchadam/feature/fullscreen-images
Fullscreen image viewer
This commit is contained in:
commit
223b5d27ef
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) } } ?: {},
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Reference in New Issue