diff --git a/app/build.gradle b/app/build.gradle
index 468255d38c..dc803f087d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -47,15 +47,15 @@ dependencies {
implementation 'com.jakewharton.timber:timber:4.7.1'
implementation 'com.github.deano2390:MaterialShowcaseView:1.2.0'
- implementation "com.google.android.material:material:1.9.0"
+ implementation "com.google.android.material:material:1.12.0"
implementation 'com.karumi:dexter:5.0.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
// Jetpack Compose
- def composeBom = platform('androidx.compose:compose-bom:2024.08.00')
+ def composeBom = platform('androidx.compose:compose-bom:2024.10.00')
- implementation "androidx.activity:activity-compose:1.9.1"
- implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4"
+ implementation "androidx.activity:activity-compose:1.9.3"
+ implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.6"
implementation (composeBom)
implementation "androidx.compose.runtime:runtime"
implementation "androidx.compose.ui:ui"
@@ -65,6 +65,13 @@ dependencies {
implementation "androidx.compose.foundation:foundation-layout"
implementation "androidx.compose.material3:material3"
androidTestImplementation(composeBom)
+ // Adaptive Layout APIs
+ implementation "androidx.compose.material3.adaptive:adaptive:1.0.0"
+ implementation "androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0"
+ implementation "androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0"
+
+ implementation "io.coil-kt:coil-compose:2.6.0"
+ implementation "androidx.navigation:navigation-compose:2.8.3"
implementation "com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:$ADAPTER_DELEGATES_VERSION"
implementation "com.hannesdorfmann:adapterdelegates4-pagination:$ADAPTER_DELEGATES_VERSION"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 29f280c9ec..5227cca3e8 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/data/ImageRepositoryImpl.kt b/app/src/main/java/fr/free/nrw/commons/customselector/data/ImageRepositoryImpl.kt
new file mode 100644
index 0000000000..6f64293e03
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/data/ImageRepositoryImpl.kt
@@ -0,0 +1,29 @@
+package fr.free.nrw.commons.customselector.data
+
+import fr.free.nrw.commons.customselector.database.NotForUploadStatus
+import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
+import fr.free.nrw.commons.customselector.domain.ImageRepository
+import fr.free.nrw.commons.customselector.domain.model.Image
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class ImageRepositoryImpl @Inject constructor(
+ private val mediaReader: MediaReader,
+ private val notForUploadStatusDao: NotForUploadStatusDao
+): ImageRepository {
+ override suspend fun getImagesFromDevice(): Flow {
+ return mediaReader.getImages()
+ }
+
+ override suspend fun markAsNotForUpload(imageSHA: String) {
+ notForUploadStatusDao.insert(NotForUploadStatus(imageSHA))
+ }
+
+ override suspend fun unmarkAsNotForUpload(imageSHA: String) {
+ notForUploadStatusDao.deleteWithImageSHA1(imageSHA)
+ }
+
+ override suspend fun isNotForUpload(imageSHA: String): Boolean {
+ return notForUploadStatusDao.find(imageSHA) > 0
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/data/MediaReader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/data/MediaReader.kt
new file mode 100644
index 0000000000..2b54f24977
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/data/MediaReader.kt
@@ -0,0 +1,71 @@
+package fr.free.nrw.commons.customselector.data
+
+import android.content.ContentUris
+import android.content.Context
+import android.provider.MediaStore
+import android.text.format.DateFormat
+import fr.free.nrw.commons.customselector.domain.model.Image
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import java.util.Calendar
+import java.util.Date
+import javax.inject.Inject
+
+class MediaReader @Inject constructor(private val context: Context) {
+ fun getImages() = flow {
+ val projection = arrayOf(
+ MediaStore.Images.Media._ID,
+ MediaStore.Images.Media.DISPLAY_NAME,
+ MediaStore.Images.Media.DATA,
+ MediaStore.Images.Media.BUCKET_ID,
+ MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
+ MediaStore.Images.Media.DATE_ADDED,
+ MediaStore.Images.Media.MIME_TYPE
+ )
+ val cursor = context.contentResolver.query(
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection,
+ null, null, MediaStore.Images.Media.DATE_ADDED + " DESC"
+ )
+
+ cursor?.use {
+ val idColumn = cursor.getColumnIndex(MediaStore.Images.Media._ID)
+ val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
+ val dataColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
+ val bucketIdColumn = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_ID)
+ val bucketNameColumn = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_DISPLAY_NAME)
+ val dateColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATE_ADDED)
+ val mimeTypeColumn = cursor.getColumnIndex(MediaStore.Images.Media.MIME_TYPE)
+
+ while(cursor.moveToNext()) {
+ val id = cursor.getLong(idColumn)
+ val name = cursor.getString(nameColumn)
+ val path = cursor.getString(dataColumn)
+ val bucketId = cursor.getLong(bucketIdColumn)
+ val bucketName = cursor.getString(bucketNameColumn)
+ val date = cursor.getLong(dateColumn)
+ val mimeType = cursor.getString(mimeTypeColumn)
+
+ val validMimeTypes = arrayOf(
+ "image/jpeg", "image/png", "image/svg+xml", "image/gif",
+ "image/tiff", "image/webp", "image/x-xcf"
+ )
+ // Skip the media items with unsupported MIME types
+ if(mimeType.lowercase() !in validMimeTypes) continue
+
+ // URI to access the image
+ val uri = ContentUris.withAppendedId(
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id
+ )
+
+ val calendar = Calendar.getInstance()
+ calendar.timeInMillis = date * 1000L
+ val calendarDate: Date = calendar.time
+ val dateFormat = DateFormat.getMediumDateFormat(context)
+ val formattedDate = dateFormat.format(calendarDate)
+
+ emit(Image(id, name, uri, path, bucketId, bucketName, date = formattedDate))
+ }
+ }
+ }.flowOn(Dispatchers.IO)
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/domain/ImageRepository.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/ImageRepository.kt
new file mode 100644
index 0000000000..eab5668952
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/ImageRepository.kt
@@ -0,0 +1,15 @@
+package fr.free.nrw.commons.customselector.domain
+
+import fr.free.nrw.commons.customselector.domain.model.Image
+import kotlinx.coroutines.flow.Flow
+
+interface ImageRepository {
+
+ suspend fun getImagesFromDevice(): Flow
+
+ suspend fun markAsNotForUpload(imageSHA: String)
+
+ suspend fun unmarkAsNotForUpload(imageSHA: String)
+
+ suspend fun isNotForUpload(imageSHA: String): Boolean
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/CallbackStatus.kt
similarity index 87%
rename from app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt
rename to app/src/main/java/fr/free/nrw/commons/customselector/domain/model/CallbackStatus.kt
index c47806f16e..9339219f7c 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/model/CallbackStatus.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/CallbackStatus.kt
@@ -1,4 +1,4 @@
-package fr.free.nrw.commons.customselector.model
+package fr.free.nrw.commons.customselector.domain.model
/**
* sealed class Callback Status.
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Folder.kt
similarity index 93%
rename from app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt
rename to app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Folder.kt
index ec08f6f73a..a0e0300e6b 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/model/Folder.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Folder.kt
@@ -1,4 +1,4 @@
-package fr.free.nrw.commons.customselector.model
+package fr.free.nrw.commons.customselector.domain.model
/**
* Custom selector data class Folder.
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Image.kt
similarity index 98%
rename from app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt
rename to app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Image.kt
index a2965fb5df..bd8dc25951 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/model/Image.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Image.kt
@@ -1,4 +1,4 @@
-package fr.free.nrw.commons.customselector.model
+package fr.free.nrw.commons.customselector.domain.model
import android.net.Uri
import android.os.Parcel
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Result.kt
similarity index 81%
rename from app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt
rename to app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Result.kt
index 5cccccae6a..c402cb62e7 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/model/Result.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/model/Result.kt
@@ -1,4 +1,4 @@
-package fr.free.nrw.commons.customselector.model
+package fr.free.nrw.commons.customselector.domain.model
/**
* Custom selector data class Result.
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/domain/use_case/ImageUseCase.kt b/app/src/main/java/fr/free/nrw/commons/customselector/domain/use_case/ImageUseCase.kt
new file mode 100644
index 0000000000..2eb24e4ef0
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/domain/use_case/ImageUseCase.kt
@@ -0,0 +1,89 @@
+package fr.free.nrw.commons.customselector.domain.use_case
+
+import android.content.Context
+import android.net.Uri
+import androidx.exifinterface.media.ExifInterface
+import fr.free.nrw.commons.customselector.ui.selector.ImageLoader
+import fr.free.nrw.commons.filepicker.PickedFiles
+import fr.free.nrw.commons.media.MediaClient
+import fr.free.nrw.commons.upload.FileProcessor
+import fr.free.nrw.commons.upload.FileUtilsWrapper
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okio.FileNotFoundException
+import timber.log.Timber
+import java.io.IOException
+import java.net.UnknownHostException
+import javax.inject.Inject
+
+class ImageUseCase @Inject constructor(
+ private val fileUtilsWrapper: FileUtilsWrapper,
+ private val fileProcessor: FileProcessor,
+ private val mediaClient: MediaClient,
+ private val context: Context
+) {
+ private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
+
+ /**
+ * Retrieves the SHA1 hash of an image from its URI.
+ *
+ * @param uri The URI of the image.
+ * @return The SHA1 hash of the image, or an empty string if the image is not found.
+ */
+ suspend fun getImageSHA1(uri: Uri): String = withContext(ioDispatcher) {
+ try {
+ val inputStream = context.contentResolver.openInputStream(uri)
+ fileUtilsWrapper.getSHA1(inputStream)
+ } catch (e: FileNotFoundException) {
+ Timber.e(e)
+ ""
+ }
+ }
+
+ /**
+ * Generates a modified SHA1 hash of an image after redacting sensitive EXIF tags.
+ *
+ * @param imageUri The URI of the image to process.
+ * @return The modified SHA1 hash of the image.
+ */
+ suspend fun generateModifiedSHA1(imageUri: Uri): String = withContext(ioDispatcher) {
+ val uploadableFile = PickedFiles.pickedExistingPicture(context, imageUri)
+ val exifInterface: ExifInterface? = try {
+ ExifInterface(uploadableFile.file!!)
+ } catch (e: IOException) {
+ Timber.e(e)
+ null
+ }
+ fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact())
+
+ val sha1 = fileUtilsWrapper.getSHA1(
+ fileUtilsWrapper.getFileInputStream(uploadableFile.filePath))
+ uploadableFile.file.delete()
+ sha1
+ }
+
+ /**
+ * Checks whether a file with the given SHA1 hash exists on Wikimedia Commons.
+ *
+ * @param sha1 The SHA1 hash of the file to check.
+ * @return An ImageLoader.Result indicating the existence of the file on Commons.
+ */
+ suspend fun checkWhetherFileExistsOnCommonsUsingSHA1(
+ sha1: String
+ ): ImageLoader.Result = withContext(ioDispatcher) {
+ return@withContext try {
+ if (mediaClient.checkFileExistsUsingSha(sha1).blockingGet()) {
+ ImageLoader.Result.TRUE
+ } else {
+ ImageLoader.Result.FALSE
+ }
+ } catch (e: UnknownHostException) {
+ Timber.e(e, "Network Connection Error")
+ ImageLoader.Result.ERROR
+ } catch (e: Exception) {
+ e.printStackTrace()
+ ImageLoader.Result.ERROR
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt
index 5df123ad2c..3de523b3cd 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/ImageHelper.kt
@@ -1,7 +1,7 @@
package fr.free.nrw.commons.customselector.helper
-import fr.free.nrw.commons.customselector.model.Folder
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Folder
+import fr.free.nrw.commons.customselector.domain.model.Image
/**
* Image Helper object, includes all the static functions and variables required by custom selector.
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt
index 78ce46c6e4..06c9b1cc6b 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageLoaderListener.kt
@@ -1,6 +1,6 @@
package fr.free.nrw.commons.customselector.listeners
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Image
/**
* Custom Selector Image Loader Listener
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt
index 24565963b6..10c2b5fd2e 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/ImageSelectListener.kt
@@ -1,6 +1,6 @@
package fr.free.nrw.commons.customselector.listeners
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Image
/**
* Custom selector Image select listener
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt
index da526be35e..3893a96e62 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/listeners/PassDataListener.kt
@@ -1,6 +1,6 @@
package fr.free.nrw.commons.customselector.listeners
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Image
/**
* Interface to pass data between fragment and activity
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt
index 87f68a3e13..ba3d36d5e0 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapter.kt
@@ -10,8 +10,8 @@ import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.listeners.FolderClickListener
-import fr.free.nrw.commons.customselector.model.Folder
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Folder
+import fr.free.nrw.commons.customselector.domain.model.Image
/**
* Custom selector FolderAdapter.
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt
index 74b937f970..a2ddfb21be 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapter.kt
@@ -17,7 +17,7 @@ import fr.free.nrw.commons.customselector.helper.ImageHelper
import fr.free.nrw.commons.customselector.helper.ImageHelper.CUSTOM_SELECTOR_PREFERENCE_KEY
import fr.free.nrw.commons.customselector.helper.ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_PREFERENCE_KEY
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.ui.selector.ImageLoader
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt
new file mode 100644
index 0000000000..e473d427ed
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/Buttons.kt
@@ -0,0 +1,91 @@
+package fr.free.nrw.commons.customselector.ui.components
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.dp
+import fr.free.nrw.commons.ui.theme.CommonsTheme
+
+@Composable
+fun PrimaryButton(
+ text: String,
+ onClick: ()-> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ shape: Shape = RoundedCornerShape(12.dp),
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier,
+ shape = shape,
+ enabled = enabled,
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 4.dp)
+ ) {
+ Text(
+ text = text,
+ textAlign = TextAlign.Center
+ )
+ }
+}
+
+@Composable
+fun SecondaryButton(
+ text: String,
+ onClick: ()-> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ shape: Shape = RoundedCornerShape(12.dp),
+) {
+ OutlinedButton(
+ onClick = onClick,
+ modifier = modifier,
+ enabled = enabled,
+ border = BorderStroke(1.dp, color = MaterialTheme.colorScheme.primary),
+ shape = shape,
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp)
+ ) {
+ Text(
+ text = text,
+ textAlign = TextAlign.Center
+ )
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun PrimaryButtonPreview() {
+ CommonsTheme {
+ Surface {
+ PrimaryButton(
+ text = "Primary Button",
+ onClick = { },
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun SecondaryButtonPreview() {
+ CommonsTheme {
+ Surface {
+ SecondaryButton(
+ text = "Secondary Button",
+ onClick = { },
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt
new file mode 100644
index 0000000000..a7b0d91d22
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorBottomBar.kt
@@ -0,0 +1,66 @@
+package fr.free.nrw.commons.customselector.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.dp
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.ui.theme.CommonsTheme
+
+@Composable
+fun CustomSelectorBottomBar(
+ onPrimaryAction: ()-> Unit,
+ onSecondaryAction: ()-> Unit,
+ modifier: Modifier = Modifier,
+ isAnyImageNotForUpload: Boolean = false
+) {
+ val buttonText = if (isAnyImageNotForUpload) {
+ R.string.unmark_as_not_for_upload
+ } else {
+ R.string.mark_as_not_for_upload
+ }
+
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ SecondaryButton(
+ text = stringResource(buttonText).uppercase(),
+ onClick = onSecondaryAction,
+ modifier = Modifier.weight(1f)
+ )
+
+ PrimaryButton(
+ text = stringResource(R.string.upload).uppercase(),
+ onClick = onPrimaryAction,
+ enabled = !isAnyImageNotForUpload,
+ modifier = Modifier
+ .weight(1f)
+ .height(IntrinsicSize.Max)
+ )
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun CustomSelectorBottomBarPreview() {
+ CommonsTheme {
+ Surface(tonalElevation = 3.dp) {
+ CustomSelectorBottomBar(
+ onPrimaryAction = { },
+ onSecondaryAction = { },
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .fillMaxWidth()
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt
new file mode 100644
index 0000000000..dd9a530ee7
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/CustomSelectorTopBar.kt
@@ -0,0 +1,139 @@
+package fr.free.nrw.commons.customselector.ui.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.ui.theme.CommonsTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CustomSelectorTopBar(
+ primaryText: String,
+ onNavigateBack: ()-> Unit,
+ modifier: Modifier = Modifier,
+ secondaryText: String? = null,
+ selectionCount: Int = 0,
+ showNavigationIcon: Boolean = true,
+ showSelectionCount: Boolean = false,
+ showAlertIcon: Boolean = false,
+ onAlertAction: ()-> Unit = { },
+ onUnselectAllAction: ()-> Unit = { }
+) {
+ TopAppBar(
+ title = {
+ Column {
+ Text(
+ text = primaryText,
+ style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ secondaryText?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ },
+ modifier = modifier,
+ navigationIcon = {
+ if(showNavigationIcon) {
+ IconButton(onClick = onNavigateBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft,
+ contentDescription = "Navigate Back",
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+ },
+ actions = {
+ if(showAlertIcon) {
+ IconButton(onClick = onAlertAction) {
+ Icon(
+ painter = painterResource(R.drawable.ic_error_red_24dp),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
+ if(showSelectionCount) {
+ ElevatedCard(
+ colors = CardDefaults.elevatedCardColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ ),
+ shape = RoundedCornerShape(50),
+ modifier = Modifier.padding(end = 8.dp)
+ .semantics { contentDescription = "$selectionCount Selected" }
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
+ .widthIn(min = 52.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ Text(text = "$selectionCount")
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = null,
+ modifier = Modifier.clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) { onUnselectAllAction() }
+ )
+ }
+ }
+ }
+ }
+ )
+}
+
+@PreviewLightDark
+@Composable
+private fun CustomSelectorTopBarPreview() {
+ CommonsTheme {
+ Surface(tonalElevation = 1.dp) {
+ CustomSelectorTopBar(
+ primaryText = "My Folder",
+ secondaryText = "10 images",
+ onNavigateBack = { },
+ showAlertIcon = true,
+ selectionCount = 2,
+ showSelectionCount = true
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/PartialStorageAccessDialog.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/PartialStorageAccessDialog.kt
new file mode 100644
index 0000000000..9cf69530ff
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/components/PartialStorageAccessDialog.kt
@@ -0,0 +1,70 @@
+package fr.free.nrw.commons.customselector.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.dp
+import fr.free.nrw.commons.ui.theme.CommonsTheme
+
+@Composable
+fun PartialStorageAccessDialog(
+ onManageAction: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ ),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(vertical = 8.dp, horizontal = 16.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "You've given access to a selected number of photos",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f)
+ )
+ Button(
+ onClick = onManageAction,
+ shape = RoundedCornerShape(8.dp),
+ contentPadding = PaddingValues(horizontal = 16.dp)
+ ) {
+ Text(text = "Manage", style = MaterialTheme.typography.labelMedium)
+ }
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+fun PartialStorageAccessIndicatorPreview() {
+ CommonsTheme {
+ Surface {
+ PartialStorageAccessDialog(
+ onManageAction = {},
+ modifier = Modifier
+ .padding(8.dp)
+ .fillMaxWidth()
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt
new file mode 100644
index 0000000000..dc79c26e16
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorEvent.kt
@@ -0,0 +1,14 @@
+package fr.free.nrw.commons.customselector.ui.screens
+
+import fr.free.nrw.commons.customselector.ui.states.ImageUiState
+import kotlinx.coroutines.CoroutineScope
+
+sealed interface CustomSelectorEvent {
+ data class OnFolderClick(val bucketId: Long): CustomSelectorEvent
+ data class OnImageSelection(val imageId: Long): CustomSelectorEvent
+ data class OnDragImageSelection(val imageIds: Set): CustomSelectorEvent
+ data object OnUnselectAll: CustomSelectorEvent
+ data class OnUpdateImageStatus(val scope: CoroutineScope, val image: ImageUiState) : CustomSelectorEvent
+ data object MarkAsNotForUpload: CustomSelectorEvent
+ data object UnmarkAsNotForUpload: CustomSelectorEvent
+}
\ No newline at end of file
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
new file mode 100644
index 0000000000..0b5ee070f1
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorScreen.kt
@@ -0,0 +1,282 @@
+package fr.free.nrw.commons.customselector.ui.screens
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.WindowAdaptiveInfo
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
+import androidx.compose.material3.adaptive.layout.AnimatedPane
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
+import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
+import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewLightDark
+import androidx.compose.ui.unit.dp
+import androidx.window.core.layout.WindowWidthSizeClass
+import coil.compose.rememberAsyncImagePainter
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.customselector.ui.components.CustomSelectorBottomBar
+import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar
+import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog
+import fr.free.nrw.commons.customselector.ui.states.CustomSelectorUiState
+import fr.free.nrw.commons.customselector.ui.states.ImageUiState
+import fr.free.nrw.commons.ui.theme.CommonsTheme
+
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+fun CustomSelectorScreen(
+ uiState: CustomSelectorUiState,
+ onEvent: (CustomSelectorEvent)-> Unit,
+ selectedImageIds: ()-> Set,
+ onViewImage: (id: Long)-> Unit,
+ hasPartialAccess: Boolean = false
+) {
+ val adaptiveInfo = currentWindowAdaptiveInfo()
+ val navigator = rememberListDetailPaneScaffoldNavigator()
+
+ BackHandler(navigator.canNavigateBack()) {
+ navigator.navigateBack()
+ }
+
+ ListDetailPaneScaffold(
+ directive = navigator.scaffoldDirective.copy(horizontalPartitionSpacerSize = 0.dp),
+ value = navigator.scaffoldValue,
+ listPane = {
+ AnimatedPane {
+ FoldersPane(
+ uiState = uiState,
+ onFolderClick = {
+ onEvent(CustomSelectorEvent.OnFolderClick(it.bucketId))
+ navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, it)
+ },
+ hasPartialAccess = hasPartialAccess,
+ adaptiveInfo = adaptiveInfo,
+ onUnselectAll = { onEvent(CustomSelectorEvent.OnUnselectAll) }
+ )
+ }
+ },
+ detailPane = {
+ AnimatedPane {
+ navigator.currentDestination?.content?.let { folder->
+ ImagesPane(
+ uiState = uiState,
+ selectedFolder = folder,
+ selectedImages = selectedImageIds,
+ onNavigateBack = { navigator.navigateBack() },
+ onViewImage = onViewImage,
+ onEvent = onEvent,
+ adaptiveInfo = adaptiveInfo,
+ hasPartialAccess = hasPartialAccess
+ )
+ }
+ }
+ },
+ )
+}
+
+@Composable
+fun FoldersPane(
+ uiState: CustomSelectorUiState,
+ onFolderClick: (Folder)-> Unit,
+ onUnselectAll: ()-> Unit,
+ adaptiveInfo: WindowAdaptiveInfo,
+ hasPartialAccess: Boolean = false
+) {
+ val isCompatWidth by remember(adaptiveInfo.windowSizeClass) {
+ derivedStateOf { adaptiveInfo.windowSizeClass
+ .windowWidthSizeClass == WindowWidthSizeClass.COMPACT }
+ }
+
+ Scaffold(
+ topBar = {
+ Surface(tonalElevation = 1.dp) {
+ CustomSelectorTopBar(
+ primaryText = stringResource(R.string.custom_selector_title),
+ onNavigateBack = { /*TODO*/ },
+ showAlertIcon = uiState.selectedImageIds.size > 20 && isCompatWidth,
+ selectionCount = uiState.selectedImageIds.size,
+ onAlertAction = { },
+ onUnselectAllAction = onUnselectAll,
+ showSelectionCount = uiState.inSelectionMode && isCompatWidth
+ )
+ }
+ },
+ bottomBar = {
+ AnimatedVisibility(
+ visible = uiState.inSelectionMode,
+ enter = slideInVertically(initialOffsetY = { it }),
+ exit = slideOutVertically(targetOffsetY = { it })
+ ) {
+ Surface(tonalElevation = 1.dp) {
+ CustomSelectorBottomBar(
+ onPrimaryAction = { /*TODO("Implement action to upload selected images")*/},
+ onSecondaryAction = {
+ /*TODO("Implement action to mark/unmark images as not for upload")*/
+ },
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ .navigationBarsPadding()
+ )
+ }
+ }
+ }
+ ) { innerPadding->
+ Surface(tonalElevation = 0.dp) {
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize()
+ ) {
+ if(hasPartialAccess && isCompatWidth) {
+ PartialStorageAccessDialog(
+ onManageAction = { /*TODO("Request permission[READ_MEDIA_IMAGES]")*/ },
+ modifier = Modifier.padding(8.dp)
+ )
+ }
+
+ if(uiState.isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize(1f),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(164.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(8.dp),
+ modifier = Modifier.fillMaxSize(1f)
+ ) {
+ items(uiState.folders, key = { it.bucketId }) {
+ FolderItem(
+ previewPainter = rememberAsyncImagePainter(model = it.preview),
+ folderName = it.bucketName,
+ itemsCount = it.itemsCount,
+ onClick = { onFolderClick(it) }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun FolderItem(
+ previewPainter: Painter,
+ folderName: String,
+ itemsCount: Int,
+ onClick: ()-> Unit,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .aspectRatio(1f)
+ .clip(RoundedCornerShape(12.dp))
+ .clickable { onClick() }
+ ) {
+ Image(
+ painter = previewPainter,
+ contentDescription = null,
+ modifier = Modifier.aspectRatio(1f),
+ contentScale = ContentScale.Crop
+ )
+ Text(
+ text = "$itemsCount",
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .widthIn(min = 32.dp)
+ .align(Alignment.TopEnd)
+ .clip(RoundedCornerShape(bottomStart = 12.dp))
+ .background(color = MaterialTheme.colorScheme.secondaryContainer)
+ .padding(4.dp)
+ )
+ Surface(
+ modifier = Modifier.align(Alignment.BottomStart),
+ color = MaterialTheme.colorScheme.secondaryContainer
+ ) {
+ Text(
+ text = folderName,
+ style = MaterialTheme.typography.titleSmall,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ modifier = Modifier
+ .padding(8.dp)
+ .fillMaxWidth()
+ )
+ }
+ }
+}
+
+@PreviewLightDark
+@Composable
+private fun FolderItemPreview() {
+ CommonsTheme {
+ Surface {
+ FolderItem(
+ previewPainter = painterResource(R.drawable.image_placeholder_96),
+ folderName = "Folder Name",
+ itemsCount = 12,
+ onClick = { },
+ modifier = Modifier
+ .padding(16.dp)
+ .size(164.dp)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun CustomSelectorScreenPreview() {
+ CommonsTheme {
+ CustomSelectorScreen(
+ uiState = CustomSelectorUiState(),
+ onViewImage = { },
+ onEvent = { },
+ selectedImageIds = { emptySet() },
+ hasPartialAccess = true
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt
new file mode 100644
index 0000000000..b146749830
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/CustomSelectorViewModel.kt
@@ -0,0 +1,156 @@
+package fr.free.nrw.commons.customselector.ui.screens
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import fr.free.nrw.commons.customselector.domain.ImageRepository
+import fr.free.nrw.commons.customselector.domain.model.Image
+import fr.free.nrw.commons.customselector.domain.use_case.ImageUseCase
+import fr.free.nrw.commons.customselector.ui.states.CustomSelectorUiState
+import fr.free.nrw.commons.customselector.ui.states.ImageUiState
+import fr.free.nrw.commons.customselector.ui.states.toImageUiState
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+typealias imageId = Long
+typealias imageSHA = String
+
+class CustomSelectorViewModel @Inject constructor(
+ private val imageRepository: ImageRepository,
+ private val imageUseCase: ImageUseCase
+): ViewModel() {
+
+ private val _uiState = MutableStateFlow(CustomSelectorUiState())
+ val uiState = _uiState.asStateFlow()
+
+ private val cacheSHA1 = mutableMapOf()
+
+ private val allImages = mutableListOf()
+ private val foldersMap = mutableMapOf>()
+
+ init {
+ viewModelScope.launch {
+ imageRepository.getImagesFromDevice().collect { image ->
+ val bucketId = image.bucketId
+
+ allImages.add(image.toImageUiState())
+ foldersMap.getOrPut(bucketId) { mutableListOf() }.add(image)
+ }
+ val folders = foldersMap.map { (bucketId, images)->
+ val firstImage = images.first()
+ Folder(
+ bucketId = bucketId,
+ bucketName = firstImage.bucketName,
+ preview = firstImage.uri,
+ itemsCount = images.size,
+ images = images
+ )
+ }
+ _uiState.update { it.copy(isLoading = false, folders = folders) }
+ }
+ }
+
+ fun onEvent(e: CustomSelectorEvent) {
+ when(e) {
+ is CustomSelectorEvent.OnFolderClick -> {
+ _uiState.update {
+ it.copy(
+ filteredImages = foldersMap[e.bucketId]?.map {
+ img -> img.toImageUiState()
+ } ?: emptyList()
+ )
+ }
+ }
+
+ is CustomSelectorEvent.OnImageSelection -> {
+ _uiState.update { state ->
+ val updatedSelectedIds = if (state.selectedImageIds.contains(e.imageId)) {
+ state.selectedImageIds - e.imageId // Remove if already selected
+ } else {
+ state.selectedImageIds + e.imageId // Add if not selected
+ }
+ state.copy(selectedImageIds = updatedSelectedIds)
+ }
+ }
+
+ is CustomSelectorEvent.OnDragImageSelection-> {
+ _uiState.update { it.copy(selectedImageIds = e.imageIds) }
+ }
+
+ CustomSelectorEvent.OnUnselectAll-> {
+ _uiState.update { it.copy(selectedImageIds = emptySet()) }
+ }
+
+ is CustomSelectorEvent.OnUpdateImageStatus -> {
+ e.scope.launch { updateNotForUploadStatus(e.image) }
+ }
+
+ is CustomSelectorEvent.MarkAsNotForUpload -> {
+ viewModelScope.launch {
+ val selectedImageIds = _uiState.value.selectedImageIds
+
+ val selectedImages = allImages.filter { image ->
+ selectedImageIds.contains(image.id)
+ }
+
+ selectedImages.forEach { image ->
+ cacheSHA1[image.id]?.let { sha ->
+ if(!imageRepository.isNotForUpload(sha)) {
+ imageRepository.markAsNotForUpload(sha)
+ updateImageStatus(true, image.id)
+ _uiState.update { it.copy(selectedImageIds = emptySet()) }
+ }
+ }
+ }
+ }
+ }
+
+ CustomSelectorEvent.UnmarkAsNotForUpload -> {
+ viewModelScope.launch {
+ val selectedImageIds = _uiState.value.selectedImageIds
+
+ val selectedImages = allImages.filter { image ->
+ selectedImageIds.contains(image.id)
+ }
+
+ selectedImages.forEach { image ->
+ cacheSHA1[image.id]?.let { sha ->
+ if(imageRepository.isNotForUpload(sha)) {
+ imageRepository.unmarkAsNotForUpload(sha)
+ updateImageStatus(false, image.id)
+ _uiState.update { it.copy(selectedImageIds = emptySet()) }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun updateImageStatus(isNotForUpload: Boolean, imageId: Long) {
+ _uiState.update { state ->
+ val updatedImages = state.filteredImages.map {
+ if (it.id == imageId) {
+ it.copy(isNotForUpload = isNotForUpload)
+ } else {
+ it
+ }
+ }
+ val updateMap = state.imagesNotForUpload.toMutableMap()
+ updateMap[imageId] = isNotForUpload
+
+ state.copy(filteredImages = updatedImages, imagesNotForUpload = updateMap)
+ }
+ }
+
+ private suspend fun updateNotForUploadStatus(image: ImageUiState) {
+ val imageSHA = cacheSHA1.getOrPut(image.id) {
+ imageUseCase.getImageSHA1(image.uri).also { sha -> cacheSHA1[image.id] = sha }
+ }
+
+ val isNotForUpload = imageRepository.isNotForUpload(imageSHA)
+ updateImageStatus(isNotForUpload, image.id)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/Folder.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/Folder.kt
new file mode 100644
index 0000000000..303edad3cd
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/Folder.kt
@@ -0,0 +1,15 @@
+package fr.free.nrw.commons.customselector.ui.screens
+
+import android.net.Uri
+import android.os.Parcelable
+import fr.free.nrw.commons.customselector.domain.model.Image
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class Folder(
+ val bucketId: Long,
+ val bucketName: String,
+ val preview: Uri,
+ val images: List,
+ val itemsCount: Int
+): Parcelable
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
new file mode 100644
index 0000000000..feb9930cfa
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/screens/ImagesPane.kt
@@ -0,0 +1,392 @@
+package fr.free.nrw.commons.customselector.ui.screens
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.scrollBy
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyGridState
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.adaptive.WindowAdaptiveInfo
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.round
+import androidx.compose.ui.unit.toIntRect
+import androidx.window.core.layout.WindowWidthSizeClass
+import coil.compose.rememberAsyncImagePainter
+import fr.free.nrw.commons.R
+import fr.free.nrw.commons.customselector.ui.components.CustomSelectorBottomBar
+import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar
+import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog
+import fr.free.nrw.commons.customselector.ui.states.CustomSelectorUiState
+import fr.free.nrw.commons.customselector.ui.states.ImageUiState
+import fr.free.nrw.commons.ui.theme.CommonsTheme
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ImagesPane(
+ uiState: CustomSelectorUiState,
+ selectedFolder: Folder,
+ selectedImages: () -> Set,
+ onNavigateBack: () -> Unit,
+ onViewImage: (id: Long)-> Unit,
+ onEvent: (CustomSelectorEvent) -> Unit,
+ adaptiveInfo: WindowAdaptiveInfo,
+ hasPartialAccess: Boolean = false
+) {
+ val lazyGridState = rememberLazyGridState()
+ var autoScrollSpeed by remember { mutableFloatStateOf(0f) }
+ val isCompatWidth by remember(adaptiveInfo.windowSizeClass) {
+ derivedStateOf {
+ adaptiveInfo.windowSizeClass
+ .windowWidthSizeClass == WindowWidthSizeClass.COMPACT
+ }
+ }
+ val isSelectedImageNotForUpload by remember(uiState.selectedImageIds) {
+ derivedStateOf { uiState.selectedImageIds.any { uiState.imagesNotForUpload[it] == true } }
+ }
+
+ LaunchedEffect(autoScrollSpeed) {
+ if (autoScrollSpeed != 0f) {
+ while (isActive) {
+ lazyGridState.scrollBy(autoScrollSpeed)
+ delay(10)
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ CustomSelectorTopBar(
+ primaryText = selectedFolder.bucketName,
+ secondaryText = "${selectedFolder.itemsCount} images",
+ onNavigateBack = onNavigateBack,
+ showNavigationIcon = isCompatWidth,
+ showAlertIcon = selectedImages().size > 20,
+ selectionCount = selectedImages().size,
+ showSelectionCount = uiState.inSelectionMode,
+ onUnselectAllAction = { onEvent(CustomSelectorEvent.OnUnselectAll) }
+ )
+ },
+ bottomBar = {
+ AnimatedVisibility(
+ visible = uiState.inSelectionMode && isCompatWidth,
+ enter = slideInVertically(initialOffsetY = { it }),
+ exit = slideOutVertically(targetOffsetY = { it })
+ ) {
+ Surface(tonalElevation = 1.dp) {
+ CustomSelectorBottomBar(
+ onPrimaryAction = { /*TODO("Implement action to upload selected images")*/ },
+ onSecondaryAction = {
+ if(isSelectedImageNotForUpload) {
+ onEvent(CustomSelectorEvent.UnmarkAsNotForUpload)
+ } else {
+ onEvent(CustomSelectorEvent.MarkAsNotForUpload)
+ }
+ },
+ isAnyImageNotForUpload = isSelectedImageNotForUpload,
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .navigationBarsPadding()
+ )
+ }
+ }
+ }
+ ) { innerPadding ->
+ Column(modifier = Modifier.padding(innerPadding)) {
+ if (hasPartialAccess) {
+ PartialStorageAccessDialog(
+ onManageAction = { /*TODO("Request permission[READ_MEDIA_IMAGES]")*/ },
+ modifier = Modifier.padding(8.dp)
+ )
+ }
+
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(96.dp),
+ modifier = Modifier
+ .fillMaxSize()
+ .imageGridDragHandler(
+ gridState = lazyGridState,
+ imageList = uiState.filteredImages,
+ selectedImageIds = selectedImages,
+ setSelectedImageIds = {
+ onEvent(CustomSelectorEvent.OnDragImageSelection(it))
+ },
+ autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() },
+ setAutoScrollSpeed = { autoScrollSpeed = it }
+ ),
+ state = lazyGridState,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(8.dp)
+ ) {
+ items(uiState.filteredImages, key = { it.id }) { image ->
+ val isSelected by remember {
+ derivedStateOf { selectedImages().contains(image.id) }
+ }
+
+ ImageItem(
+ imagePainter = rememberAsyncImagePainter(model = image.uri),
+ isSelected = isSelected,
+ inSelectionMode = uiState.inSelectionMode,
+ isNotForUpload = image.isNotForUpload,
+ onImageStatusChange = { scope ->
+ onEvent(CustomSelectorEvent.OnUpdateImageStatus(scope, image))
+ },
+ modifier = Modifier.combinedClickable(
+ onClick = {
+ if (uiState.inSelectionMode) {
+ onEvent(CustomSelectorEvent.OnImageSelection(image.id))
+ } else {
+ onViewImage(image.id)
+ }
+ },
+ onLongClick = {
+ onEvent(CustomSelectorEvent.OnImageSelection(image.id))
+ }
+ )
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ImageItem(
+ imagePainter: Painter,
+ isSelected: Boolean,
+ onImageStatusChange: (scope: CoroutineScope) -> Unit,
+ modifier: Modifier = Modifier,
+ inSelectionMode: Boolean = false,
+ isNotForUpload: Boolean = false
+) {
+ // This side-effect updates the image status, like:- isNotForUpload, for visible image only
+ LaunchedEffect(Unit) {
+ onImageStatusChange(this)
+ }
+
+ Box(modifier = modifier.clip(RoundedCornerShape(12.dp))) {
+ Image(
+ painter = imagePainter,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f),
+ contentScale = ContentScale.Crop
+ )
+
+ if (inSelectionMode) {
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier
+ .size(24.dp)
+ .clip(RoundedCornerShape(bottomEnd = 12.dp))
+ .background(color = MaterialTheme.colorScheme.primary)
+ .padding(2.dp)
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.secondary,
+ modifier = Modifier
+ .size(24.dp)
+ .clip(RoundedCornerShape(bottomEnd = 12.dp))
+ .background(
+ color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
+ ).padding(2.dp)
+ )
+ }
+ }
+
+ if(isNotForUpload) {
+ Icon(
+ painter = painterResource(id = R.drawable.not_for_upload),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .clip(RoundedCornerShape(bottomStart = 12.dp))
+ .background(color = MaterialTheme.colorScheme.errorContainer)
+ .padding(4.dp)
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun ImageItemPreview() {
+ CommonsTheme {
+ Surface {
+ ImageItem(
+ imagePainter = painterResource(id = R.drawable.image_placeholder_96),
+ isSelected = false,
+ inSelectionMode = true,
+ isNotForUpload = true,
+ onImageStatusChange = { },
+ modifier = Modifier
+ .padding(16.dp)
+ .size(116.dp)
+ )
+ }
+ }
+}
+
+/**
+ * A modifier that handles drag gestures on an image grid to allow for selecting multiple images.
+ *
+ * This modifier detects drag gestures and updates the selected images based on the drag position.
+ * It also handles auto-scrolling when the drag reaches the edges of the grid.
+ *
+ * @param gridState The state of the lazy grid.
+ * @param imageList The list of images displayed in the grid.
+ * @param selectedImageIds A function that returns the currently selected image IDs.
+ * @param autoScrollThreshold The distance from the edge of the grid at which auto-scrolling should start.
+ * @param setSelectedImageIds A callback function that is invoked when the selected images change.
+ * @param setAutoScrollSpeed A callback function that is invoked to set the auto-scroll speed.
+ */
+fun Modifier.imageGridDragHandler(
+ gridState: LazyGridState,
+ imageList: List,
+ selectedImageIds: () -> Set,
+ autoScrollThreshold: Float,
+ setSelectedImageIds: (Set) -> Unit,
+ setAutoScrollSpeed: (Float) -> Unit,
+) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, imageList) {
+
+ fun imageIndexAtOffset(hitPoint: Offset): Int? =
+ gridState.layoutInfo.visibleItemsInfo.find { itemInfo ->
+ itemInfo.size.toIntRect().contains(hitPoint.round() - itemInfo.offset)
+ }?.index
+
+ var dragStartIndex: Int? = null
+ var currentDragIndex: Int? = null
+ var isSelecting = true
+
+ detectDragGestures(
+ onDragStart = { offset ->
+ imageIndexAtOffset(offset)?.let {
+ val imageId = imageList[it].id
+ dragStartIndex = it
+ currentDragIndex = it
+
+ if (!selectedImageIds().contains(imageId)) {
+ isSelecting = true
+ setSelectedImageIds(selectedImageIds().plus(imageId))
+ } else {
+ isSelecting = false
+ setSelectedImageIds(selectedImageIds().minus(imageId))
+ }
+ }
+ },
+ onDragEnd = { setAutoScrollSpeed(0f); dragStartIndex = null },
+ onDragCancel = { setAutoScrollSpeed(0f); dragStartIndex = null },
+ onDrag = { change, _ ->
+ dragStartIndex?.let { startIndex ->
+ val distFromBottom = gridState.layoutInfo.viewportSize.height - change.position.y
+ val distFromTop = change.position.y
+ setAutoScrollSpeed(
+ when {
+ distFromBottom < autoScrollThreshold -> autoScrollThreshold - distFromBottom
+ distFromTop < autoScrollThreshold -> -(autoScrollThreshold - distFromTop)
+ else -> 0f
+ }
+ )
+
+ currentDragIndex?.let { currentIndex ->
+ imageIndexAtOffset(change.position)?.let { pointerIndex ->
+ if (currentIndex != pointerIndex) {
+ if (isSelecting) {
+ setSelectedImageIds(
+ selectedImageIds().minus(
+ imageList.getImageIdsInRange(startIndex, currentIndex)
+ ).plus(
+ imageList.getImageIdsInRange(startIndex, pointerIndex)
+ )
+ )
+ } else {
+ setSelectedImageIds(
+ selectedImageIds().plus(
+ imageList.getImageIdsInRange(currentIndex, pointerIndex)
+ ).minus(
+ imageList.getImageIdsInRange(startIndex, pointerIndex)
+ )
+ )
+ }
+ currentDragIndex = pointerIndex
+ }
+ }
+ }
+ }
+ }
+ )
+}
+
+/**
+ * Calculates a set of image IDs within a given range of indices in a list of images.
+ *
+ * @param initialKey The starting index of the range.
+ * @param pointerKey The ending index of the range.
+ * @return A set of image IDs within the specified range.
+ */
+fun List.getImageIdsInRange(initialKey: Int, pointerKey: Int): Set {
+ val setOfKeys = mutableSetOf()
+ if (initialKey < pointerKey) {
+ (initialKey..pointerKey).forEach {
+ setOfKeys.add(this[it].id)
+ }
+ } else {
+ (pointerKey..initialKey).forEach {
+ setOfKeys.add(this[it].id)
+ }
+ }
+ return setOfKeys
+}
\ No newline at end of file
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 0000000000..0cb3bfcb8b
--- /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.ui.states.ImageUiState
+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 6c6d7e53f4..d1999eabce 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,54 +15,41 @@ import android.widget.Button
import android.widget.ImageButton
import android.widget.PopupMenu
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.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedCard
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.colorResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+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.database.NotForUploadStatus
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
+import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants
import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper
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.customselector.utils.CustomSelectorViewModelFactory
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.media.ZoomableActivity
import fr.free.nrw.commons.theme.BaseActivity
+import fr.free.nrw.commons.ui.theme.CommonsTheme
import fr.free.nrw.commons.upload.FileUtilsWrapper
import fr.free.nrw.commons.utils.CustomSelectorUtils
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.MainScope
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
+import kotlinx.coroutines.*
import java.io.File
import java.lang.Integer.max
import javax.inject.Inject
@@ -171,6 +159,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(
@@ -181,33 +170,63 @@ class CustomSelectorActivity :
showPartialAccessIndicator = true
}
- binding = ActivityCustomSelectorBinding.inflate(layoutInflater)
- toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root)
- bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root)
- binding.partialAccessIndicator.setContent {
- partialStorageAccessIndicator(
- isVisible = showPartialAccessIndicator,
- onManage = {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
- requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_IMAGES), 1)
- }
- },
- modifier =
- Modifier
- .padding(vertical = 8.dp, horizontal = 4.dp)
- .fillMaxWidth(),
- )
- }
- val view = binding.root
- setContentView(view)
+// binding = ActivityCustomSelectorBinding.inflate(layoutInflater)
+// toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root)
+// bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root)
+// binding.partialAccessIndicator.setContent {
+// PartialStorageAccessDialog(
+// isVisible = showPartialAccessIndicator,
+// onManage = {
+// if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+// requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_IMAGES), 1)
+// }
+// },
+// modifier = Modifier
+// .padding(vertical = 8.dp, horizontal = 4.dp)
+// .fillMaxWidth()
+// )
+// }
+// val view = binding.root
+// setContentView(view)
prefs = applicationContext.getSharedPreferences("CustomSelector", MODE_PRIVATE)
- viewModel =
- ViewModelProvider(this, customSelectorViewModelFactory).get(
- CustomSelectorViewModel::class.java,
+
+ setContent {
+ val csViewModel = ViewModelProvider(this, customSelectorViewModelFactory).get(
+ fr.free.nrw.commons.customselector.ui.screens.CustomSelectorViewModel::class.java
)
- setupViews()
+ val uiState by csViewModel.uiState.collectAsStateWithLifecycle()
+
+ CommonsTheme {
+ 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
+ )
+ }
+ }
+ }
+ }
+
+// setupViews()
if (prefs.getBoolean("customSelectorFirstLaunch", true)) {
// show welcome dialog on first launch
@@ -239,7 +258,7 @@ class CustomSelectorActivity :
override fun onResume() {
super.onResume()
- fetchData()
+// fetchData()
}
/**
@@ -661,23 +680,6 @@ class CustomSelectorActivity :
finish()
}
- /**
- * Back pressed.
- * Change toolbar title.
- */
- override fun onBackPressed() {
- super.onBackPressed()
- val fragment = supportFragmentManager.findFragmentById(R.id.fragment_container)
- if (fragment != null && fragment is FolderFragment) {
- isImageFragmentOpen = false
- changeTitle(getString(R.string.custom_selector_title), 0)
- }
-
- //hide overflow menu when not in folder
- showOverflowMenu = false
- setUpToolbar()
- }
-
/**
* Displays a dialog explaining the upload limit warning.
*/
@@ -722,59 +724,3 @@ class CustomSelectorActivity :
const val ITEM_ID: String = "ItemId"
}
}
-
-@Composable
-fun partialStorageAccessIndicator(
- isVisible: Boolean,
- onManage: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- if (isVisible) {
- OutlinedCard(
- modifier = modifier,
- colors =
- CardDefaults.cardColors(
- containerColor = colorResource(R.color.primarySuperLightColor),
- ),
- border = BorderStroke(0.5.dp, color = colorResource(R.color.primaryColor)),
- shape = RoundedCornerShape(8.dp),
- ) {
- Row(modifier = Modifier.padding(16.dp).fillMaxWidth()) {
- Text(
- text = "You've given access to a select number of photos",
- modifier = Modifier.weight(1f),
- )
- TextButton(
- onClick = onManage,
- modifier = Modifier.align(Alignment.Bottom),
- colors =
- ButtonDefaults.buttonColors(
- containerColor = colorResource(R.color.primaryColor),
- ),
- shape = RoundedCornerShape(8.dp),
- ) {
- Text(
- text = "Manage",
- style = MaterialTheme.typography.labelMedium,
- color = colorResource(R.color.primaryTextColor),
- )
- }
- }
- }
- }
-}
-
-@Preview
-@Composable
-fun partialStorageAccessIndicatorPreview() {
- Surface {
- partialStorageAccessIndicator(
- isVisible = true,
- onManage = {},
- modifier =
- Modifier
- .padding(vertical = 8.dp, horizontal = 4.dp)
- .fillMaxWidth(),
- )
- }
-}
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt
index f3465063a7..b8821bda2b 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorViewModel.kt
@@ -4,9 +4,9 @@ import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener
-import fr.free.nrw.commons.customselector.model.CallbackStatus
-import fr.free.nrw.commons.customselector.model.Image
-import fr.free.nrw.commons.customselector.model.Result
+import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
+import fr.free.nrw.commons.customselector.domain.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Result
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt
index 6ca2b06e40..83fa5366c0 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/FolderFragment.kt
@@ -10,9 +10,9 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.customselector.helper.ImageHelper
import fr.free.nrw.commons.customselector.listeners.FolderClickListener
-import fr.free.nrw.commons.customselector.model.CallbackStatus
-import fr.free.nrw.commons.customselector.model.Folder
-import fr.free.nrw.commons.customselector.model.Result
+import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
+import fr.free.nrw.commons.customselector.domain.model.Folder
+import fr.free.nrw.commons.customselector.domain.model.Result
import fr.free.nrw.commons.customselector.ui.adapter.FolderAdapter
import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt
index f079dee507..9083c74b4e 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFileLoader.kt
@@ -5,7 +5,7 @@ import android.content.Context
import android.provider.MediaStore
import android.text.format.DateFormat
import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Image
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt
index 3912a4d12f..2c898259af 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageFragment.kt
@@ -1,7 +1,6 @@
package fr.free.nrw.commons.customselector.ui.selector
import android.app.Activity
-import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import android.os.Bundle
@@ -26,15 +25,14 @@ import fr.free.nrw.commons.customselector.helper.ImageHelper.SHOW_ALREADY_ACTION
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
import fr.free.nrw.commons.customselector.listeners.PassDataListener
import fr.free.nrw.commons.customselector.listeners.RefreshUIListener
-import fr.free.nrw.commons.customselector.model.CallbackStatus
-import fr.free.nrw.commons.customselector.model.Image
-import fr.free.nrw.commons.customselector.model.Result
+import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
+import fr.free.nrw.commons.customselector.domain.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Result
import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter
import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding
import fr.free.nrw.commons.databinding.ProgressDialogBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
import fr.free.nrw.commons.media.MediaClient
-import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.FileProcessor
import fr.free.nrw.commons.upload.FileUtilsWrapper
import io.reactivex.schedulers.Schedulers
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt
index ddfcf341ea..b36e054a15 100644
--- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/ImageLoader.kt
@@ -8,7 +8,7 @@ import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.database.UploadedStatus
import fr.free.nrw.commons.customselector.database.UploadedStatusDao
import fr.free.nrw.commons.customselector.helper.ImageHelper
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter.ImageViewHolder
import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.upload.FileProcessor
@@ -17,7 +17,6 @@ import fr.free.nrw.commons.utils.CustomSelectorUtils
import fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.checkWhetherFileExistsOnCommonsUsingSHA1
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import java.util.Calendar
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/CustomSelectorUiState.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/CustomSelectorUiState.kt
new file mode 100644
index 0000000000..346801a718
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/CustomSelectorUiState.kt
@@ -0,0 +1,17 @@
+package fr.free.nrw.commons.customselector.ui.states
+
+import fr.free.nrw.commons.customselector.ui.screens.Folder
+import fr.free.nrw.commons.customselector.ui.screens.imageId
+
+typealias isNotForUpload = Boolean
+
+data class CustomSelectorUiState(
+ val isLoading: Boolean = true,
+ val folders: List = emptyList(),
+ val filteredImages: List = emptyList(),
+ val selectedImageIds: Set = emptySet(),
+ val imagesNotForUpload: Map = emptyMap()
+) {
+ val inSelectionMode: Boolean
+ get() = selectedImageIds.isNotEmpty()
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/ImageUiState.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/ImageUiState.kt
new file mode 100644
index 0000000000..e986c83875
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/states/ImageUiState.kt
@@ -0,0 +1,20 @@
+package fr.free.nrw.commons.customselector.ui.states
+
+import android.net.Uri
+import fr.free.nrw.commons.customselector.domain.model.Image
+
+data class ImageUiState(
+ val id: Long,
+ val name: String,
+ val uri: Uri,
+ val bucketId: Long,
+ val isNotForUpload: Boolean = false,
+ val isUploaded: Boolean = false
+)
+
+fun Image.toImageUiState() = ImageUiState(
+ id = id,
+ name = name,
+ uri = uri,
+ bucketId = bucketId
+)
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/utils/CustomSelectorViewModelFactory.kt b/app/src/main/java/fr/free/nrw/commons/customselector/utils/CustomSelectorViewModelFactory.kt
new file mode 100644
index 0000000000..10d78c7ddd
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/customselector/utils/CustomSelectorViewModelFactory.kt
@@ -0,0 +1,21 @@
+package fr.free.nrw.commons.customselector.utils
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import fr.free.nrw.commons.customselector.domain.ImageRepository
+import fr.free.nrw.commons.customselector.domain.use_case.ImageUseCase
+import fr.free.nrw.commons.customselector.ui.screens.CustomSelectorViewModel
+import javax.inject.Inject
+
+class CustomSelectorViewModelFactory @Inject constructor(
+ private val imageRepository: ImageRepository,
+ private val imageUseCase: ImageUseCase
+): ViewModelProvider.Factory {
+ override fun create(
+ modelClass: Class
+ ): CustomSelectorViewModel {
+ return CustomSelectorViewModel(
+ imageRepository, imageUseCase
+ ) as CustomSelectorViewModel
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java
index cd7324c633..3e57163509 100644
--- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java
+++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java
@@ -17,8 +17,12 @@
import fr.free.nrw.commons.auth.AccountUtil;
import fr.free.nrw.commons.auth.SessionManager;
import fr.free.nrw.commons.contributions.ContributionDao;
+import fr.free.nrw.commons.customselector.data.ImageRepositoryImpl;
+import fr.free.nrw.commons.customselector.data.MediaReader;
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao;
import fr.free.nrw.commons.customselector.database.UploadedStatusDao;
+import fr.free.nrw.commons.customselector.domain.ImageRepository;
+import fr.free.nrw.commons.customselector.domain.use_case.ImageUseCase;
import fr.free.nrw.commons.customselector.ui.selector.ImageFileLoader;
import fr.free.nrw.commons.data.DBOpenHelper;
import fr.free.nrw.commons.db.AppDatabase;
@@ -317,4 +321,13 @@ public ReviewDao providesReviewDao(AppDatabase appDatabase){
public ContentResolver providesContentResolver(Context context){
return context.getContentResolver();
}
+
+ @Provides
+ public ImageRepository providesImageRepository(
+ MediaReader mediaReader,
+ NotForUploadStatusDao notForUploadStatusDao,
+ ImageUseCase imageUseCase
+ ) {
+ return new ImageRepositoryImpl(mediaReader, notForUploadStatusDao);
+ }
}
diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java
index b64db24c5f..30f9ac2763 100644
--- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java
+++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java
@@ -17,7 +17,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.PreferenceManager;
-import fr.free.nrw.commons.customselector.model.Image;
+import fr.free.nrw.commons.customselector.domain.model.Image;
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity;
import java.io.File;
import java.io.IOException;
diff --git a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt
index 14b5788c24..f455668c03 100644
--- a/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt
+++ b/app/src/main/java/fr/free/nrw/commons/media/ZoomableActivity.kt
@@ -28,9 +28,9 @@ import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants
import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants.SHOULD_REFRESH
import fr.free.nrw.commons.customselector.helper.ImageHelper
import fr.free.nrw.commons.customselector.helper.OnSwipeTouchListener
-import fr.free.nrw.commons.customselector.model.CallbackStatus
-import fr.free.nrw.commons.customselector.model.Image
-import fr.free.nrw.commons.customselector.model.Result
+import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
+import fr.free.nrw.commons.customselector.domain.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Result
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorViewModel
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorViewModelFactory
import fr.free.nrw.commons.databinding.ActivityZoomableBinding
diff --git a/app/src/main/java/fr/free/nrw/commons/ui/theme/Color.kt b/app/src/main/java/fr/free/nrw/commons/ui/theme/Color.kt
new file mode 100644
index 0000000000..f4d19b9880
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/ui/theme/Color.kt
@@ -0,0 +1,219 @@
+package fr.free.nrw.commons.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val primaryLight = Color(0xFF004B7D)
+val onPrimaryLight = Color(0xFFFFFFFF)
+val primaryContainerLight = Color(0xFF2970AD)
+val onPrimaryContainerLight = Color(0xFFFFFFFF)
+val secondaryLight = Color(0xFF4A6079)
+val onSecondaryLight = Color(0xFFFFFFFF)
+val secondaryContainerLight = Color(0xFFD3E6FF)
+val onSecondaryContainerLight = Color(0xFF354B63)
+val tertiaryLight = Color(0xFF643377)
+val onTertiaryLight = Color(0xFFFFFFFF)
+val tertiaryContainerLight = Color(0xFF8B579E)
+val onTertiaryContainerLight = Color(0xFFFFFFFF)
+val errorLight = Color(0xFFBA1A1A)
+val onErrorLight = Color(0xFFFFFFFF)
+val errorContainerLight = Color(0xFFFFDAD6)
+val onErrorContainerLight = Color(0xFF410002)
+val backgroundLight = Color(0xFFF8F9FF)
+val onBackgroundLight = Color(0xFF191C20)
+val surfaceLight = Color(0xFFF8F9FF)
+val onSurfaceLight = Color(0xFF191C20)
+val surfaceVariantLight = Color(0xFFDDE3EE)
+val onSurfaceVariantLight = Color(0xFF414750)
+val outlineLight = Color(0xFF717781)
+val outlineVariantLight = Color(0xFFC1C7D1)
+val scrimLight = Color(0xFF000000)
+val inverseSurfaceLight = Color(0xFF2E3135)
+val inverseOnSurfaceLight = Color(0xFFEFF0F6)
+val inversePrimaryLight = Color(0xFF9CCAFF)
+val surfaceDimLight = Color(0xFFD8DADF)
+val surfaceBrightLight = Color(0xFFF8F9FF)
+val surfaceContainerLowestLight = Color(0xFFFFFFFF)
+val surfaceContainerLowLight = Color(0xFFF2F3F9)
+val surfaceContainerLight = Color(0xFFECEEF3)
+val surfaceContainerHighLight = Color(0xFFE7E8EE)
+val surfaceContainerHighestLight = Color(0xFFE1E2E8)
+
+val primaryLightMediumContrast = Color(0xFF004574)
+val onPrimaryLightMediumContrast = Color(0xFFFFFFFF)
+val primaryContainerLightMediumContrast = Color(0xFF2970AD)
+val onPrimaryContainerLightMediumContrast = Color(0xFFFFFFFF)
+val secondaryLightMediumContrast = Color(0xFF2F445C)
+val onSecondaryLightMediumContrast = Color(0xFFFFFFFF)
+val secondaryContainerLightMediumContrast = Color(0xFF617690)
+val onSecondaryContainerLightMediumContrast = Color(0xFFFFFFFF)
+val tertiaryLightMediumContrast = Color(0xFF5F2E72)
+val onTertiaryLightMediumContrast = Color(0xFFFFFFFF)
+val tertiaryContainerLightMediumContrast = Color(0xFF8B579E)
+val onTertiaryContainerLightMediumContrast = Color(0xFFFFFFFF)
+val errorLightMediumContrast = Color(0xFF8C0009)
+val onErrorLightMediumContrast = Color(0xFFFFFFFF)
+val errorContainerLightMediumContrast = Color(0xFFDA342E)
+val onErrorContainerLightMediumContrast = Color(0xFFFFFFFF)
+val backgroundLightMediumContrast = Color(0xFFF8F9FF)
+val onBackgroundLightMediumContrast = Color(0xFF191C20)
+val surfaceLightMediumContrast = Color(0xFFF8F9FF)
+val onSurfaceLightMediumContrast = Color(0xFF191C20)
+val surfaceVariantLightMediumContrast = Color(0xFFDDE3EE)
+val onSurfaceVariantLightMediumContrast = Color(0xFF3D434C)
+val outlineLightMediumContrast = Color(0xFF596069)
+val outlineVariantLightMediumContrast = Color(0xFF757B85)
+val scrimLightMediumContrast = Color(0xFF000000)
+val inverseSurfaceLightMediumContrast = Color(0xFF2E3135)
+val inverseOnSurfaceLightMediumContrast = Color(0xFFEFF0F6)
+val inversePrimaryLightMediumContrast = Color(0xFF9CCAFF)
+val surfaceDimLightMediumContrast = Color(0xFFD8DADF)
+val surfaceBrightLightMediumContrast = Color(0xFFF8F9FF)
+val surfaceContainerLowestLightMediumContrast = Color(0xFFFFFFFF)
+val surfaceContainerLowLightMediumContrast = Color(0xFFF2F3F9)
+val surfaceContainerLightMediumContrast = Color(0xFFECEEF3)
+val surfaceContainerHighLightMediumContrast = Color(0xFFE7E8EE)
+val surfaceContainerHighestLightMediumContrast = Color(0xFFE1E2E8)
+
+val primaryLightHighContrast = Color(0xFF002440)
+val onPrimaryLightHighContrast = Color(0xFFFFFFFF)
+val primaryContainerLightHighContrast = Color(0xFF004574)
+val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF)
+val secondaryLightHighContrast = Color(0xFF0B243A)
+val onSecondaryLightHighContrast = Color(0xFFFFFFFF)
+val secondaryContainerLightHighContrast = Color(0xFF2F445C)
+val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF)
+val tertiaryLightHighContrast = Color(0xFF3A064F)
+val onTertiaryLightHighContrast = Color(0xFFFFFFFF)
+val tertiaryContainerLightHighContrast = Color(0xFF5F2E72)
+val onTertiaryContainerLightHighContrast = Color(0xFFFFFFFF)
+val errorLightHighContrast = Color(0xFF4E0002)
+val onErrorLightHighContrast = Color(0xFFFFFFFF)
+val errorContainerLightHighContrast = Color(0xFF8C0009)
+val onErrorContainerLightHighContrast = Color(0xFFFFFFFF)
+val backgroundLightHighContrast = Color(0xFFF8F9FF)
+val onBackgroundLightHighContrast = Color(0xFF191C20)
+val surfaceLightHighContrast = Color(0xFFF8F9FF)
+val onSurfaceLightHighContrast = Color(0xFF000000)
+val surfaceVariantLightHighContrast = Color(0xFFDDE3EE)
+val onSurfaceVariantLightHighContrast = Color(0xFF1E242C)
+val outlineLightHighContrast = Color(0xFF3D434C)
+val outlineVariantLightHighContrast = Color(0xFF3D434C)
+val scrimLightHighContrast = Color(0xFF000000)
+val inverseSurfaceLightHighContrast = Color(0xFF2E3135)
+val inverseOnSurfaceLightHighContrast = Color(0xFFFFFFFF)
+val inversePrimaryLightHighContrast = Color(0xFFE1EDFF)
+val surfaceDimLightHighContrast = Color(0xFFD8DADF)
+val surfaceBrightLightHighContrast = Color(0xFFF8F9FF)
+val surfaceContainerLowestLightHighContrast = Color(0xFFFFFFFF)
+val surfaceContainerLowLightHighContrast = Color(0xFFF2F3F9)
+val surfaceContainerLightHighContrast = Color(0xFFECEEF3)
+val surfaceContainerHighLightHighContrast = Color(0xFFE7E8EE)
+val surfaceContainerHighestLightHighContrast = Color(0xFFE1E2E8)
+
+val primaryDark = Color(0xFF9CCAFF)
+val onPrimaryDark = Color(0xFF003257)
+val primaryContainerDark = Color(0xFF00568E)
+val onPrimaryContainerDark = Color(0xFFF1F5FF)
+val secondaryDark = Color(0xFFB2C9E5)
+val onSecondaryDark = Color(0xFF1B3249)
+val secondaryContainerDark = Color(0xFF2B4159)
+val onSecondaryContainerDark = Color(0xFFC0D7F4)
+val tertiaryDark = Color(0xFFECB1FF)
+val onTertiaryDark = Color(0xFF4A195E)
+val tertiaryContainerDark = Color(0xFF713F84)
+val onTertiaryContainerDark = Color(0xFFFFF2FD)
+val errorDark = Color(0xFFFFB4AB)
+val onErrorDark = Color(0xFF690005)
+val errorContainerDark = Color(0xFF93000A)
+val onErrorContainerDark = Color(0xFFFFDAD6)
+val backgroundDark = Color(0xFF111417)
+val onBackgroundDark = Color(0xFFE1E2E8)
+val surfaceDark = Color(0xFF111417)
+val onSurfaceDark = Color(0xFFE1E2E8)
+val surfaceVariantDark = Color(0xFF414750)
+val onSurfaceVariantDark = Color(0xFFC1C7D1)
+val outlineDark = Color(0xFF8B919B)
+val outlineVariantDark = Color(0xFF414750)
+val scrimDark = Color(0xFF000000)
+val inverseSurfaceDark = Color(0xFFE1E2E8)
+val inverseOnSurfaceDark = Color(0xFF2E3135)
+val inversePrimaryDark = Color(0xFF10629E)
+val surfaceDimDark = Color(0xFF111417)
+val surfaceBrightDark = Color(0xFF37393E)
+val surfaceContainerLowestDark = Color(0xFF0B0E12)
+val surfaceContainerLowDark = Color(0xFF191C20)
+val surfaceContainerDark = Color(0xFF1D2024)
+val surfaceContainerHighDark = Color(0xFF272A2E)
+val surfaceContainerHighestDark = Color(0xFF323539)
+
+val primaryDarkMediumContrast = Color(0xFFA4CEFF)
+val onPrimaryDarkMediumContrast = Color(0xFF00172C)
+val primaryContainerDarkMediumContrast = Color(0xFF5595D4)
+val onPrimaryContainerDarkMediumContrast = Color(0xFF000000)
+val secondaryDarkMediumContrast = Color(0xFFB6CDEA)
+val onSecondaryDarkMediumContrast = Color(0xFF00172C)
+val secondaryContainerDarkMediumContrast = Color(0xFF7D93AE)
+val onSecondaryContainerDarkMediumContrast = Color(0xFF000000)
+val tertiaryDarkMediumContrast = Color(0xFFEEB7FF)
+val onTertiaryDarkMediumContrast = Color(0xFF2A003B)
+val tertiaryContainerDarkMediumContrast = Color(0xFFB37CC6)
+val onTertiaryContainerDarkMediumContrast = Color(0xFF000000)
+val errorDarkMediumContrast = Color(0xFFFFBAB1)
+val onErrorDarkMediumContrast = Color(0xFF370001)
+val errorContainerDarkMediumContrast = Color(0xFFFF5449)
+val onErrorContainerDarkMediumContrast = Color(0xFF000000)
+val backgroundDarkMediumContrast = Color(0xFF111417)
+val onBackgroundDarkMediumContrast = Color(0xFFE1E2E8)
+val surfaceDarkMediumContrast = Color(0xFF111417)
+val onSurfaceDarkMediumContrast = Color(0xFFFAFAFF)
+val surfaceVariantDarkMediumContrast = Color(0xFF414750)
+val onSurfaceVariantDarkMediumContrast = Color(0xFFC5CBD6)
+val outlineDarkMediumContrast = Color(0xFF9DA3AD)
+val outlineVariantDarkMediumContrast = Color(0xFF7D848D)
+val scrimDarkMediumContrast = Color(0xFF000000)
+val inverseSurfaceDarkMediumContrast = Color(0xFFE1E2E8)
+val inverseOnSurfaceDarkMediumContrast = Color(0xFF272A2E)
+val inversePrimaryDarkMediumContrast = Color(0xFF004B7D)
+val surfaceDimDarkMediumContrast = Color(0xFF111417)
+val surfaceBrightDarkMediumContrast = Color(0xFF37393E)
+val surfaceContainerLowestDarkMediumContrast = Color(0xFF0B0E12)
+val surfaceContainerLowDarkMediumContrast = Color(0xFF191C20)
+val surfaceContainerDarkMediumContrast = Color(0xFF1D2024)
+val surfaceContainerHighDarkMediumContrast = Color(0xFF272A2E)
+val surfaceContainerHighestDarkMediumContrast = Color(0xFF323539)
+
+val primaryDarkHighContrast = Color(0xFFFAFAFF)
+val onPrimaryDarkHighContrast = Color(0xFF000000)
+val primaryContainerDarkHighContrast = Color(0xFFA4CEFF)
+val onPrimaryContainerDarkHighContrast = Color(0xFF000000)
+val secondaryDarkHighContrast = Color(0xFFFAFAFF)
+val onSecondaryDarkHighContrast = Color(0xFF000000)
+val secondaryContainerDarkHighContrast = Color(0xFFB6CDEA)
+val onSecondaryContainerDarkHighContrast = Color(0xFF000000)
+val tertiaryDarkHighContrast = Color(0xFFFFF9FA)
+val onTertiaryDarkHighContrast = Color(0xFF000000)
+val tertiaryContainerDarkHighContrast = Color(0xFFEEB7FF)
+val onTertiaryContainerDarkHighContrast = Color(0xFF000000)
+val errorDarkHighContrast = Color(0xFFFFF9F9)
+val onErrorDarkHighContrast = Color(0xFF000000)
+val errorContainerDarkHighContrast = Color(0xFFFFBAB1)
+val onErrorContainerDarkHighContrast = Color(0xFF000000)
+val backgroundDarkHighContrast = Color(0xFF111417)
+val onBackgroundDarkHighContrast = Color(0xFFE1E2E8)
+val surfaceDarkHighContrast = Color(0xFF111417)
+val onSurfaceDarkHighContrast = Color(0xFFFFFFFF)
+val surfaceVariantDarkHighContrast = Color(0xFF414750)
+val onSurfaceVariantDarkHighContrast = Color(0xFFFAFAFF)
+val outlineDarkHighContrast = Color(0xFFC5CBD6)
+val outlineVariantDarkHighContrast = Color(0xFFC5CBD6)
+val scrimDarkHighContrast = Color(0xFF000000)
+val inverseSurfaceDarkHighContrast = Color(0xFFE1E2E8)
+val inverseOnSurfaceDarkHighContrast = Color(0xFF000000)
+val inversePrimaryDarkHighContrast = Color(0xFF002C4C)
+val surfaceDimDarkHighContrast = Color(0xFF111417)
+val surfaceBrightDarkHighContrast = Color(0xFF37393E)
+val surfaceContainerLowestDarkHighContrast = Color(0xFF0B0E12)
+val surfaceContainerLowDarkHighContrast = Color(0xFF191C20)
+val surfaceContainerDarkHighContrast = Color(0xFF1D2024)
+val surfaceContainerHighDarkHighContrast = Color(0xFF272A2E)
+val surfaceContainerHighestDarkHighContrast = Color(0xFF323539)
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/ui/theme/Theme.kt b/app/src/main/java/fr/free/nrw/commons/ui/theme/Theme.kt
new file mode 100644
index 0000000000..d77f3864f2
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/ui/theme/Theme.kt
@@ -0,0 +1,111 @@
+package fr.free.nrw.commons.ui.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val lightScheme = lightColorScheme(
+ primary = primaryLight,
+ onPrimary = onPrimaryLight,
+ primaryContainer = primaryContainerLight,
+ onPrimaryContainer = onPrimaryContainerLight,
+ secondary = secondaryLight,
+ onSecondary = onSecondaryLight,
+ secondaryContainer = secondaryContainerLight,
+ onSecondaryContainer = onSecondaryContainerLight,
+ tertiary = tertiaryLight,
+ onTertiary = onTertiaryLight,
+ tertiaryContainer = tertiaryContainerLight,
+ onTertiaryContainer = onTertiaryContainerLight,
+ error = errorLight,
+ onError = onErrorLight,
+ errorContainer = errorContainerLight,
+ onErrorContainer = onErrorContainerLight,
+ background = backgroundLight,
+ onBackground = onBackgroundLight,
+ surface = surfaceLight,
+ onSurface = onSurfaceLight,
+ surfaceVariant = surfaceVariantLight,
+ onSurfaceVariant = onSurfaceVariantLight,
+ outline = outlineLight,
+ outlineVariant = outlineVariantLight,
+ scrim = scrimLight,
+ inverseSurface = inverseSurfaceLight,
+ inverseOnSurface = inverseOnSurfaceLight,
+ inversePrimary = inversePrimaryLight,
+ surfaceDim = surfaceDimLight,
+ surfaceBright = surfaceBrightLight,
+ surfaceContainerLowest = surfaceContainerLowestLight,
+ surfaceContainerLow = surfaceContainerLowLight,
+ surfaceContainer = surfaceContainerLight,
+ surfaceContainerHigh = surfaceContainerHighLight,
+ surfaceContainerHighest = surfaceContainerHighestLight,
+)
+
+private val darkScheme = darkColorScheme(
+ primary = primaryDark,
+ onPrimary = onPrimaryDark,
+ primaryContainer = primaryContainerDark,
+ onPrimaryContainer = onPrimaryContainerDark,
+ secondary = secondaryDark,
+ onSecondary = onSecondaryDark,
+ secondaryContainer = secondaryContainerDark,
+ onSecondaryContainer = onSecondaryContainerDark,
+ tertiary = tertiaryDark,
+ onTertiary = onTertiaryDark,
+ tertiaryContainer = tertiaryContainerDark,
+ onTertiaryContainer = onTertiaryContainerDark,
+ error = errorDark,
+ onError = onErrorDark,
+ errorContainer = errorContainerDark,
+ onErrorContainer = onErrorContainerDark,
+ background = backgroundDark,
+ onBackground = onBackgroundDark,
+ surface = surfaceDark,
+ onSurface = onSurfaceDark,
+ surfaceVariant = surfaceVariantDark,
+ onSurfaceVariant = onSurfaceVariantDark,
+ outline = outlineDark,
+ outlineVariant = outlineVariantDark,
+ scrim = scrimDark,
+ inverseSurface = inverseSurfaceDark,
+ inverseOnSurface = inverseOnSurfaceDark,
+ inversePrimary = inversePrimaryDark,
+ surfaceDim = surfaceDimDark,
+ surfaceBright = surfaceBrightDark,
+ surfaceContainerLowest = surfaceContainerLowestDark,
+ surfaceContainerLow = surfaceContainerLowDark,
+ surfaceContainer = surfaceContainerDark,
+ surfaceContainerHigh = surfaceContainerHighDark,
+ surfaceContainerHighest = surfaceContainerHighestDark,
+)
+
+@Composable
+fun CommonsTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = false, /* TODO("Enable this when app is ready for dynamic colors") */
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> darkScheme
+ else -> lightScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/ui/theme/Type.kt b/app/src/main/java/fr/free/nrw/commons/ui/theme/Type.kt
new file mode 100644
index 0000000000..a3046ea97a
--- /dev/null
+++ b/app/src/main/java/fr/free/nrw/commons/ui/theme/Type.kt
@@ -0,0 +1,33 @@
+package fr.free.nrw.commons.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt
index fc80252fc9..250d81f678 100644
--- a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt
+++ b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt
@@ -4,7 +4,7 @@ import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.ui.selector.ImageLoader
import fr.free.nrw.commons.filepicker.PickedFiles
import fr.free.nrw.commons.media.MediaClient
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/helper/ImageHelperTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/helper/ImageHelperTest.kt
index d8b501522d..2fc73c2ad2 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/helper/ImageHelperTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/helper/ImageHelperTest.kt
@@ -1,8 +1,8 @@
package fr.free.nrw.commons.customselector.helper
import android.net.Uri
-import fr.free.nrw.commons.customselector.model.Folder
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Folder
+import fr.free.nrw.commons.customselector.domain.model.Image
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.mockito.Mockito.mock
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapterTest.kt
index 08dadca25a..5ee286468e 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapterTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/FolderAdapterTest.kt
@@ -11,8 +11,8 @@ import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.customselector.listeners.FolderClickListener
-import fr.free.nrw.commons.customselector.model.Folder
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Folder
+import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity
import org.junit.Before
import org.junit.Test
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt
index 2a4c8c7915..d604bd06a3 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/adapter/ImageAdapterTest.kt
@@ -11,7 +11,7 @@ import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity
import fr.free.nrw.commons.customselector.ui.selector.ImageLoader
import kotlinx.coroutines.Dispatchers
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivityTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivityTest.kt
index b1d66ee4d3..760e772577 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivityTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivityTest.kt
@@ -9,7 +9,7 @@ import fr.free.nrw.commons.OkHttpConnectionFactory
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.createTestClient
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter
import org.junit.Before
import org.junit.Test
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt
index 49da532591..e50442e32e 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/FolderFragmentTest.kt
@@ -16,8 +16,8 @@ import fr.free.nrw.commons.OkHttpConnectionFactory
import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.createTestClient
-import fr.free.nrw.commons.customselector.model.CallbackStatus
-import fr.free.nrw.commons.customselector.model.Result
+import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
+import fr.free.nrw.commons.customselector.domain.model.Result
import fr.free.nrw.commons.customselector.ui.adapter.FolderAdapter
import org.junit.Before
import org.junit.Test
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt
index eeb6db46a5..5cf5c19669 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageFragmentTest.kt
@@ -20,9 +20,9 @@ import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.createTestClient
-import fr.free.nrw.commons.customselector.model.CallbackStatus
-import fr.free.nrw.commons.customselector.model.Image
-import fr.free.nrw.commons.customselector.model.Result
+import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
+import fr.free.nrw.commons.customselector.domain.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Result
import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter
import org.junit.Before
import org.junit.Test
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt
index 64447384b8..0ae53c858b 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/customselector/ui/selector/ImageLoaderTest.kt
@@ -11,7 +11,7 @@ import fr.free.nrw.commons.TestUtility.setFinalStatic
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.database.UploadedStatus
import fr.free.nrw.commons.customselector.database.UploadedStatusDao
-import fr.free.nrw.commons.customselector.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter
import fr.free.nrw.commons.filepicker.PickedFiles
import fr.free.nrw.commons.filepicker.UploadableFile
diff --git a/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt b/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt
index 848e0881aa..f8c387c9ff 100644
--- a/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt
+++ b/app/src/test/kotlin/fr/free/nrw/commons/media/ZoomableActivityUnitTests.kt
@@ -9,9 +9,9 @@ import com.facebook.soloader.SoLoader
import fr.free.nrw.commons.OkHttpConnectionFactory
import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.createTestClient
-import fr.free.nrw.commons.customselector.model.CallbackStatus
-import fr.free.nrw.commons.customselector.model.Image
-import fr.free.nrw.commons.customselector.model.Result
+import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
+import fr.free.nrw.commons.customselector.domain.model.Image
+import fr.free.nrw.commons.customselector.domain.model.Result
import org.junit.Assert
import org.junit.Before
import org.junit.Test