diff --git a/CLAUDE.md b/CLAUDE.md index 1fb4baa..6f653b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Project Purpose This project implements an image picker library for Android using Jetpack Compose. -v1 focuses on the core image selection flow. +v1 focuses on the core image selection flow. continue to work v3 ### Development Goals - Implement a reusable Image Picker component for use across apps diff --git a/app/src/main/java/com/universe/dynamicimagepicker/MainActivity.kt b/app/src/main/java/com/universe/dynamicimagepicker/MainActivity.kt index 76a8b7b..b7bfce7 100644 --- a/app/src/main/java/com/universe/dynamicimagepicker/MainActivity.kt +++ b/app/src/main/java/com/universe/dynamicimagepicker/MainActivity.kt @@ -31,7 +31,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.draw.clip @@ -62,8 +62,8 @@ class MainActivity : ComponentActivity() { @Composable private fun PickerHost(modifier: Modifier = Modifier) { - var showPicker by remember { mutableStateOf(false) } - var selectedImages by remember { mutableStateOf(emptyList()) } + var showPicker by rememberSaveable { mutableStateOf(false) } + var selectedImages by rememberSaveable { mutableStateOf(emptyList()) } if (showPicker) { DynamicImagePicker( diff --git a/build.gradle.kts b/build.gradle.kts index 65fb656..1a68949 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,4 +4,5 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.parcelize) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8290c1..2923441 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,8 @@ coroutines = "1.9.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" +turbine = "1.2.0" +mockk = "1.13.13" exifinterface = "1.4.2" paging = "3.3.6" @@ -63,6 +65,9 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" } androidx-paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } @@ -72,3 +77,4 @@ android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } \ No newline at end of file diff --git a/imagepicker/build.gradle.kts b/imagepicker/build.gradle.kts index e3fc18c..d0c789c 100644 --- a/imagepicker/build.gradle.kts +++ b/imagepicker/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.parcelize) id("maven-publish") id("signing") } @@ -181,6 +182,10 @@ dependencies { // Testing testImplementation(libs.junit) + testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.mockk) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/ImageFileDataSource.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/ImageFileDataSource.kt index 8fe9544..7f2c6ba 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/ImageFileDataSource.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/ImageFileDataSource.kt @@ -230,7 +230,6 @@ internal class ImageFileDataSource( /** * 캐시 파일에 대한 content:// URI를 반환한다. - * file:// URI는 API 24+ 에서 앱 간 공유 시 FileUriExposedException을 유발하므로 * FileProvider를 통해 content:// URI로 변환한다. */ private fun uriForCacheFile(file: File): Uri { diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/CropRect.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/CropRect.kt index e0355d6..55972e2 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/CropRect.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/CropRect.kt @@ -1,14 +1,18 @@ package io.github.seunghee17.imagepicker +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + /** * 정규화된 크롭 영역. 모든 값은 이미지 크기 대비 [0f, 1f] 범위. */ +@Parcelize data class CropRect( val left: Float, val top: Float, val right: Float, val bottom: Float -) { +) : Parcelable { init { require(left >= 0f && top >= 0f && right <= 1f && bottom <= 1f) { "CropRect 값은 [0f, 1f] 범위 내에 있어야 합니다." diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/PickedImage.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/PickedImage.kt index 185aaca..0e61e9e 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/PickedImage.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/PickedImage.kt @@ -1,8 +1,10 @@ package io.github.seunghee17.imagepicker import android.net.Uri -import io.github.seunghee17.imagepicker.CropRect +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +@Parcelize data class PickedImage( val originalUri: Uri, val editedUri: Uri? = null, @@ -11,4 +13,4 @@ data class PickedImage( val isCropped: Boolean = cropRect != null, // cropRect가 없어도 true로 명시 가능 val isVideo: Boolean = false, val videoDurationMs: Long = 0L, // isVideo == true 일 때만 유효 (밀리초) -) +) : Parcelable diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryContract.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryContract.kt index ba90708..0e753a8 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryContract.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryContract.kt @@ -16,6 +16,7 @@ internal interface GalleryContract { val maxSelectionCount: Int = 10, val showAlbumSelector: Boolean = true, val editResults: Map = emptyMap(), + val isAlbumsLoading: Boolean = true, ) { val isSelectionLimitReached: Boolean get() = selectedImages.size >= maxSelectionCount @@ -28,6 +29,9 @@ internal interface GalleryContract { data object Initialize : Intent data class SelectAlbum(val album: GalleryAlbum) : Intent data class ToggleImageSelection(val image: GalleryImage) : Intent + data class BeginDragSelection(val anchorImage: GalleryImage) : Intent + data class UpdateDragSelectionRange(val rangeImages: List) : Intent + data object EndDragSelection : Intent data class OnEditResult(val pickedImage: PickedImage) : Intent data object Confirm : Intent data object Cancel : Intent diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreen.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreen.kt index 77178ed..634cbd2 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreen.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreen.kt @@ -41,12 +41,13 @@ import androidx.paging.compose.itemKey import io.github.seunghee17.imagepicker.domain.model.GalleryImage import io.github.seunghee17.imagepicker.presentation.component.TopBarWithCount import io.github.seunghee17.imagepicker.presentation.utils.DragSelectionState -import io.github.seunghee17.imagepicker.presentation.utils.gridItemKeyAtPosition +import io.github.seunghee17.imagepicker.presentation.utils.computeDragRange +import io.github.seunghee17.imagepicker.presentation.utils.gridItemInfoAtPosition import io.github.seunghee17.imagepicker.presentation.utils.photoGridDragHandler import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -/// 갤러리 이미지 그리드 화면 (권한이 허용된 상태에서 표시). +/// 갤러리 이미지 화면 (권한이 허용된 상태에서 표시). @Composable internal fun GalleryScreen( @@ -61,16 +62,15 @@ internal fun GalleryScreen( val gridState = rememberLazyGridState() val autoScrollSpeed = remember { mutableFloatStateOf(0f) } val currentDragState = remember { mutableStateOf(null) } - val currentState by rememberUpdatedState(state) var dropDownExpanded by rememberSaveable { mutableStateOf(false) } val pagingItems = pagingFlow.collectAsLazyPagingItems() - // 현재 로드된 아이템을 id → GalleryImage 맵으로 캐시 (드래그 선택 조회용) - val itemsById = remember(pagingItems.itemSnapshotList) { - pagingItems.itemSnapshotList.items.associateBy { it.id } + // grid 인덱스 순으로 정렬된, 현재 로드된 이미지 스냅샷 (드래그 range 선택 조회용) + val itemsSnapshot = remember(pagingItems.itemSnapshotList) { + pagingItems.itemSnapshotList.items } - val currentItemsById by rememberUpdatedState(itemsById) + val currentItemsSnapshot by rememberUpdatedState(itemsSnapshot) // 화면이 컴포지션을 떠날 때(에디터 진입 등) 잔여 스낵바 제거 DisposableEffect(Unit) { @@ -89,21 +89,22 @@ internal fun GalleryScreen( } } - // 드래그 중 autoScrollSpeed 값에 따라 그리드를 자동 스크롤 + // 드래그 중 autoScrollSpeed 값에 따라 그리드를 자동 스크롤하면서, 손가락이 고정된 채로 + // 그리드가 스크롤되어 새로운 아이템이 그 아래로 지나갈 때도 anchor~현재 위치 range를 계속 갱신 LaunchedEffect(gridState) { snapshotFlow { autoScrollSpeed.floatValue } .collect { _ -> while (autoScrollSpeed.floatValue != 0f) { gridState.scrollBy(autoScrollSpeed.floatValue) currentDragState.value?.let { dragState -> - gridState.gridItemKeyAtPosition(dragState.offset)?.let { key -> - if (dragState.lastProcessedKey != key && - currentState.selectedImages.none { it.id == key } - ) { - currentItemsById[key]?.let { image -> - onIntent(GalleryContract.Intent.ToggleImageSelection(image)) - currentDragState.value = dragState.copy(lastProcessedKey = key) - } + gridState.gridItemInfoAtPosition(dragState.offset)?.let { info -> + if (dragState.lastProcessedIndex != info.index) { + onIntent( + GalleryContract.Intent.UpdateDragSelectionRange( + computeDragRange(dragState.anchorIndex, info.index, currentItemsSnapshot) + ) + ) + currentDragState.value = dragState.copy(lastProcessedIndex = info.index) } } } @@ -167,11 +168,15 @@ internal fun GalleryScreen( .photoGridDragHandler( lazyGridState = gridState, haptics = LocalHapticFeedback.current, - selectedImages = state.selectedImages, - onSelect = { id -> - currentItemsById[id]?.let { image -> - onIntent(GalleryContract.Intent.ToggleImageSelection(image)) - } + imagesSnapshot = itemsSnapshot, + onBeginDrag = { image -> + onIntent(GalleryContract.Intent.BeginDragSelection(image)) + }, + onUpdateRange = { images -> + onIntent(GalleryContract.Intent.UpdateDragSelectionRange(images)) + }, + onEndDrag = { + onIntent(GalleryContract.Intent.EndDragSelection) }, autoScrollSpeed = autoScrollSpeed, autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }, diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreenViewModel.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreenViewModel.kt index 81489c9..eaf275c 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreenViewModel.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreenViewModel.kt @@ -18,9 +18,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update @@ -35,24 +36,6 @@ internal class GalleryScreenViewModel( showAlbumSelector: Boolean = true, ) : ViewModel() { - // 앨범 선택 상태: 아직 앨범 목록이 로드되지 않은 Pending vs 실제 선택된 Active - private sealed interface AlbumFilter { - data object Pending : AlbumFilter - data class Active(val albumId: String?) : AlbumFilter - } - - private val _albumFilter = MutableStateFlow(AlbumFilter.Pending) - - /** - * 현재 선택된 앨범의 이미지를 페이지 단위로 방출하는 Flow. - * [GalleryScreen] 에서 [collectAsLazyPagingItems] 로 소비한다. - */ - val pagingFlow: Flow> = _albumFilter - .filterIsInstance() - .distinctUntilChanged() - .flatMapLatest { filter -> getPagedImages(filter.albumId) } - .cachedIn(viewModelScope) - private val _state = MutableStateFlow( GalleryContract.State( maxSelectionCount = maxSelectionCount, @@ -64,9 +47,22 @@ internal class GalleryScreenViewModel( private val _effect = Channel(Channel.BUFFERED) val effect = _effect.receiveAsFlow() + + val pagingFlow: Flow> = _state + .filter { !it.isAlbumsLoading } + .map { it.selectedAlbum?.id } + .distinctUntilChanged() + .flatMapLatest { albumId -> getPagedImages(albumId) } + .cachedIn(viewModelScope) + private var albumsObserved = false private var pendingCacheClean = false + // 드래그 range 선택 중에만 쓰이는 임시 상태 (드래그 제스처 시작~종료 스코프) + private var dragBaseline: List? = null + private var dragIsDeselecting = false + private var dragLimitSnackbarShown = false + fun handleIntent(intent: GalleryContract.Intent) { when (intent) { GalleryContract.Intent.Initialize -> { @@ -76,11 +72,12 @@ internal class GalleryScreenViewModel( } observeAlbums() } - is GalleryContract.Intent.SelectAlbum -> { + is GalleryContract.Intent.SelectAlbum -> _state.update { it.copy(selectedAlbum = intent.album) } - _albumFilter.value = AlbumFilter.Active(intent.album.id) - } is GalleryContract.Intent.ToggleImageSelection -> toggleSelection(intent.image) + is GalleryContract.Intent.BeginDragSelection -> beginDragSelection(intent.anchorImage) + is GalleryContract.Intent.UpdateDragSelectionRange -> updateDragSelectionRange(intent.rangeImages) + GalleryContract.Intent.EndDragSelection -> endDragSelection() is GalleryContract.Intent.OnEditResult -> applyEditResult(intent.pickedImage) GalleryContract.Intent.Confirm -> confirmSelection() GalleryContract.Intent.Cancel -> cancel() @@ -93,13 +90,11 @@ internal class GalleryScreenViewModel( getAlbums() .onEach { albums -> _state.update { current -> - val selectedAlbum = current.selectedAlbum ?: albums.firstOrNull() - current.copy(albums = albums, selectedAlbum = selectedAlbum) - } - val selectedAlbumId = _state.value.selectedAlbum?.id - val nextFilter = AlbumFilter.Active(selectedAlbumId) - if (_albumFilter.value != nextFilter) { - _albumFilter.value = nextFilter + current.copy( + albums = albums, + selectedAlbum = current.selectedAlbum ?: albums.firstOrNull(), + isAlbumsLoading = false, + ) } } .launchIn(viewModelScope) @@ -125,6 +120,47 @@ internal class GalleryScreenViewModel( _state.update { it.copy(selectedImages = it.selectedImages + image) } } + // Google Photos 스타일 드래그 range 선택: 롱프레스한 anchor가 이미 선택되어 있었다면 해제 모드, + // 아니라면 선택 모드로 이번 드래그 제스처의 목표 동작을 고정한다. + private fun beginDragSelection(anchorImage: GalleryImage) { + val current = _state.value + dragBaseline = current.selectedImages + dragIsDeselecting = current.selectedImages.any { it.id == anchorImage.id } + dragLimitSnackbarShown = false + } + + // anchor~현재 손가락 위치 사이의 range를 매번 baseline 기준으로 다시 계산한다. + // (증분 누적이 아니므로 손가락을 되돌리면 range 밖으로 나간 항목이 자동으로 원상복구된다) + private fun updateDragSelectionRange(rangeImages: List) { + val baseline = dragBaseline ?: return + val rangeIds = rangeImages.map { it.id }.toSet() + + val desired = if (dragIsDeselecting) { + baseline.filter { it.id !in rangeIds } + } else { + baseline + rangeImages.filter { image -> baseline.none { it.id == image.id } } + } + + val maxCount = _state.value.maxSelectionCount + if (desired.size > maxCount) { + _state.update { it.copy(selectedImages = desired.take(maxCount)) } + if (!dragLimitSnackbarShown) { + dragLimitSnackbarShown = true + viewModelScope.launch { + _effect.send(GalleryContract.Effect.ShowSelectionLimitSnackbar(maxCount)) + } + } + } else { + _state.update { it.copy(selectedImages = desired) } + } + } + + private fun endDragSelection() { + dragBaseline = null + dragIsDeselecting = false + dragLimitSnackbarShown = false + } + private fun applyEditResult(pickedImage: PickedImage) { _state.update { current -> val targetId = current.selectedImages diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/utils/Utils.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/utils/Utils.kt index aae35de..23744fb 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/utils/Utils.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/utils/Utils.kt @@ -1,5 +1,6 @@ package io.github.seunghee17.imagepicker.presentation.utils +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier @@ -17,68 +18,85 @@ import io.github.seunghee17.imagepicker.domain.model.GalleryImage internal data class DragSelectionState( val offset: Offset, - val lastProcessedKey: Long?, + val anchorIndex: Int, + val lastProcessedIndex: Int?, ) // 현재 터치한 좌표를 매개변수로 받는다 -// 이 아이템 영역 안에 현재 터치좌표가 들어가 있는가? 만약 찾은 아이템이 있다면 key 반환 없으면 null -internal fun LazyGridState.gridItemKeyAtPosition(hitPoint: Offset): Long? = +// 이 아이템 영역 안에 현재 터치좌표가 들어가 있는가? 만약 찾은 아이템이 있다면 그 아이템 정보(키/그리드 인덱스 포함) 반환, 없으면 null +internal fun LazyGridState.gridItemInfoAtPosition(hitPoint: Offset): LazyGridItemInfo? = // 현재 화면에 보이는 아이템 정보 리스트 layoutInfo.visibleItemsInfo.find { itemInfo -> itemInfo.size.toIntRect() .contains(hitPoint.round() - itemInfo.offset) // global 좌표를 item local 좌표로 변환 - }?.key as? Long + } + +// anchor(드래그 시작 지점)와 current(현재 손가락 위치) 사이의 grid 선형 인덱스 범위를 +// 방향과 무관하게 오름차순(좌→우, 위→아래)으로 계산한다. 아직 로드되지 않은 인덱스는 건너뛴다. +internal fun computeDragRange( + anchorIndex: Int, + currentIndex: Int, + images: List, +): List { + val from = minOf(anchorIndex, currentIndex) + val to = maxOf(anchorIndex, currentIndex) + return (from..to).mapNotNull { images.getOrNull(it) } +} internal fun Modifier.photoGridDragHandler( lazyGridState: LazyGridState, haptics: HapticFeedback, - selectedImages: List, - onSelect: (Long) -> Unit, // viewmodel에 정의한 사진 선택 콜백 주입하도록 수정 + imagesSnapshot: List, // grid 인덱스 순으로 정렬된, 현재 로드된 이미지 스냅샷 + onBeginDrag: (GalleryImage) -> Unit, // anchor로 선택된 이미지를 viewmodel에 알림 (선택/해제 모드 결정용) + onUpdateRange: (List) -> Unit, // anchor~현재 위치 range를 viewmodel에 알림 + onEndDrag: () -> Unit, // 드래그 종료(취소 포함)를 viewmodel에 알림 autoScrollSpeed: MutableState, autoScrollThreshold: Float, - currentDragState: MutableState // 자동 스크롤 중 중복 토글 방지를 위해 좌표와 마지막 처리 key를 함께 노출 + currentDragState: MutableState // 자동 스크롤 중 range 재계산을 위해 anchor/좌표/마지막 처리 인덱스를 함께 노출 ): Modifier = composed { - val currentSelectedImages by rememberUpdatedState(selectedImages) - val currentOnSelect by rememberUpdatedState(onSelect) + val currentImagesSnapshot by rememberUpdatedState(imagesSnapshot) + val currentOnBeginDrag by rememberUpdatedState(onBeginDrag) + val currentOnUpdateRange by rememberUpdatedState(onUpdateRange) + val currentOnEndDrag by rememberUpdatedState(onEndDrag) pointerInput(Unit) { - var initialKey: Long? = null - var currentKey: Long? = null + var anchorIndex: Int? = null + var lastProcessedIndex: Int? = null detectDragGesturesAfterLongPress( onDragStart = { offset -> - lazyGridState.gridItemKeyAtPosition(offset)?.let { key -> - if (currentSelectedImages.none { it.id == key }) { - // 새롭게 선택 상태 업데이트가 필요한 아이템 + lazyGridState.gridItemInfoAtPosition(offset)?.let { info -> + currentImagesSnapshot.getOrNull(info.index)?.let { image -> haptics.performHapticFeedback(HapticFeedbackType.LongPress) - initialKey = key - currentKey = key - currentOnSelect(key) + anchorIndex = info.index + lastProcessedIndex = info.index + currentOnBeginDrag(image) + currentOnUpdateRange(listOf(image)) currentDragState.value = DragSelectionState( offset = offset, - lastProcessedKey = key, + anchorIndex = info.index, + lastProcessedIndex = info.index, ) } } }, onDragCancel = { - initialKey = null - currentKey = null + anchorIndex = null + lastProcessedIndex = null autoScrollSpeed.value = 0f currentDragState.value = null + currentOnEndDrag() }, onDragEnd = { - initialKey = null - currentKey = null + anchorIndex = null + lastProcessedIndex = null autoScrollSpeed.value = 0f currentDragState.value = null + currentOnEndDrag() }, onDrag = { change, _ -> - if (initialKey != null) { - currentDragState.value = DragSelectionState( - offset = change.position, - lastProcessedKey = currentDragState.value?.lastProcessedKey, - ) + val anchor = anchorIndex + if (anchor != null) { val distFromBottom = lazyGridState.layoutInfo.viewportSize.height - change.position.y val distFromTop = change.position.y @@ -88,18 +106,19 @@ internal fun Modifier.photoGridDragHandler( distFromTop < autoScrollThreshold -> -(autoScrollThreshold - distFromTop) else -> 0f } - lazyGridState.gridItemKeyAtPosition(change.position)?.let { key -> - if (currentKey != key && currentSelectedImages.none { it.id == key }) { - currentOnSelect(key) - currentKey = key - currentDragState.value = DragSelectionState( - offset = change.position, - lastProcessedKey = key, - ) - } + val hitIndex = lazyGridState.gridItemInfoAtPosition(change.position)?.index + if (hitIndex != null && hitIndex != lastProcessedIndex) { + lastProcessedIndex = hitIndex + currentOnUpdateRange(computeDragRange(anchor, hitIndex, currentImagesSnapshot)) } + // autoscroll 폴링 루프가 이 좌표를 기준으로 계속 range를 재계산할 수 있도록 항상 최신 offset을 반영 + currentDragState.value = DragSelectionState( + offset = change.position, + anchorIndex = anchor, + lastProcessedIndex = lastProcessedIndex, + ) } } ) } -} +} \ No newline at end of file diff --git a/imagepicker/src/test/java/io/github/seunghee17/imagepicker/ExampleUnitTest.kt b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/ExampleUnitTest.kt deleted file mode 100644 index 099cbec..0000000 --- a/imagepicker/src/test/java/io/github/seunghee17/imagepicker/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.github.seunghee17.imagepicker - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/imagepicker/src/test/java/io/github/seunghee17/imagepicker/fake/FakeGalleryRepository.kt b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/fake/FakeGalleryRepository.kt new file mode 100644 index 0000000..30eaea5 --- /dev/null +++ b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/fake/FakeGalleryRepository.kt @@ -0,0 +1,19 @@ +package io.github.seunghee17.imagepicker.fake + +import androidx.paging.PagingData +import io.github.seunghee17.imagepicker.domain.model.GalleryAlbum +import io.github.seunghee17.imagepicker.domain.model.GalleryImage +import io.github.seunghee17.imagepicker.domain.repository.GalleryRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf + +internal class FakeGalleryRepository : GalleryRepository { + + val albums = MutableStateFlow>(emptyList()) + + override fun getAlbums(): Flow> = albums + + override fun getPagedImages(albumId: String?): Flow> = + flowOf(PagingData.empty()) +} diff --git a/imagepicker/src/test/java/io/github/seunghee17/imagepicker/fake/FakeImageEditRepository.kt b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/fake/FakeImageEditRepository.kt new file mode 100644 index 0000000..8e46d42 --- /dev/null +++ b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/fake/FakeImageEditRepository.kt @@ -0,0 +1,33 @@ +package io.github.seunghee17.imagepicker.fake + +import android.net.Uri +import io.github.seunghee17.imagepicker.CropRect +import io.github.seunghee17.imagepicker.domain.repository.ImageEditRepository + +internal class FakeImageEditRepository : ImageEditRepository { + + var rotateResult: Uri? = null + var cropResult: Uri? = null + var rotateThrows: Boolean = false + var cropThrows: Boolean = false + var clearCacheCalled: Boolean = false + + var lastRotateSourceUri: Uri? = null + var lastCropSourceUri: Uri? = null + + override suspend fun rotateImage(sourceUri: Uri, degrees: Int): Uri { + lastRotateSourceUri = sourceUri + if (rotateThrows) throw RuntimeException("Rotate failed") + return rotateResult ?: sourceUri + } + + override suspend fun cropImage(sourceUri: Uri, cropRect: CropRect): Uri { + lastCropSourceUri = sourceUri + if (cropThrows) throw RuntimeException("Crop failed") + return cropResult ?: sourceUri + } + + override suspend fun clearEditCache() { + clearCacheCalled = true + } +} diff --git a/imagepicker/src/test/java/io/github/seunghee17/imagepicker/presentation/editor/EditorViewModelTest.kt b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/presentation/editor/EditorViewModelTest.kt new file mode 100644 index 0000000..83f5095 --- /dev/null +++ b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/presentation/editor/EditorViewModelTest.kt @@ -0,0 +1,239 @@ +package io.github.seunghee17.imagepicker.presentation.editor + +import android.net.Uri +import app.cash.turbine.test +import io.github.seunghee17.imagepicker.CropRect +import io.github.seunghee17.imagepicker.domain.usecase.CropImageUseCase +import io.github.seunghee17.imagepicker.domain.usecase.RotateImageUseCase +import io.github.seunghee17.imagepicker.fake.FakeImageEditRepository +import io.github.seunghee17.imagepicker.util.MainDispatcherRule +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class EditorViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val originalUri: Uri = mockk(relaxed = true) + + private fun createVm(repo: FakeImageEditRepository = FakeImageEditRepository()): EditorViewModel = + EditorViewModel( + originalUri = originalUri, + rotateImage = RotateImageUseCase(repo), + cropImage = CropImageUseCase(repo), + ) + + // ───────────────────────────────────────────────────────────────────────── + // Rotate state update + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `RotateClockwise updates previewUri to rotated result`() = runTest { + val rotatedUri = mockk(relaxed = true) + val repo = FakeImageEditRepository().apply { rotateResult = rotatedUri } + val vm = createVm(repo) + + vm.handleIntent(EditorContract.Intent.RotateClockwise) + + assertEquals(rotatedUri, vm.state.value.previewUri) + } + + @Test + fun `totalRotation wraps to 0 after four rotations`() = runTest { + val vm = createVm() + repeat(4) { vm.handleIntent(EditorContract.Intent.RotateClockwise) } + assertEquals(0, vm.state.value.totalRotation) + } + + @Test + fun `rotate failure emits ShowError effect`() = runTest { + val repo = FakeImageEditRepository().apply { rotateThrows = true } + val vm = createVm(repo) + + vm.effect.test { + vm.handleIntent(EditorContract.Intent.RotateClockwise) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Crop state commit + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `ApplyCrop updates committedUri and previewUri to cropped result`() = runTest { + val croppedUri = mockk(relaxed = true) + val repo = FakeImageEditRepository().apply { cropResult = croppedUri } + val vm = createVm(repo) + + vm.handleIntent(EditorContract.Intent.EnterCropMode) + vm.handleIntent(EditorContract.Intent.ApplyCrop) + + assertEquals(croppedUri, vm.state.value.committedUri) + assertEquals(croppedUri, vm.state.value.previewUri) + } + + @Test + fun `ApplyCrop resets pendingRotation to 0`() = runTest { + val vm = createVm() + vm.handleIntent(EditorContract.Intent.RotateClockwise) // pendingRotation = 90 + vm.handleIntent(EditorContract.Intent.EnterCropMode) + vm.handleIntent(EditorContract.Intent.ApplyCrop) + + assertEquals(0, vm.state.value.pendingRotation) + } + + @Test + fun `ApplyCrop resets cropRect to FULL`() = runTest { + val vm = createVm() + vm.handleIntent( + EditorContract.Intent.UpdateCropRect(CropRect(0.1f, 0.1f, 0.9f, 0.9f)) + ) + vm.handleIntent(EditorContract.Intent.EnterCropMode) + vm.handleIntent(EditorContract.Intent.ApplyCrop) + + assertEquals(CropRect.FULL, vm.state.value.cropRect) + } + + @Test + fun `ExitCropMode restores cropRect to value on enter`() = runTest { + val vm = createVm() + val rect = CropRect(0.2f, 0.2f, 0.8f, 0.8f) + vm.handleIntent(EditorContract.Intent.UpdateCropRect(rect)) + vm.handleIntent(EditorContract.Intent.EnterCropMode) // saves rect as cropRectOnEnter + vm.handleIntent(EditorContract.Intent.UpdateCropRect(CropRect(0.5f, 0.5f, 0.9f, 0.9f))) + vm.handleIntent(EditorContract.Intent.ExitCropMode) + + assertEquals(rect, vm.state.value.cropRect) + } + + @Test + fun `crop failure emits ShowError effect`() = runTest { + val repo = FakeImageEditRepository().apply { cropThrows = true } + val vm = createVm(repo) + vm.handleIntent(EditorContract.Intent.EnterCropMode) + + vm.effect.test { + vm.handleIntent(EditorContract.Intent.ApplyCrop) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Combined scenarios: rotate ↔ crop ordering + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `rotate then crop uses rotated uri as crop source`() = runTest { + val rotatedUri = mockk(relaxed = true) + val repo = FakeImageEditRepository().apply { rotateResult = rotatedUri } + val vm = createVm(repo) + + vm.handleIntent(EditorContract.Intent.RotateClockwise) + vm.handleIntent(EditorContract.Intent.EnterCropMode) + vm.handleIntent(EditorContract.Intent.ApplyCrop) + + // cropImage must receive the rotated uri, not originalUri + assertEquals(rotatedUri, repo.lastCropSourceUri) + assertEquals(0, vm.state.value.pendingRotation) + } + + @Test + fun `crop then rotate uses cropped uri as rotate source`() = runTest { + val croppedUri = mockk(relaxed = true) + val repo = FakeImageEditRepository().apply { cropResult = croppedUri } + val vm = createVm(repo) + + vm.handleIntent(EditorContract.Intent.EnterCropMode) + vm.handleIntent(EditorContract.Intent.ApplyCrop) + vm.handleIntent(EditorContract.Intent.RotateClockwise) + + // rotateImage must receive the cropped uri, not originalUri + assertEquals(croppedUri, repo.lastRotateSourceUri) + } + + @Test + fun `rotate then crop then save returns crop result as editedUri`() = runTest { + val rotatedUri = mockk(relaxed = true) + val croppedUri = mockk(relaxed = true) + val repo = FakeImageEditRepository().apply { + rotateResult = rotatedUri + cropResult = croppedUri + } + val vm = createVm(repo) + + vm.handleIntent(EditorContract.Intent.RotateClockwise) + vm.handleIntent(EditorContract.Intent.EnterCropMode) + vm.handleIntent(EditorContract.Intent.ApplyCrop) + + vm.effect.test { + vm.handleIntent(EditorContract.Intent.SaveAndReturn) + val item = awaitItem() as EditorContract.Effect.ReturnEditedImage + assertEquals(originalUri, item.pickedImage.originalUri) + assertEquals(croppedUri, item.pickedImage.editedUri) + assertEquals(90, item.pickedImage.rotationDegrees) + assertTrue(item.pickedImage.isCropped) + cancelAndIgnoreRemainingEvents() + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Save result correctness + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `SaveAndReturn with no edits returns null editedUri`() = runTest { + val vm = createVm() + + vm.effect.test { + vm.handleIntent(EditorContract.Intent.SaveAndReturn) + val item = awaitItem() as EditorContract.Effect.ReturnEditedImage + assertNull(item.pickedImage.editedUri) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `SaveAndReturn after rotate returns editedUri and correct rotationDegrees`() = runTest { + val rotatedUri = mockk(relaxed = true) + val repo = FakeImageEditRepository().apply { rotateResult = rotatedUri } + val vm = createVm(repo) + vm.handleIntent(EditorContract.Intent.RotateClockwise) + + vm.effect.test { + vm.handleIntent(EditorContract.Intent.SaveAndReturn) + val item = awaitItem() as EditorContract.Effect.ReturnEditedImage + assertEquals(originalUri, item.pickedImage.originalUri) + assertEquals(rotatedUri, item.pickedImage.editedUri) + assertEquals(90, item.pickedImage.rotationDegrees) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `SaveAndReturn after crop sets isCropped`() = runTest { + val croppedUri = mockk(relaxed = true) + val repo = FakeImageEditRepository().apply { cropResult = croppedUri } + val vm = createVm(repo) + vm.handleIntent(EditorContract.Intent.EnterCropMode) + vm.handleIntent(EditorContract.Intent.ApplyCrop) + + vm.effect.test { + vm.handleIntent(EditorContract.Intent.SaveAndReturn) + val item = awaitItem() as EditorContract.Effect.ReturnEditedImage + assertTrue(item.pickedImage.isCropped) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/imagepicker/src/test/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreenViewModelTest.kt b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreenViewModelTest.kt new file mode 100644 index 0000000..33edf18 --- /dev/null +++ b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreenViewModelTest.kt @@ -0,0 +1,409 @@ +package io.github.seunghee17.imagepicker.presentation.gallery + +import android.net.Uri +import app.cash.turbine.test +import io.github.seunghee17.imagepicker.PickedImage +import io.github.seunghee17.imagepicker.domain.model.GalleryAlbum +import io.github.seunghee17.imagepicker.domain.model.GalleryImage +import io.github.seunghee17.imagepicker.domain.model.MediaType +import io.github.seunghee17.imagepicker.domain.usecase.ClearEditCacheUseCase +import io.github.seunghee17.imagepicker.domain.usecase.GetGalleryAlbumsUseCase +import io.github.seunghee17.imagepicker.domain.usecase.GetPagedImagesUseCase +import io.github.seunghee17.imagepicker.fake.FakeGalleryRepository +import io.github.seunghee17.imagepicker.fake.FakeImageEditRepository +import io.github.seunghee17.imagepicker.util.MainDispatcherRule +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class GalleryScreenViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private fun fakeImage(id: Long): GalleryImage = GalleryImage( + id = id, + uri = mockk(relaxed = true), + displayName = "image_$id.jpg", + dateTaken = 0L, + albumId = "album1", + albumName = "Camera", + width = 100, + height = 100, + mimeType = "image/jpeg", + mediaType = MediaType.IMAGE, + ) + + private fun fakeAlbum(id: String, name: String): GalleryAlbum = GalleryAlbum( + id = id, + name = name, + coverUri = mockk(relaxed = true), + count = 1, + ) + + private fun createVm( + maxSelectionCount: Int = 10, + fakeGallery: FakeGalleryRepository = FakeGalleryRepository(), + ): GalleryScreenViewModel = GalleryScreenViewModel( + getAlbums = GetGalleryAlbumsUseCase(fakeGallery), + getPagedImages = GetPagedImagesUseCase(fakeGallery), + clearEditCache = ClearEditCacheUseCase(FakeImageEditRepository()), + maxSelectionCount = maxSelectionCount, + ) + + // ───────────────────────────────────────────────────────────────────────── + // Album loading state + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `isAlbumsLoading is true in initial state`() = runTest { + val vm = createVm() + assertTrue(vm.state.value.isAlbumsLoading) + } + + @Test + fun `isAlbumsLoading becomes false after first album emission`() = runTest { + val fakeGallery = FakeGalleryRepository() + val vm = createVm(fakeGallery = fakeGallery) + vm.handleIntent(GalleryContract.Intent.Initialize) + + fakeGallery.albums.value = listOf(fakeAlbum("1", "Camera")) + + assertFalse(vm.state.value.isAlbumsLoading) + } + + @Test + fun `first album in list becomes selectedAlbum when none was pre-selected`() = runTest { + val fakeGallery = FakeGalleryRepository() + val vm = createVm(fakeGallery = fakeGallery) + val album = fakeAlbum("1", "Camera") + vm.handleIntent(GalleryContract.Intent.Initialize) + + fakeGallery.albums.value = listOf(album) + + assertEquals(album, vm.state.value.selectedAlbum) + } + + // ───────────────────────────────────────────────────────────────────────── + // Album switching safety + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `rapid album switching keeps only last selection`() = runTest { + val fakeGallery = FakeGalleryRepository() + val vm = createVm(fakeGallery = fakeGallery) + val album1 = fakeAlbum("1", "Camera") + val album2 = fakeAlbum("2", "Downloads") + val album3 = fakeAlbum("3", "Screenshots") + vm.handleIntent(GalleryContract.Intent.Initialize) + fakeGallery.albums.value = listOf(album1, album2, album3) + + vm.handleIntent(GalleryContract.Intent.SelectAlbum(album1)) + vm.handleIntent(GalleryContract.Intent.SelectAlbum(album2)) + vm.handleIntent(GalleryContract.Intent.SelectAlbum(album3)) + + assertEquals(album3, vm.state.value.selectedAlbum) + } + + @Test + fun `album list refresh does not overwrite user album selection`() = runTest { + val fakeGallery = FakeGalleryRepository() + val vm = createVm(fakeGallery = fakeGallery) + val album1 = fakeAlbum("1", "Camera") + val album2 = fakeAlbum("2", "Downloads") + vm.handleIntent(GalleryContract.Intent.Initialize) + fakeGallery.albums.value = listOf(album1, album2) + + vm.handleIntent(GalleryContract.Intent.SelectAlbum(album2)) + assertEquals(album2, vm.state.value.selectedAlbum) + + // MediaStore 갱신으로 앨범 목록이 다시 방출되어도 사용자 선택이 유지돼야 한다 + fakeGallery.albums.value = listOf(album1, album2) + + assertEquals(album2, vm.state.value.selectedAlbum) + } + + // ───────────────────────────────────────────────────────────────────────── + // Selection toggle + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `toggling unselected image adds it to selectedImages`() = runTest { + val vm = createVm() + val image = fakeImage(1) + + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(image)) + + assertEquals(listOf(image), vm.state.value.selectedImages) + } + + @Test + fun `toggling already selected image removes it from selectedImages`() = runTest { + val vm = createVm() + val image = fakeImage(1) + + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(image)) + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(image)) + + assertTrue(vm.state.value.selectedImages.isEmpty()) + } + + @Test + fun `selectionOrderMap reflects insertion order`() = runTest { + val vm = createVm() + val image1 = fakeImage(1) + val image2 = fakeImage(2) + + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(image1)) + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(image2)) + + assertEquals(1, vm.state.value.selectionOrderMap[1L]) + assertEquals(2, vm.state.value.selectionOrderMap[2L]) + } + + // ───────────────────────────────────────────────────────────────────────── + // Max selection enforcement + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `selecting beyond limit emits ShowSelectionLimitSnackbar`() = runTest { + val vm = createVm(maxSelectionCount = 2) + + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(fakeImage(1))) + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(fakeImage(2))) + + vm.effect.test { + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(fakeImage(3))) + val item = awaitItem() + assertIs(item) + assertEquals(2, item.maxSelectionCount) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `image beyond limit is not added to selectedImages`() = runTest { + val vm = createVm(maxSelectionCount = 2) + + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(fakeImage(1))) + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(fakeImage(2))) + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(fakeImage(3))) + + assertEquals(2, vm.state.value.selectedImages.size) + } + + // ───────────────────────────────────────────────────────────────────────── + // Drag range selection (anchor~current, Google Photos style) + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `dragging over a row selects the whole range in grid order`() = runTest { + val vm = createVm() + val images = (1..7L).map { fakeImage(it) } + + vm.handleIntent(GalleryContract.Intent.BeginDragSelection(images[1])) // index 1 + vm.handleIntent(GalleryContract.Intent.UpdateDragSelectionRange(images.subList(1, 7))) // index 1..6 + + assertEquals(images.subList(1, 7), vm.state.value.selectedImages) + } + + @Test + fun `reversing drag direction shrinks the range back to baseline`() = runTest { + val vm = createVm() + val images = (1..7L).map { fakeImage(it) } + + vm.handleIntent(GalleryContract.Intent.BeginDragSelection(images[1])) + vm.handleIntent(GalleryContract.Intent.UpdateDragSelectionRange(images.subList(1, 7))) + // 손가락을 되돌려 anchor~index3 까지만 남김 + vm.handleIntent(GalleryContract.Intent.UpdateDragSelectionRange(images.subList(1, 4))) + + assertEquals(images.subList(1, 4), vm.state.value.selectedImages) + } + + @Test + fun `pre-existing tap selections outside the range are preserved during select-mode drag`() = runTest { + val vm = createVm() + val tapped = fakeImage(100) + val images = (1..7L).map { fakeImage(it) } + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(tapped)) + + vm.handleIntent(GalleryContract.Intent.BeginDragSelection(images[1])) + vm.handleIntent(GalleryContract.Intent.UpdateDragSelectionRange(images.subList(1, 4))) + + assertTrue(vm.state.value.selectedImages.containsAll(images.subList(1, 4))) + assertTrue(vm.state.value.selectedImages.contains(tapped)) + } + + @Test + fun `long-pressing an already-selected image enters drag-deselect mode`() = runTest { + val vm = createVm() + val images = (1..7L).map { fakeImage(it) } + images.forEach { vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(it)) } + + vm.handleIntent(GalleryContract.Intent.BeginDragSelection(images[1])) + vm.handleIntent(GalleryContract.Intent.UpdateDragSelectionRange(images.subList(1, 4))) + + assertEquals( + listOf(images[0]) + images.subList(4, 7), + vm.state.value.selectedImages, + ) + } + + @Test + fun `drag-deselect removes range items even if pre-existing outside this drag`() = runTest { + val vm = createVm() + val images = (1..20L).map { fakeImage(it) } + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(images[4])) // id=5 + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(images[19])) // id=20 + + vm.handleIntent(GalleryContract.Intent.BeginDragSelection(images[4])) + vm.handleIntent( + GalleryContract.Intent.UpdateDragSelectionRange(images.subList(4, 20)) + ) + + assertTrue(vm.state.value.selectedImages.isEmpty()) + } + + @Test + fun `range selection beyond max fills up to the limit and emits snackbar once`() = runTest { + val vm = createVm(maxSelectionCount = 10) + val images = (1..20L).map { fakeImage(it) } + images.subList(0, 8).forEach { vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(it)) } + + vm.handleIntent(GalleryContract.Intent.BeginDragSelection(images[8])) + vm.effect.test { + vm.handleIntent(GalleryContract.Intent.UpdateDragSelectionRange(images.subList(8, 11))) + val item = awaitItem() + assertIs(item) + // 다음 tick에서도 계속 초과 상태라면 스낵바를 다시 보내지 않는다 + vm.handleIntent(GalleryContract.Intent.UpdateDragSelectionRange(images.subList(8, 12))) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertEquals(10, vm.state.value.selectedImages.size) + } + + @Test + fun `ending drag selection allows a fresh drag gesture to start its own baseline`() = runTest { + val vm = createVm() + val images = (1..7L).map { fakeImage(it) } + + vm.handleIntent(GalleryContract.Intent.BeginDragSelection(images[1])) + vm.handleIntent(GalleryContract.Intent.UpdateDragSelectionRange(images.subList(1, 4))) + vm.handleIntent(GalleryContract.Intent.EndDragSelection) + + vm.handleIntent(GalleryContract.Intent.BeginDragSelection(images[4])) + vm.handleIntent(GalleryContract.Intent.UpdateDragSelectionRange(images.subList(4, 6))) + + assertEquals(images.subList(1, 4) + images.subList(4, 6), vm.state.value.selectedImages) + } + + // ───────────────────────────────────────────────────────────────────────── + // Confirm result composition + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `Confirm emits SelectionConfirmed with selected images`() = runTest { + val vm = createVm() + val image = fakeImage(1) + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(image)) + + vm.effect.test { + vm.handleIntent(GalleryContract.Intent.Confirm) + val item = awaitItem() + assertIs(item) + assertEquals(1, item.result.items.size) + assertEquals(image.uri, item.result.items[0].originalUri) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Confirm resets selectedImages`() = runTest { + val vm = createVm() + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(fakeImage(1))) + vm.handleIntent(GalleryContract.Intent.Confirm) + + assertTrue(vm.state.value.selectedImages.isEmpty()) + } + + // ───────────────────────────────────────────────────────────────────────── + // Cancel + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `Cancel resets selectedImages and editResults`() = runTest { + val vm = createVm() + val image = fakeImage(1) + val editedUri = mockk(relaxed = true) + + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(image)) + vm.handleIntent( + GalleryContract.Intent.OnEditResult( + PickedImage(originalUri = image.uri, editedUri = editedUri) + ) + ) + + vm.handleIntent(GalleryContract.Intent.Cancel) + + assertTrue(vm.state.value.selectedImages.isEmpty()) + assertTrue(vm.state.value.editResults.isEmpty()) + } + + // ───────────────────────────────────────────────────────────────────────── + // Edit result merge + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `edit result is merged into confirm result`() = runTest { + val vm = createVm() + val image = fakeImage(1) + val editedUri = mockk(relaxed = true) + val pickedImage = PickedImage( + originalUri = image.uri, + editedUri = editedUri, + rotationDegrees = 90, + ) + + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(image)) + vm.handleIntent(GalleryContract.Intent.OnEditResult(pickedImage)) + + vm.effect.test { + vm.handleIntent(GalleryContract.Intent.Confirm) + val item = awaitItem() as GalleryContract.Effect.SelectionConfirmed + val result = item.result.items[0] + assertEquals(editedUri, result.editedUri) + assertEquals(90, result.rotationDegrees) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `edit result for non-selected image is ignored`() = runTest { + val vm = createVm() + val selectedImage = fakeImage(1) + val unrelatedImage = fakeImage(99) + + vm.handleIntent(GalleryContract.Intent.ToggleImageSelection(selectedImage)) + vm.handleIntent( + GalleryContract.Intent.OnEditResult( + PickedImage(originalUri = unrelatedImage.uri, rotationDegrees = 90) + ) + ) + + vm.effect.test { + vm.handleIntent(GalleryContract.Intent.Confirm) + val item = awaitItem() as GalleryContract.Effect.SelectionConfirmed + assertNull(item.result.items[0].editedUri) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/imagepicker/src/test/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerViewModelTest.kt b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerViewModelTest.kt new file mode 100644 index 0000000..dd1b421 --- /dev/null +++ b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerViewModelTest.kt @@ -0,0 +1,152 @@ +package io.github.seunghee17.imagepicker.presentation.picker + +import app.cash.turbine.test +import io.github.seunghee17.imagepicker.domain.model.PermissionStatus +import io.github.seunghee17.imagepicker.util.MainDispatcherRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class ImagePickerViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + // ───────────────────────────────────────────────────────────────────────── + // Initialize + // ───────────────────────────────────────────────────────────────────────── + + // ───────────────────────────────────────────────────────────────────────── + // PARTIALLY_GRANTED flow + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `PARTIALLY_GRANTED on INITIAL sends RequestPermission and marks flag`() = runTest { + val vm = ImagePickerViewModel() + vm.effect.test { + vm.handleIntent( + ImagePickerContract.Intent.OnPermissionEvaluated( + PermissionStatus.PARTIALLY_GRANTED, + ImagePickerContract.PermissionCheckSource.INITIAL, + ) + ) + assertIs(awaitItem()) + assertTrue(vm.state.value.hasRequestedFullAccessAfterPartial) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `PARTIALLY_GRANTED on RESUME does not send any effect`() = runTest { + val vm = ImagePickerViewModel() + vm.effect.test { + vm.handleIntent( + ImagePickerContract.Intent.OnPermissionEvaluated( + PermissionStatus.PARTIALLY_GRANTED, + ImagePickerContract.PermissionCheckSource.RESUME, + ) + ) + expectNoEvents() + assertEquals(PermissionStatus.PARTIALLY_GRANTED, vm.state.value.permissionStatus) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `PARTIALLY_GRANTED second time does not send RequestPermission again`() = runTest { + val vm = ImagePickerViewModel() + vm.effect.test { + // First evaluation sets the flag and emits RequestPermission + vm.handleIntent( + ImagePickerContract.Intent.OnPermissionEvaluated( + PermissionStatus.PARTIALLY_GRANTED, + ImagePickerContract.PermissionCheckSource.INITIAL, + ) + ) + assertIs(awaitItem()) + assertTrue(vm.state.value.hasRequestedFullAccessAfterPartial) + + // Second evaluation: flag already set, should produce no effect + vm.handleIntent( + ImagePickerContract.Intent.OnPermissionEvaluated( + PermissionStatus.PARTIALLY_GRANTED, + ImagePickerContract.PermissionCheckSource.PERMISSION_RESULT, + ) + ) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + // ───────────────────────────────────────────────────────────────────────── + // PERMANENTLY_DENIED → settings navigation + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `PERMANENTLY_DENIED on PERMISSION_RESULT sends NavigateToSettings`() = runTest { + val vm = ImagePickerViewModel() + vm.effect.test { + vm.handleIntent( + ImagePickerContract.Intent.OnPermissionEvaluated( + PermissionStatus.PERMANENTLY_DENIED, + ImagePickerContract.PermissionCheckSource.PERMISSION_RESULT, + ) + ) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `PERMANENTLY_DENIED on INITIAL does not send NavigateToSettings`() = runTest { + val vm = ImagePickerViewModel() + vm.effect.test { + vm.handleIntent( + ImagePickerContract.Intent.OnPermissionEvaluated( + PermissionStatus.PERMANENTLY_DENIED, + ImagePickerContract.PermissionCheckSource.INITIAL, + ) + ) + expectNoEvents() + assertEquals(PermissionStatus.PERMANENTLY_DENIED, vm.state.value.permissionStatus) + cancelAndIgnoreRemainingEvents() + } + } + + // ───────────────────────────────────────────────────────────────────────── + // GRANTED + // ───────────────────────────────────────────────────────────────────────── + + @Test + fun `GRANTED resets hasRequestedFullAccessAfterPartial flag`() = runTest { + val vm = ImagePickerViewModel() + vm.effect.test { + // Set flag via partial grant + vm.handleIntent( + ImagePickerContract.Intent.OnPermissionEvaluated( + PermissionStatus.PARTIALLY_GRANTED, + ImagePickerContract.PermissionCheckSource.INITIAL, + ) + ) + awaitItem() // consume RequestPermission effect + assertTrue(vm.state.value.hasRequestedFullAccessAfterPartial) + + // Full grant should reset the flag + vm.handleIntent( + ImagePickerContract.Intent.OnPermissionEvaluated( + PermissionStatus.GRANTED, + ImagePickerContract.PermissionCheckSource.PERMISSION_RESULT, + ) + ) + assertEquals(PermissionStatus.GRANTED, vm.state.value.permissionStatus) + assertFalse(vm.state.value.hasRequestedFullAccessAfterPartial) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/imagepicker/src/test/java/io/github/seunghee17/imagepicker/util/MainDispatcherRule.kt b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/util/MainDispatcherRule.kt new file mode 100644 index 0000000..79f6a56 --- /dev/null +++ b/imagepicker/src/test/java/io/github/seunghee17/imagepicker/util/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package io.github.seunghee17.imagepicker.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +}