Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,80 +18,53 @@

package com.wire.android.ui.home.gallery

import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTransformGestures
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.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.compose.rememberAsyncImagePainter
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.wire.android.ui.common.image.ZoomableImageContainer

@Composable
fun ZoomableImage(image: MediaGalleryImage, contentDescription: String, modifier: Modifier = Modifier) {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
var zoom by remember { mutableStateOf(1f) }
val minScale = 1.0f
val maxScale = 3f
fun ZoomableImage(
image: MediaGalleryImage,
contentDescription: String,
modifier: Modifier = Modifier
) {
val context = LocalContext.current

val painter = when (image) {
is MediaGalleryImage.PrivateAsset -> image.asset.paint()
is MediaGalleryImage.LocalAsset -> rememberAsyncImagePainter(image.path)
is MediaGalleryImage.UrlAsset -> rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current)
.data(image.url)
.diskCacheKey(image.contentHash)
.memoryCacheKey(image.contentHash)
.diskCachePolicy(CachePolicy.ENABLED)
.build(),
placeholder = image.placeholder?.let {
rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current)
.diskCacheKey(image.contentHash)
.memoryCacheKey(image.contentHash)
.data(it)
.crossfade(true)
.build()
)
}
)

is MediaGalleryImage.LocalAsset ->
rememberAsyncImagePainter(image.path)

is MediaGalleryImage.UrlAsset ->
rememberAsyncImagePainter(
ImageRequest.Builder(context)
.data(image.url)
.diskCacheKey(image.contentHash)
.memoryCacheKey(image.contentHash)
.diskCachePolicy(CachePolicy.ENABLED)
.build(),
placeholder = image.placeholder?.let {
rememberAsyncImagePainter(
ImageRequest.Builder(context)
.data(it)
.diskCacheKey(image.contentHash)
.memoryCacheKey(image.contentHash)
.crossfade(true)
.build()
)
}
)
}

Image(
ZoomableImageContainer(
painter = painter,
contentDescription = contentDescription,
modifier = modifier
.graphicsLayer(
scaleX = zoom,
scaleY = zoom,
translationX = offsetX,
translationY = offsetY,
)
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(minScale, maxScale)
if (zoom > 1) {
offsetX += pan.x * zoom
offsetY += pan.y * zoom
} else {
offsetX = 0f
offsetY = 0f
}
}
)
}
.fillMaxSize(),
contentScale = ContentScale.Fit
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Wire
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/
package com.wire.android.ui.common.image

import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTransformGestures
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.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import com.wire.android.ui.common.R
import com.wire.android.ui.common.preview.MultipleThemePreviews

@Composable
fun ZoomableImageContainer(
painter: Painter,
contentDescription: String,
modifier: Modifier = Modifier,
) {
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
var zoom by remember { mutableStateOf(1f) }

val minScale = 1f
val maxScale = 3f

Image(
painter = painter,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = modifier
.fillMaxSize()
.graphicsLayer(
scaleX = zoom,
scaleY = zoom,
translationX = offsetX,
translationY = offsetY,
)
.pointerInput(Unit) {
detectTransformGestures { _, pan, gestureZoom, _ ->
zoom = (zoom * gestureZoom).coerceIn(minScale, maxScale)

if (zoom > 1f) {
offsetX += pan.x * zoom
offsetY += pan.y * zoom
} else {
offsetX = 0f
offsetY = 0f
}
}
}
)
}

@MultipleThemePreviews
@Composable
fun ZoomableImageContainerPreview() {
ZoomableImageContainer(
painter = painterResource(id = R.drawable.mock_image),
contentDescription = "Placeholder image"
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.paging.compose.collectAsLazyPagingItems
import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination
import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination
import com.ramcosta.composedestinations.generated.cells.destinations.PublicLinkScreenDestination
import com.ramcosta.composedestinations.generated.cells.destinations.SearchScreenDestination
import com.wire.android.feature.cells.R
import com.wire.android.feature.cells.ui.common.OfflineBanner
import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs
import com.wire.android.feature.cells.ui.search.DriveSearchScreenType
import com.wire.android.navigation.NavigationCommand
import com.wire.android.navigation.WireNavigator
Expand Down Expand Up @@ -116,6 +118,21 @@ fun AllFilesScreen(
},
isRefreshing = viewModel.isPullToRefresh.collectAsState(),
onRefresh = { viewModel.onPullToRefresh() },
showImageViewer = { file ->
navigator.navigate(
NavigationCommand(
CellImageViewerScreenDestination(
CellImageViewerNavArgs(
localPath = file.localPath,
contentUrl = file.contentUrl,
previewUrl = file.previewUrl,
contentHash = file.contentHash,
fileName = file.name,
)
)
)
)
},
fileReadyFlow = viewModel.fileReadyFlow,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ internal fun CellScreenContent(
lazyListState: LazyListState = rememberLazyListState(),
retryEditNodeError: (String) -> Unit = {},
showVersionHistoryScreen: (String, String) -> Unit = { _, _ -> },
showImageViewer: (CellNodeUi.File) -> Unit = {},
fileReadyFlow: Flow<CellNodeUi.File>? = emptyFlow(),
) {

Expand Down Expand Up @@ -255,6 +256,7 @@ internal fun CellScreenContent(
Toast.LENGTH_SHORT
).show()
}
is OpenImageViewer -> showImageViewer(action.file)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@

isConversationFiles() -> "${currentNodeUuid()}/${cellNode.name}"
else -> cellNode.remotePath
} ?: run {

Check warning on line 348 in features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless elvis operation ?:, it always succeeds.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-android&issues=AZ6sGfFRx_okN0qn6bsk&open=AZ6sGfFRx_okN0qn6bsk&pullRequest=4939
sendAction(ShowError(CellError.OTHER_ERROR))
return
}
Expand All @@ -371,6 +371,10 @@
}

