diff --git a/core/ui/src/main/java/com/shifthackz/aisdv1/core/extensions/ShakeExtensions.kt b/core/ui/src/main/java/com/shifthackz/aisdv1/core/extensions/ShakeExtensions.kt new file mode 100644 index 00000000..c092424a --- /dev/null +++ b/core/ui/src/main/java/com/shifthackz/aisdv1/core/extensions/ShakeExtensions.kt @@ -0,0 +1,54 @@ +package com.shifthackz.aisdv1.core.extensions + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.debugInspectorInfo + +fun Modifier.shake( + enabled: Boolean, + animationDurationMillis: Int = 167, + animationStartOffset: Int = 0, +) = this.composed( + factory = { + val infiniteTransition = rememberInfiniteTransition(label = "shake") + val scaleInfinite by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = .99f, + animationSpec = infiniteRepeatable( + animation = tween(animationDurationMillis, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(animationStartOffset), + ), + label = "shake", + ) + val rotation by infiniteTransition.animateFloat( + initialValue = -1.5f, + targetValue = 1.5f, + animationSpec = infiniteRepeatable( + animation = tween(animationDurationMillis, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + initialStartOffset = StartOffset(animationStartOffset), + ), + label = "shake", + ) + + Modifier.graphicsLayer { + scaleX = if (enabled) scaleInfinite else 1f + scaleY = if (enabled) scaleInfinite else 1f + rotationZ = if (enabled) rotation else 0f + } + }, + inspectorInfo = debugInspectorInfo { + name = "shake" + properties["enabled"] = enabled + }, +) diff --git a/data/src/main/java/com/shifthackz/aisdv1/data/local/GenerationResultLocalDataSource.kt b/data/src/main/java/com/shifthackz/aisdv1/data/local/GenerationResultLocalDataSource.kt index 99922e71..90c7bd57 100644 --- a/data/src/main/java/com/shifthackz/aisdv1/data/local/GenerationResultLocalDataSource.kt +++ b/data/src/main/java/com/shifthackz/aisdv1/data/local/GenerationResultLocalDataSource.kt @@ -28,7 +28,13 @@ internal class GenerationResultLocalDataSource( .queryById(id) .map(GenerationResultEntity::mapEntityToDomain) + override fun queryByIdList(idList: List) = dao + .queryByIdList(idList) + .map(List::mapEntityToDomain) + override fun deleteById(id: Long) = dao.deleteById(id) + override fun deleteByIdList(idList: List) = dao.deleteByIdList(idList) + override fun deleteAll() = dao.deleteAll() } diff --git a/data/src/main/java/com/shifthackz/aisdv1/data/repository/GenerationResultRepositoryImpl.kt b/data/src/main/java/com/shifthackz/aisdv1/data/repository/GenerationResultRepositoryImpl.kt index 2fb396c8..b6de9b8a 100644 --- a/data/src/main/java/com/shifthackz/aisdv1/data/repository/GenerationResultRepositoryImpl.kt +++ b/data/src/main/java/com/shifthackz/aisdv1/data/repository/GenerationResultRepositoryImpl.kt @@ -28,11 +28,15 @@ internal class GenerationResultRepositoryImpl( override fun getById(id: Long) = localDataSource.queryById(id) + override fun getByIds(idList: List) = localDataSource.queryByIdList(idList) + override fun insert(result: AiGenerationResult) = localDataSource .insert(result) .flatMap { id -> exportToMediaStore(result).andThen(Single.just(id)) } override fun deleteById(id: Long) = localDataSource.deleteById(id) + override fun deleteByIdList(idList: List) = localDataSource.deleteByIdList(idList) + override fun deleteAll() = localDataSource.deleteAll() } diff --git a/domain/src/main/java/com/shifthackz/aisdv1/domain/datasource/GenerationResultDataSource.kt b/domain/src/main/java/com/shifthackz/aisdv1/domain/datasource/GenerationResultDataSource.kt index 02facff9..8b28ef90 100644 --- a/domain/src/main/java/com/shifthackz/aisdv1/domain/datasource/GenerationResultDataSource.kt +++ b/domain/src/main/java/com/shifthackz/aisdv1/domain/datasource/GenerationResultDataSource.kt @@ -11,7 +11,9 @@ sealed interface GenerationResultDataSource { fun queryAll(): Single> fun queryPage(limit: Int, offset: Int): Single> fun queryById(id: Long): Single + fun queryByIdList(idList: List): Single> fun deleteById(id: Long): Completable + fun deleteByIdList(idList: List): Completable fun deleteAll(): Completable } } diff --git a/domain/src/main/java/com/shifthackz/aisdv1/domain/di/DomainModule.kt b/domain/src/main/java/com/shifthackz/aisdv1/domain/di/DomainModule.kt index 0e6dab0c..bccb6ebd 100755 --- a/domain/src/main/java/com/shifthackz/aisdv1/domain/di/DomainModule.kt +++ b/domain/src/main/java/com/shifthackz/aisdv1/domain/di/DomainModule.kt @@ -40,10 +40,16 @@ import com.shifthackz.aisdv1.domain.usecase.downloadable.GetLocalAiModelsUseCase import com.shifthackz.aisdv1.domain.usecase.downloadable.GetLocalAiModelsUseCaseImpl import com.shifthackz.aisdv1.domain.usecase.downloadable.ObserveLocalAiModelsUseCase import com.shifthackz.aisdv1.domain.usecase.downloadable.ObserveLocalAiModelsUseCaseImpl +import com.shifthackz.aisdv1.domain.usecase.gallery.DeleteAllGalleryUseCase +import com.shifthackz.aisdv1.domain.usecase.gallery.DeleteAllGalleryUseCaseImpl import com.shifthackz.aisdv1.domain.usecase.gallery.DeleteGalleryItemUseCase import com.shifthackz.aisdv1.domain.usecase.gallery.DeleteGalleryItemUseCaseImpl +import com.shifthackz.aisdv1.domain.usecase.gallery.DeleteGalleryItemsUseCase +import com.shifthackz.aisdv1.domain.usecase.gallery.DeleteGalleryItemsUseCaseImpl import com.shifthackz.aisdv1.domain.usecase.gallery.GetAllGalleryUseCase import com.shifthackz.aisdv1.domain.usecase.gallery.GetAllGalleryUseCaseImpl +import com.shifthackz.aisdv1.domain.usecase.gallery.GetGalleryItemsUseCase +import com.shifthackz.aisdv1.domain.usecase.gallery.GetGalleryItemsUseCaseImpl import com.shifthackz.aisdv1.domain.usecase.gallery.GetMediaStoreInfoUseCase import com.shifthackz.aisdv1.domain.usecase.gallery.GetMediaStoreInfoUseCaseImpl import com.shifthackz.aisdv1.domain.usecase.generation.GetGenerationResultPagedUseCase @@ -123,8 +129,11 @@ internal val useCasesModule = module { factoryOf(::SelectStableDiffusionModelUseCaseImpl) bind SelectStableDiffusionModelUseCase::class factoryOf(::GetGenerationResultPagedUseCaseImpl) bind GetGenerationResultPagedUseCase::class factoryOf(::GetAllGalleryUseCaseImpl) bind GetAllGalleryUseCase::class + factoryOf(::GetGalleryItemsUseCaseImpl) bind GetGalleryItemsUseCase::class factoryOf(::GetGenerationResultUseCaseImpl) bind GetGenerationResultUseCase::class factoryOf(::DeleteGalleryItemUseCaseImpl) bind DeleteGalleryItemUseCase::class + factoryOf(::DeleteGalleryItemsUseCaseImpl) bind DeleteGalleryItemsUseCase::class + factoryOf(::DeleteAllGalleryUseCaseImpl) bind DeleteAllGalleryUseCase::class factoryOf(::GetStableDiffusionSamplersUseCaseImpl) bind GetStableDiffusionSamplersUseCase::class factoryOf(::FetchAndGetLorasUseCaseImpl) bind FetchAndGetLorasUseCase::class factoryOf(::FetchAndGetHyperNetworksUseCaseImpl) bind FetchAndGetHyperNetworksUseCase::class diff --git a/domain/src/main/java/com/shifthackz/aisdv1/domain/repository/GenerationResultRepository.kt b/domain/src/main/java/com/shifthackz/aisdv1/domain/repository/GenerationResultRepository.kt index 2acbffc0..d469da63 100644 --- a/domain/src/main/java/com/shifthackz/aisdv1/domain/repository/GenerationResultRepository.kt +++ b/domain/src/main/java/com/shifthackz/aisdv1/domain/repository/GenerationResultRepository.kt @@ -15,9 +15,13 @@ interface GenerationResultRepository { fun getById(id: Long): Single + fun getByIds(idList: List): Single> + fun insert(result: AiGenerationResult): Single fun deleteById(id: Long): Completable + fun deleteByIdList(idList: List): Completable + fun deleteAll(): Completable } diff --git a/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteAllGalleryUseCase.kt b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteAllGalleryUseCase.kt new file mode 100644 index 00000000..7b50772e --- /dev/null +++ b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteAllGalleryUseCase.kt @@ -0,0 +1,7 @@ +package com.shifthackz.aisdv1.domain.usecase.gallery + +import io.reactivex.rxjava3.core.Completable + +interface DeleteAllGalleryUseCase { + operator fun invoke(): Completable +} diff --git a/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteAllGalleryUseCaseImpl.kt b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteAllGalleryUseCaseImpl.kt new file mode 100644 index 00000000..b0046b80 --- /dev/null +++ b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteAllGalleryUseCaseImpl.kt @@ -0,0 +1,11 @@ +package com.shifthackz.aisdv1.domain.usecase.gallery + +import com.shifthackz.aisdv1.domain.repository.GenerationResultRepository +import io.reactivex.rxjava3.core.Completable + +internal class DeleteAllGalleryUseCaseImpl( + private val generationResultRepository: GenerationResultRepository, +) : DeleteAllGalleryUseCase { + + override fun invoke(): Completable = generationResultRepository.deleteAll() +} diff --git a/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteGalleryItemsUseCase.kt b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteGalleryItemsUseCase.kt new file mode 100644 index 00000000..98e77aca --- /dev/null +++ b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteGalleryItemsUseCase.kt @@ -0,0 +1,7 @@ +package com.shifthackz.aisdv1.domain.usecase.gallery + +import io.reactivex.rxjava3.core.Completable + +interface DeleteGalleryItemsUseCase { + operator fun invoke(ids: List): Completable +} diff --git a/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteGalleryItemsUseCaseImpl.kt b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteGalleryItemsUseCaseImpl.kt new file mode 100644 index 00000000..4652b637 --- /dev/null +++ b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/DeleteGalleryItemsUseCaseImpl.kt @@ -0,0 +1,11 @@ +package com.shifthackz.aisdv1.domain.usecase.gallery + +import com.shifthackz.aisdv1.domain.repository.GenerationResultRepository +import io.reactivex.rxjava3.core.Completable + +internal class DeleteGalleryItemsUseCaseImpl( + private val generationResultRepository: GenerationResultRepository, +) : DeleteGalleryItemsUseCase { + + override fun invoke(ids: List): Completable = generationResultRepository.deleteByIdList(ids) +} diff --git a/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/GetGalleryItemsUseCase.kt b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/GetGalleryItemsUseCase.kt new file mode 100644 index 00000000..7dde0a36 --- /dev/null +++ b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/GetGalleryItemsUseCase.kt @@ -0,0 +1,8 @@ +package com.shifthackz.aisdv1.domain.usecase.gallery + +import com.shifthackz.aisdv1.domain.entity.AiGenerationResult +import io.reactivex.rxjava3.core.Single + +interface GetGalleryItemsUseCase { + operator fun invoke(ids: List): Single> +} diff --git a/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/GetGalleryItemsUseCaseImpl.kt b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/GetGalleryItemsUseCaseImpl.kt new file mode 100644 index 00000000..c441b9b4 --- /dev/null +++ b/domain/src/main/java/com/shifthackz/aisdv1/domain/usecase/gallery/GetGalleryItemsUseCaseImpl.kt @@ -0,0 +1,10 @@ +package com.shifthackz.aisdv1.domain.usecase.gallery + +import com.shifthackz.aisdv1.domain.repository.GenerationResultRepository + +internal class GetGalleryItemsUseCaseImpl( + private val generationResultRepository: GenerationResultRepository, +) : GetGalleryItemsUseCase { + + override fun invoke(ids: List) = generationResultRepository.getByIds(ids) +} diff --git a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/modal/ModalRenderer.kt b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/modal/ModalRenderer.kt index f878949b..6eafb554 100644 --- a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/modal/ModalRenderer.kt +++ b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/modal/ModalRenderer.kt @@ -175,20 +175,48 @@ fun ModalRenderer( ) } - Modal.DeleteImageConfirm -> DecisionInteractiveDialog( - title = R.string.interaction_delete_generation_title.asUiText(), - text = R.string.interaction_delete_generation_sub_title.asUiText(), + is Modal.DeleteImageConfirm -> DecisionInteractiveDialog( + title = when { + screenModal.isAll -> R.string.interaction_delete_all_title + screenModal.isMultiple -> R.string.interaction_delete_selection_title + else -> R.string.interaction_delete_generation_title + }.asUiText(), + text = when { + screenModal.isAll -> R.string.interaction_delete_all_sub_title + screenModal.isMultiple -> R.string.interaction_delete_selection_sub_title + else -> R.string.interaction_delete_generation_sub_title + }.asUiText(), confirmActionResId = R.string.yes, dismissActionResId = R.string.no, - onConfirmAction = { processIntent(GalleryDetailIntent.Delete.Confirm) }, + onConfirmAction = { + val intent = if (screenModal.isAll) { + GalleryIntent.Delete.All.Confirm + } else if (screenModal.isMultiple) { + GalleryIntent.Delete.Selection.Confirm + } else { + GalleryDetailIntent.Delete.Confirm + } + processIntent(intent) + }, onDismissRequest = dismiss, ) - Modal.ConfirmExport -> DecisionInteractiveDialog( + is Modal.ConfirmExport -> DecisionInteractiveDialog( title = R.string.interaction_export_title.asUiText(), - text = R.string.interaction_export_sub_title.asUiText(), + text = if (screenModal.exportAll) { + R.string.interaction_export_sub_title + } else { + R.string.interaction_export_sub_title_selection + }.asUiText(), confirmActionResId = R.string.action_export, - onConfirmAction = { processIntent(GalleryIntent.Export.Confirm) }, + onConfirmAction = { + val intent = if (screenModal.exportAll) { + GalleryIntent.Export.All.Confirm + } else { + GalleryIntent.Export.Selection.Confirm + } + processIntent(intent) + }, onDismissRequest = dismiss, ) diff --git a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/model/Modal.kt b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/model/Modal.kt index a2348ab0..116bb1ce 100644 --- a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/model/Modal.kt +++ b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/model/Modal.kt @@ -16,9 +16,12 @@ sealed interface Modal { data object ClearAppCache : Modal - data object DeleteImageConfirm : Modal + data class DeleteImageConfirm( + val isAll: Boolean, + val isMultiple: Boolean, + ) : Modal - data object ConfirmExport : Modal + data class ConfirmExport(val exportAll: Boolean) : Modal data object ExportInProgress : Modal diff --git a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/detail/GalleryDetailViewModel.kt b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/detail/GalleryDetailViewModel.kt index 841a469a..6b334dd9 100644 --- a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/detail/GalleryDetailViewModel.kt +++ b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/detail/GalleryDetailViewModel.kt @@ -47,7 +47,9 @@ class GalleryDetailViewModel( emitEffect(GalleryDetailEffect.ShareClipBoard(intent.content.toString())) } - GalleryDetailIntent.Delete.Request -> setActiveModal(Modal.DeleteImageConfirm) + GalleryDetailIntent.Delete.Request -> setActiveModal( + Modal.DeleteImageConfirm(false, isMultiple = false) + ) GalleryDetailIntent.Delete.Confirm -> { setActiveModal(Modal.None) diff --git a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryEffect.kt b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryEffect.kt index 2996ebd7..b3cacb1f 100644 --- a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryEffect.kt +++ b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryEffect.kt @@ -5,6 +5,9 @@ import com.shifthackz.android.core.mvi.MviEffect import java.io.File sealed interface GalleryEffect : MviEffect { + + data object Refresh : GalleryEffect + data class Share(val zipFile: File) : GalleryEffect data class OpenUri(val uri: Uri) : GalleryEffect diff --git a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryExporter.kt b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryExporter.kt index d2e0f172..bbdd70e4 100644 --- a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryExporter.kt +++ b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryExporter.kt @@ -7,6 +7,7 @@ import com.shifthackz.aisdv1.core.imageprocessing.Base64ToBitmapConverter.Input import com.shifthackz.aisdv1.core.imageprocessing.Base64ToBitmapConverter.Output import com.shifthackz.aisdv1.domain.entity.AiGenerationResult import com.shifthackz.aisdv1.domain.usecase.gallery.GetAllGalleryUseCase +import com.shifthackz.aisdv1.domain.usecase.gallery.GetGalleryItemsUseCase import com.shifthackz.aisdv1.presentation.utils.FileSavableExporter import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single @@ -14,21 +15,25 @@ import java.io.File class GalleryExporter( override val fileProviderDescriptor: FileProviderDescriptor, + private val getGalleryItemsUseCase: GetGalleryItemsUseCase, private val getAllGalleryUseCase: GetAllGalleryUseCase, private val base64ToBitmapConverter: Base64ToBitmapConverter, private val schedulersProvider: SchedulersProvider, ) : FileSavableExporter.BmpToFile, FileSavableExporter.FilesToZip { - operator fun invoke(): Single = getAllGalleryUseCase() - .subscribeOn(schedulersProvider.io) - .flatMapObservable { Observable.fromIterable(it) } - .map { aiDomain -> aiDomain to Input(aiDomain.image) } - .flatMapSingle { (aiDomain, input) -> - base64ToBitmapConverter(input).map { out -> aiDomain to out } - } - .flatMapSingle(::saveBitmapToFileImpl) - .toList() - .flatMap(::saveFilesToZip) + operator fun invoke(ids: List? = null): Single { + val chain = ids?.let(getGalleryItemsUseCase::invoke) ?: getAllGalleryUseCase() + return chain + .subscribeOn(schedulersProvider.io) + .flatMapObservable { Observable.fromIterable(it) } + .map { aiDomain -> aiDomain to Input(aiDomain.image) } + .flatMapSingle { (aiDomain, input) -> + base64ToBitmapConverter(input).map { out -> aiDomain to out } + } + .flatMapSingle(::saveBitmapToFileImpl) + .toList() + .flatMap(::saveFilesToZip) + } private fun saveBitmapToFileImpl(data: Pair) = saveBitmapToFile(data.first.hashCode().toString(), data.second.bitmap) diff --git a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryIntent.kt b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryIntent.kt index e6439bc0..449a0b19 100644 --- a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryIntent.kt +++ b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryIntent.kt @@ -6,8 +6,30 @@ import com.shifthackz.android.core.mvi.MviIntent sealed interface GalleryIntent : MviIntent { - enum class Export : GalleryIntent { - Request, Confirm; + sealed interface Export : GalleryIntent { + + enum class All : Export { + Request, Confirm; + } + + enum class Selection : Export { + Request, Confirm; + } + } + + sealed interface Delete : GalleryIntent { + + enum class All : Export { + Request, Confirm; + } + + enum class Selection : Delete { + Request, Confirm; + } + } + + enum class Dropdown : GalleryIntent { + Toggle, Show, Close; } data object DismissDialog : GalleryIntent @@ -17,4 +39,10 @@ sealed interface GalleryIntent : MviIntent { data class OpenMediaStoreFolder(val uri: Uri) : GalleryIntent data class Drawer(val intent: DrawerIntent) : GalleryIntent + + data class ChangeSelectionMode(val flag: Boolean) : GalleryIntent + + data object UnselectAll : GalleryIntent + + data class ToggleItemSelection(val id: Long) : GalleryIntent } diff --git a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryScreen.kt b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryScreen.kt index 2fdc3081..0dca3cb0 100644 --- a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryScreen.kt +++ b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryScreen.kt @@ -1,13 +1,27 @@ -@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@file:OptIn( + ExperimentalMaterial3Api::class, + ExperimentalFoundationApi::class, + ExperimentalAnimationApi::class, +) package com.shifthackz.aisdv1.presentation.screen.gallery.list import android.content.Intent import android.provider.DocumentsContract +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,53 +38,69 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.toUpperCase import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.paging.LoadState -import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.shifthackz.aisdv1.core.common.file.FileProviderDescriptor import com.shifthackz.aisdv1.core.extensions.items +import com.shifthackz.aisdv1.core.extensions.shake import com.shifthackz.aisdv1.core.extensions.shimmer import com.shifthackz.aisdv1.core.sharing.shareFile import com.shifthackz.aisdv1.core.ui.MviComponent -import com.shifthackz.aisdv1.domain.entity.MediaStoreInfo import com.shifthackz.aisdv1.presentation.R import com.shifthackz.aisdv1.presentation.modal.ModalRenderer import com.shifthackz.aisdv1.presentation.screen.drawer.DrawerIntent import com.shifthackz.aisdv1.presentation.utils.Constants -import kotlinx.coroutines.flow.Flow import org.koin.androidx.compose.koinViewModel import org.koin.compose.koinInject +import kotlin.random.Random @Composable fun GalleryScreen() { val viewModel = koinViewModel() val context = LocalContext.current val fileProviderDescriptor: FileProviderDescriptor = koinInject() + val pagingFlow = viewModel.pagingFlow + val lazyGalleryItems = pagingFlow.collectAsLazyPagingItems() MviComponent( viewModel = viewModel, processEffect = { effect -> @@ -87,13 +117,20 @@ fun GalleryScreen() { fileProviderPath = fileProviderDescriptor.providerPath, fileMimeType = Constants.MIME_TYPE_ZIP, ) + + GalleryEffect.Refresh -> { + lazyGalleryItems.refresh() + } } }, applySystemUiColors = false, ) { state, intentHandler -> + BackHandler(state.selectionMode) { + intentHandler(GalleryIntent.ChangeSelectionMode(false)) + } ScreenContent( state = state, - pagingFlow = viewModel.pagingFlow, + lazyGalleryItems = lazyGalleryItems, processIntent = intentHandler, ) } @@ -103,11 +140,10 @@ fun GalleryScreen() { private fun ScreenContent( modifier: Modifier = Modifier, state: GalleryState, - pagingFlow: Flow>, + lazyGalleryItems: LazyPagingItems, processIntent: (GalleryIntent) -> Unit = {}, ) { val listState = rememberLazyGridState() - val lazyGalleryItems = pagingFlow.collectAsLazyPagingItems() val emptyStatePredicate: () -> Boolean = { lazyGalleryItems.loadState.refresh is LoadState.NotLoading @@ -116,73 +152,274 @@ private fun ScreenContent( } Box(modifier) { - Scaffold(modifier = Modifier.fillMaxSize(), topBar = { - CenterAlignedTopAppBar( - navigationIcon = { - IconButton(onClick = { - processIntent(GalleryIntent.Drawer(DrawerIntent.Open)) - }) { - Icon( - imageVector = Icons.Default.Menu, - contentDescription = "Menu", + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + CenterAlignedTopAppBar( + navigationIcon = { + AnimatedContent( + targetState = state.selectionMode, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "main_nav_icon_animation", + ) { isInSelectionMode -> + IconButton( + onClick = { + val intent = if (isInSelectionMode) { + GalleryIntent.ChangeSelectionMode(false) + } else { + GalleryIntent.Drawer(DrawerIntent.Open) + } + processIntent(intent) + }, + ) { + Icon( + imageVector = if (isInSelectionMode) { + Icons.Default.Close + } else { + Icons.Default.Menu + }, + contentDescription = if (isInSelectionMode) "Close" else "Menu", + ) + } + } + }, + title = { + Text( + text = stringResource(id = R.string.title_gallery), + style = MaterialTheme.typography.headlineMedium, ) - } - }, - title = { - Text( - text = stringResource(id = R.string.title_gallery), - style = MaterialTheme.typography.headlineMedium, - ) - }, - actions = { - if (lazyGalleryItems.itemCount > 0) IconButton( - onClick = { processIntent(GalleryIntent.Export.Request) }, - content = { - Image( - modifier = Modifier.size(24.dp), - painter = painterResource(id = R.drawable.ic_share), - contentDescription = "Export", - colorFilter = ColorFilter.tint(LocalContentColor.current), + }, + actions = { + AnimatedContent( + targetState = state.selectionMode, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "action_nav_icon_animation", + ) { isInSelectionMode -> + if (isInSelectionMode) { + AnimatedVisibility( + visible = state.selection.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + Row { + IconButton( + onClick = { + processIntent(GalleryIntent.Delete.Selection.Request) + }, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + ) + } + IconButton( + onClick = { + processIntent(GalleryIntent.Export.Selection.Request) + }, + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(id = R.drawable.ic_share), + contentDescription = "Export", + colorFilter = ColorFilter.tint(LocalContentColor.current), + ) + } + } + } + } else { + AnimatedVisibility( + visible = lazyGalleryItems.itemCount != 0, + enter = fadeIn(), + exit = fadeOut(), + ) { + IconButton( + onClick = { + processIntent(GalleryIntent.Dropdown.Toggle) + }, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Dropdown", + ) + } + } + } + } + DropdownMenu( + expanded = state.dropdownMenuShow, + onDismissRequest = { processIntent(GalleryIntent.Dropdown.Close) }, + ) { + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Default.Checklist, + contentDescription = "Dropdown", + tint = LocalContentColor.current, + ) + }, + text = { + Text( + text = stringResource( + id = R.string.gallery_menu_selection_mode, + ), + ) + }, + onClick = { + processIntent(GalleryIntent.Dropdown.Close) + processIntent(GalleryIntent.ChangeSelectionMode(true)) + }, ) - }, - ) - }, - ) - }, bottomBar = { - state.mediaStoreInfo.takeIf(MediaStoreInfo::isNotEmpty)?.let { info -> - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceTint) + if (state.mediaStoreInfo.isNotEmpty) DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Default.FileOpen, + contentDescription = "Browse", + tint = LocalContentColor.current, + ) + }, + text = { + Text( + text = stringResource(id = R.string.browse) + ) + }, + onClick = { + processIntent(GalleryIntent.Dropdown.Close) + state.mediaStoreInfo.folderUri?.let { + processIntent(GalleryIntent.OpenMediaStoreFolder(it)) + } + }, + ) + DropdownMenuItem( + leadingIcon = { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(id = R.drawable.ic_share), + contentDescription = "Export", + colorFilter = ColorFilter.tint(LocalContentColor.current), + ) + }, + text = { + Text( + text = stringResource(id = R.string.gallery_menu_export_all) + ) + }, + onClick = { + processIntent(GalleryIntent.Dropdown.Close) + processIntent(GalleryIntent.Export.All.Request) + }, + ) + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + ) + }, + text = { + Text( + text = stringResource(id = R.string.gallery_menu_delete_all), + ) + }, + onClick = { + processIntent(GalleryIntent.Dropdown.Close) + processIntent(GalleryIntent.Delete.All.Request) + }, + ) + } + }, + ) + }, + bottomBar = { + AnimatedVisibility( + visible = state.selectionMode, + enter = fadeIn(), + exit = fadeOut(), ) { - Text( + Row( modifier = Modifier - .padding(top = 4.dp, start = 16.dp) - .fillMaxWidth(0.65f), - text = stringResource( - id = R.string.gallery_media_store_banner, - "${info.count}", - ), - ) - Spacer(modifier = Modifier.weight(1f)) - TextButton( - modifier = Modifier.padding(bottom = 4.dp, end = 16.dp), - onClick = { - state.mediaStoreInfo.folderUri?.let { - processIntent(GalleryIntent.OpenMediaStoreFolder(it)) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceTint), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .padding(start = 16.dp), + text = stringResource( + id = R.string.gallery_menu_selected, + "${state.selection.size}", + ), + style = MaterialTheme.typography.bodyLarge, + lineHeight = 17.sp, + fontWeight = FontWeight.W400, + ) + Spacer(modifier = Modifier.weight(1f)) + TextButton( + modifier = Modifier.padding(end = 16.dp), + onClick = { + if (state.selection.isNotEmpty()) { + processIntent(GalleryIntent.UnselectAll) + } else { + processIntent(GalleryIntent.ChangeSelectionMode(false)) + } + }, + ) { + val resId = if (state.selection.isNotEmpty()) { + R.string.gallery_menu_unselect_all + } else { + R.string.cancel } - }, + Text( + text = stringResource(resId).toUpperCase(Locale.current), + textAlign = TextAlign.Center, + color = LocalContentColor.current, + ) + } + } + } + AnimatedVisibility( + visible = state.mediaStoreInfo.isNotEmpty && !state.selectionMode, + enter = fadeIn(), + exit = fadeOut(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceTint), + verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(id = R.string.browse).toUpperCase(Locale.current), - color = LocalContentColor.current, + modifier = Modifier + .padding(start = 16.dp) + .fillMaxWidth(0.65f), + text = stringResource( + id = R.string.gallery_media_store_banner, + "${state.mediaStoreInfo.count}", + ), + style = MaterialTheme.typography.bodyLarge, + lineHeight = 17.sp, + fontWeight = FontWeight.W400, ) + Spacer(modifier = Modifier.weight(1f)) + TextButton( + modifier = Modifier.padding(end = 16.dp), + onClick = { + state.mediaStoreInfo.folderUri?.let { + processIntent(GalleryIntent.OpenMediaStoreFolder(it)) + } + }, + ) { + Text( + text = stringResource(id = R.string.browse).toUpperCase(Locale.current), + color = LocalContentColor.current, + ) + } } } - } - }, content = { paddingValues -> + }, + ) { paddingValues -> when { emptyStatePredicate() -> GalleryEmptyState(Modifier.fillMaxSize()) + lazyGalleryItems.itemCount == 0 -> LazyVerticalGrid( modifier = Modifier .fillMaxSize() @@ -199,29 +436,49 @@ private fun ScreenContent( } } - else -> LazyVerticalGrid( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - columns = GridCells.Fixed(2), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - state = listState, - ) { - items(lazyGalleryItems) { galleryItemUi -> - if (galleryItemUi != null) { - GalleryUiItem( - item = galleryItemUi, - onClick = { processIntent(GalleryIntent.OpenItem(it)) }, - ) - } else { - GalleryUiItemShimmer() + else -> { + LazyVerticalGrid( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + columns = GridCells.Fixed(2), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + state = listState, + ) { + items(lazyGalleryItems) { item -> + if (item != null) { + val selected = state.selection.contains(item.id) + GalleryUiItem( + modifier = Modifier + .animateItemPlacement(tween(500)) + .shake( + enabled = state.selectionMode && !selected, + animationDurationMillis = 188, + animationStartOffset = Random.nextInt(0, 320), + ), + item = item, + selectionMode = state.selectionMode, + checked = selected, + onCheckedChange = { + processIntent(GalleryIntent.ToggleItemSelection(item.id)) + }, + onLongClick = { + processIntent(GalleryIntent.ChangeSelectionMode(true)) + }, + onClick = { + processIntent(GalleryIntent.OpenItem(it)) + }, + ) + } else { + GalleryUiItemShimmer() + } } } } } - }) + } ModalRenderer(screenModal = state.screenModal) { (it as? GalleryIntent)?.let(processIntent::invoke) } @@ -230,19 +487,74 @@ private fun ScreenContent( @Composable fun GalleryUiItem( + modifier: Modifier = Modifier, item: GalleryGridItemUi, + checked: Boolean = false, onClick: (GalleryGridItemUi) -> Unit = {}, + onLongClick: () -> Unit = {}, + onCheckedChange: (Boolean) -> Unit = {}, + selectionMode: Boolean = false, ) { - Image( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(12.dp)) - .clickable { onClick(item) }, - bitmap = item.bitmap.asImageBitmap(), - contentScale = ContentScale.Crop, - contentDescription = "gallery_item", + val shape = RoundedCornerShape(12.dp) + val borderColor by animateColorAsState( + targetValue = if (selectionMode && checked) { + MaterialTheme.colorScheme.primary + } else { + Color.Transparent + }, + label = "border_color", ) + Box( + modifier = modifier.clip(shape), + ) { + Image( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .border( + width = 4.dp, + color = borderColor, + shape = shape, + ) + .combinedClickable( + onLongClick = if (!selectionMode) onLongClick else null, + onClick = { + if (!selectionMode) { + onClick(item) + } else { + onCheckedChange(!checked) + } + }, + ), + bitmap = item.bitmap.asImageBitmap(), + contentScale = ContentScale.Crop, + contentDescription = "gallery_item", + ) + if (selectionMode) { + val checkBoxShape = RoundedCornerShape(4.dp) + Box( + modifier = Modifier + .padding(8.dp) + .size(20.dp) + .align(Alignment.TopEnd) + .clip(checkBoxShape) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), + shape = checkBoxShape, + ), + ) { + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + colors = CheckboxDefaults.colors( + uncheckedColor = MaterialTheme.colorScheme.primary, + ), + ) + } + } + } + } } @Composable diff --git a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryState.kt b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryState.kt index cbdaaa15..70933add 100644 --- a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryState.kt +++ b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryState.kt @@ -10,6 +10,9 @@ import com.shifthackz.android.core.mvi.MviState data class GalleryState( val screenModal: Modal = Modal.None, val mediaStoreInfo: MediaStoreInfo = MediaStoreInfo(), + val dropdownMenuShow: Boolean = false, + val selectionMode: Boolean = false, + val selection: List = emptyList(), ) : MviState data class GalleryGridItemUi( diff --git a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryViewModel.kt b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryViewModel.kt index 3776de3d..7cf6122d 100644 --- a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryViewModel.kt +++ b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryViewModel.kt @@ -9,6 +9,8 @@ import com.shifthackz.aisdv1.core.common.schedulers.subscribeOnMainThread import com.shifthackz.aisdv1.core.imageprocessing.Base64ToBitmapConverter import com.shifthackz.aisdv1.core.model.asUiText import com.shifthackz.aisdv1.core.viewmodel.MviRxViewModel +import com.shifthackz.aisdv1.domain.usecase.gallery.DeleteAllGalleryUseCase +import com.shifthackz.aisdv1.domain.usecase.gallery.DeleteGalleryItemsUseCase import com.shifthackz.aisdv1.domain.usecase.gallery.GetMediaStoreInfoUseCase import com.shifthackz.aisdv1.domain.usecase.generation.GetGenerationResultPagedUseCase import com.shifthackz.aisdv1.presentation.model.Modal @@ -16,11 +18,14 @@ import com.shifthackz.aisdv1.presentation.navigation.router.drawer.DrawerRouter import com.shifthackz.aisdv1.presentation.navigation.router.main.MainRouter import com.shifthackz.aisdv1.presentation.screen.drawer.DrawerIntent import com.shifthackz.aisdv1.presentation.utils.Constants +import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.kotlin.subscribeBy import kotlinx.coroutines.flow.Flow class GalleryViewModel( getMediaStoreInfoUseCase: GetMediaStoreInfoUseCase, + private val deleteAllGalleryUseCase: DeleteAllGalleryUseCase, + private val deleteGalleryItemsUseCase: DeleteGalleryItemsUseCase, private val getGenerationResultPagedUseCase: GetGenerationResultPagedUseCase, private val base64ToBitmapConverter: Base64ToBitmapConverter, private val galleryExporter: GalleryExporter, @@ -61,18 +66,90 @@ class GalleryViewModel( override fun processIntent(intent: GalleryIntent) { when (intent) { GalleryIntent.DismissDialog -> setActiveModal(Modal.None) - GalleryIntent.Export.Request -> setActiveModal(Modal.ConfirmExport) - GalleryIntent.Export.Confirm -> launchGalleryExport() + + GalleryIntent.Export.All.Request -> setActiveModal(Modal.ConfirmExport(true)) + + GalleryIntent.Export.All.Confirm -> launchGalleryExport(true) + + GalleryIntent.Export.Selection.Request -> setActiveModal(Modal.ConfirmExport(false)) + + GalleryIntent.Export.Selection.Confirm -> launchGalleryExport(false) + is GalleryIntent.OpenItem -> mainRouter.navigateToGalleryDetails(intent.item.id) + is GalleryIntent.OpenMediaStoreFolder -> emitEffect(GalleryEffect.OpenUri(intent.uri)) + is GalleryIntent.Drawer -> when (intent.intent) { DrawerIntent.Close -> drawerRouter.closeDrawer() DrawerIntent.Open -> drawerRouter.openDrawer() } + + is GalleryIntent.ChangeSelectionMode -> updateState { + it.copy( + selectionMode = intent.flag, + selection = if (!intent.flag) emptyList() else it.selection, + ) + } + + is GalleryIntent.ToggleItemSelection -> updateState { + val selectionIndex = it.selection.indexOf(intent.id) + val newSelection = it.selection.toMutableList() + if (selectionIndex != -1) { + newSelection.removeAt(selectionIndex) + } else { + newSelection.add(intent.id) + } + it.copy(selection = newSelection) + } + + GalleryIntent.Delete.Selection.Request -> setActiveModal( + Modal.DeleteImageConfirm(isAll = false, isMultiple = true) + ) + + GalleryIntent.Delete.Selection.Confirm -> !deleteGalleryItemsUseCase + .invoke(currentState.selection) + .processDeletion() + + GalleryIntent.Delete.All.Request -> setActiveModal( + Modal.DeleteImageConfirm(isAll = true, isMultiple = false) + ) + + GalleryIntent.Delete.All.Confirm -> !deleteAllGalleryUseCase() + .processDeletion() + + GalleryIntent.UnselectAll -> updateState { + it.copy(selection = emptyList()) + } + + GalleryIntent.Dropdown.Toggle -> updateState { + it.copy(dropdownMenuShow = !it.dropdownMenuShow) + } + + GalleryIntent.Dropdown.Show -> updateState { + it.copy(dropdownMenuShow = true) + } + + GalleryIntent.Dropdown.Close -> updateState { + it.copy(dropdownMenuShow = false) + } } } - private fun launchGalleryExport() = galleryExporter() + private fun Completable.processDeletion() = this + .doOnSubscribe { setActiveModal(Modal.None) } + .subscribeOnMainThread(schedulersProvider) + .subscribeBy(::errorLog) { + updateState { + it.copy( + selectionMode = false, + selection = emptyList() + ) + } + emitEffect(GalleryEffect.Refresh) + } + + private fun launchGalleryExport(exportAll: Boolean) = !galleryExporter + .invoke(if (exportAll) null else currentState.selection) .doOnSubscribe { setActiveModal(Modal.ExportInProgress) } .subscribeOnMainThread(schedulersProvider) .subscribeBy( @@ -89,9 +166,8 @@ class GalleryViewModel( emitEffect(GalleryEffect.Share(zipFile)) } ) - .addToDisposable() private fun setActiveModal(dialog: Modal) = updateState { - it.copy(screenModal = dialog) + it.copy(screenModal = dialog, dropdownMenuShow = false) } } diff --git a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/utils/Constants.kt b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/utils/Constants.kt index dd3cfd45..5b56330e 100644 --- a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/utils/Constants.kt +++ b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/utils/Constants.kt @@ -1,7 +1,7 @@ package com.shifthackz.aisdv1.presentation.utils object Constants { - const val PAGINATION_PAYLOAD_SIZE = 20 + const val PAGINATION_PAYLOAD_SIZE = 1000 const val DEBUG_MENU_ACCESS_TAPS = 7 const val PARAM_ITEM_ID = "itemId" diff --git a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/widget/dialog/GenerationImageResultDialog.kt b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/widget/dialog/GenerationImageResultDialog.kt index c9d1c64d..ff163a2d 100644 --- a/presentation/src/main/java/com/shifthackz/aisdv1/presentation/widget/dialog/GenerationImageResultDialog.kt +++ b/presentation/src/main/java/com/shifthackz/aisdv1/presentation/widget/dialog/GenerationImageResultDialog.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.grid.GridCells @@ -23,7 +22,6 @@ import androidx.compose.material.icons.filled.Save import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.Button import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -158,7 +156,10 @@ fun ColumnScope.GenerationImageBatchResultModal( val result = results[index] val bmp = base64ToBitmap(result.image) val item = GalleryGridItemUi(result.id, bmp) - GalleryUiItem(item) { onViewDetailRequest(result) } + GalleryUiItem( + item = item, + onClick = { onViewDetailRequest(result) } + ) } } Box( diff --git a/presentation/src/main/res/values-ru/strings.xml b/presentation/src/main/res/values-ru/strings.xml index d821e521..6bb0928e 100644 --- a/presentation/src/main/res/values-ru/strings.xml +++ b/presentation/src/main/res/values-ru/strings.xml @@ -169,6 +169,12 @@ Коэффициент вариации Коэффициент шумоподавления + Отменить выбор + Выбранно: %1$s + Удалить все + Экспортировать все + Режим выбора + Параметры AI Параметры приложения Информация @@ -214,14 +220,21 @@ Это может занять некоторое время в зависимости от количества фотографий Экспорт галереи - Эта функция экспортирует все изображения галереи в архив *.zip. Этот процесс может длиться долго, если у вас много изображений. Хотите продолжить? + Эта функция экспортирует все изображения галереи в архив *.zip. Этот процесс может длиться долго, если у вас много изображений. Желаете продолжить? + Эта функция экспортирует выбранные изображения галереи в архив *.zip. Желаете продолжить? Это приведет к сбросу настроек программы и удалению всех созданных изображений. Вы хотите продолжить? Предупреждение Вы пытаетесь подключиться к локальному серверу localhost (127.0.0.1).\n\nЭто может не сработать, если на вашем Android-устройстве не настроено туннелирование ssh или другой механизм переадресации портов. Удалить изображение - Вы уверены, что хотите окончательно удалить это изображение? + Вы уверены, что хотите безвозвратно удалить это изображение? + + Удалить все изображения + Вы уверены, что хотите безвозвратно удалить все изображения? + + Удалить изображения + Вы уверены, что хотите безвозвратно удалить выбранные изображения? Удалить модель Вы уверены, что хотите удалить модель "%1$s"? diff --git a/presentation/src/main/res/values-tr/strings.xml b/presentation/src/main/res/values-tr/strings.xml index 4d4a1b86..50575cf4 100644 --- a/presentation/src/main/res/values-tr/strings.xml +++ b/presentation/src/main/res/values-tr/strings.xml @@ -169,6 +169,12 @@ Varyasyon oranı Gürültü Azaltma oranı + Tümünü seç + Seçili resimler: %1$s + Tümünü sil + Tümünü dışa aktar + Seçim modu + AI ayarları Uygulama ayarları Bilgi @@ -215,6 +221,7 @@ Galeriyi Dışa Aktar Bu işlem bütün galerideki resimleri tek bir .zip arşivi dosyası olarak dışa aktaracaktır. Galerinizin boyutuna göre bu işlem uzun bir zaman alabailir. Devam etmek istiyor musunuz? + Bu, seçili galeri resimlerini *.zip arşivi olarak dışa aktaracaktır. Devam etmek istiyor musunuz? Bu işlem bütün uygulama ayarlarını ve oluşturulan resimleri silecektir. Devam etmek istiyor musunuz? Uyarı @@ -223,6 +230,12 @@ Resmi sil Kalıcı olarak bu resmi silmek istediğinize emin misiniz? + Tüm resimleri sil + Tüm görselleri kalıcı olarak silmek istediğinizden emin misiniz? + + Resimleri sil + Seçili görselleri kalıcı olarak silmek istediğinizden emin misiniz? + Modeli sil Modeli silmek istediğinizden emin misiniz "%1$s"? diff --git a/presentation/src/main/res/values-uk/strings.xml b/presentation/src/main/res/values-uk/strings.xml index 1bd87cc3..03b3e8fb 100644 --- a/presentation/src/main/res/values-uk/strings.xml +++ b/presentation/src/main/res/values-uk/strings.xml @@ -169,6 +169,12 @@ Коефіцієнт варіації Коефіцієнт шумозаглушення + Скасувати вибір + Обрано: %1$s + Видалити все + Експортувати все + Режим вибору + Параметри AI Параметри додатку Інформація @@ -215,6 +221,7 @@ Експорт галереї Ця функція експортує всі зображення галереї у архів *.zip. Цей процес може тривати довго, якщо у вас багато зображень. Бажаєте продовжити? + Ця функція експортує обрані зображення галереї у архів *.zip. Бажаєте продовжити? Це призведе до скидання налаштувань програми та видалення всіх створених зображень. Ви бажаєте продовжити? Попередження @@ -223,6 +230,12 @@ Видалити зображення Ви впевнені, що хочете остаточно видалити це зображення? + Видалити всі зображення + Ви впевнені, що хочете остаточно видалити всі зображення? + + Видалити зображення + Ви впевнені, що хочете остаточно видалити обрані зображення? + Видалити модель Ви впевнені, що хочете видалити модель "%1$s"? diff --git a/presentation/src/main/res/values-zh/strings.xml b/presentation/src/main/res/values-zh/strings.xml index fd958e2b..2fd54d2a 100644 --- a/presentation/src/main/res/values-zh/strings.xml +++ b/presentation/src/main/res/values-zh/strings.xml @@ -210,6 +210,12 @@ 变体强度 降噪强度 + 取消全部选择 + 已选图片:%1$s + 全部删除 + 全部导出 + 选择模式 + AI设置 应用设置 @@ -264,6 +270,7 @@ 图库导出 这将把所有图库图像导出为*.zip压缩包。此过程可能很长,如果您有很多图片,您想继续吗? + 这会将选定的图库图像导出为 *.zip 存档。您要继续吗? 这将重置应用设置并删除所有生成的图像。您想继续吗? @@ -274,6 +281,12 @@ 删除图像 您确定要永久删除此图像吗? + 删除所有图像 + 您确定要永久删除所有图像吗? + + 删除图像 + 您确实要永久删除选定的图像吗? + 删除模型 您确定要删除模型 "%1$s" 吗? diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index 52271fd8..310feda2 100755 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -189,6 +189,12 @@ Variation strength Denoising strength + Unselect all + Selected images: %1$s + Delete all + Export all + Selection mode + AI settings App settings Info @@ -235,6 +241,7 @@ Gallery Export This will export all the gallery images as *.zip archive. This process may be long if you have many images, would you like to proceed? + This will export selected gallery images as *.zip archive. Would you like to proceed? This will reset app settings and delete all the generated images. Do you want to proceed? Warning @@ -243,6 +250,12 @@ Delete image Are you sure you want to permanently delete this image? + Delete all images + Are you sure you want to permanently delete all images? + + Delete images + Are you sure you want to permanently delete selected images? + Delete model Are you sure you want to delete model "%1$s"? diff --git a/presentation/src/test/java/com/shifthackz/aisdv1/presentation/screen/gallery/detail/GalleryDetailViewModelTest.kt b/presentation/src/test/java/com/shifthackz/aisdv1/presentation/screen/gallery/detail/GalleryDetailViewModelTest.kt index 387a24e2..77bca40e 100644 --- a/presentation/src/test/java/com/shifthackz/aisdv1/presentation/screen/gallery/detail/GalleryDetailViewModelTest.kt +++ b/presentation/src/test/java/com/shifthackz/aisdv1/presentation/screen/gallery/detail/GalleryDetailViewModelTest.kt @@ -120,7 +120,7 @@ class GalleryDetailViewModelTest : CoreViewModelTest() { fun `given received Delete Request intent, expected modal field in UI state is DeleteImageConfirm`() { viewModel.processIntent(GalleryDetailIntent.Delete.Request) runTest { - val expected = Modal.DeleteImageConfirm + val expected = Modal.DeleteImageConfirm(isAll = false, isMultiple = false) val actual = (viewModel.state.value as? GalleryDetailState.Content)?.screenModal Assert.assertEquals(expected, actual) } diff --git a/presentation/src/test/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryViewModelTest.kt b/presentation/src/test/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryViewModelTest.kt index 935a2ece..41b8426f 100644 --- a/presentation/src/test/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryViewModelTest.kt +++ b/presentation/src/test/java/com/shifthackz/aisdv1/presentation/screen/gallery/list/GalleryViewModelTest.kt @@ -6,6 +6,8 @@ import android.graphics.Bitmap import android.net.Uri import com.shifthackz.aisdv1.core.imageprocessing.Base64ToBitmapConverter import com.shifthackz.aisdv1.domain.entity.MediaStoreInfo +import com.shifthackz.aisdv1.domain.usecase.gallery.DeleteAllGalleryUseCase +import com.shifthackz.aisdv1.domain.usecase.gallery.DeleteGalleryItemsUseCase import com.shifthackz.aisdv1.domain.usecase.gallery.GetMediaStoreInfoUseCase import com.shifthackz.aisdv1.domain.usecase.generation.GetGenerationResultPagedUseCase import com.shifthackz.aisdv1.presentation.core.CoreViewModelTest @@ -40,6 +42,8 @@ class GalleryViewModelTest : CoreViewModelTest() { private val stubGalleryExporter = mockk() private val stubMainRouter = mockk() private val stubDrawerRouter = mockk() + private val stubDeleteAllGalleryUseCase = mockk() + private val stubDeleteGalleryItemsUseCase = mockk() override fun initializeViewModel() = GalleryViewModel( getMediaStoreInfoUseCase = stubGetMediaStoreInfoUseCase, @@ -49,6 +53,8 @@ class GalleryViewModelTest : CoreViewModelTest() { schedulersProvider = stubSchedulersProvider, mainRouter = stubMainRouter, drawerRouter = stubDrawerRouter, + deleteAllGalleryUseCase = stubDeleteAllGalleryUseCase, + deleteGalleryItemsUseCase = stubDeleteGalleryItemsUseCase, ) @Before @@ -81,9 +87,9 @@ class GalleryViewModelTest : CoreViewModelTest() { @Test fun `given received Export Request intent, expected screenModal field in UI state is ConfirmExport`() { - viewModel.processIntent(GalleryIntent.Export.Request) + viewModel.processIntent(GalleryIntent.Export.All.Request) runTest { - val expected = Modal.ConfirmExport + val expected = Modal.ConfirmExport(true) val actual = viewModel.state.value.screenModal Assert.assertEquals(expected, actual) } @@ -97,7 +103,7 @@ class GalleryViewModelTest : CoreViewModelTest() { Dispatchers.setMain(UnconfinedTestDispatcher()) - viewModel.processIntent(GalleryIntent.Export.Confirm) + viewModel.processIntent(GalleryIntent.Export.All.Confirm) runTest { val expectedUiState = Modal.None diff --git a/storage/src/main/java/com/shifthackz/aisdv1/storage/db/persistent/dao/GenerationResultDao.kt b/storage/src/main/java/com/shifthackz/aisdv1/storage/db/persistent/dao/GenerationResultDao.kt index 1d854988..8b39a965 100644 --- a/storage/src/main/java/com/shifthackz/aisdv1/storage/db/persistent/dao/GenerationResultDao.kt +++ b/storage/src/main/java/com/shifthackz/aisdv1/storage/db/persistent/dao/GenerationResultDao.kt @@ -21,12 +21,18 @@ interface GenerationResultDao { @Query("SELECT * FROM ${GenerationResultContract.TABLE} WHERE ${GenerationResultContract.ID} = :id LIMIT 1") fun queryById(id: Long): Single + @Query("SELECT * FROM ${GenerationResultContract.TABLE} WHERE ${GenerationResultContract.ID} IN (:idList)") + fun queryByIdList(idList: List): Single> + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(item: GenerationResultEntity): Single @Query("DELETE FROM ${GenerationResultContract.TABLE} WHERE ${GenerationResultContract.ID} = :id") fun deleteById(id: Long): Completable + @Query("DELETE FROM ${GenerationResultContract.TABLE} WHERE ${GenerationResultContract.ID} IN (:idList)") + fun deleteByIdList(idList: List): Completable + @Query("DELETE FROM ${GenerationResultContract.TABLE}") fun deleteAll(): Completable }