This commit is contained in:
Rohit Verma 2025-06-23 18:49:09 +05:30 committed by GitHub
commit 58e05ae4de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2167 additions and 170 deletions

View file

@ -1,3 +1,4 @@
import org.gradle.kotlin.dsl.implementation
import java.util.Properties import java.util.Properties
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@ -239,6 +240,13 @@ dependencies {
implementation(libs.dexter) implementation(libs.dexter)
// Jetpack Compose // Jetpack Compose
implementation(libs.androidx.adaptive)
implementation(libs.androidx.adaptive.layout.android)
implementation(libs.androidx.adaptive.navigation.android)
implementation(libs.coil.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)

View file

@ -148,6 +148,7 @@
android:label="@string/result" /> android:label="@string/result" />
<activity <activity
android:name=".customselector.ui.selector.CustomSelectorActivity" android:name=".customselector.ui.selector.CustomSelectorActivity"
android:windowSoftInputMode="adjustResize"
android:configChanges="screenSize|keyboard|orientation" android:configChanges="screenSize|keyboard|orientation"
android:label="@string/title_activity_custom_selector" android:label="@string/title_activity_custom_selector"
android:parentActivityName=".contributions.MainActivity" /> android:parentActivityName=".contributions.MainActivity" />

View file

@ -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<Image> {
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
}
}

View file

@ -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)
}

View file

@ -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<Image>
suspend fun markAsNotForUpload(imageSHA: String)
suspend fun unmarkAsNotForUpload(imageSHA: String)
suspend fun isNotForUpload(imageSHA: String): Boolean
}

View file