private fun openFileContentUrl(file: CellNodeUi.File) {
if (file.assetType == AttachmentFileType.IMAGE) {
sendAction(OpenImageViewer(file))
return
}
file.contentUrl?.let { url ->
fileHelper.openAssetUrlWithExternalApp(
url = url,
Expand All @@ -383,6 +387,10 @@
}

private fun openLocalFile(file: CellNodeUi.File) {
if (file.assetType == AttachmentFileType.IMAGE) {
sendAction(OpenImageViewer(file))
return
}
file.localPath?.let { path ->
fileHelper.openAssetFileWithExternalApp(
localPath = path.toPath(),
Expand Down Expand Up @@ -629,6 +637,7 @@
internal data class OpenFolder(val path: String, val title: String, val parentFolderUuid: String?) : CellViewAction
internal data class ShowEditErrorDialog(val nodeUuid: String) : CellViewAction
internal data object ShowOfflineFileSaved : CellViewAction
internal data class OpenImageViewer(val file: CellNodeUi.File) : CellViewAction

internal enum class CellError(val message: Int) {
NO_APP_FOUND(R.string.no_app_found),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import androidx.lifecycle.SavedStateHandle
import com.wire.android.feature.cells.ui.create.file.CreateFileViewModel
import com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel
import com.wire.android.feature.cells.ui.edit.OnlineEditor
import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerViewModel
import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModel
import com.wire.android.feature.cells.ui.publiclink.PublicLinkViewModel
import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenViewModel
Expand Down Expand Up @@ -209,4 +210,8 @@ class CellsViewModelFactory @Inject constructor(
getEditorUrl = getEditorUrl,
dispatchers = dispatchers,
)

internal fun cellImageViewerViewModel(savedStateHandle: SavedStateHandle) = CellImageViewerViewModel(
savedStateHandle = savedStateHandle,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import com.wire.android.di.metro.sessionKeyedMetroViewModel
import com.wire.android.feature.cells.ui.create.file.CreateFileViewModel
import com.wire.android.feature.cells.ui.create.folder.CreateFolderViewModel
import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerViewModel
import com.wire.android.feature.cells.ui.movetofolder.MoveToFolderViewModel
import com.wire.android.feature.cells.ui.publiclink.PublicLinkViewModel
import com.wire.android.feature.cells.ui.publiclink.settings.expiration.PublicLinkExpirationScreenViewModel
Expand Down Expand Up @@ -83,3 +84,6 @@ fun addRemoveTagsViewModel(): AddRemoveTagsViewModel = cellsViewModel()

@Composable
fun versionHistoryViewModel(): VersionHistoryViewModel = cellsViewModel()

@Composable
fun cellImageViewerViewModel(): CellImageViewerViewModel = cellsViewModel { cellImageViewerViewModel(it) }
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.ramcosta.composedestinations.generated.cells.destinations.AddRemoveTagsScreenDestination
import com.ramcosta.composedestinations.generated.cells.destinations.CellImageViewerScreenDestination
import com.ramcosta.composedestinations.generated.cells.destinations.ConversationFilesWithSlideInTransitionScreenDestination
import com.ramcosta.composedestinations.generated.cells.destinations.CreateFileScreenDestination
import com.ramcosta.composedestinations.generated.cells.destinations.CreateFolderScreenDestination
Expand All @@ -57,6 +58,7 @@ import com.ramcosta.composedestinations.generated.cells.destinations.VersionHist
import com.wire.android.feature.cells.R
import com.wire.android.feature.cells.domain.model.AttachmentFileType
import com.wire.android.feature.cells.ui.common.OfflineBanner
import com.wire.android.feature.cells.ui.imageviewer.CellImageViewerNavArgs
import com.wire.android.feature.cells.ui.create.FileTypeBottomSheetDialog
import com.wire.android.feature.cells.ui.create.file.CreateFileScreenNavArgs
import com.wire.android.feature.cells.ui.dialog.CellsNewActionBottomSheet
Expand Down Expand Up @@ -361,6 +363,21 @@ internal fun ConversationFilesScreenContent(
showVersionHistoryScreen = { uuid, fileName ->
navigator.navigate(NavigationCommand(VersionHistoryScreenDestination(uuid, fileName)))
},
showImageViewer = { file ->
navigator.navigate(
NavigationCommand(
CellImageViewerScreenDestination(
CellImageViewerNavArgs(
localPath = file.localPath,
contentUrl = file.contentUrl,
previewUrl = file.previewUrl,
contentHash = file.contentHash,
fileName = file.name,
)
)
)
)
},
retryEditNodeError = { retryEditNodeError(it) },
isRefreshing = isRefreshing,
onRefresh = onRefresh,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Wire
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/
package com.wire.android.feature.cells.ui.imageviewer

data class CellImageViewerNavArgs(
val localPath: String? = null,
val contentUrl: String? = null,
val previewUrl: String? = null,
val contentHash: String? = null,
val fileName: String? = null,
)

Loading
Loading