From 9f735eab64b92352d4270472ff3bada5b4d91de9 Mon Sep 17 00:00:00 2001 From: seunghee Date: Sun, 29 Mar 2026 20:04:05 +0900 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=EC=9D=98=20?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=8B=9C=20=EC=9E=90=EB=8F=99=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EA=B5=AC=EC=A1=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/gallery/GalleryScreen.kt | 25 ++++++++++++++++--- .../imagepicker/presentation/utils/Utils.kt | 7 +++++- 2 files changed, 28 insertions(+), 4 deletions(-) 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 1fd433a..236ccc6 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 @@ -17,17 +17,21 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import io.github.seunghee17.imagepicker.domain.model.GalleryImage import io.github.seunghee17.imagepicker.presentation.component.TopBarWithCount +import io.github.seunghee17.imagepicker.presentation.utils.gridItemKeyAtPosition import io.github.seunghee17.imagepicker.presentation.utils.photoGridDragHandler import io.github.seunghee17.imagepicker.PickerResult import kotlinx.coroutines.delay @@ -49,7 +53,9 @@ internal fun GalleryScreen( val snackbarHostState = remember { SnackbarHostState() } val gridState = rememberLazyGridState() val autoScrollSpeed = remember { mutableFloatStateOf(0f) } - var dropDownExpanded by rememberSaveable { androidx.compose.runtime.mutableStateOf(false) } + val currentDragOffset = remember { mutableStateOf(null) } + val currentState by rememberUpdatedState(state) + var dropDownExpanded by rememberSaveable { mutableStateOf(false) } LaunchedEffect(effect) { effect.collect { galleryEffect -> @@ -68,10 +74,22 @@ internal fun GalleryScreen( } // 드래그 중 autoScrollSpeed 값에 따라 그리드를 자동 스크롤 + // 스크롤 후 현재 손가락 위치의 아이템을 체크하여 선택 LaunchedEffect(gridState) { while (true) { val speed = autoScrollSpeed.floatValue - if (speed != 0f) gridState.scrollBy(speed) + if (speed != 0f) { + gridState.scrollBy(speed) + currentDragOffset.value?.let { offset -> + gridState.gridItemKeyAtPosition(offset)?.let { key -> + if (currentState.selectedImages.none { it.id == key }) { + currentState.images.firstOrNull { it.id == key }?.let { image -> + onIntent(GalleryContract.Intent.ToggleImageSelection(image)) + } + } + } + } + } delay(16L) // ~60fps } } @@ -123,7 +141,8 @@ internal fun GalleryScreen( } }, autoScrollSpeed = autoScrollSpeed, - autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() } + autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }, + currentDragOffset = currentDragOffset ) ) { items(state.images, key = { it.id }) { image -> 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 1aefc2d..638778b 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 @@ -30,7 +30,8 @@ internal fun Modifier.photoGridDragHandler( selectedImages: List, onSelect: (Long) -> Unit, // viewmodel에 정의한 사진 선택 콜백 주입하도록 수정 autoScrollSpeed: MutableState, - autoScrollThreshold: Float + autoScrollThreshold: Float, + currentDragOffset: MutableState // 자동 스크롤 중 아이템 선택을 위해 현재 드래그 좌표 노출 ): Modifier = composed { val currentSelectedImages by rememberUpdatedState(selectedImages) val currentOnSelect by rememberUpdatedState(onSelect) @@ -48,19 +49,23 @@ internal fun Modifier.photoGridDragHandler( initialKey = key currentKey = key currentOnSelect(key) + currentDragOffset.value = offset } } }, onDragCancel = { initialKey = null autoScrollSpeed.value = 0f + currentDragOffset.value = null }, onDragEnd = { initialKey = null autoScrollSpeed.value = 0f + currentDragOffset.value = null }, onDrag = { change, _ -> if (initialKey != null) { + currentDragOffset.value = change.position val distFromBottom = lazyGridState.layoutInfo.viewportSize.height - change.position.y val distFromTop = change.position.y From 4056573f4564ab28e1a02587504aa571de272524 Mon Sep 17 00:00:00 2001 From: seunghee Date: Sun, 29 Mar 2026 21:10:22 +0900 Subject: [PATCH 02/20] =?UTF-8?q?fix:=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=84=A0=ED=83=9D=20=EC=A0=9C=ED=95=9C=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20=EC=8B=9C=20=EC=8A=A4=EB=82=B5=EB=B0=94=20?= =?UTF-8?q?=EC=A6=89=EC=8B=9C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/editor/EditorContract.kt | 2 - .../presentation/editor/EditorScreen.kt | 214 +++++++++++++----- .../presentation/editor/EditorViewModel.kt | 2 - .../presentation/gallery/GalleryScreen.kt | 19 +- .../presentation/picker/EditorRoute.kt | 69 +++--- .../presentation/picker/ImagePickerScreen.kt | 37 ++- 6 files changed, 225 insertions(+), 118 deletions(-) diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorContract.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorContract.kt index 4a9b777..691bad5 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorContract.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorContract.kt @@ -47,12 +47,10 @@ internal interface EditorContract { data object ApplyCrop : Intent data object ExitCropMode : Intent data object SaveAndReturn : Intent - data object Cancel : Intent } sealed interface Effect { data class ReturnEditedImage(val pickedImage: PickedImage) : Effect - data object Cancelled : Effect data class ShowError(val message: String) : Effect } } \ No newline at end of file diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt index be492b3..0982705 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt @@ -1,5 +1,6 @@ package io.github.seunghee17.imagepicker.presentation.editor +import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -10,14 +11,21 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,101 +35,207 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter +import io.github.seunghee17.imagepicker.PickedImage +import io.github.seunghee17.imagepicker.domain.model.GalleryImage +import io.github.seunghee17.imagepicker.presentation.component.SelectionBadge +import kotlinx.coroutines.flow.collectLatest /** - * 이미지 편집 화면 (회전 / 크롭). + * 이미지 편집 화면. + * + * - 상단 바 / 하단 버튼 / SelectionBadge 는 스와이프 영역 밖에 고정된다. + * - HorizontalPager 는 이미지 영역(weight=1)만 감싸므로, 스와이프 시 사진만 전환된다. + * - 각 페이지는 독립된 EditorViewModel 을 가지며, 현재 페이지의 state/intent 가 + * SideEffect 를 통해 상단 바 / 버튼에 노출된다. * * 크롭 모드: - * - TopAppBar: 취소(ExitCropMode) / 완료(ApplyCrop) - * - 이미지 위에 CropOverlay 표시 (보라색 경계선 + 드래그 핸들) - * - 하단 버튼 숨김 + * - 상단 바: 취소(ExitCropMode) / 완료(ApplyCrop) + * - 스와이프 비활성화 + * - SelectionBadge 숨김 * 일반 모드: - * - TopAppBar: 취소(Cancel) / 완료(SaveAndReturn) + * - 상단 바: 취소(→갤러리 복귀) / 완료(SaveAndReturn) * - 하단 버튼: 회전, 크롭 + * - 우측 상단: SelectionBadge (선택 순서 표시, 탭으로 토글) */ @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun EditorScreen( - state: EditorContract.State, - onIntent: (EditorContract.Intent) -> Unit, + pagerState: PagerState, + allImages: List, + selectedImages: List, + entryId: Long, + snackbarHostState: SnackbarHostState, + onEditApplied: (PickedImage) -> Unit, + onDismiss: () -> Unit, + onToggleSelection: (GalleryImage) -> Unit, + onError: (String) -> Unit, modifier: Modifier = Modifier, allowEditing: Boolean = true, ) { - val isCropping = state.mode == EditorContract.Mode.CROPPING + // 화면이 컴포지션을 떠날 때(갤러리 복귀 등) 잔여 스낵바 제거 + DisposableEffect(Unit) { + onDispose { snackbarHostState.currentSnackbarData?.dismiss() } + } + + // 현재 페이지의 ViewModel state / intent 를 페이저 밖에서 참조하기 위한 홀더 + val activeState = remember { mutableStateOf(null) } + val activeOnIntent = remember { mutableStateOf<((EditorContract.Intent) -> Unit)?>(null) } + + val currentState = activeState.value + val isCropping = currentState?.mode == EditorContract.Mode.CROPPING + + val currentImage = allImages.getOrNull(pagerState.settledPage) + val selectionOrder = currentImage?.let { img -> + selectedImages.indexOfFirst { it.id == img.id }.takeIf { it >= 0 }?.let { it + 1 } + } Scaffold( modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { Text(if (isCropping) "크롭 영역 선택" else "편집") }, navigationIcon = { - TextButton( - onClick = { - onIntent( - if (isCropping) EditorContract.Intent.ExitCropMode - else EditorContract.Intent.Cancel - ) - } - ) { Text("취소") } + TextButton(onClick = { + if (isCropping) activeOnIntent.value?.invoke(EditorContract.Intent.ExitCropMode) + else onDismiss() + }) { Text("취소") } }, actions = { TextButton( onClick = { - onIntent( - if (isCropping) EditorContract.Intent.ApplyCrop - else EditorContract.Intent.SaveAndReturn - ) + if (isCropping) activeOnIntent.value?.invoke(EditorContract.Intent.ApplyCrop) + else activeOnIntent.value?.invoke(EditorContract.Intent.SaveAndReturn) }, - enabled = !state.isSaving + enabled = currentState?.isSaving != true, ) { Text("완료") } } ) } ) { innerPadding -> - Column( + Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding), - horizontalAlignment = Alignment.CenterHorizontally + .padding(innerPadding) ) { - // 이미지 + 오버레이 - ImageWithCropOverlay( - state = state, - onIntent = onIntent, - modifier = Modifier - .weight(1f) - .fillMaxWidth() - ) + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 이미지 영역만 페이저 — 상단 바/하단 버튼은 고정 + HorizontalPager( + state = pagerState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + key = { allImages[it].id }, + userScrollEnabled = !isCropping, // 크롭 중 스와이프 비활성화 + ) { pageIndex -> + EditorImagePage( + image = allImages[pageIndex], + entryId = entryId, + isCurrentPage = pageIndex == pagerState.settledPage, + onActivate = { state, onIntent -> + activeState.value = state + activeOnIntent.value = onIntent + }, + onEditApplied = onEditApplied, + onDismiss = onDismiss, + onError = onError, + ) + } - // 하단 도구 버튼 (크롭 모드 또는 편집 비허용 시 숨김) - if (!isCropping && allowEditing) { - Row( + // 하단 편집 버튼 (크롭 모드·편집 비허용 시 숨김) + if (!isCropping && allowEditing) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + ) { + OutlinedButton( + onClick = { activeOnIntent.value?.invoke(EditorContract.Intent.RotateClockwise) }, + enabled = currentState?.isSaving != true, + ) { Text("회전") } + Spacer(modifier = Modifier.width(16.dp)) + OutlinedButton( + onClick = { activeOnIntent.value?.invoke(EditorContract.Intent.EnterCropMode) }, + enabled = currentState?.isSaving != true, + ) { Text("크롭") } + } + } + } + + // 우측 상단 SelectionBadge — 크롭 모드 진입 시 숨김 + if (!isCropping) { + SelectionBadge( + order = selectionOrder, + onTap = { currentImage?.let { onToggleSelection(it) } }, modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.Center - ) { - OutlinedButton( - onClick = { onIntent(EditorContract.Intent.RotateClockwise) }, - enabled = !state.isSaving - ) { Text("회전") } - Spacer(modifier = Modifier.width(16.dp)) - OutlinedButton( - onClick = { onIntent(EditorContract.Intent.EnterCropMode) }, - enabled = !state.isSaving - ) { Text("크롭") } + .align(Alignment.TopEnd) + .padding(12.dp) + ) + } + } + } +} + +/** + * 페이저 내 단일 페이지. + * - 독립된 EditorViewModel 을 생성·보유한다. + * - 현재 페이지일 때 SideEffect 로 상위에 state / intent 핸들러를 노출한다. + * - 이미지 + CropOverlay 만 렌더링한다. + */ +@Composable +private fun EditorImagePage( + image: GalleryImage, + entryId: Long, + isCurrentPage: Boolean, + onActivate: (EditorContract.State, (EditorContract.Intent) -> Unit) -> Unit, + onEditApplied: (PickedImage) -> Unit, + onDismiss: () -> Unit, + onError: (String) -> Unit, +) { + val context = LocalContext.current + val viewModel: EditorViewModel = viewModel( + key = "editor-$entryId-${image.id}", + factory = EditorViewModelFactory(originalUri = image.uri, context = context), + ) + val state by viewModel.state.collectAsStateWithLifecycle() + + // 현재 페이지가 되면 상위 상태 홀더를 즉시 업데이트 + if (isCurrentPage) { + SideEffect { onActivate(state, viewModel::handleIntent) } + } + + LaunchedEffect(Unit) { + viewModel.effect.collectLatest { effect -> + when (effect) { + is EditorContract.Effect.ReturnEditedImage -> onEditApplied(effect.pickedImage) + is EditorContract.Effect.ShowError -> { + Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + onError(effect.message) } } } } + + ImageWithCropOverlay( + state = state, + onIntent = viewModel::handleIntent, + modifier = Modifier.fillMaxSize(), + ) } /** - * 이미지를 렌더링하고, 크롭 모드일 때 CropOverlay를 덮어씌운다. + * 이미지를 렌더링하고, 크롭 모드일 때 CropOverlay 를 덮어씌운다. */ @Composable private fun ImageWithCropOverlay( @@ -134,7 +248,6 @@ private fun ImageWithCropOverlay( contentScale = ContentScale.Fit, ) - // 컨테이너 크기와 이미지 intrinsic 크기를 추적하여 imageRect 계산 var containerSize by remember { mutableStateOf(IntSize.Zero) } val intrinsicSize: IntSize? = remember(painter.state) { @@ -168,7 +281,6 @@ private fun ImageWithCropOverlay( CircularProgressIndicator() } - // 이미지 로드 완료 + 크롭 모드일 때만 오버레이 표시 if (isCropping && imageRect != null) { CropOverlay( cropRect = state.cropRect, diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorViewModel.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorViewModel.kt index 3cecc93..8a5f7e3 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorViewModel.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorViewModel.kt @@ -72,7 +72,6 @@ internal class EditorViewModel( cropRect = it.cropRectOnEnter, ) } EditorContract.Intent.SaveAndReturn -> save() - EditorContract.Intent.Cancel -> sendEffect(EditorContract.Effect.Cancelled) } } @@ -124,7 +123,6 @@ internal class EditorViewModel( * committedUri에 pendingRotation을 EXIF로 적용하여 previewUri를 갱신한다. * pendingRotation == 0이면 committedUri를 그대로 사용한다. * - * [Job 취소 전략] * 회전 버튼 연타 시 이전 작업을 취소하고 최신 상태로 재시작하여 * 불필요한 중간 연산을 생략한다. */ 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 236ccc6..406c068 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 @@ -14,6 +14,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -35,37 +36,27 @@ import io.github.seunghee17.imagepicker.presentation.utils.gridItemKeyAtPosition import io.github.seunghee17.imagepicker.presentation.utils.photoGridDragHandler import io.github.seunghee17.imagepicker.PickerResult import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow /// 갤러리 이미지 그리드 화면 (권한이 허용된 상태에서 표시). @Composable internal fun GalleryScreen( state: GalleryContract.State, - effect: Flow, + snackbarHostState: SnackbarHostState, onIntent: (GalleryContract.Intent) -> Unit, onOpenEditor: (GalleryImage) -> Unit, - onConfirm: (PickerResult) -> Unit, - onCancel: () -> Unit, modifier: Modifier = Modifier ) { val context = LocalContext.current - val snackbarHostState = remember { SnackbarHostState() } val gridState = rememberLazyGridState() val autoScrollSpeed = remember { mutableFloatStateOf(0f) } val currentDragOffset = remember { mutableStateOf(null) } val currentState by rememberUpdatedState(state) var dropDownExpanded by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(effect) { - effect.collect { galleryEffect -> - when (galleryEffect) { - is GalleryContract.Effect.ShowSelectionLimitSnackbar -> - snackbarHostState.showSnackbar(galleryEffect.message) - is GalleryContract.Effect.SelectionConfirmed -> onConfirm(galleryEffect.result) - GalleryContract.Effect.Cancelled -> onCancel() - } - } + // 화면이 컴포지션을 떠날 때(에디터 진입 등) 잔여 스낵바 제거 + DisposableEffect(Unit) { + onDispose { snackbarHostState.currentSnackbarData?.dismiss() } } // 이미지 로드 실패 시 토스트 표시 diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/EditorRoute.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/EditorRoute.kt index 3ced59e..8c0575a 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/EditorRoute.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/EditorRoute.kt @@ -1,28 +1,21 @@ package io.github.seunghee17.imagepicker.presentation.picker import android.net.Uri -import android.util.Log -import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.Saver import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.viewmodel.compose.viewModel import io.github.seunghee17.imagepicker.PickedImage -import io.github.seunghee17.imagepicker.presentation.editor.EditorContract +import io.github.seunghee17.imagepicker.domain.model.GalleryImage import io.github.seunghee17.imagepicker.presentation.editor.EditorScreen -import io.github.seunghee17.imagepicker.presentation.editor.EditorViewModel -import io.github.seunghee17.imagepicker.presentation.editor.EditorViewModelFactory -import kotlinx.coroutines.flow.collectLatest internal data class EditorDestination( val entryId: Long, val imageId: Long, - val originalUri: Uri + val originalUri: Uri, + val initialIndex: Int = 0, ) internal fun editorDestinationSaver(): Saver = Saver( @@ -31,7 +24,8 @@ internal fun editorDestinationSaver(): Saver = Saver( listOf( it.entryId.toString(), it.imageId.toString(), - it.originalUri.toString() + it.originalUri.toString(), + it.initialIndex.toString(), ) } }, @@ -41,7 +35,8 @@ internal fun editorDestinationSaver(): Saver = Saver( EditorDestination( entryId = values[0].toLong(), imageId = values[1].toLong(), - originalUri = Uri.parse(values[2]) + originalUri = Uri.parse(values[2]), + initialIndex = values.getOrNull(3)?.toIntOrNull() ?: 0, ) } ) @@ -49,41 +44,35 @@ internal fun editorDestinationSaver(): Saver = Saver( @Composable internal fun EditorRoute( destination: EditorDestination, + allImages: List, + selectedImages: List, + snackbarHostState: SnackbarHostState, onEditApplied: (PickedImage) -> Unit, onDismiss: () -> Unit, + onToggleSelection: (GalleryImage) -> Unit, onError: (String) -> Unit = {}, modifier: Modifier = Modifier, allowEditing: Boolean = true, ) { - val context = LocalContext.current - val viewModel: EditorViewModel = viewModel( - key = "editor-${destination.entryId}", - factory = EditorViewModelFactory( - originalUri = destination.originalUri, - context = context - ) - ) - val state by viewModel.state.collectAsStateWithLifecycle() - - LaunchedEffect(viewModel) { - viewModel.effect.collectLatest { effect -> - when (effect) { - is EditorContract.Effect.ReturnEditedImage -> onEditApplied(effect.pickedImage) - EditorContract.Effect.Cancelled -> onDismiss() - is EditorContract.Effect.ShowError -> { - Log.d("TTAG", "에러 ${effect.message}") - Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() - onError(effect.message) - } - } - } - } - BackHandler { onDismiss() } + val initialPage = destination.initialIndex + .coerceIn(0, (allImages.size - 1).coerceAtLeast(0)) + val pagerState = rememberPagerState( + initialPage = initialPage, + pageCount = { allImages.size }, + ) + EditorScreen( - state = state, - onIntent = viewModel::handleIntent, + pagerState = pagerState, + allImages = allImages, + selectedImages = selectedImages, + entryId = destination.entryId, + snackbarHostState = snackbarHostState, + onEditApplied = onEditApplied, + onDismiss = onDismiss, + onToggleSelection = onToggleSelection, + onError = onError, modifier = modifier, allowEditing = allowEditing, ) diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt index f0f12d5..4cf9ac7 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt @@ -3,12 +3,14 @@ package io.github.seunghee17.imagepicker.presentation.picker import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue @@ -37,6 +39,7 @@ internal fun ImagePickerScreen( ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val snackbarHostState = remember { SnackbarHostState() } val viewModel: ImagePickerViewModel = viewModel( factory = ImagePickerViewModelFactory() @@ -85,6 +88,19 @@ internal fun ImagePickerScreen( onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } + LaunchedEffect(Unit) { + galleryViewModel.effect.collectLatest { galleryEffect -> + when (galleryEffect) { + is GalleryContract.Effect.ShowSelectionLimitSnackbar -> + snackbarHostState.showSnackbar(galleryEffect.message) + is GalleryContract.Effect.SelectionConfirmed -> + viewModel.handleIntent(ImagePickerContract.Intent.ConfirmSelection(galleryEffect.result)) + GalleryContract.Effect.Cancelled -> + viewModel.handleIntent(ImagePickerContract.Intent.Cancel) + } + } + } + // 권한 허용(전체 또는 부분) 시 갤러리 초기화 LaunchedEffect(state.permissionStatus) { if (state.permissionStatus == PermissionStatus.GRANTED || @@ -119,10 +135,13 @@ internal fun ImagePickerScreen( is ImagePickerContract.Effect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() is ImagePickerContract.Effect.NavigateToEditor -> { + val index = galleryState.images.indexOfFirst { it.id == effect.image.id } + .coerceAtLeast(0) editorDestination = EditorDestination( entryId = effect.entryId, imageId = effect.image.id, - originalUri = effect.image.uri + originalUri = effect.image.uri, + initialIndex = index, ) } } @@ -138,17 +157,11 @@ internal fun ImagePickerScreen( GalleryScreen( modifier = modifier, state = galleryState, - effect = galleryViewModel.effect, + snackbarHostState = snackbarHostState, onIntent = galleryViewModel::handleIntent, onOpenEditor = { image -> viewModel.handleIntent(ImagePickerContract.Intent.OpenEditor(image = image)) }, - onConfirm = { result -> - viewModel.handleIntent(ImagePickerContract.Intent.ConfirmSelection(result)) - }, - onCancel = { - viewModel.handleIntent(ImagePickerContract.Intent.Cancel) - } ) } } else { @@ -157,11 +170,17 @@ internal fun ImagePickerScreen( ) { EditorRoute( destination = editorDestination!!, + allImages = galleryState.images, + selectedImages = galleryState.selectedImages, + snackbarHostState = snackbarHostState, onEditApplied = { pickedImage -> galleryViewModel.handleIntent(GalleryContract.Intent.OnEditResult(pickedImage)) - editorDestination = null + editorDestination = null // 편집 완료 → 갤러리로 복귀 }, onDismiss = { editorDestination = null }, + onToggleSelection = { image -> + galleryViewModel.handleIntent(GalleryContract.Intent.ToggleImageSelection(image)) + }, onError = onError, modifier = modifier, allowEditing = config.allowEditing, From 56fe1e6b8e688a17643464acee0f96e3506e9e2e Mon Sep 17 00:00:00 2001 From: seunghee Date: Sun, 29 Mar 2026 21:17:00 +0900 Subject: [PATCH 03/20] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/editor/EditorScreen.kt | 14 ++++++++------ .../presentation/picker/ImagePickerScreen.kt | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt index 0982705..53289b8 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt @@ -25,11 +25,12 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect @@ -147,7 +148,6 @@ internal fun EditorScreen( activeOnIntent.value = onIntent }, onEditApplied = onEditApplied, - onDismiss = onDismiss, onError = onError, ) } @@ -200,7 +200,6 @@ private fun EditorImagePage( isCurrentPage: Boolean, onActivate: (EditorContract.State, (EditorContract.Intent) -> Unit) -> Unit, onEditApplied: (PickedImage) -> Unit, - onDismiss: () -> Unit, onError: (String) -> Unit, ) { val context = LocalContext.current @@ -210,9 +209,12 @@ private fun EditorImagePage( ) val state by viewModel.state.collectAsStateWithLifecycle() - // 현재 페이지가 되면 상위 상태 홀더를 즉시 업데이트 - if (isCurrentPage) { - SideEffect { onActivate(state, viewModel::handleIntent) } + val latestOnActivate by rememberUpdatedState(onActivate) + LaunchedEffect(isCurrentPage) { + if (!isCurrentPage) return@LaunchedEffect + snapshotFlow { state }.collect { latestState -> + latestOnActivate(latestState, viewModel::handleIntent) + } } LaunchedEffect(Unit) { diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt index 4cf9ac7..84e9a02 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt @@ -111,7 +111,7 @@ internal fun ImagePickerScreen( } // Effect 처리 - LaunchedEffect(viewModel, context, hasRequestedPermission) { + LaunchedEffect(hasRequestedPermission) { viewModel.effect.collectLatest { effect -> when (effect) { is ImagePickerContract.Effect.CheckPermission -> { From 7c26c28398d5838f13dc223dffc655170e24725d Mon Sep 17 00:00:00 2001 From: seunghee Date: Sun, 29 Mar 2026 21:50:13 +0900 Subject: [PATCH 04/20] =?UTF-8?q?feat:=20Paging=203=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 1 + gradle/libs.versions.toml | 3 + imagepicker/build.gradle.kts | 4 + .../data/repository/GalleryRepositoryImpl.kt | 19 ++- .../data/source/GalleryImagePagingSource.kt | 135 ++++++++++++++++ .../domain/repository/GalleryRepository.kt | 5 +- .../domain/usecase/GetImagesInAlbumUseCase.kt | 14 -- .../domain/usecase/GetPagedImagesUseCase.kt | 14 ++ .../presentation/gallery/GalleryContract.kt | 7 +- .../presentation/gallery/GalleryScreen.kt | 147 ++++++++++++------ .../gallery/GalleryScreenViewModel.kt | 57 ++++--- .../gallery/GalleryScreenViewModelFactory.kt | 4 +- .../presentation/picker/EditorRoute.kt | 9 ++ .../presentation/picker/ImagePickerScreen.kt | 38 +++-- 14 files changed, 352 insertions(+), 105 deletions(-) create mode 100644 imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/GalleryImagePagingSource.kt delete mode 100644 imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/usecase/GetImagesInAlbumUseCase.kt create mode 100644 imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/usecase/GetPagedImagesUseCase.kt diff --git a/CLAUDE.md b/CLAUDE.md index 81b58d9..2c25638 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -352,6 +352,7 @@ enum class PermissionStatus { GRANTED, PARTIALLY_GRANTED, DENIED, PERMANENTLY_DE 5. 해당 화면의 `*Intent`, `*State`, `*Effect` sealed class에 항목 추가 6. ViewModel의 `handleIntent()`에 처리 로직 추가 7. Composable에서 State/Effect 소비 +8. 모든 코드 수정은 공식 문서에 의거한 권장방식으로 접근할 것 ### 2. UI 수정 - 재사용 컴포넌트는 `presentation/component/`에 위치 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 779ad28..b128d84 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ junitVersion = "1.3.0" espressoCore = "3.7.0" exifinterface = "1.4.2" +paging = "3.3.6" [libraries] # Core @@ -62,6 +63,8 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co 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" } 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" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/imagepicker/build.gradle.kts b/imagepicker/build.gradle.kts index bc9f672..c3163db 100644 --- a/imagepicker/build.gradle.kts +++ b/imagepicker/build.gradle.kts @@ -155,6 +155,10 @@ dependencies { // Coroutines implementation(libs.kotlinx.coroutines.android) + // Paging 3 + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + // Image loading implementation(libs.coil.compose) diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/repository/GalleryRepositoryImpl.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/repository/GalleryRepositoryImpl.kt index 0bf2483..f172f5e 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/repository/GalleryRepositoryImpl.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/repository/GalleryRepositoryImpl.kt @@ -3,6 +3,10 @@ package io.github.seunghee17.imagepicker.data.repository import android.content.ContentResolver import android.database.ContentObserver import android.provider.MediaStore +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import io.github.seunghee17.imagepicker.data.source.GalleryImagePagingSource import io.github.seunghee17.imagepicker.data.source.MediaStoreDataSource import io.github.seunghee17.imagepicker.domain.model.GalleryAlbum import io.github.seunghee17.imagepicker.domain.model.GalleryImage @@ -23,7 +27,6 @@ internal class GalleryRepositoryImpl( * 최신 앨범 목록을 재emit한다. */ override fun getAlbums(): Flow> = callbackFlow { - // 최초 emit send(dataSource.queryAlbums()) val observer = object : ContentObserver(null) { @@ -38,13 +41,19 @@ internal class GalleryRepositoryImpl( contentResolver.registerContentObserver( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, - observer + observer, ) awaitClose { contentResolver.unregisterContentObserver(observer) } } - override suspend fun getImagesInAlbum(albumId: String?): List { - return dataSource.queryImages(albumId) + override fun getPagedImages(albumId: String?): Flow> = + Pager( + config = PagingConfig(pageSize = PAGE_SIZE, enablePlaceholders = false), + pagingSourceFactory = { GalleryImagePagingSource(contentResolver, albumId) }, + ).flow + + companion object { + private const val PAGE_SIZE = 30 } -} \ No newline at end of file +} diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/GalleryImagePagingSource.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/GalleryImagePagingSource.kt new file mode 100644 index 0000000..ab15f9c --- /dev/null +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/GalleryImagePagingSource.kt @@ -0,0 +1,135 @@ +package io.github.seunghee17.imagepicker.data.source + +import android.content.ContentResolver +import android.database.ContentObserver +import android.content.ContentUris +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import androidx.paging.PagingSource +import androidx.paging.PagingState +import io.github.seunghee17.imagepicker.domain.model.GalleryImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * MediaStore 를 오프셋 기반으로 페이징하는 PagingSource. + * + * ContentObserver 를 내부에 등록하여 갤러리 변경(신규 사진 추가 등)이 감지되면 + * 자동으로 [invalidate] 를 호출한다. Pager 는 이 신호를 받아 새 PagingSource 를 생성한다. + */ +internal class GalleryImagePagingSource( + private val contentResolver: ContentResolver, + private val albumId: String?, +) : PagingSource() { + + private val collection: Uri + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + + private val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) = invalidate() + } + + init { + contentResolver.registerContentObserver( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + true, + observer, + ) + registerInvalidatedCallback { + contentResolver.unregisterContentObserver(observer) + } + } + + override fun getRefreshKey(state: PagingState): Int? = + state.anchorPosition?.let { anchor -> + val page = state.closestPageToPosition(anchor) + page?.prevKey?.plus(state.config.pageSize) + ?: page?.nextKey?.minus(state.config.pageSize) + } + + override suspend fun load(params: LoadParams): LoadResult { + val offset = params.key ?: 0 + val limit = params.loadSize + return withContext(Dispatchers.IO) { + try { + val images = query(offset, limit) + LoadResult.Page( + data = images, + prevKey = if (offset == 0) null else maxOf(0, offset - limit), + nextKey = if (images.size < limit) null else offset + images.size, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + } + + private fun query(offset: Int, limit: Int): List { + val projection = arrayOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.DATE_TAKEN, + MediaStore.Images.Media.BUCKET_ID, + MediaStore.Images.Media.BUCKET_DISPLAY_NAME, + MediaStore.Images.Media.WIDTH, + MediaStore.Images.Media.HEIGHT, + MediaStore.Images.Media.MIME_TYPE, + ) + + val queryArgs = Bundle().apply { + putInt(ContentResolver.QUERY_ARG_OFFSET, offset) + putInt(ContentResolver.QUERY_ARG_LIMIT, limit) + putStringArray( + ContentResolver.QUERY_ARG_SORT_COLUMNS, + arrayOf(MediaStore.Images.Media.DATE_TAKEN), + ) + putInt( + ContentResolver.QUERY_ARG_SORT_DIRECTION, + ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, + ) + if (albumId != null) { + putString( + ContentResolver.QUERY_ARG_SQL_SELECTION, + "${MediaStore.Images.Media.BUCKET_ID} = ?", + ) + putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(albumId)) + } + } + + val images = mutableListOf() + contentResolver.query(collection, projection, queryArgs, null)?.use { cursor -> + val idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) + val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) + val dateCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN) + val bucketIdCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_ID) + val bucketNameCol = + cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME) + val widthCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.WIDTH) + val heightCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.HEIGHT) + val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idCol) + val uri = ContentUris.withAppendedId(collection, id) + images += GalleryImage( + id = id, + uri = uri, + displayName = cursor.getString(nameCol) ?: "", + dateTaken = cursor.getLong(dateCol), + albumId = cursor.getString(bucketIdCol) ?: "", + albumName = cursor.getString(bucketNameCol) ?: "", + width = cursor.getInt(widthCol), + height = cursor.getInt(heightCol), + mimeType = cursor.getString(mimeCol) ?: "", + ) + } + } + return images + } +} diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/repository/GalleryRepository.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/repository/GalleryRepository.kt index ef8245f..c412dd6 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/repository/GalleryRepository.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/repository/GalleryRepository.kt @@ -1,5 +1,6 @@ package io.github.seunghee17.imagepicker.domain.repository +import androidx.paging.PagingData import io.github.seunghee17.imagepicker.domain.model.GalleryAlbum import io.github.seunghee17.imagepicker.domain.model.GalleryImage import kotlinx.coroutines.flow.Flow @@ -12,8 +13,8 @@ internal interface GalleryRepository { fun getAlbums(): Flow> /** - * 특정 앨범의 이미지 목록을 dateTaken 내림차순으로 반환. + * 특정 앨범의 이미지를 페이지 단위로 반환 * @param albumId null이면 전체 이미지(앨범 필터 없음) */ - suspend fun getImagesInAlbum(albumId: String?): List + fun getPagedImages(albumId: String?): Flow> } diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/usecase/GetImagesInAlbumUseCase.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/usecase/GetImagesInAlbumUseCase.kt deleted file mode 100644 index 33ebbff..0000000 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/usecase/GetImagesInAlbumUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.seunghee17.imagepicker.domain.usecase - -import io.github.seunghee17.imagepicker.domain.model.GalleryImage -import io.github.seunghee17.imagepicker.domain.repository.GalleryRepository - -internal class GetImagesInAlbumUseCase( - private val repository: GalleryRepository -) { - /** - * @param albumId null이면 전체 이미지(앨범 필터 없음) - */ - suspend operator fun invoke(albumId: String?): List = - repository.getImagesInAlbum(albumId) -} diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/usecase/GetPagedImagesUseCase.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/usecase/GetPagedImagesUseCase.kt new file mode 100644 index 0000000..d6102ef --- /dev/null +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/usecase/GetPagedImagesUseCase.kt @@ -0,0 +1,14 @@ +package io.github.seunghee17.imagepicker.domain.usecase + +import androidx.paging.PagingData +import io.github.seunghee17.imagepicker.domain.model.GalleryImage +import io.github.seunghee17.imagepicker.domain.repository.GalleryRepository +import kotlinx.coroutines.flow.Flow + +internal class GetPagedImagesUseCase( + private val repository: GalleryRepository, +) { + /** @param albumId null이면 전체 이미지(앨범 필터 없음) */ + operator fun invoke(albumId: String?): Flow> = + repository.getPagedImages(albumId) +} 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 33b16c4..ae206b1 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 @@ -10,13 +10,10 @@ internal interface GalleryContract { data class State( val albums: List = emptyList(), val selectedAlbum: GalleryAlbum? = null, - val images: List = emptyList(), val selectedImages: List = emptyList(), - val isLoadingImages: Boolean = false, val maxSelectionCount: Int = 10, val showAlbumSelector: Boolean = true, - val error: String? = null, - val editResults: Map = emptyMap() + val editResults: Map = emptyMap(), ) { val isSelectionLimitReached: Boolean get() = selectedImages.size >= maxSelectionCount @@ -36,4 +33,4 @@ internal interface GalleryContract { data class SelectionConfirmed(val result: PickerResult) : Effect data object Cancelled : Effect } -} \ No newline at end of file +} 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 406c068..12d19cc 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 @@ -5,14 +5,16 @@ import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -30,12 +32,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +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.gridItemKeyAtPosition import io.github.seunghee17.imagepicker.presentation.utils.photoGridDragHandler -import io.github.seunghee17.imagepicker.PickerResult import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow /// 갤러리 이미지 그리드 화면 (권한이 허용된 상태에서 표시). @@ -43,6 +49,7 @@ import kotlinx.coroutines.delay internal fun GalleryScreen( state: GalleryContract.State, snackbarHostState: SnackbarHostState, + pagingFlow: Flow>, onIntent: (GalleryContract.Intent) -> Unit, onOpenEditor: (GalleryImage) -> Unit, modifier: Modifier = Modifier @@ -54,18 +61,32 @@ internal fun GalleryScreen( 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 } + } + val currentItemsById by rememberUpdatedState(itemsById) + // 화면이 컴포지션을 떠날 때(에디터 진입 등) 잔여 스낵바 제거 DisposableEffect(Unit) { onDispose { snackbarHostState.currentSnackbarData?.dismiss() } } // 이미지 로드 실패 시 토스트 표시 - LaunchedEffect(state.error) { - state.error?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } + LaunchedEffect(pagingItems.loadState.refresh) { + val refreshState = pagingItems.loadState.refresh + if (refreshState is LoadState.Error) { + Toast.makeText( + context, + refreshState.error.message ?: "이미지를 불러오는 데 실패했습니다.", + Toast.LENGTH_SHORT + ).show() + } } // 드래그 중 autoScrollSpeed 값에 따라 그리드를 자동 스크롤 - // 스크롤 후 현재 손가락 위치의 아이템을 체크하여 선택 LaunchedEffect(gridState) { while (true) { val speed = autoScrollSpeed.floatValue @@ -74,7 +95,7 @@ internal fun GalleryScreen( currentDragOffset.value?.let { offset -> gridState.gridItemKeyAtPosition(offset)?.let { key -> if (currentState.selectedImages.none { it.id == key }) { - currentState.images.firstOrNull { it.id == key }?.let { image -> + currentItemsById[key]?.let { image -> onIntent(GalleryContract.Intent.ToggleImageSelection(image)) } } @@ -106,49 +127,83 @@ internal fun GalleryScreen( ) } ) { innerPadding -> - if (state.isLoadingImages) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() + val refreshState = pagingItems.loadState.refresh + when { + refreshState is LoadState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + refreshState is LoadState.NotLoading && pagingItems.itemCount == 0 -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Text("표시할 이미지가 없습니다.") + } } - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(3), - state = gridState, - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .photoGridDragHandler( - lazyGridState = gridState, - haptics = LocalHapticFeedback.current, - selectedImages = state.selectedImages, - onSelect = { id -> - state.images.firstOrNull { it.id == id }?.let { image -> + + else -> { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + state = gridState, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .photoGridDragHandler( + lazyGridState = gridState, + haptics = LocalHapticFeedback.current, + selectedImages = state.selectedImages, + onSelect = { id -> + currentItemsById[id]?.let { image -> + onIntent(GalleryContract.Intent.ToggleImageSelection(image)) + } + }, + autoScrollSpeed = autoScrollSpeed, + autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }, + currentDragOffset = currentDragOffset, + ) + ) { + items( + count = pagingItems.itemCount, + key = pagingItems.itemKey { it.id }, + ) { index -> + val image = pagingItems[index] ?: return@items + val selectedIndex = + state.selectedImages.indexOfFirst { it.id == image.id } + val order = selectedIndex.takeIf { it >= 0 }?.let { it + 1 } + + GalleryGridItem( + image = image, + selectionOrder = order, + onSelectionBadgeTap = { onIntent(GalleryContract.Intent.ToggleImageSelection(image)) - } - }, - autoScrollSpeed = autoScrollSpeed, - autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }, - currentDragOffset = currentDragOffset - ) - ) { - items(state.images, key = { it.id }) { image -> - val selectedIndex = state.selectedImages.indexOfFirst { it.id == image.id } - val order = selectedIndex - .takeIf { it >= 0 }?.let { it + 1 } + }, + onOpenEditor = { onOpenEditor(image) }, + ) + } - GalleryGridItem( - image = image, - selectionOrder = order, - onSelectionBadgeTap = { - onIntent(GalleryContract.Intent.ToggleImageSelection(image)) - }, - onOpenEditor = { onOpenEditor(image) } - ) + // 추가 페이지 로딩 인디케이터 + if (pagingItems.loadState.append is LoadState.Loading) { + item(span = { GridItemSpan(maxLineSpan) }) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .wrapContentSize(Alignment.Center), + ) { + CircularProgressIndicator() + } + } + } } } } 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 a4d04a3..784021e 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 @@ -2,30 +2,56 @@ package io.github.seunghee17.imagepicker.presentation.gallery import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import androidx.paging.PagingData +import androidx.paging.cachedIn import io.github.seunghee17.imagepicker.PickedImage import io.github.seunghee17.imagepicker.domain.model.GalleryImage import io.github.seunghee17.imagepicker.domain.usecase.ClearEditCacheUseCase import io.github.seunghee17.imagepicker.domain.usecase.GetGalleryAlbumsUseCase -import io.github.seunghee17.imagepicker.domain.usecase.GetImagesInAlbumUseCase +import io.github.seunghee17.imagepicker.domain.usecase.GetPagedImagesUseCase import io.github.seunghee17.imagepicker.PickerResult import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow 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.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +@OptIn(ExperimentalCoroutinesApi::class) internal class GalleryScreenViewModel( private val getAlbums: GetGalleryAlbumsUseCase, - private val getImagesInAlbum: GetImagesInAlbumUseCase, + private val getPagedImages: GetPagedImagesUseCase, private val clearEditCache: ClearEditCacheUseCase, maxSelectionCount: Int, 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, @@ -38,8 +64,6 @@ internal class GalleryScreenViewModel( val effect = _effect.receiveAsFlow() private var albumsObserved = false - - // confirmSelection() 후 다음 세션 시작 시 캐시를 정리하기 위한 플래그 private var pendingCacheClean = false fun handleIntent(intent: GalleryContract.Intent) { @@ -53,7 +77,7 @@ internal class GalleryScreenViewModel( } is GalleryContract.Intent.SelectAlbum -> { _state.update { it.copy(selectedAlbum = intent.album) } - loadImages(intent.album.id) + _albumFilter.value = AlbumFilter.Active(intent.album.id) } is GalleryContract.Intent.ToggleImageSelection -> toggleSelection(intent.image) is GalleryContract.Intent.OnEditResult -> applyEditResult(intent.pickedImage) @@ -71,24 +95,15 @@ internal class GalleryScreenViewModel( val selectedAlbum = current.selectedAlbum ?: albums.firstOrNull() current.copy(albums = albums, selectedAlbum = selectedAlbum) } - loadImages(_state.value.selectedAlbum?.id) + // 첫 번째 앨범 로드 시에만 Pending → Active 전환. + // 이후 ContentObserver 로 앨범이 갱신되어도 PagingSource 가 자체 invalidate 처리. + if (_albumFilter.value is AlbumFilter.Pending) { + _albumFilter.value = AlbumFilter.Active(_state.value.selectedAlbum?.id) + } } .launchIn(viewModelScope) } - private fun loadImages(albumId: String?) { - viewModelScope.launch { - _state.update { it.copy(isLoadingImages = true, error = null) } - runCatching { getImagesInAlbum(albumId) } - .onSuccess { images -> - _state.update { it.copy(images = images, isLoadingImages = false) } - } - .onFailure { e -> - _state.update { it.copy(isLoadingImages = false, error = e.message) } - } - } - } - private fun toggleSelection(image: GalleryImage) { val current = _state.value val selected = current.selectedImages.toMutableList() @@ -127,7 +142,6 @@ internal class GalleryScreenViewModel( private fun confirmSelection() { val result = buildPickerResult() resetSelection() - // 호스트 앱이 editedUri 파일을 사용할 수 있도록 다음 세션 시작 시 정리 pendingCacheClean = true viewModelScope.launch { _effect.send(GalleryContract.Effect.SelectionConfirmed(result)) @@ -136,7 +150,6 @@ internal class GalleryScreenViewModel( private fun cancel() { resetSelection() - // 결과를 반환하지 않으므로 캐시 파일 즉시 삭제 viewModelScope.launch { _effect.send(GalleryContract.Effect.Cancelled) runCatching { clearEditCache() } @@ -155,7 +168,7 @@ internal class GalleryScreenViewModel( _state.update { it.copy( selectedImages = emptyList(), - editResults = emptyMap() + editResults = emptyMap(), ) } } diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreenViewModelFactory.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreenViewModelFactory.kt index f79d8c5..24d4301 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreenViewModelFactory.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryScreenViewModelFactory.kt @@ -11,7 +11,7 @@ import io.github.seunghee17.imagepicker.data.source.ImageFileDataSource import io.github.seunghee17.imagepicker.data.source.MediaStoreDataSource import io.github.seunghee17.imagepicker.domain.usecase.ClearEditCacheUseCase import io.github.seunghee17.imagepicker.domain.usecase.GetGalleryAlbumsUseCase -import io.github.seunghee17.imagepicker.domain.usecase.GetImagesInAlbumUseCase +import io.github.seunghee17.imagepicker.domain.usecase.GetPagedImagesUseCase internal class GalleryScreenViewModelFactory( private val context: Context, @@ -29,7 +29,7 @@ internal class GalleryScreenViewModelFactory( val imageEditRepository = ImageEditRepositoryImpl(ImageFileDataSource(appContext)) return GalleryScreenViewModel( getAlbums = GetGalleryAlbumsUseCase(galleryRepository), - getImagesInAlbum = GetImagesInAlbumUseCase(galleryRepository), + getPagedImages = GetPagedImagesUseCase(galleryRepository), clearEditCache = ClearEditCacheUseCase(imageEditRepository), maxSelectionCount = config.maxSelectionCount, showAlbumSelector = config.showAlbumSelector, diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/EditorRoute.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/EditorRoute.kt index 8c0575a..4283444 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/EditorRoute.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/EditorRoute.kt @@ -16,6 +16,7 @@ internal data class EditorDestination( val imageId: Long, val originalUri: Uri, val initialIndex: Int = 0, + val tappedImage: GalleryImage? = null, ) internal fun editorDestinationSaver(): Saver = Saver( @@ -26,17 +27,25 @@ internal fun editorDestinationSaver(): Saver = Saver( it.imageId.toString(), it.originalUri.toString(), it.initialIndex.toString(), + it.tappedImage?.uri?.toString() ?: "", ) } }, restore = { saved -> @Suppress("UNCHECKED_CAST") val values = saved as? List ?: return@Saver null + val tappedUri = values.getOrNull(4)?.takeIf { it.isNotEmpty() }?.let { Uri.parse(it) } + val tappedId = values.getOrNull(1)?.toLongOrNull() ?: 0L + val tappedImage = tappedUri?.let { + GalleryImage(id = tappedId, uri = it, displayName = "", dateTaken = 0L, + albumId = "", albumName = "", width = 0, height = 0, mimeType = "") + } EditorDestination( entryId = values[0].toLong(), imageId = values[1].toLong(), originalUri = Uri.parse(values[2]), initialIndex = values.getOrNull(3)?.toIntOrNull() ?: 0, + tappedImage = tappedImage, ) } ) diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt index 84e9a02..a6b1012 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerScreen.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import io.github.seunghee17.imagepicker.ImagePickerConfig +import io.github.seunghee17.imagepicker.domain.model.GalleryImage import io.github.seunghee17.imagepicker.domain.model.PermissionStatus import io.github.seunghee17.imagepicker.presentation.gallery.GalleryContract import io.github.seunghee17.imagepicker.presentation.gallery.GalleryScreen @@ -133,15 +134,17 @@ internal fun ImagePickerScreen( is ImagePickerContract.Effect.ReturnResult -> onResult(effect.result) is ImagePickerContract.Effect.Cancelled -> onCancel() is ImagePickerContract.Effect.ShowToast -> - Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + Toast.makeText(context, effect.message, android.widget.Toast.LENGTH_SHORT).show() is ImagePickerContract.Effect.NavigateToEditor -> { - val index = galleryState.images.indexOfFirst { it.id == effect.image.id } - .coerceAtLeast(0) + val tappedImage = effect.image + val selected = galleryState.selectedImages + val index = selected.indexOfFirst { it.id == tappedImage.id }.coerceAtLeast(0) editorDestination = EditorDestination( entryId = effect.entryId, - imageId = effect.image.id, - originalUri = effect.image.uri, + imageId = tappedImage.id, + originalUri = tappedImage.uri, initialIndex = index, + tappedImage = tappedImage, ) } } @@ -157,6 +160,7 @@ internal fun ImagePickerScreen( GalleryScreen( modifier = modifier, state = galleryState, + pagingFlow = galleryViewModel.pagingFlow, snackbarHostState = snackbarHostState, onIntent = galleryViewModel::handleIntent, onOpenEditor = { image -> @@ -168,14 +172,30 @@ internal fun ImagePickerScreen( saveableStateHolder.SaveableStateProvider( key = "$EDITOR_SCREEN_KEY-${editorDestination!!.entryId}" ) { + val destination = editorDestination!! + val selected = galleryState.selectedImages + // 탭한 이미지가 선택 목록에 없으면 맨 앞에 추가하여 단독 표시 + val allImages: List = when { + selected.any { it.id == destination.imageId } -> selected + destination.tappedImage != null -> listOf(destination.tappedImage) + selected + selected.isNotEmpty() -> selected + else -> listOf( + GalleryImage( + id = destination.imageId, + uri = destination.originalUri, + displayName = "", dateTaken = 0L, + albumId = "", albumName = "", width = 0, height = 0, mimeType = "", + ) + ) + } EditorRoute( - destination = editorDestination!!, - allImages = galleryState.images, - selectedImages = galleryState.selectedImages, + destination = destination, + allImages = allImages, + selectedImages = selected, snackbarHostState = snackbarHostState, onEditApplied = { pickedImage -> galleryViewModel.handleIntent(GalleryContract.Intent.OnEditResult(pickedImage)) - editorDestination = null // 편집 완료 → 갤러리로 복귀 + editorDestination = null }, onDismiss = { editorDestination = null }, onToggleSelection = { image -> From bcb179d7449b2d5bf8340930a6039ce0462a3656 Mon Sep 17 00:00:00 2001 From: seunghee Date: Mon, 30 Mar 2026 06:20:06 +0900 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/component/TopBarWithCount.kt | 6 +++--- .../imagepicker/presentation/editor/EditorScreen.kt | 12 ++++++------ .../presentation/gallery/AlbumDropdown.kt | 4 ++-- .../presentation/gallery/GalleryScreen.kt | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/component/TopBarWithCount.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/component/TopBarWithCount.kt index c2ed3fc..6c00828 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/component/TopBarWithCount.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/component/TopBarWithCount.kt @@ -31,11 +31,11 @@ internal fun TopBarWithCount( TopAppBar( modifier = modifier, title = { - Text(text = if (selectedCount > 0) "$selectedCount / $maxCount" else "사진 선택") + Text(text = if (selectedCount > 0) "$selectedCount / $maxCount" else "Select Photo") }, navigationIcon = { TextButton(onClick = onCancel) { - Text("취소") + Text("Cancel") } }, actions = { @@ -43,7 +43,7 @@ internal fun TopBarWithCount( onClick = onConfirm, enabled = selectedCount > 0 ) { - Text("완료") + Text("Ok") } if (showAlbumSelector) { AlbumDropdown( diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt index 53289b8..999f29d 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/editor/EditorScreen.kt @@ -102,12 +102,12 @@ internal fun EditorScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text(if (isCropping) "크롭 영역 선택" else "편집") }, + title = { Text(if (isCropping) "Select crop area" else "Edit") }, navigationIcon = { TextButton(onClick = { if (isCropping) activeOnIntent.value?.invoke(EditorContract.Intent.ExitCropMode) else onDismiss() - }) { Text("취소") } + }) { Text("Cancel") } }, actions = { TextButton( @@ -116,7 +116,7 @@ internal fun EditorScreen( else activeOnIntent.value?.invoke(EditorContract.Intent.SaveAndReturn) }, enabled = currentState?.isSaving != true, - ) { Text("완료") } + ) { Text("Ok") } } ) } @@ -163,12 +163,12 @@ internal fun EditorScreen( OutlinedButton( onClick = { activeOnIntent.value?.invoke(EditorContract.Intent.RotateClockwise) }, enabled = currentState?.isSaving != true, - ) { Text("회전") } + ) { Text("Rotate") } Spacer(modifier = Modifier.width(16.dp)) OutlinedButton( onClick = { activeOnIntent.value?.invoke(EditorContract.Intent.EnterCropMode) }, enabled = currentState?.isSaving != true, - ) { Text("크롭") } + ) { Text("Crop") } } } } @@ -275,7 +275,7 @@ private fun ImageWithCropOverlay( Image( modifier = Modifier.fillMaxSize(), painter = painter, - contentDescription = "미리보기", + contentDescription = "Preview", contentScale = ContentScale.Fit, ) diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/AlbumDropdown.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/AlbumDropdown.kt index fbeb07d..d769f4e 100644 --- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/AlbumDropdown.kt +++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/AlbumDropdown.kt @@ -39,12 +39,12 @@ internal fun AlbumDropdown( ) { Row { Text( - text = selectedAlbum?.name ?: "전체", + text = selectedAlbum?.name ?: "All", color = Color.Black ) Icon( imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = "드롭다운", + contentDescription = "Dropdown", tint = Color.Black ) } 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 12d19cc..c6ebcfb 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 @@ -80,7 +80,7 @@ internal fun GalleryScreen( if (refreshState is LoadState.Error) { Toast.makeText( context, - refreshState.error.message ?: "이미지를 불러오는 데 실패했습니다.", + refreshState.error.message ?: "Fail to load Image", Toast.LENGTH_SHORT ).show() } @@ -147,7 +147,7 @@ internal fun GalleryScreen( .padding(innerPadding), contentAlignment = Alignment.Center, ) { - Text("표시할 이미지가 없습니다.") + Text("There is No Image In Gallery") } } From 6e6004607aee6ab3ad0ebcc61ed5687d1ec2903f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Mon, 30 Mar 2026 10:39:12 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat:=20CLAUDE.md=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 502 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 260 insertions(+), 242 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2c25638..1fb4baa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,335 +1,353 @@ -# 프로젝트 목적 -본 프로젝트는 Compose를 이용해서 Android에서 사용할 수 있는 이미지 피커 라이브러리를 구현한다. -v1 에서 구현할 기능으로 이미지 선택의 핵심 흐름에 집중한다. +# Project Purpose +This project implements an image picker library for Android using Jetpack Compose. +v1 focuses on the core image selection flow. -### 개발 목표 -- 제 앱에서 재사용 가능한 Image Picker 컴포넌트 구현 -- Compose 기반 UI 및 상태 관리 구조 설계 -- 이미지 선택과 편집의 최소 기능을 안정적으로 제공 -- 비디오 지원 등 확장 가능한 구조 확보 +### Development Goals +- Implement a reusable Image Picker component for use across apps +- Design a Compose-based UI and state management architecture +- Provide minimal but stable image selection and editing features +- Establish an extensible structure for future support (e.g., video) -## v1 기능 범위 -### 포함 기능 +## v1 Feature Scope +### Included Features -1. 이미지 접근 권한 요청 -2. 기기 갤러리 이미지 목록 조회 -3. 이미지 최대 10장 선택 제한 -4. 선택 이미지 편집 - - 90도 단위 회전 - - 크롭 -5. 드래그 멀티 선택 -6. 선택 결과 반환 +1. Image access permission request +2. Device gallery image list retrieval +3. Maximum 10 image selection limit +4. Selected image editing + - 90-degree rotation + - Crop +5. Drag multi-select +6. Return selection result -## 3. 핵심 요구사항 +## 3. Core Requirements -## 3.1 권한 처리 +## 3.1 Permission Handling -### 기능 설명 -사용자가 갤러리 이미지를 선택할 수 있도록 기기 저장소 또는 사진 접근 권한을 요청한다. +### Description +Request device storage or photo access permission so users can select gallery images. -### 요구사항 +### Requirements -- 최초 진입 시 필요한 권한이 없으면 권한 요청을 수행한다. -- 권한이 허용된 경우 이미지 목록을 조회한다. -- 권한이 영구 거부된 경우 설정 화면 이동 유도를 제공한다. +- If required permissions are missing on first entry, prompt the user. +- If permission is granted, load the image list. +- If permission is permanently denied, guide the user to the Settings screen. + +### Success Criteria +- After granting permission, the user must be able to access the image list screen. -### 성공 조건 -- 사용자가 권한을 허용하면 이미지 목록 화면으로 진입할 수 있어야 한다. --- -## 3.2 이미지 목록 조회 +## 3.2 Image List -### 기능 설명 -기기 갤러리에 저장된 이미지를 조회하여 Grid 형태로 표시한다. +### Description +Retrieve images stored in the device gallery and display them in a grid layout. -### 요구사항 -- 갤러리 앨범 명으로 접근할 수 있도록 한다. -- 최신 이미지 순으로 정렬하여 표시한다. -- 각 이미지는 썸네일 형태로 표시한다. -- 스크롤 가능한 Grid UI를 제공한다. -- 이미지 로딩 중에도 UI가 비정상적으로 멈추지 않아야 한다. +### Requirements +- Allow access by gallery album name. +- Display images sorted by most recent first. +- Show each image as a thumbnail. +- Provide a scrollable grid UI. +- UI must not freeze during image loading. -### 성공 조건 -- 사용자는 기기 갤러리에 있는 이미지들을 목록 형태로 확인할 수 있어야 한다. +### Success Criteria +- Users must be able to view all gallery images in a list format. --- -## 3.3 이미지 선택 -### 기능 설명 -사용자는 갤러리 목록에서 이미지를 선택할 수 있다. - -### 요구사항 -- 이미지는 탭을 통해 선택/해제할 수 있다. -- 최대 10장까지 선택할 수 있다. -- 10장 선택 이후 추가 선택 시 제한 안내를 제공한다. -- 선택된 이미지는 UI상에서 명확하게 구분되어야 한다. -- 선택된 순서는 grid view의 item에 숫자로 표시된다. -- 드래그로 멀티 선택이 가능하다. -- 선택 개수를 화면에서 확인할 수 있어야 한다. - -### 성공 조건 -- 사용자는 최대 10장까지 이미지를 선택할 수 있어야 한다. -- 선택 수 초과 시 시스템이 추가 선택을 막아야 한다. - -### 정책 - -- 최대 선택 개수: 10장 -- 11번째 이미지 선택 시 선택되지 않아야 함 -- 제한 메시지 예시: - - `이미지는 최대 10장까지 선택할 수 있습니다.` - + +## 3.3 Image Selection + +### Description +Users can select images from the gallery list. + +### Requirements +- Images can be selected/deselected by tapping. +- Up to 10 images can be selected. +- Attempting to select more than 10 images must show a limit warning. +- Selected images must be visually distinguished in the UI. +- Selection order is shown as a number on each grid item. +- Drag multi-select is supported. +- The current selection count must be visible on screen. + +### Success Criteria +- Users must be able to select up to 10 images. +- The system must block selection beyond the limit. + +### Policy + +- Maximum selection count: 10 +- The 11th selection attempt must be rejected +- Limit message example: + + `You can select up to 10 images.` + --- -## 3.4 이미지 편집 +## 3.4 Image Editing -### 기능 설명 -사용자는 선택한 이미지에 대해 기본 편집 기능을 수행할 수 있다. +### Description +Users can perform basic editing on selected images. -### 지원 기능 -- 회전 -- 크롭 +### Supported Features +- Rotation +- Crop --- -### 3.4.1 회전 +### 3.4.1 Rotation -### 요구사항 -- 사용자는 이미지를 90도 단위로 회전할 수 있다. -- 회전은 버튼 탭으로 수행한다. -- 회전 결과는 즉시 미리보기 화면에 반영되어야 한다. +### Requirements +- Users can rotate images in 90-degree increments. +- Rotation is triggered by a button tap. +- The rotation result must be reflected immediately in the preview. -### 성공 조건 -- 사용자가 회전 버튼을 누를 때마다 이미지가 90도씩 회전해야 한다. +### Success Criteria +- Each tap of the rotate button must rotate the image by 90 degrees. -### 제약사항 -- v1에서는 자유 각도 회전을 지원하지 않는다. -- 회전 방향은 시계 방향 90도로 제한한다. +### Constraints +- Free-angle rotation is not supported in v1. +- Rotation direction is limited to clockwise 90 degrees. --- -### 3.4.2 크롭 +### 3.4.2 Crop -### 요구사항 +### Requirements -- 사용자는 이미지의 크롭 영역을 지정할 수 있어야 한다. -- 크롭 영역은 화면에서 시각적으로 확인 가능해야 한다. -- 크롭 완료 시 지정한 영역만 잘린 결과를 생성해야 한다. +- Users must be able to specify a crop region. +- The crop region must be visually displayed on screen. +- Upon crop confirmation, only the selected region should be retained. -### 성공 조건 -- 사용자가 지정한 영역 기준으로 이미지가 잘려 결과에 반영되어야 한다. +### Success Criteria +- The image must be cropped to the user-specified region and reflected in the result. -### 제약사항 -- v1에서는 크롭 비율 옵션을 제한적으로 제공한다. -- 고급 편집(확대/축소 기반 정교한 편집, 다중 비율 프리셋 다수 제공 등)은 제외한다. +### Constraints +- Crop ratio options are limited in v1. +- Advanced editing (zoom-based precision editing, multiple ratio presets, etc.) is excluded. --- -## 3.5 결과 반환 +## 3.5 Result Return -### 기능 설명 +### Description -사용자는 이미지 선택 및 편집 완료 후 결과를 호출한 화면으로 반환할 수 있어야 한다. +After completing selection and editing, the result must be returned to the caller. -### 요구사항 +### Requirements -- 선택 완료 시 선택된 이미지 목록을 반환한다. -- 편집이 적용된 이미지는 편집 결과 기준으로 반환한다. -- 원본 URI와 편집 결과 URI를 구분할 수 있어야 한다. +- On completion, return the list of selected images. +- If editing was applied, return the edited result instead of the original. +- Original URI and edited URI must be distinguishable. -### 성공 조건 +### Success Criteria -- 호출 측에서 선택/편집 결과를 받아 후속 처리를 수행할 수 있어야 한다. +- The caller must be able to receive the selection/edit result and perform follow-up processing. -### 반환 데이터 예시 +### Return Data Example -``` -dataclassPickerResult( -valitems:List +```kotlin +data class PickerResult( + val items: List ) -dataclassPickedImage( -valoriginalUri:Uri, -valeditedUri:Uri?=null, -valrotationDegrees:Int=0, -valisCropped:Boolean=false +data class PickedImage( + val originalUri: Uri, + val editedUri: Uri? = null, + val rotationDegrees: Int = 0, + val cropRect: CropRect? = null // isCropped is determined by cropRect != null ) ``` --- -## 4. 사용자 흐름 +## 4. User Flows -## 4.1 기본 선택 흐름 +## 4.1 Basic Selection Flow -1. 사용자가 Image Picker를 실행한다. -2. 권한이 없으면 권한 요청 UI를 표시한다. -3. 권한 허용 후 갤러리 이미지 목록을 조회한다. -4. 사용자가 이미지를 탭 혹은 드래그하여 선택한다. -5. 최대 10장까지 선택한다. -6. 완료 버튼을 눌러 선택 결과를 반환한다. +1. User launches the Image Picker. +2. If permissions are missing, display the permission request UI. +3. After permission is granted, load the gallery image list. +4. User selects images by tapping or dragging. +5. Up to 10 images can be selected. +6. User taps the Done button to return the selection result. --- -## 4.2 편집 흐름 +## 4.2 Editing Flow -1. 사용자가 선택한 이미지의 체크박스를 제외한 영역을 누르면 편집 화면에서 열린다. -2. 회전 또는 크롭을 수행한다. -3. 편집 결과를 저장한다. -4. 편집 완료 후 선택 결과에 반영한다. -5. 최종 완료 시 호출 측으로 반환한다. +1. Tapping an image (outside its checkbox area) opens the editor screen. +2. User performs rotation or crop. +3. Editing result is saved. +4. The result is reflected in the selection on completion. +5. On final Done, the result is returned to the caller. --- -## 5. 화면 단위 요구사항 +## 5. Screen Requirements -## 5.1 권한 요청 화면 +## 5.1 Permission Request Screen -### 표시 요소 -- 설정 이동 버튼(필요 시) +### Elements +- Settings navigation button (when needed) -### 상태 -- 최초 요청 전 -- 거부됨 -- 영구 거부됨 +### States +- Before first request +- Denied +- Permanently denied --- -## 5.2 이미지 목록 화면 +## 5.2 Image List Screen -### 표시 요소 -- 상단 바 -- 선택 개수 표시 -- 완료 버튼 -- Grid 형태 이미지 썸네일 목록 +### Elements +- Top bar +- Selection count display +- Done button +- Scrollable grid of image thumbnails -### 상태 -- 로딩 -- 데이터 있음 -- 데이터 없음 -- 권한 없음 +### States +- Loading +- Has data +- No data +- No permission --- -## 5.3 편집 화면 +## 5.3 Editor Screen -### 표시 요소 -- 원본/편집 이미지 미리보기 -- 회전 버튼 -- 크롭 버튼 -- 취소 버튼 -- 완료 버튼 +### Elements +- Original/edited image preview +- Rotate button +- Crop button +- Cancel button +- Done button -### 상태 -- 편집 전 -- 회전 적용 -- 크롭 적용 -- 저장 중 +### States +- Before editing +- Rotation applied +- Crop applied +- Saving --- -## 6. 예외 처리 +## 6. Error Handling -### 6.1 권한 거부 -- 권한 거부 시 이미지 목록을 노출하지 않는다. -- 권한 필요 안내 문구를 표시한다. +### 6.1 Permission Denied +- Do not show the image list if permission is denied. +- Display a message explaining that permission is required. -### 6.2 이미지 없음 -- 갤러리에 이미지가 없는 경우 빈 상태 UI를 표시한다. +### 6.2 No Images +- Display an empty state UI if the gallery has no images. -### 6.3 선택 개수 초과 -- 10장 초과 선택 시 추가 선택을 막고 안내 메시지를 표시한다. +### 6.3 Selection Limit Exceeded +- Block additional selection and show a warning message when the limit is exceeded. -### 6.4 편집 실패 -- 크롭 또는 회전 처리 실패 시 오류 메시지를 표시한다. -- 원본 이미지는 손상되지 않아야 한다. +### 6.4 Edit Failure +- Display an error message if crop or rotation processing fails. +- The original image must not be damaged. -### 6.5 결과 저장 실패 -- 편집 결과 파일 저장 실패 시 재시도 또는 취소 선택지를 제공한다. +### 6.5 Save Failure +- If saving the edited result fails, provide options to retry or cancel. --- -# 프로젝트 구조 및 작업 가이드 +# Project Structure & Work Guide -## 모듈 구성 +## Module Layout -프로젝트는 두 모듈로 구성된다. +The project consists of two modules: -- **`:app`** — 라이브러리 사용 방식을 보여주는 샘플 앱 (`com.universe.dynamicimagepicker`) -- **`:imagepicker`** — 실제 라이브러리 구현체 (`com.universe.imagepicker`) +- **`:app`** — Sample app demonstrating library usage (`com.universe.dynamicimagepicker`) +- **`:imagepicker`** — The actual library implementation (`com.universe.imagepicker`) -> 기능 구현은 반드시 `:imagepicker` 모듈에서 수행한다. `:app`은 통합 방식만 보여주며 건드리지 않는 것을 원칙으로 한다. +> All feature implementation must be done in `:imagepicker`. `:app` only demonstrates integration and should not be modified. --- -## 아키텍처 패턴: MVI +## Architecture Pattern: MVI -전체 구조는 **MVI (Model-View-Intent)** 패턴을 따른다. +The overall structure follows the **MVI (Model-View-Intent)** pattern. ``` -사용자 액션 → Intent → ViewModel → State 갱신 → Composable 렌더링 +User Action → Intent → ViewModel → State update → Composable render ↓ - Effect (Channel) → 일회성 사이드 이펙트 + Effect (Channel) → one-time side effects ``` -- **State**: UI에 표시할 불변 데이터. ViewModel의 `StateFlow`로 관리한다. -- **Intent**: 사용자 또는 시스템이 발생시키는 이벤트. sealed class로 정의한다. -- **Effect**: 화면 이동, 토스트, 권한 요청 등 한 번만 소비되는 이벤트. `Channel`로 관리한다. +- **State**: Immutable data for the UI. Managed via `StateFlow` in ViewModel. +- **Intent**: Events triggered by the user or system. Defined as sealed classes. +- **Effect**: One-time events such as navigation, toasts, or permission requests. Managed via `Channel`. --- -## 레이어 구성 +## Layer Structure ``` presentation/ - picker/ ← 갤러리 선택 화면 (Screen, State, Intent, Effect, ViewModel, Factory) - editor/ ← 이미지 편집 화면 (Screen, State, Intent, Effect, ViewModel, Factory) - gallery/ ← Gallery Grid 컴포넌트 (GalleryScreen, GalleryGridItem, AlbumDropdown) - component/ ← 재사용 UI 컴포넌트 (TopBarWithCount, SelectionBadge) - utils/ ← 드래그 멀티 선택 헬퍼 (photoGridDragHandler modifier) + picker/ ← Gallery selection screen (Screen, State, Intent, Effect, ViewModel, Factory) + editor/ ← Image editor screen (Screen, State, Intent, Effect, ViewModel, Factory) + gallery/ ← Gallery grid components (GalleryScreen, GalleryGridItem, AlbumDropdown) + component/ ← Reusable UI components (TopBarWithCount, SelectionBadge) + utils/ ← Drag multi-select helper (photoGridDragHandler modifier) domain/ - model/ ← 도메인 모델 (GalleryImage, GalleryAlbum, PickedImage, PickerResult, CropRect, PermissionStatus) - repository/ ← Repository 인터페이스 (GalleryRepository, ImageEditRepository) - usecase/ ← UseCase (GetGalleryAlbums, GetImagesInAlbum, RotateImage, CropImage, CheckPermission) + model/ ← Domain models (GalleryImage, GalleryAlbum, PickedImage, PickerResult, CropRect, PermissionStatus) + repository/ ← Repository interfaces (GalleryRepository, ImageEditRepository) + usecase/ ← Use cases (GetGalleryAlbums, GetImagesInAlbum, RotateImage, CropImage, CheckPermission) data/ - source/ ← 데이터 소스 (MediaStoreDataSource, ImageFileDataSource) - repository/ ← Repository 구현체 (GalleryRepositoryImpl, ImageEditRepositoryImpl) + source/ ← Data sources (MediaStoreDataSource, ImageFileDataSource) + repository/ ← Repository implementations (GalleryRepositoryImpl, ImageEditRepositoryImpl) ``` --- -## 주요 파일 역할 +## Key File Roles -| 파일 | 역할 | +| File | Role | |------|------| -| `DynamicImagePicker.kt` | 라이브러리 퍼블릭 진입점. `DynamicImagePicker.Content(config, onResult, onCancel)` 제공 | -| `ImagePickerConfig.kt` | 라이브러리 설정 (`maxSelectionCount`, `showAlbumSelector`, `allowEditing`) | -| `ImagePickerScreen.kt` | 권한 처리 + 화면 라우팅 (갤러리/권한 거부 화면 분기) | -| `GalleryScreen.kt` | 3열 Grid, 드래그 멀티 선택, 상단 바, 선택 제한 스낵바 | -| `GalleryGridItem.kt` | 썸네일 + 선택 오버레이 + SelectionBadge | -| `ImagePickerViewModel.kt` | 갤러리 선택 전체 상태 관리, 편집 결과 병합, 최종 결과 반환 | -| `EditorScreen.kt` | 이미지 편집 UI (회전/크롭) | -| `EditorViewModel.kt` | 편집 상태 관리. 항상 originalUri 기준으로 회전하여 JPEG 화질 손실 방지 | -| `MediaStoreDataSource.kt` | MediaStore ContentResolver로 이미지/앨범 조회 (API 26-36 호환) | -| `ImageFileDataSource.kt` | 비트맵 회전·크롭 처리. 캐시: `context.cacheDir/imagepicker_edits/` | -| `Utils.kt` | `photoGridDragHandler` Modifier — 롱프레스 후 드래그 멀티 선택 구현 | +| `DynamicImagePicker.kt` | Public entry point of the library. Exposes the top-level `@Composable fun DynamicImagePicker(config, onResult, onCancel, onError, modifier)` | +| `ImagePickerConfig.kt` | Library configuration (`maxSelectionCount`, `showAlbumSelector`, `allowEditing`) | +| `ImagePickerScreen.kt` | Permission handling + screen routing (gallery vs. permission denied screen) | +| `GalleryScreen.kt` | 3-column grid, drag multi-select, top bar, selection limit snackbar | +| `GalleryGridItem.kt` | Thumbnail + selection overlay + SelectionBadge | +| `ImagePickerViewModel.kt` | Manages overall gallery selection state, merges edit results, returns final result | +| `EditorScreen.kt` | Image editing UI (rotation/crop) | +| `EditorViewModel.kt` | Manages editing state. Always rotates from `originalUri` to prevent JPEG quality degradation | +| `MediaStoreDataSource.kt` | Queries images/albums via MediaStore ContentResolver (API 26–36 compatible) | +| `ImageFileDataSource.kt` | Bitmap rotation and crop. Cache: `context.cacheDir/imagepicker_edits/` | +| `Utils.kt` | `photoGridDragHandler` Modifier — implements long-press + drag multi-select | + +--- + +## Usage + +The library exposes a single top-level Composable function. Call it directly — there is no wrapper object or `.Content()` method. + +```kotlin +DynamicImagePicker( + config = ImagePickerConfig(), + onResult = { result: PickerResult -> /* handle result */ }, + onCancel = { /* handle cancel */ }, + onError = { message -> /* handle error */ } +) +``` --- -## 도메인 모델 요약 +## Domain Model Summary ```kotlin -// 최종 반환 결과 +// Final return result data class PickerResult(val items: List) data class PickedImage( val originalUri: Uri, - val editedUri: Uri? = null, // 편집 없으면 null + val editedUri: Uri? = null, // null if no editing was applied val rotationDegrees: Int = 0, - val cropRect: CropRect? = null // isCropped는 cropRect != null 로 판단 + val cropRect: CropRect? = null // isCropped is determined by cropRect != null ) -// 정규화 크롭 좌표 [0f, 1f] +// Normalized crop coordinates [0f, 1f] data class CropRect(val left: Float, val top: Float, val right: Float, val bottom: Float) { companion object { val FULL = CropRect(0f, 0f, 1f, 1f) } } @@ -342,45 +360,45 @@ enum class PermissionStatus { GRANTED, PARTIALLY_GRANTED, DENIED, PERMANENTLY_DE --- -## 작업 시 접근 방법 - -### 1. 새 기능 추가 -1. `domain/model/`에 필요한 데이터 모델 추가 또는 기존 모델 수정 -2. 필요한 경우 `domain/repository/` 인터페이스에 메서드 추가 -3. `domain/usecase/`에 UseCase 추가 -4. `data/source/` 및 `data/repository/`에 구현체 반영 -5. 해당 화면의 `*Intent`, `*State`, `*Effect` sealed class에 항목 추가 -6. ViewModel의 `handleIntent()`에 처리 로직 추가 -7. Composable에서 State/Effect 소비 -8. 모든 코드 수정은 공식 문서에 의거한 권장방식으로 접근할 것 - -### 2. UI 수정 -- 재사용 컴포넌트는 `presentation/component/`에 위치 -- 화면별 컴포넌트는 해당 화면 패키지 내에서 관리 (`picker/`, `gallery/`, `editor/`) -- Compose 상태는 ViewModel의 `StateFlow`를 `collectAsStateWithLifecycle()`로 수집 - -### 3. 드래그 선택 수정 -- `presentation/utils/Utils.kt`의 `photoGridDragHandler` Modifier 수정 -- 롱프레스 감지 → 드래그 시작 → 아이템 하이트박스 기반 선택 순서로 동작 - -### 4. 편집 기능 수정 -- `EditorViewModel`은 항상 `originalUri`를 기준으로 회전을 적용한다 (JPEG 재압축 누적 방지) -- 크롭은 현재 `previewUri`를 기준으로 적용 -- 편집 완료 시 `PickedImage(editedUri = ..., rotationDegrees = ..., cropRect = ...)`로 반환 - -### 5. 권한 처리 수정 -- 권한 분기 로직: `ImagePickerScreen.kt` -- 상태 표현: `PermissionStatus` enum -- Android 14+ 부분 권한(`READ_MEDIA_VISUAL_USER_SELECTED`)은 `PARTIALLY_GRANTED`로 처리 +## How to Work + +### 1. Adding a New Feature +1. Add or modify data models in `domain/model/` +2. If needed, add methods to `domain/repository/` interfaces +3. Add a UseCase in `domain/usecase/` +4. Reflect changes in `data/source/` and `data/repository/` implementations +5. Add entries to the relevant `*Intent`, `*State`, `*Effect` sealed classes +6. Add handling logic to `handleIntent()` in the ViewModel +7. Consume State/Effect in the Composable +8. All code changes must follow the officially recommended approach per the relevant documentation + +### 2. Modifying UI +- Reusable components live in `presentation/component/` +- Screen-specific components are managed within their screen package (`picker/`, `gallery/`, `editor/`) +- Compose state is collected from ViewModel `StateFlow` using `collectAsStateWithLifecycle()` + +### 3. Modifying Drag Selection +- Modify the `photoGridDragHandler` Modifier in `presentation/utils/Utils.kt` +- Flow: long-press detection → drag start → item selection based on hit-box + +### 4. Modifying Editing Features +- `EditorViewModel` always applies rotation from `originalUri` (prevents accumulated JPEG re-compression) +- Crop is applied based on the current `previewUri` +- On edit completion, return `PickedImage(editedUri = ..., rotationDegrees = ..., cropRect = ...)` + +### 5. Modifying Permission Handling +- Permission branching logic: `ImagePickerScreen.kt` +- State representation: `PermissionStatus` enum +- Android 14+ partial permission (`READ_MEDIA_VISUAL_USER_SELECTED`) is handled as `PARTIALLY_GRANTED` --- -## 빌드 설정 요약 +## Build Configuration - **compileSdk / targetSdk**: 36 - **minSdk**: 26 - **Kotlin**: 2.0.21 - **Compose BOM**: 2025.05.00 -- **이미지 로딩**: Coil 2.7.0 (`AsyncImage`) -- **비동기**: Kotlin Coroutines 1.9.0 -- **ViewModel**: AndroidX Lifecycle 2.9.0 \ No newline at end of file +- **Image loading**: Coil 2.7.0 (`AsyncImage`) +- **Async**: Kotlin Coroutines 1.9.0 +- **ViewModel**: AndroidX Lifecycle 2.9.0 From cfb98da5be04e236fabff259ac67a0f662e97fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Mon, 30 Mar 2026 11:04:32 +0900 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20=EB=8F=99=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/inspectionProfiles/Project_Default.xml | 11 ++ gradle/libs.versions.toml | 1 + imagepicker/build.gradle.kts | 1 + imagepicker/src/main/AndroidManifest.xml | 1 + .../imagepicker/DynamicImagePicker.kt | 51 ++++++--- .../imagepicker/ImagePickerConfig.kt | 4 +- .../data/repository/GalleryRepositoryImpl.kt | 21 ++-- .../data/source/GalleryImagePagingSource.kt | 100 ++++++++++-------- .../data/source/MediaStoreDataSource.kt | 70 ++++++------ .../imagepicker/domain/model/GalleryImage.kt | 6 +- .../imagepicker/domain/model/MediaType.kt | 3 + .../presentation/gallery/GalleryGridItem.kt | 65 ++++++++++-- .../presentation/gallery/GalleryScreen.kt | 4 +- .../gallery/GalleryScreenViewModel.kt | 2 +- .../gallery/GalleryScreenViewModelFactory.kt | 1 + .../presentation/picker/ImagePickerScreen.kt | 10 +- .../picker/PickerPermissionHelper.kt | 17 +-- 17 files changed, 251 insertions(+), 117 deletions(-) create mode 100644 imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/MediaType.kt diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index f0c6ad0..7061a0d 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -3,15 +3,19 @@