@ -1,4 +1,4 @@
package fr.free.nrw.commons.customselector.model package fr.free.nrw.commons.customselector.domain.model
/** /**
* sealed class Callback Status. * sealed class Callback Status.

View file

@ -1,4 +1,4 @@
package fr.free.nrw.commons.customselector.model package fr.free.nrw.commons.customselector.domain.model
/** /**
* Custom selector data class Folder. * Custom selector data class Folder.

View file

@ -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.net.Uri
import android.os.Parcel import android.os.Parcel

View file

@ -1,4 +1,4 @@
package fr.free.nrw.commons.customselector.model package fr.free.nrw.commons.customselector.domain.model
/** /**
* Custom selector data class Result. * Custom selector data class Result.

View file

@ -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.getFilePath()))
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
}
}
}

View file

@ -1,7 +1,7 @@
package fr.free.nrw.commons.customselector.helper package fr.free.nrw.commons.customselector.helper
import fr.free.nrw.commons.customselector.model.Folder import fr.free.nrw.commons.customselector.domain.model.Folder
import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.domain.model.Image
/** /**
* Image Helper object, includes all the static functions and variables required by custom selector. * Image Helper object, includes all the static functions and variables required by custom selector.

View file

@ -1,6 +1,6 @@
package fr.free.nrw.commons.customselector.listeners 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 * Custom Selector Image Loader Listener

View file

@ -1,6 +1,6 @@
package fr.free.nrw.commons.customselector.listeners 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 * Custom selector Image select listener

View file

@ -1,6 +1,6 @@
package fr.free.nrw.commons.customselector.listeners 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 * Interface to pass data between fragment and activity

View file

@ -10,8 +10,8 @@ import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.model.Folder import fr.free.nrw.commons.customselector.domain.model.Folder
import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.domain.model.Image
/** /**
* Custom selector FolderAdapter. * Custom selector FolderAdapter.

View file

@ -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.CUSTOM_SELECTOR_PREFERENCE_KEY
import fr.free.nrw.commons.customselector.helper.ImageHelper.SHOW_ALREADY_ACTIONED_IMAGES_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.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 fr.free.nrw.commons.customselector.ui.selector.ImageLoader
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope

View file

@ -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)
)
}
}
}

View file

@ -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()
)
}
}
}

View file

@ -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
)
}
}
}

View file

@ -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()
)
}
}
}

View file

@ -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<Long>): CustomSelectorEvent
data object OnUnselectAll: CustomSelectorEvent
data class OnUpdateImageStatus(val scope: CoroutineScope, val image: ImageUiState) : CustomSelectorEvent
data object MarkAsNotForUpload: CustomSelectorEvent
data object UnmarkAsNotForUpload: CustomSelectorEvent
}

View file

@ -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<Long>,
onViewImage: (id: Long)-> Unit,
hasPartialAccess: Boolean = false
) {
val adaptiveInfo = currentWindowAdaptiveInfo()
val navigator = rememberListDetailPaneScaffoldNavigator<Folder>()
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
)
}
}

View file

@ -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<imageId, imageSHA>()
private val allImages = mutableListOf<ImageUiState>()
private val foldersMap = mutableMapOf<Long, MutableList<Image>>()
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)
}
}

View file

@ -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<Image>,
val itemsCount: Int
): Parcelable

View file

@ -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<Long>,
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<ImageUiState>,
selectedImageIds: () -> Set<Long>,
autoScrollThreshold: Float,
setSelectedImageIds: (Set<Long>) -> 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<ImageUiState>.getImageIdsInRange(initialKey: Int, pointerKey: Int): Set<Long> {
val setOfKeys = mutableSetOf<Long>()
if (initialKey < pointerKey) {
(initialKey..pointerKey).forEach {
setOfKeys.add(this[it].id)
}
} else {
(pointerKey..initialKey).forEach {
setOfKeys.add(this[it].id)
}
}
return setOfKeys
}

View file

@ -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<ImageUiState>,
) {
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))
)
}

View file

@ -6,6 +6,7 @@ import android.app.Dialog
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
@ -14,54 +15,41 @@ import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.PopupMenu import android.widget.PopupMenu
import android.widget.TextView import android.widget.TextView
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue 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.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider 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.R
import fr.free.nrw.commons.customselector.database.NotForUploadStatus import fr.free.nrw.commons.customselector.database.NotForUploadStatus
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao 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.CustomSelectorConstants
import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper
import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener 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.ActivityCustomSelectorBinding
import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding
import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding
import fr.free.nrw.commons.media.ZoomableActivity import fr.free.nrw.commons.media.ZoomableActivity
import fr.free.nrw.commons.theme.BaseActivity 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.upload.FileUtilsWrapper
import fr.free.nrw.commons.utils.CustomSelectorUtils import fr.free.nrw.commons.utils.CustomSelectorUtils
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.lang.Integer.max import java.lang.Integer.max
import javax.inject.Inject import javax.inject.Inject
@ -171,6 +159,7 @@ class CustomSelectorActivity :
* onCreate Activity, sets theme, initialises the view model, setup view. * onCreate Activity, sets theme, initialises the view model, setup view.
*/ */
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
ContextCompat.checkSelfPermission( ContextCompat.checkSelfPermission(
@ -181,36 +170,63 @@ class CustomSelectorActivity :
showPartialAccessIndicator = true showPartialAccessIndicator = true
} }
binding = ActivityCustomSelectorBinding.inflate(layoutInflater) // binding = ActivityCustomSelectorBinding.inflate(layoutInflater)
toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root) // toolbarBinding = CustomSelectorToolbarBinding.bind(binding.root)
bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root) // bottomSheetBinding = CustomSelectorBottomLayoutBinding.bind(binding.root)
binding.partialAccessIndicator.setContent { // binding.partialAccessIndicator.setContent {
partialStorageAccessIndicator( // PartialStorageAccessDialog(
isVisible = showPartialAccessIndicator, // isVisible = showPartialAccessIndicator,
onManage = { // onManage = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_IMAGES), 1) // requestPermissions(arrayOf(Manifest.permission.READ_MEDIA_IMAGES), 1)
} // }
}, // },
modifier = // modifier = Modifier
Modifier // .padding(vertical = 8.dp, horizontal = 4.dp)
.padding(vertical = 8.dp, horizontal = 4.dp) // .fillMaxWidth()
.fillMaxWidth(), // )
) // }
} // val view = binding.root
val view = binding.root // setContentView(view)
setContentView(view)
prefs = applicationContext.getSharedPreferences("CustomSelector", MODE_PRIVATE) prefs = applicationContext.getSharedPreferences("CustomSelector", MODE_PRIVATE)
viewModel =
ViewModelProvider(this, customSelectorViewModelFactory).get( setContent {
CustomSelectorViewModel::class.java, val csViewModel = ViewModelProvider(this, customSelectorViewModelFactory).get(
fr.free.nrw.commons.customselector.ui.screens.CustomSelectorViewModel::class.java
) )
// Check for single selection extra val uiState by csViewModel.uiState.collectAsStateWithLifecycle()
uploadLimit = if (intent.getBooleanExtra(EXTRA_SINGLE_SELECTION, false)) 1 else 20
setupViews() 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)) { if (prefs.getBoolean("customSelectorFirstLaunch", true)) {
// show welcome dialog on first launch // show welcome dialog on first launch
@ -242,7 +258,7 @@ class CustomSelectorActivity :
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
fetchData() // fetchData()
} }
/** /**
@ -671,23 +687,6 @@ class CustomSelectorActivity :
finish() 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. * Displays a dialog explaining the upload limit warning.
*/ */
@ -734,59 +733,3 @@ class CustomSelectorActivity :
const val EXTRA_SINGLE_SELECTION: String = "EXTRA_SINGLE_SELECTION" const val EXTRA_SINGLE_SELECTION: String = "EXTRA_SINGLE_SELECTION"
} }
} }
@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(),
)
}
}

