diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 29f280c9e..5227cca3e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -144,6 +144,7 @@ android:label="@string/result" /> diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt index 26724fe65..4fa3bbdca 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt @@ -63,6 +63,7 @@ fun CustomSelectorScreen( uiState: CustomSelectorState, onEvent: (CustomSelectorEvent)-> Unit, selectedImageIds: ()-> Set, + onViewImage: (id: Long)-> Unit, hasPartialAccess: Boolean = false ) { val adaptiveInfo = currentWindowAdaptiveInfo() @@ -96,6 +97,7 @@ fun CustomSelectorScreen( selectedFolder = folder, selectedImages = selectedImageIds, onNavigateBack = { navigator.navigateBack() }, + onViewImage = onViewImage, onEvent = onEvent, adaptiveInfo = adaptiveInfo, hasPartialAccess = hasPartialAccess @@ -264,6 +266,7 @@ private fun CustomSelectorScreenPreview() { CommonsTheme { CustomSelectorScreen( uiState = CustomSelectorState(), + onViewImage = { }, onEvent = { }, selectedImageIds = { emptySet() }, hasPartialAccess = true diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt index a984323b1..77c32a24c 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt @@ -68,6 +68,7 @@ fun ImagesPane( selectedFolder: Folder, selectedImages: () -> Set, onNavigateBack: () -> Unit, + onViewImage: (id: Long)-> Unit, onEvent: (CustomSelectorEvent) -> Unit, adaptiveInfo: WindowAdaptiveInfo, hasPartialAccess: Boolean = false @@ -128,7 +129,7 @@ fun ImagesPane( } LazyVerticalGrid( - columns = GridCells.Adaptive(116.dp), + columns = GridCells.Adaptive(96.dp), modifier = Modifier .fillMaxSize() .imageGridDragHandler( @@ -159,6 +160,8 @@ fun ImagesPane( onClick = { if (uiState.inSelectionMode) { onEvent(CustomSelectorEvent.OnImageSelection(image.id)) + } else { + onViewImage(image.id) } }, onLongClick = { diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt new file mode 100644 index 000000000..90b9b641c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ViewImageScreen.kt @@ -0,0 +1,179 @@ +package fr.free.nrw.commons.customselector.ui.screens + +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import fr.free.nrw.commons.customselector.model.Image +import kotlin.math.abs + +@Composable +fun ViewImageScreen( + currentImageIndex: Int, + imageList: List, +) { + var imageScale by remember { mutableFloatStateOf(1f) } + var imageOffset by remember { mutableStateOf(Offset.Zero) } + var imageSize by remember { mutableStateOf(IntSize.Zero) } + var containerSize by remember { mutableStateOf(IntSize.Zero) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(imageSize) { + println("Image Size : $imageSize") + } + + val pagerState = rememberPagerState(initialPage = currentImageIndex) { imageList.size } + + val scrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + return if (imageScale > 1f) { + // If zoomed in, consume the scroll for panning + println("Consuming for panning...") + available + } else if ( + source == NestedScrollSource.UserInput && abs(pagerState.currentPageOffsetFraction) > 1e-6 + ) { + println("Handling swipe gestures...") + // Handle page swipes only if the image isn't zoomed + val delta = available.x + val consumed = -pagerState.dispatchRawDelta(-delta) + Offset(consumed, 0f) + } else { + println("Just passing the as it is...") + Offset.Zero + } + } + } + + HorizontalPager( + state = pagerState, + key = { imageList[it].id }, + pageSpacing = 16.dp + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { + containerSize = it + }, + contentAlignment = Alignment.Center + ) { +// val state = rememberTransformableState { zoomChange, panChange, _ -> +// imageScale = (imageScale * zoomChange).coerceIn(1f, 7f) +// +// val imageWidth = imageSize.width * imageScale +// val imageHeight = imageSize.height * imageScale +// +// val extraWidth = (imageWidth - constraints.maxWidth).coerceAtLeast(0f) +// val extraHeight = (imageHeight - constraints.maxHeight).coerceAtLeast(0f) +// +// val maxX = extraWidth / 2 +// val maxY = extraHeight / 2 +// +// imageOffset = Offset( +// x = (imageOffset.x + imageScale * panChange.x).coerceIn(-maxX, maxX), +// y = (imageOffset.y + imageScale * panChange.y).coerceIn(-maxY, maxY) +// ) +// } + + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageList[it].uri) + .build(), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { imageSize = it } +// .pointerInput(Unit) { +// detectTransformGestures { centroid, pan, zoom, _ -> +// imageScale = (imageScale * zoom).coerceIn(1f, 7f) +// +// val imageWidth = imageSize.width * imageScale +// val imageHeight = imageSize.height * imageScale +// +// val extraWidth = (imageWidth-constraints.maxWidth).coerceAtLeast(0f) +// val extraHeight = (imageHeight-constraints.maxHeight).coerceAtLeast(0f) +// +// val maxX = extraWidth / 2 +// val maxY = extraHeight / 2 +// +// imageOffset = Offset( +// x = (imageOffset.x + imageScale * pan.x).coerceIn( +// -maxX, +// maxX +// ), +// y = (imageOffset.y + imageScale * pan.y).coerceIn(-maxY, maxY) +// ) +// } +// } + .graphicsLayer { + scaleX = imageScale + scaleY = imageScale + translationX = imageOffset.x + translationY = imageOffset.y + } + ) + } + } +} + +suspend fun PointerInputScope.detectDragAndZoomGestures( + onZoom: (Float) -> Unit, + onDrag: (Offset) -> Unit +) { + detectTransformGestures { _, pan, zoom, _ -> + onZoom(zoom) + onDrag(pan) + } +} + +fun Offset.calculateNewOffset( + centroid: Offset, + pan: Offset, + zoom: Float, + gestureZoom: Float, + size: IntSize +): Offset { + val newScale = maxOf(1f, zoom * gestureZoom) + val newOffset = (this + centroid / zoom) - + (centroid / newScale + pan / zoom) + return Offset( + newOffset.x.coerceIn(0f, (size.width / zoom) * (zoom - 1f)), + newOffset.y.coerceIn(0f, (size.height / zoom) * (zoom - 1f)) + ) +} + +fun calculateDoubleTapOffset( + zoom: Float, + size: IntSize, + tapOffset: Offset +): Offset { + val newOffset = Offset(tapOffset.x, tapOffset.y) + return Offset( + newOffset.x.coerceIn(0f, (size.width / zoom) * (zoom - 1f)), + newOffset.y.coerceIn(0f, (size.height / zoom) * (zoom - 1f)) + ) +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 47e139147..1d9fcd118 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -6,6 +6,7 @@ import android.app.Dialog import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.os.Bundle import android.view.View @@ -14,6 +15,9 @@ import android.widget.Button import android.widget.ImageButton import android.widget.TextView import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -22,6 +26,9 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import fr.free.nrw.commons.R import fr.free.nrw.commons.customselector.data.MediaReader import fr.free.nrw.commons.customselector.database.NotForUploadStatus @@ -31,10 +38,10 @@ import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.ui.screens.CustomSelectorScreen +import fr.free.nrw.commons.customselector.ui.screens.ViewImageScreen import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding -import fr.free.nrw.commons.filepicker.Constants import fr.free.nrw.commons.media.ZoomableActivity import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.ui.theme.CommonsTheme @@ -129,7 +136,7 @@ class CustomSelectorActivity : private var showPartialAccessIndicator by mutableStateOf(false) - private val startForResult = registerForActivityResult(StartActivityForResult()){ result -> + private val startForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()){ result -> onFullScreenDataReceived(result) } @@ -137,6 +144,7 @@ class CustomSelectorActivity : * onCreate Activity, sets theme, initialises the view model, setup view. */ override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && ContextCompat.checkSelfPermission( @@ -182,12 +190,30 @@ class CustomSelectorActivity : val uiState by csViewModel.uiState.collectAsStateWithLifecycle() CommonsTheme { - CustomSelectorScreen( - uiState = uiState, - onEvent = csViewModel::onEvent, - selectedImageIds = { uiState.selectedImageIds }, - hasPartialAccess = showPartialAccessIndicator - ) + val navController = rememberNavController() + + NavHost(navController = navController, startDestination = "main") { + composable(route = "main") { + CustomSelectorScreen( + uiState = uiState, + onEvent = csViewModel::onEvent, + onViewImage = { navController.navigate("view_image/$it") }, + selectedImageIds = { uiState.selectedImageIds }, + hasPartialAccess = showPartialAccessIndicator + ) + } + + composable(route = "view_image/{imageId}") { backStackEntry-> + val imageId = backStackEntry.arguments?.getString("imageId")?.toLongOrNull() + val imageUri = uiState.filteredImages.find { it.id == imageId }?.uri ?: Uri.EMPTY + val imageIndex = uiState.filteredImages.indexOfFirst { it.id == imageId } + + ViewImageScreen( + currentImageIndex = imageIndex, + imageList = uiState.filteredImages + ) + } + } } }