View file

@ -4,9 +4,9 @@ import android.content.Context
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener
import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.model.Result import fr.free.nrw.commons.customselector.domain.model.Result
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel

View file

@ -10,9 +10,9 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import fr.free.nrw.commons.customselector.helper.ImageHelper import fr.free.nrw.commons.customselector.helper.ImageHelper
import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Folder import fr.free.nrw.commons.customselector.domain.model.Folder
import fr.free.nrw.commons.customselector.model.Result import fr.free.nrw.commons.customselector.domain.model.Result
import fr.free.nrw.commons.customselector.ui.adapter.FolderAdapter import fr.free.nrw.commons.customselector.ui.adapter.FolderAdapter
import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment import fr.free.nrw.commons.di.CommonsDaggerSupportFragment

View file

@ -5,7 +5,7 @@ import android.content.Context
import android.provider.MediaStore import android.provider.MediaStore
import android.text.format.DateFormat import android.text.format.DateFormat
import fr.free.nrw.commons.customselector.listeners.ImageLoaderListener 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch

View file

@ -1,7 +1,6 @@
package fr.free.nrw.commons.customselector.ui.selector package fr.free.nrw.commons.customselector.ui.selector
import android.app.Activity import android.app.Activity
import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
@ -30,15 +29,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.ImageSelectListener
import fr.free.nrw.commons.customselector.listeners.PassDataListener import fr.free.nrw.commons.customselector.listeners.PassDataListener
import fr.free.nrw.commons.customselector.listeners.RefreshUIListener import fr.free.nrw.commons.customselector.listeners.RefreshUIListener
import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.model.Result import fr.free.nrw.commons.customselector.domain.model.Result
import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter
import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding import fr.free.nrw.commons.databinding.FragmentCustomSelectorBinding
import fr.free.nrw.commons.databinding.ProgressDialogBinding import fr.free.nrw.commons.databinding.ProgressDialogBinding
import fr.free.nrw.commons.di.CommonsDaggerSupportFragment import fr.free.nrw.commons.di.CommonsDaggerSupportFragment
import fr.free.nrw.commons.media.MediaClient 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.FileProcessor
import fr.free.nrw.commons.upload.FileUtilsWrapper import fr.free.nrw.commons.upload.FileUtilsWrapper
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers

View file

@ -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.UploadedStatus
import fr.free.nrw.commons.customselector.database.UploadedStatusDao import fr.free.nrw.commons.customselector.database.UploadedStatusDao
import fr.free.nrw.commons.customselector.helper.ImageHelper 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.customselector.ui.adapter.ImageAdapter.ImageViewHolder
import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.MediaClient
import fr.free.nrw.commons.upload.FileProcessor 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 fr.free.nrw.commons.utils.CustomSelectorUtils.Companion.checkWhetherFileExistsOnCommonsUsingSHA1
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Calendar import java.util.Calendar

View file

@ -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<Folder> = emptyList(),
val filteredImages: List<ImageUiState> = emptyList(),
val selectedImageIds: Set<Long> = emptySet(),
val imagesNotForUpload: Map<imageId, isNotForUpload> = emptyMap()
) {
val inSelectionMode: Boolean
get() = selectedImageIds.isNotEmpty()
}

View file

@ -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
)

View file

@ -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 <CustomSelectorViewModel : ViewModel> create(
modelClass: Class<CustomSelectorViewModel>
): CustomSelectorViewModel {
return CustomSelectorViewModel(
imageRepository, imageUseCase
) as CustomSelectorViewModel
}
}

View file

@ -19,8 +19,11 @@ import fr.free.nrw.commons.auth.SessionManager
import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao import fr.free.nrw.commons.bookmarks.category.BookmarkCategoriesDao
import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao import fr.free.nrw.commons.bookmarks.locations.BookmarkLocationsDao
import fr.free.nrw.commons.contributions.ContributionDao 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.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.database.UploadedStatusDao import fr.free.nrw.commons.customselector.database.UploadedStatusDao
import fr.free.nrw.commons.customselector.domain.ImageRepository
import fr.free.nrw.commons.customselector.ui.selector.ImageFileLoader import fr.free.nrw.commons.customselector.ui.selector.ImageFileLoader
import fr.free.nrw.commons.data.DBOpenHelper import fr.free.nrw.commons.data.DBOpenHelper
import fr.free.nrw.commons.db.AppDatabase import fr.free.nrw.commons.db.AppDatabase
@ -240,6 +243,14 @@ open class CommonsApplicationModule(private val applicationContext: Context) {
fun providesContentResolver(context: Context): ContentResolver = fun providesContentResolver(context: Context): ContentResolver =
context.contentResolver context.contentResolver
@Provides
fun providesImageRepository(
mediaReader: MediaReader,
notForUploadStatusDao: NotForUploadStatusDao
): ImageRepository {
return ImageRepositoryImpl(mediaReader, notForUploadStatusDao)
}
@Provides @Provides
fun provideTimeProvider(): TimeProvider { fun provideTimeProvider(): TimeProvider {
return TimeProvider(System::currentTimeMillis) return TimeProvider(System::currentTimeMillis)

View file

@ -9,7 +9,7 @@ import android.provider.MediaStore
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.preference.PreferenceManager 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 fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity
import fr.free.nrw.commons.filepicker.PickedFiles.singleFileList import fr.free.nrw.commons.filepicker.PickedFiles.singleFileList
import java.io.File import java.io.File

View file

@ -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.CustomSelectorConstants.SHOULD_REFRESH
import fr.free.nrw.commons.customselector.helper.ImageHelper import fr.free.nrw.commons.customselector.helper.ImageHelper
import fr.free.nrw.commons.customselector.helper.OnSwipeTouchListener import fr.free.nrw.commons.customselector.helper.OnSwipeTouchListener
import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.model.Result 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.CustomSelectorViewModel
import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorViewModelFactory import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorViewModelFactory
import fr.free.nrw.commons.databinding.ActivityZoomableBinding import fr.free.nrw.commons.databinding.ActivityZoomableBinding

View file

@ -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)

View file

@ -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
)
}

View file

@ -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
)
*/
)

View file

@ -4,7 +4,7 @@ import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.exifinterface.media.ExifInterface 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.customselector.ui.selector.ImageLoader
import fr.free.nrw.commons.filepicker.PickedFiles import fr.free.nrw.commons.filepicker.PickedFiles
import fr.free.nrw.commons.media.MediaClient import fr.free.nrw.commons.media.MediaClient

View file

@ -1,8 +1,8 @@
package fr.free.nrw.commons.customselector.helper package fr.free.nrw.commons.customselector.helper
import android.net.Uri import android.net.Uri
import fr.free.nrw.commons.customselector.model.Folder import fr.free.nrw.commons.customselector.domain.model.Folder
import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.domain.model.Image
import org.junit.Test import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.mockito.Mockito.mock import org.mockito.Mockito.mock

View file

@ -11,8 +11,8 @@ import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.model.Folder import fr.free.nrw.commons.customselector.domain.model.Folder
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.CustomSelectorActivity
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test

View file

@ -11,7 +11,7 @@ import com.nhaarman.mockitokotlin2.whenever
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener 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.CustomSelectorActivity
import fr.free.nrw.commons.customselector.ui.selector.ImageLoader import fr.free.nrw.commons.customselector.ui.selector.ImageLoader
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers

View file

@ -9,7 +9,7 @@ import fr.free.nrw.commons.OkHttpConnectionFactory
import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.contributions.ContributionDao import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.createTestClient 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 fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test

View file

@ -16,8 +16,8 @@ import fr.free.nrw.commons.OkHttpConnectionFactory
import fr.free.nrw.commons.R import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.createTestClient import fr.free.nrw.commons.createTestClient
import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Result import fr.free.nrw.commons.customselector.domain.model.Result
import fr.free.nrw.commons.customselector.ui.adapter.FolderAdapter import fr.free.nrw.commons.customselector.ui.adapter.FolderAdapter
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test

View file

@ -20,9 +20,9 @@ import fr.free.nrw.commons.R
import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.contributions.ContributionDao import fr.free.nrw.commons.contributions.ContributionDao
import fr.free.nrw.commons.createTestClient import fr.free.nrw.commons.createTestClient
import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.model.Result import fr.free.nrw.commons.customselector.domain.model.Result
import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter import fr.free.nrw.commons.customselector.ui.adapter.ImageAdapter
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test

View file

@ -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.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.database.UploadedStatus import fr.free.nrw.commons.customselector.database.UploadedStatus
import fr.free.nrw.commons.customselector.database.UploadedStatusDao 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.customselector.ui.adapter.ImageAdapter
import fr.free.nrw.commons.filepicker.PickedFiles import fr.free.nrw.commons.filepicker.PickedFiles
import fr.free.nrw.commons.filepicker.UploadableFile import fr.free.nrw.commons.filepicker.UploadableFile

View file

@ -9,9 +9,9 @@ import com.facebook.soloader.SoLoader
import fr.free.nrw.commons.OkHttpConnectionFactory import fr.free.nrw.commons.OkHttpConnectionFactory
import fr.free.nrw.commons.TestCommonsApplication import fr.free.nrw.commons.TestCommonsApplication
import fr.free.nrw.commons.createTestClient import fr.free.nrw.commons.createTestClient
import fr.free.nrw.commons.customselector.model.CallbackStatus import fr.free.nrw.commons.customselector.domain.model.CallbackStatus
import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.customselector.domain.model.Image
import fr.free.nrw.commons.customselector.model.Result import fr.free.nrw.commons.customselector.domain.model.Result
import org.junit.Assert import org.junit.Assert
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test

View file

@ -1,4 +1,5 @@
[versions] [versions]
adaptive = "1.0.0"
agp = "8.9.1" agp = "8.9.1"
acra = "5.8.4" acra = "5.8.4"
activityCompose = "1.9.3" activityCompose = "1.9.3"
@ -10,12 +11,14 @@ androidxJunitVersion = "1.1.5"
annotation = "1.3.0" annotation = "1.3.0"
browser = "1.3.0" browser = "1.3.0"
cardview = "1.0.0" cardview = "1.0.0"
coilCompose = "2.6.0"
commonsIo = "2.6" commonsIo = "2.6"
composeBom = "2024.11.00" composeBom = "2024.11.00"
constraintlayout = "1.1.3" constraintlayout = "1.1.3"
coordinates2country = "1.8" coordinates2country = "1.8"
dexcount = "4.0.0" dexcount = "4.0.0"
githubTripletPlay = "2.7.2" githubTripletPlay = "2.7.2"
navigationCompose = "2.8.3"
osmdroidAndroid = "6.1.17" osmdroidAndroid = "6.1.17"
testCore = "1.4.0" testCore = "1.4.0"
coreKtx = "1.9.0" coreKtx = "1.9.0"
@ -73,11 +76,15 @@ workManager = "2.8.1"
[libraries] [libraries]
# AndroidX Core Dependencies # AndroidX Core Dependencies
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-adaptive-navigation-android = { module = "androidx.compose.material3.adaptive:adaptive-navigation-android", version.ref = "adaptive" }
androidx-adaptive-layout-android = { module = "androidx.compose.material3.adaptive:adaptive-layout-android", version.ref = "adaptive" }
androidx-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" }
androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" } androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "annotation" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-test-core = { module = "androidx.test:core", version.ref = "testCore" } androidx-test-core = { module = "androidx.test:core", version.ref = "testCore" }
androidx-databinding-compiler = { module = "androidx.databinding:databinding-compiler", version.ref = "databindingCompiler" } androidx-databinding-compiler = { module = "androidx.databinding:databinding-compiler", version.ref = "databindingCompiler" }
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" } androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" }
@ -118,6 +125,7 @@ androidx-work-runtime = { module = "androidx.work:work-runtime", version.ref = "
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workManager" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workManager" }
# Dependency injection (DI) # Dependency injection (DI)
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
dagger-android-processor = { module = "com.google.dagger:dagger-android-processor", version.ref = "dagger" } dagger-android-processor = { module = "com.google.dagger:dagger-android-processor", version.ref = "dagger" }
dagger-android-support = { module = "com.google.dagger:dagger-android-support", version.ref = "dagger" } dagger-android-support = { module = "com.google.dagger:dagger-android-support", version.ref = "dagger" }
dagger-android = { module = "com.google.dagger:dagger-android", version.ref = "dagger" } dagger-android = { module = "com.google.dagger:dagger-android", version.ref = "dagger" }