diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index f0c6ad0..35339e3 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -3,48 +3,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index 1ad84c9..0000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,401 +0,0 @@
-# 프로젝트 목적
-본 프로젝트는 Compose를 이용해서 Android에서 사용할 수 있는 이미지 피커 라이브러리를 구현한다.
-v1 에서 구현할 기능으로 이미지 선택의 핵심 흐름에 집중한다.
-
-### 개발 목표
-- 제 앱에서 재사용 가능한 Image Picker 컴포넌트 구현
-- Compose 기반 UI 및 상태 관리 구조 설계
-- 이미지 선택과 편집의 최소 기능을 안정적으로 제공
-- 비디오 지원 등 확장 가능한 구조 확보
-
-## v1 기능 범위
-### 포함 기능
-
-1. 이미지 접근 권한 요청
-2. 기기 갤러리 이미지 목록 조회
-3. 이미지 최대 10장 선택 제한
-4. 선택 이미지 편집
- - 90도 단위 회전
- - 크롭
-5. 드래그 멀티 선택
-6. 선택 결과 반환
-
-## 3. 핵심 요구사항
-
-## 3.1 권한 처리
-
-### 기능 설명
-사용자가 갤러리 이미지를 선택할 수 있도록 기기 저장소 또는 사진 접근 권한을 요청한다.
-
-### 요구사항
-
-- 최초 진입 시 필요한 권한이 없으면 권한 요청을 수행한다.
-- 권한이 허용된 경우 이미지 목록을 조회한다.
-- 권한이 영구 거부된 경우 설정 화면 이동 유도를 제공한다.
-
-### 성공 조건
-- 사용자가 권한을 허용하면 이미지 목록 화면으로 진입할 수 있어야 한다.
----
-
-## 3.2 이미지 목록 조회
-
-### 기능 설명
-기기 갤러리에 저장된 이미지를 조회하여 Grid 형태로 표시한다.
-
-### 요구사항
-- 갤러리 앨범 명으로 접근할 수 있도록 한다.
-- 최신 이미지 순으로 정렬하여 표시한다.
-- 각 이미지는 썸네일 형태로 표시한다.
-- 스크롤 가능한 Grid UI를 제공한다.
-- 이미지 로딩 중에도 UI가 비정상적으로 멈추지 않아야 한다.
-
-### 성공 조건
-- 사용자는 기기 갤러리에 있는 이미지들을 목록 형태로 확인할 수 있어야 한다.
-
----
-## 3.3 이미지 선택
-### 기능 설명
-사용자는 갤러리 목록에서 이미지를 선택할 수 있다.
-
-### 요구사항
-- 이미지는 탭을 통해 선택/해제할 수 있다.
-- 최대 10장까지 선택할 수 있다.
-- 10장 선택 이후 추가 선택 시 제한 안내를 제공한다.
-- 선택된 이미지는 UI상에서 명확하게 구분되어야 한다.
-- 선택된 순서는 grid view의 item에 숫자로 표시된다.
-- 드래그로 멀티 선택이 가능하다.
-- 선택 개수를 화면에서 확인할 수 있어야 한다.
-
-### 성공 조건
-- 사용자는 최대 10장까지 이미지를 선택할 수 있어야 한다.
-- 선택 수 초과 시 시스템이 추가 선택을 막아야 한다.
-
-### 정책
-
-- 최대 선택 개수: 10장
-- 11번째 이미지 선택 시 선택되지 않아야 함
-- 제한 메시지 예시:
-
- `이미지는 최대 10장까지 선택할 수 있습니다.`
-
-
----
-
-## 3.4 이미지 편집
-
-### 기능 설명
-사용자는 선택한 이미지에 대해 기본 편집 기능을 수행할 수 있다.
-
-### 지원 기능
-- 회전
-- 크롭
-
----
-
-### 3.4.1 회전
-
-### 요구사항
-- 사용자는 이미지를 90도 단위로 회전할 수 있다.
-- 회전은 버튼 탭으로 수행한다.
-- 회전 결과는 즉시 미리보기 화면에 반영되어야 한다.
-
-### 성공 조건
-- 사용자가 회전 버튼을 누를 때마다 이미지가 90도씩 회전해야 한다.
-
-### 제약사항
-- v1에서는 자유 각도 회전을 지원하지 않는다.
-- 회전 방향은 시계 방향 90도로 제한한다.
-
----
-
-### 3.4.2 크롭
-
-### 요구사항
-
-- 사용자는 이미지의 크롭 영역을 지정할 수 있어야 한다.
-- 크롭 영역은 화면에서 시각적으로 확인 가능해야 한다.
-- 크롭 완료 시 지정한 영역만 잘린 결과를 생성해야 한다.
-
-### 성공 조건
-- 사용자가 지정한 영역 기준으로 이미지가 잘려 결과에 반영되어야 한다.
-
-### 제약사항
-- v1에서는 크롭 비율 옵션을 제한적으로 제공한다.
-- 고급 편집(확대/축소 기반 정교한 편집, 다중 비율 프리셋 다수 제공 등)은 제외한다.
-
----
-
-## 3.5 결과 반환
-
-### 기능 설명
-
-사용자는 이미지 선택 및 편집 완료 후 결과를 호출한 화면으로 반환할 수 있어야 한다.
-
-### 요구사항
-
-- 선택 완료 시 선택된 이미지 목록을 반환한다.
-- 편집이 적용된 이미지는 편집 결과 기준으로 반환한다.
-- 원본 URI와 편집 결과 URI를 구분할 수 있어야 한다.
-
-### 성공 조건
-
-- 호출 측에서 선택/편집 결과를 받아 후속 처리를 수행할 수 있어야 한다.
-
-### 반환 데이터 예시
-
-```
-dataclassPickerResult(
-valitems:List
-)
-
-dataclassPickedImage(
-valoriginalUri:Uri,
-valeditedUri:Uri?=null,
-valrotationDegrees:Int=0,
-valisCropped:Boolean=false
-)
-```
-
----
-
-## 4. 사용자 흐름
-
-## 4.1 기본 선택 흐름
-
-1. 사용자가 Image Picker를 실행한다.
-2. 권한이 없으면 권한 요청 UI를 표시한다.
-3. 권한 허용 후 갤러리 이미지 목록을 조회한다.
-4. 사용자가 이미지를 탭 혹은 드래그하여 선택한다.
-5. 최대 10장까지 선택한다.
-6. 완료 버튼을 눌러 선택 결과를 반환한다.
-
----
-
-## 4.2 편집 흐름
-
-1. 사용자가 선택한 이미지의 체크박스를 제외한 영역을 누르면 편집 화면에서 열린다.
-2. 회전 또는 크롭을 수행한다.
-3. 편집 결과를 저장한다.
-4. 편집 완료 후 선택 결과에 반영한다.
-5. 최종 완료 시 호출 측으로 반환한다.
-
----
-
-## 5. 화면 단위 요구사항
-
-## 5.1 권한 요청 화면
-
-### 표시 요소
-- 설정 이동 버튼(필요 시)
-
-### 상태
-- 최초 요청 전
-- 거부됨
-- 영구 거부됨
-
----
-
-## 5.2 이미지 목록 화면
-
-### 표시 요소
-- 상단 바
-- 선택 개수 표시
-- 완료 버튼
-- Grid 형태 이미지 썸네일 목록
-
-### 상태
-- 로딩
-- 데이터 있음
-- 데이터 없음
-- 권한 없음
-
----
-
-## 5.3 편집 화면
-
-### 표시 요소
-- 원본/편집 이미지 미리보기
-- 회전 버튼
-- 크롭 버튼
-- 취소 버튼
-- 완료 버튼
-
-### 상태
-- 편집 전
-- 회전 적용
-- 크롭 적용
-- 저장 중
-
----
-
-## 6. 예외 처리
-
-### 6.1 권한 거부
-- 권한 거부 시 이미지 목록을 노출하지 않는다.
-- 권한 필요 안내 문구를 표시한다.
-
-### 6.2 이미지 없음
-- 갤러리에 이미지가 없는 경우 빈 상태 UI를 표시한다.
-
-### 6.3 선택 개수 초과
-- 10장 초과 선택 시 추가 선택을 막고 안내 메시지를 표시한다.
-
-### 6.4 편집 실패
-- 크롭 또는 회전 처리 실패 시 오류 메시지를 표시한다.
-- 원본 이미지는 손상되지 않아야 한다.
-
-### 6.5 결과 저장 실패
-- 편집 결과 파일 저장 실패 시 재시도 또는 취소 선택지를 제공한다.
-
----
-
-# 프로젝트 구조 및 작업 가이드
-
-## 모듈 구성
-
-프로젝트는 두 모듈로 구성된다.
-
-- **`:app`** — 라이브러리 사용 방식을 보여주는 샘플 앱 (`com.universe.dynamicimagepicker`)
-- **`:imagepicker`** — 실제 라이브러리 구현체 (`com.universe.imagepicker`)
-
-> 기능 구현은 반드시 `:imagepicker` 모듈에서 수행한다. `:app`은 통합 방식만 보여주며 건드리지 않는 것을 원칙으로 한다.
-
----
-
-## 아키텍처 패턴: MVI
-
-전체 구조는 **MVI (Model-View-Intent)** 패턴을 따른다.
-
-```
-사용자 액션 → Intent → ViewModel → State 갱신 → Composable 렌더링
- ↓
- Effect (Channel) → 일회성 사이드 이펙트
-```
-
-- **State**: UI에 표시할 불변 데이터. ViewModel의 `StateFlow`로 관리한다.
-- **Intent**: 사용자 또는 시스템이 발생시키는 이벤트. sealed class로 정의한다.
-- **Effect**: 화면 이동, 토스트, 권한 요청 등 한 번만 소비되는 이벤트. `Channel`로 관리한다.
-
----
-
-## 레이어 구성
-
-```
-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)
-domain/
- model/ ← 도메인 모델 (GalleryImage, GalleryAlbum, PickedImage, PickerResult, CropRect, PermissionStatus)
- repository/ ← Repository 인터페이스 (GalleryRepository, ImageEditRepository)
- usecase/ ← UseCase (GetGalleryAlbums, GetImagesInAlbum, RotateImage, CropImage, CheckPermission)
-data/
- source/ ← 데이터 소스 (MediaStoreDataSource, ImageFileDataSource)
- repository/ ← Repository 구현체 (GalleryRepositoryImpl, ImageEditRepositoryImpl)
-```
-
----
-
-## 주요 파일 역할
-
-| 파일 | 역할 |
-|------|------|
-| `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 — 롱프레스 후 드래그 멀티 선택 구현 |
-
----
-
-## 도메인 모델 요약
-
-```kotlin
-// 최종 반환 결과
-data class PickerResult(val items: List)
-
-data class PickedImage(
- val originalUri: Uri,
- val editedUri: Uri? = null, // 편집 없으면 null
- val rotationDegrees: Int = 0,
- val cropRect: CropRect? = null // isCropped는 cropRect != null 로 판단
-)
-
-// 정규화 크롭 좌표 [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) }
-}
-
-data class GalleryImage(val id: Long, val uri: Uri, val albumId: String, ...)
-data class GalleryAlbum(val id: String, val name: String, val coverUri: Uri, val imageCount: Int)
-
-enum class PermissionStatus { GRANTED, PARTIALLY_GRANTED, DENIED, PERMANENTLY_DENIED }
-```
-
----
-
-## 작업 시 접근 방법
-
-### 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 소비
-
-### 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`로 처리
-
----
-
-## 빌드 설정 요약
-
-- **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
-
----
-
-## 라이브러리 통합 방법 (`:app` 참고)
-
-```kotlin
-// build.gradle.kts
-implementation(project(":imagepicker"))
-
-// 사용
-DynamicImagePicker.Content(
- config = ImagePickerConfig(maxSelectionCount = 10),
- onResult = { result: PickerResult -> /* items: List */ },
- onCancel = { /* 취소 처리 */ }
-)
-```
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 81b58d9..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,44 +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 소비
-
-### 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
diff --git a/README.md b/README.md
index 99d1957..f7175f0 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,7 @@ It focuses on the v1 core flow:
- runtime permission handling
- gallery image browsing
+- configurable max image selection count with selection order support
- drag multi-select
- basic editing with rotate and crop
- returning original and edited results
diff --git a/app/src/main/java/com/universe/dynamicimagepicker/MainActivity.kt b/app/src/main/java/com/universe/dynamicimagepicker/MainActivity.kt
index 9cc7ce6..76a8b7b 100644
--- a/app/src/main/java/com/universe/dynamicimagepicker/MainActivity.kt
+++ b/app/src/main/java/com/universe/dynamicimagepicker/MainActivity.kt
@@ -9,15 +9,22 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -28,6 +35,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
@@ -58,7 +67,9 @@ private fun PickerHost(modifier: Modifier = Modifier) {
if (showPicker) {
DynamicImagePicker(
- config = ImagePickerConfig(),
+ config = ImagePickerConfig(
+ allowVideo = true
+ ),
onResult = { result ->
selectedImages = result.items
showPicker = false
@@ -150,13 +161,50 @@ private fun SelectedImageTile(
) {
AsyncImage(
model = image.editedUri ?: image.originalUri,
- contentDescription = "선택된 이미지",
+ contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
+ if (image.isVideo) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.55f))
+ )
+ )
+ )
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(horizontal = 5.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(13.dp),
+ )
+ Spacer(Modifier.width(2.dp))
+ Text(
+ text = formatDuration(image.videoDurationMs),
+ color = Color.White,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ }
}
}
+private fun formatDuration(durationMs: Long): String {
+ val totalSecs = (durationMs / 1000).coerceAtLeast(0)
+ val mins = totalSecs / 60
+ val secs = totalSecs % 60
+ return "%d:%02d".format(mins, secs)
+}
+
@Composable
private fun EmptySelectionCard(modifier: Modifier = Modifier) {
Box(
diff --git a/gradle.properties b/gradle.properties
index 932975e..2eff792 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -24,4 +24,4 @@ android.nonTransitiveRClass=true
# Library publishing coordinates
GROUP=io.github.seunghee17
-VERSION_NAME=1.0.0
+VERSION_NAME=1.0.1
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 779ad28..b8290c1 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
@@ -54,6 +55,7 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-
# Image loading
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
+coil-video = { group = "io.coil-kt", name = "coil-video", version.ref = "coil" }
# Testing
junit = { group = "junit", name = "junit", version.ref = "junit" }
@@ -62,6 +64,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 4324c6f..e3fc18c 100644
--- a/imagepicker/build.gradle.kts
+++ b/imagepicker/build.gradle.kts
@@ -61,6 +61,10 @@ android {
kotlinOptions {
jvmTarget = "11"
+ freeCompilerArgs += listOf(
+ "-P", "plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=" +
+ file("stability_config.conf").absolutePath
+ )
}
publishing {
@@ -167,8 +171,13 @@ 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)
+ implementation(libs.coil.video)
// Testing
testImplementation(libs.junit)
diff --git a/imagepicker/src/main/AndroidManifest.xml b/imagepicker/src/main/AndroidManifest.xml
index a26e449..f35cdbe 100644
--- a/imagepicker/src/main/AndroidManifest.xml
+++ b/imagepicker/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/DynamicImagePicker.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/DynamicImagePicker.kt
index 2af326e..24bf2b1 100644
--- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/DynamicImagePicker.kt
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/DynamicImagePicker.kt
@@ -1,18 +1,26 @@
package io.github.seunghee17.imagepicker
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import coil.ImageLoader
+import coil.compose.LocalImageLoader
+import coil.decode.VideoFrameDecoder
import io.github.seunghee17.imagepicker.presentation.picker.ImagePickerScreen
-import io.github.seunghee17.imagepicker.PickerResult
/**
- * DynamicImagePicker 라이브러리의 공개 진입점.
+ * Public entry point of the DynamicImagePicker library.
*
- * @param config 피커 동작 설정 (최대 선택 수, 앨범 표시, 편집 허용 여부)
- * @param onResult 선택/편집 완료 시 [PickerResult]를 전달
- * @param onCancel 사용자가 취소 버튼을 눌렀을 때 호출
- * @param onError 편집 처리 중 복구 불가 에러 발생 시 에러 메시지를 전달 (기본값: no-op)
- * @param modifier Composable 레이아웃 수정자
+ * Provides a video-aware Coil [ImageLoader] scoped to this composable tree so
+ * that video thumbnails are decoded without affecting the host app's image loader.
+ *
+ * @param config Picker behaviour config (max selection, album selector, editing, video)
+ * @param onResult Called with [PickerResult] when the user confirms selection
+ * @param onCancel Called when the user cancels
+ * @param onError Called with an error message on unrecoverable edit errors (default: no-op)
+ * @param modifier Layout modifier
*/
@Composable
fun DynamicImagePicker(
@@ -22,11 +30,24 @@ fun DynamicImagePicker(
onError: (String) -> Unit = {},
modifier: Modifier = Modifier
) {
- ImagePickerScreen(
- config = config,
- onResult = onResult,
- onCancel = onCancel,
- onError = onError,
- modifier = modifier
- )
-}
\ No newline at end of file
+ val context = LocalContext.current
+ // Build a scoped ImageLoader that adds VideoFrameDecoder for video thumbnail support.
+ // Memory and disk caching use Coil defaults (25% of available memory + disk cache),
+ // keeping memory usage efficient — frames are only decoded on demand and cached lazily.
+ val imageLoader = remember(context) {
+ ImageLoader.Builder(context)
+ .components {
+ add(VideoFrameDecoder.Factory())
+ }
+ .build()
+ }
+ CompositionLocalProvider(LocalImageLoader provides imageLoader) {
+ ImagePickerScreen(
+ config = config,
+ onResult = onResult,
+ onCancel = onCancel,
+ onError = onError,
+ modifier = modifier
+ )
+ }
+}
diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/ImagePickerConfig.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/ImagePickerConfig.kt
index 79a9280..07e6307 100644
--- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/ImagePickerConfig.kt
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/ImagePickerConfig.kt
@@ -6,9 +6,11 @@ package io.github.seunghee17.imagepicker
* @param maxSelectionCount 최대 선택 가능 이미지 수 (기본값: 10)
* @param showAlbumSelector 앨범 선택 드롭다운 표시 여부
* @param allowEditing 편집(회전/크롭) 기능 제공 여부
+ * @param allowVideo 비디오 선택 허용 여부
*/
data class ImagePickerConfig(
val maxSelectionCount: Int = 10,
val showAlbumSelector: Boolean = true,
- val allowEditing: Boolean = true
+ val allowEditing: Boolean = true,
+ val allowVideo: Boolean = false,
)
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..e36b0a8 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
@@ -15,21 +19,16 @@ import kotlinx.coroutines.launch
internal class GalleryRepositoryImpl(
private val dataSource: MediaStoreDataSource,
private val contentResolver: ContentResolver,
+ private val allowVideo: Boolean = false,
) : GalleryRepository {
- /**
- * 앨범 목록을 Flow로 반환한다.
- * ContentObserver로 MediaStore 변경을 감지하여 갤러리에 사진이 추가/삭제되면
- * 최신 앨범 목록을 재emit한다.
- */
override fun getAlbums(): Flow> = callbackFlow {
- // 최초 emit
- send(dataSource.queryAlbums())
+ send(dataSource.queryAlbums(allowVideo))
val observer = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean) {
launch {
- runCatching { dataSource.queryAlbums() }
+ runCatching { dataSource.queryAlbums(allowVideo) }
.onSuccess { trySend(it) }
}
}
@@ -38,13 +37,28 @@ internal class GalleryRepositoryImpl(
contentResolver.registerContentObserver(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
true,
- observer
+ observer,
)
+ if (allowVideo) {
+ contentResolver.registerContentObserver(
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
+ true,
+ 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, allowVideo)
+ },
+ ).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..4b89518
--- /dev/null
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/GalleryImagePagingSource.kt
@@ -0,0 +1,151 @@
+package io.github.seunghee17.imagepicker.data.source
+
+import android.content.ContentResolver
+import android.content.ContentUris
+import android.database.ContentObserver
+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 io.github.seunghee17.imagepicker.domain.model.MediaType
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * PagingSource that queries both images and videos from MediaStore.Files.
+ *
+ * Uses offset-based paging via the Bundle query API (API 26+).
+ * A ContentObserver is registered to invalidate the source when the gallery changes.
+ */
+internal class GalleryImagePagingSource(
+ private val contentResolver: ContentResolver,
+ private val albumId: String?,
+ private val allowVideo: Boolean,
+) : PagingSource() {
+
+ private val collection = MediaStore.Files.getContentUri("external")
+
+ private val observer = object : ContentObserver(null) {
+ override fun onChange(selfChange: Boolean) = invalidate()
+ }
+
+ init {
+ contentResolver.registerContentObserver(collection, 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 items = query(offset, limit)
+ LoadResult.Page(
+ data = items,
+ prevKey = if (offset == 0) null else maxOf(0, offset - limit),
+ nextKey = if (items.size < limit) null else offset + items.size,
+ )
+ } catch (e: Exception) {
+ LoadResult.Error(e)
+ }
+ }
+ }
+
+ private fun query(offset: Int, limit: Int): List {
+ val projection = arrayOf(
+ MediaStore.Files.FileColumns._ID,
+ MediaStore.MediaColumns.DISPLAY_NAME,
+ MediaStore.MediaColumns.DATE_TAKEN,
+ BUCKET_ID,
+ BUCKET_DISPLAY_NAME,
+ MediaStore.MediaColumns.WIDTH,
+ MediaStore.MediaColumns.HEIGHT,
+ MediaStore.MediaColumns.MIME_TYPE,
+ MediaStore.Files.FileColumns.MEDIA_TYPE,
+ MediaStore.Video.VideoColumns.DURATION,
+ )
+
+ val mediaTypeSelection = if (allowVideo) {
+ "${MediaStore.Files.FileColumns.MEDIA_TYPE} IN " +
+ "(${MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE}," +
+ "${MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO})"
+ } else {
+ "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ${MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE}"
+ }
+
+ val queryArgs = Bundle().apply {
+ putInt(ContentResolver.QUERY_ARG_OFFSET, offset)
+ putInt(ContentResolver.QUERY_ARG_LIMIT, limit)
+ putStringArray(
+ ContentResolver.QUERY_ARG_SORT_COLUMNS,
+ arrayOf(MediaStore.MediaColumns.DATE_TAKEN),
+ )
+ putInt(
+ ContentResolver.QUERY_ARG_SORT_DIRECTION,
+ ContentResolver.QUERY_SORT_DIRECTION_DESCENDING,
+ )
+ if (albumId != null) {
+ putString(
+ ContentResolver.QUERY_ARG_SQL_SELECTION,
+ "$mediaTypeSelection AND $BUCKET_ID = ?",
+ )
+ putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(albumId))
+ } else {
+ putString(ContentResolver.QUERY_ARG_SQL_SELECTION, mediaTypeSelection)
+ }
+ }
+
+ val items = mutableListOf()
+ contentResolver.query(collection, projection, queryArgs, null)?.use { cursor ->
+ val idCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
+ val nameCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
+ val dateCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_TAKEN)
+ val bucketIdCol = cursor.getColumnIndexOrThrow(BUCKET_ID)
+ val bucketNameCol = cursor.getColumnIndexOrThrow(BUCKET_DISPLAY_NAME)
+ val widthCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
+ val heightCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
+ val mimeCol = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.MIME_TYPE)
+ val mediaTypeCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
+ val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.DURATION)
+
+ while (cursor.moveToNext()) {
+ val id = cursor.getLong(idCol)
+ val uri = ContentUris.withAppendedId(collection, id)
+ val mediaTypeInt = cursor.getInt(mediaTypeCol)
+ val mediaType = if (mediaTypeInt == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO)
+ MediaType.VIDEO else MediaType.IMAGE
+ items += 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) ?: "",
+ mediaType = mediaType,
+ videoDuration = if (mediaType == MediaType.VIDEO) cursor.getLong(durationCol) else 0L,
+ )
+ }
+ }
+ return items
+ }
+
+ companion object {
+ // "bucket_id" and "bucket_display_name" are stable column names available for
+ // MediaStore.Files queries across all supported API levels (26+).
+ private const val BUCKET_ID = "bucket_id"
+ private const val BUCKET_DISPLAY_NAME = "bucket_display_name"
+ }
+}
diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/MediaStoreDataSource.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/MediaStoreDataSource.kt
index e8eb346..3c627aa 100644
--- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/MediaStoreDataSource.kt
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/data/source/MediaStoreDataSource.kt
@@ -3,101 +3,51 @@ package io.github.seunghee17.imagepicker.data.source
import android.content.ContentResolver
import android.content.ContentUris
import android.net.Uri
-import android.os.Build
import android.provider.MediaStore
import io.github.seunghee17.imagepicker.domain.model.GalleryAlbum
-import io.github.seunghee17.imagepicker.domain.model.GalleryImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
- * MediaStore ContentResolver를 통해 기기 갤러리 이미지를 조회한다.
+ * Queries device gallery items (images and/or videos) via MediaStore ContentResolver.
+ * Image/video queries are delegated to GalleryImagePagingSource for pagination support.
+ * This class handles album queries which return all media types matching the allowVideo filter.
*/
internal class MediaStoreDataSource(
private val contentResolver: ContentResolver
) {
- 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
- }
- suspend fun queryImages(albumId: String? = null): List =
+ suspend fun queryAlbums(allowVideo: Boolean = false): List =
withContext(Dispatchers.IO) {
+ // Use MediaStore.Files for a unified image+video album query.
+ val collection = MediaStore.Files.getContentUri("external")
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
+ MediaStore.Files.FileColumns._ID,
+ BUCKET_ID,
+ BUCKET_DISPLAY_NAME,
)
-
- // 특정 앨범만 필터링
- val selection = albumId?.let { "${MediaStore.Images.Media.BUCKET_ID} = ?" }
- val selectionArgs = albumId?.let { arrayOf(it) }
- val sortOrder = "${MediaStore.Images.Media.DATE_TAKEN} DESC"
-
- val images = mutableListOf()
- // 어떤 테이블 조회 / 어떤 컬럼 / 어떤 조건 / 조건 값 / 정렬 방식
- contentResolver.query(
- collection, projection, selection, selectionArgs, sortOrder
- )?.use { cursor -> // 끝나면 자동으로 cursor을 닫아 리소스 누수 방지
- val idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) // 이미지 고유 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) // 앨범 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) ?: ""
- )
- }
+ val mediaTypeSelection = if (allowVideo) {
+ "${MediaStore.Files.FileColumns.MEDIA_TYPE} IN " +
+ "(${MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE}," +
+ "${MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO})"
+ } else {
+ "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ${MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE}"
}
- images
- }
+ val sortOrder = "${MediaStore.MediaColumns.DATE_TAKEN} DESC"
- suspend fun queryAlbums(): List =
- withContext(Dispatchers.IO) {
- // 앨범 목록만 조회하기 위한 경량 쿼리 (전체 이미지 로드 없이 버킷 정보만 조회)
- val projection = arrayOf(
- MediaStore.Images.Media._ID,
- MediaStore.Images.Media.BUCKET_ID,
- MediaStore.Images.Media.BUCKET_DISPLAY_NAME,
- )
- val sortOrder = "${MediaStore.Images.Media.DATE_TAKEN} DESC"
-
- // 버킷별로 첫 번째 이미지(대표 이미지)와 개수를 수집
- val coverUriMap = mutableMapOf() // albumId → coverUri
- val albumNameMap = mutableMapOf() // albumId → albumName
- val albumCountMap = mutableMapOf() // albumId → count
+ val coverUriMap = mutableMapOf()
+ val albumNameMap = mutableMapOf()
+ val albumCountMap = mutableMapOf()
contentResolver.query(
- collection, projection, null, null, sortOrder
+ collection, projection, mediaTypeSelection, null, sortOrder
)?.use { cursor ->
- val idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
- val bucketIdCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_ID)
- val bucketNameCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME)
+ val idCol = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)
+ val bucketIdCol = cursor.getColumnIndexOrThrow(BUCKET_ID)
+ val bucketNameCol = cursor.getColumnIndexOrThrow(BUCKET_DISPLAY_NAME)
while (cursor.moveToNext()) {
- val bucketId = cursor.getString(bucketIdCol) ?: ""
+ val bucketId = cursor.getString(bucketIdCol) ?: continue
albumCountMap[bucketId] = (albumCountMap[bucketId] ?: 0) + 1
if (!coverUriMap.containsKey(bucketId)) {
val id = cursor.getLong(idCol)
@@ -115,8 +65,13 @@ internal class MediaStoreDataSource(
id = albumId,
name = albumNameMap[albumId] ?: "",
coverUri = coverUri,
- imageCount = count
+ count = count
)
}
}
+
+ companion object {
+ private const val BUCKET_ID = "bucket_id"
+ private const val BUCKET_DISPLAY_NAME = "bucket_display_name"
+ }
}
diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/GalleryAlbum.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/GalleryAlbum.kt
index 18d6820..3ed22e4 100644
--- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/GalleryAlbum.kt
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/GalleryAlbum.kt
@@ -6,5 +6,5 @@ internal data class GalleryAlbum(
val id: String,
val name: String,
val coverUri: Uri,
- val imageCount: Int
+ val count: Int
)
diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/GalleryImage.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/GalleryImage.kt
index 1377390..0fad2f7 100644
--- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/GalleryImage.kt
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/GalleryImage.kt
@@ -6,10 +6,12 @@ internal data class GalleryImage(
val id: Long,
val uri: Uri,
val displayName: String,
- val dateTaken: Long, // epoch millis, 최신 순 정렬 기준
+ val dateTaken: Long,
val albumId: String,
val albumName: String,
val width: Int,
val height: Int,
- val mimeType: String
+ val mimeType: String,
+ val mediaType: MediaType = MediaType.IMAGE,
+ val videoDuration: Long = 0L, // milliseconds, only meaningful when mediaType == VIDEO
)
diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/MediaType.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/MediaType.kt
new file mode 100644
index 0000000..dc7df43
--- /dev/null
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/domain/model/MediaType.kt
@@ -0,0 +1,3 @@
+package io.github.seunghee17.imagepicker.domain.model
+
+internal enum class MediaType { IMAGE, VIDEO }
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 d0f1f9b..185aaca 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
@@ -9,4 +9,6 @@ data class PickedImage(
val rotationDegrees: Int = 0, // 0, 90, 180, 270 (시계 방향)
val cropRect: CropRect? = null, // null = 크롭 좌표 미제공 (크롭 결과는 editedUri에 반영)
val isCropped: Boolean = cropRect != null, // cropRect가 없어도 true로 명시 가능
+ val isVideo: Boolean = false,
+ val videoDurationMs: Long = 0L, // isVideo == true 일 때만 유효 (밀리초)
)
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/component/TopBarWithCount.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/component/TopBarWithCount.kt
index c2ed3fc..fccc76c 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
@@ -6,6 +6,8 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import io.github.seunghee17.imagepicker.R
import io.github.seunghee17.imagepicker.domain.model.GalleryAlbum
import io.github.seunghee17.imagepicker.presentation.gallery.AlbumDropdown
@@ -31,11 +33,11 @@ internal fun TopBarWithCount(
TopAppBar(
modifier = modifier,
title = {
- Text(text = if (selectedCount > 0) "$selectedCount / $maxCount" else "사진 선택")
+ Text(text = if (selectedCount > 0) stringResource(R.string.selection_count, selectedCount, maxCount) else stringResource(R.string.select_photo))
},
navigationIcon = {
TextButton(onClick = onCancel) {
- Text("취소")
+ Text(stringResource(R.string.cancel))
}
},
actions = {
@@ -43,7 +45,7 @@ internal fun TopBarWithCount(
onClick = onConfirm,
enabled = selectedCount > 0
) {
- Text("완료")
+ Text(stringResource(R.string.done))
}
if (showAlbumSelector) {
AlbumDropdown(
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..fa9563e 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
@@ -1,6 +1,7 @@
package io.github.seunghee17.imagepicker.presentation.editor
import android.net.Uri
+import androidx.compose.runtime.Stable
import io.github.seunghee17.imagepicker.CropRect
import io.github.seunghee17.imagepicker.PickedImage
@@ -8,6 +9,7 @@ internal interface EditorContract {
enum class Mode { NORMAL, CROPPING }
+ @Stable
data class State(
val originalUri: Uri,
@@ -47,12 +49,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..8d79b59 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,118 +11,234 @@ 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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.res.stringResource
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.R
+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 "편집") },
+ title = { Text(if (isCropping) stringResource(R.string.select_crop_area) else stringResource(R.string.edit)) },
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(stringResource(R.string.cancel)) }
},
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
- ) { Text("완료") }
+ enabled = currentState?.isSaving != true,
+ ) { Text(stringResource(R.string.done)) }
}
)
}
) { 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,
+ onError = onError,
+ )
+ }
+
+ // 하단 편집 버튼 (크롭 모드·편집 비허용 시 숨김)
+ 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(stringResource(R.string.rotate)) }
+ Spacer(modifier = Modifier.width(16.dp))
+ OutlinedButton(
+ onClick = { activeOnIntent.value?.invoke(EditorContract.Intent.EnterCropMode) },
+ enabled = currentState?.isSaving != true,
+ ) { Text(stringResource(R.string.crop)) }
+ }
+ }
+ }
- // 하단 도구 버튼 (크롭 모드 또는 편집 비허용 시 숨김)
- if (!isCropping && allowEditing) {
- Row(
+ // 우측 상단 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,
+ 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()
+
+ val latestOnActivate by rememberUpdatedState(onActivate)
+ LaunchedEffect(isCurrentPage) {
+ if (!isCurrentPage) return@LaunchedEffect
+ viewModel.state.collect { latestState ->
+ latestOnActivate(latestState, 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 +251,6 @@ private fun ImageWithCropOverlay(
contentScale = ContentScale.Fit,
)
- // 컨테이너 크기와 이미지 intrinsic 크기를 추적하여 imageRect 계산
var containerSize by remember { mutableStateOf(IntSize.Zero) }
val intrinsicSize: IntSize? = remember(painter.state) {
@@ -160,7 +276,7 @@ private fun ImageWithCropOverlay(
Image(
modifier = Modifier.fillMaxSize(),
painter = painter,
- contentDescription = "미리보기",
+ contentDescription = "Preview",
contentScale = ContentScale.Fit,
)
@@ -168,7 +284,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/AlbumDropdown.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/AlbumDropdown.kt
index fbeb07d..4406f06 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
@@ -1,6 +1,5 @@
package io.github.seunghee17.imagepicker.presentation.gallery
-import android.provider.CalendarContract
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
@@ -39,12 +38,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
)
}
@@ -55,7 +54,7 @@ internal fun AlbumDropdown(
) {
albums.forEach { album ->
DropdownMenuItem(
- text = { Text("${album.name} (${album.imageCount})") },
+ text = { Text("${album.name} (${album.count})") },
onClick = {
onAlbumSelected(album)
closeDropDown()
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..ba90708 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
@@ -1,5 +1,6 @@
package io.github.seunghee17.imagepicker.presentation.gallery
+import androidx.compose.runtime.Stable
import io.github.seunghee17.imagepicker.domain.model.GalleryAlbum
import io.github.seunghee17.imagepicker.domain.model.GalleryImage
import io.github.seunghee17.imagepicker.PickedImage
@@ -7,19 +8,20 @@ import io.github.seunghee17.imagepicker.PickerResult
internal interface GalleryContract {
+ @Stable
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
+
+ val selectionOrderMap: Map
+ get() = selectedImages.mapIndexed { idx, img -> img.id to (idx + 1) }.toMap()
}
sealed interface Intent {
@@ -32,8 +34,8 @@ internal interface GalleryContract {
}
sealed interface Effect {
- data class ShowSelectionLimitSnackbar(val message: String) : Effect
+ data class ShowSelectionLimitSnackbar(val maxSelectionCount: Int) : Effect
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/GalleryGridItem.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryGridItem.kt
index 1dd6ce2..351f0d3 100644
--- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryGridItem.kt
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/gallery/GalleryGridItem.kt
@@ -4,36 +4,59 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import io.github.seunghee17.imagepicker.domain.model.GalleryImage
+import io.github.seunghee17.imagepicker.domain.model.MediaType
import io.github.seunghee17.imagepicker.presentation.component.SelectionBadge
/**
- * 그리드 내 개별 이미지 아이템.
- * - 탭: 선택/해제 토글
- * - 드래그: 드래그 멀티 선택 시작
- * - 선택 시: 배지 번호 및 오버레이 표시
+ * Single grid item rendering an image or video thumbnail.
+ * - Image tap: opens editor
+ * - Video tap: toggles selection
+ * - Badge tap: toggles selection
+ * - Video items show a play icon + duration overlay at the bottom-start
*/
@Composable
internal fun GalleryGridItem(
image: GalleryImage,
- selectionOrder: Int?, // null = 미선택, 1 이상 = 선택 순서
+ selectionOrder: Int?, // null = unselected, 1+ = selection order
onOpenEditor: () -> Unit,
onSelectionBadgeTap: () -> Unit,
modifier: Modifier = Modifier
) {
val isSelected = selectionOrder != null
+ val currentOnOpenEditor by rememberUpdatedState(onOpenEditor)
+ val currentOnSelectionBadgeTap by rememberUpdatedState(onSelectionBadgeTap)
+
+ val handleTileTap: () -> Unit = {
+ if (image.mediaType == MediaType.VIDEO) {
+ currentOnSelectionBadgeTap()
+ } else {
+ currentOnOpenEditor()
+ }
+ }
Box(
modifier = modifier
@@ -42,10 +65,8 @@ internal fun GalleryGridItem(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary)
else Modifier
)
- .pointerInput(Unit) {
- detectTapGestures(
- onTap = { onOpenEditor() },
- )
+ .pointerInput(image.mediaType) {
+ detectTapGestures(onTap = { handleTileTap() })
}
) {
AsyncImage(
@@ -62,12 +83,53 @@ internal fun GalleryGridItem(
.background(Color.Black.copy(alpha = 0.3f))
)
}
+
+ // Video indicator: gradient scrim + play icon + duration
+ if (image.mediaType == MediaType.VIDEO) {
+ // Bottom gradient scrim for text readability
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.55f)),
+ )
+ )
+ )
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(horizontal = 5.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(13.dp),
+ )
+ Spacer(Modifier.width(2.dp))
+ Text(
+ text = formatDuration(image.videoDuration),
+ color = Color.White,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ }
+
SelectionBadge(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp),
order = selectionOrder,
- onTap = onSelectionBadgeTap,
+ onTap = { currentOnSelectionBadgeTap() },
)
}
}
+
+private fun formatDuration(durationMs: Long): String {
+ val totalSecs = (durationMs / 1000).coerceAtLeast(0)
+ val mins = totalSecs / 60
+ val secs = totalSecs % 60
+ return "%d:%02d".format(mins, secs)
+}
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..77178ed 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,31 +5,44 @@ 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
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.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import io.github.seunghee17.imagepicker.R
+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.DragSelectionState
+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
@@ -38,42 +51,65 @@ import kotlinx.coroutines.flow.Flow
@Composable
internal fun GalleryScreen(
state: GalleryContract.State,
- effect: Flow,
+ snackbarHostState: SnackbarHostState,
+ pagingFlow: Flow>,
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) }
- var dropDownExpanded by rememberSaveable { androidx.compose.runtime.mutableStateOf(false) }
+ val currentDragState = 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()
- }
- }
+ 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 ?: "Fail to load Media",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
}
// 드래그 중 autoScrollSpeed 값에 따라 그리드를 자동 스크롤
LaunchedEffect(gridState) {
- while (true) {
- val speed = autoScrollSpeed.floatValue
- if (speed != 0f) gridState.scrollBy(speed)
- delay(16L) // ~60fps
- }
+ 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)
+ }
+ }
+ }
+ }
+ delay(16L) // ~60fps
+ }
+ }
}
Scaffold(
@@ -97,48 +133,81 @@ 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(stringResource(R.string.gallery_empty))
+ }
}
- } 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() },
+ currentDragState = currentDragState,
+ )
+ ) {
+ items(
+ count = pagingItems.itemCount,
+ key = pagingItems.itemKey { it.id },
+ ) { index ->
+ val image = pagingItems[index] ?: return@items
+ val order = state.selectionOrderMap[image.id]
+
+ GalleryGridItem(
+ image = image,
+ selectionOrder = order,
+ onSelectionBadgeTap = {
onIntent(GalleryContract.Intent.ToggleImageSelection(image))
- }
- },
- autoScrollSpeed = autoScrollSpeed,
- autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() }
- )
- ) {
- 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..81489c9 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,57 @@ 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.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.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 +65,6 @@ internal class GalleryScreenViewModel(
val effect = _effect.receiveAsFlow()
private var albumsObserved = false
-
- // confirmSelection() 후 다음 세션 시작 시 캐시를 정리하기 위한 플래그
private var pendingCacheClean = false
fun handleIntent(intent: GalleryContract.Intent) {
@@ -53,7 +78,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,47 +96,33 @@ internal class GalleryScreenViewModel(
val selectedAlbum = current.selectedAlbum ?: albums.firstOrNull()
current.copy(albums = albums, selectedAlbum = selectedAlbum)
}
- loadImages(_state.value.selectedAlbum?.id)
+ val selectedAlbumId = _state.value.selectedAlbum?.id
+ val nextFilter = AlbumFilter.Active(selectedAlbumId)
+ if (_albumFilter.value != nextFilter) {
+ _albumFilter.value = nextFilter
+ }
}
.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()
- if (selected.any { it.id == image.id }) {
- selected.removeAll { it.id == image.id }
- _state.update { it.copy(selectedImages = selected) }
+ if (current.selectedImages.any { it.id == image.id }) {
+ _state.update { it.copy(selectedImages = it.selectedImages.filter { img -> img.id != image.id }) }
return
}
if (current.isSelectionLimitReached) {
viewModelScope.launch {
_effect.send(
- GalleryContract.Effect.ShowSelectionLimitSnackbar(
- "이미지는 최대 ${current.maxSelectionCount}장까지 선택할 수 있습니다."
- )
+ GalleryContract.Effect.ShowSelectionLimitSnackbar(current.maxSelectionCount)
)
}
return
}
- selected.add(image)
- _state.update { it.copy(selectedImages = selected) }
+ _state.update { it.copy(selectedImages = it.selectedImages + image) }
}
private fun applyEditResult(pickedImage: PickedImage) {
@@ -127,7 +138,6 @@ internal class GalleryScreenViewModel(
private fun confirmSelection() {
val result = buildPickerResult()
resetSelection()
- // 호스트 앱이 editedUri 파일을 사용할 수 있도록 다음 세션 시작 시 정리
pendingCacheClean = true
viewModelScope.launch {
_effect.send(GalleryContract.Effect.SelectionConfirmed(result))
@@ -136,7 +146,6 @@ internal class GalleryScreenViewModel(
private fun cancel() {
resetSelection()
- // 결과를 반환하지 않으므로 캐시 파일 즉시 삭제
viewModelScope.launch {
_effect.send(GalleryContract.Effect.Cancelled)
runCatching { clearEditCache() }
@@ -146,7 +155,11 @@ internal class GalleryScreenViewModel(
private fun buildPickerResult(): PickerResult {
val current = _state.value
val items = current.selectedImages.map { image ->
- current.editResults[image.id] ?: PickedImage(originalUri = image.uri)
+ val base = current.editResults[image.id] ?: PickedImage(originalUri = image.uri)
+ base.copy(
+ isVideo = image.mediaType == MediaType.VIDEO,
+ videoDurationMs = image.videoDuration,
+ )
}
return PickerResult(items)
}
@@ -155,8 +168,13 @@ internal class GalleryScreenViewModel(
_state.update {
it.copy(
selectedImages = emptyList(),
- editResults = emptyMap()
+ editResults = emptyMap(),
)
}
}
+
+ override fun onCleared() {
+ super.onCleared()
+ _effect.close()
+ }
}
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..4897904 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,
@@ -25,11 +25,12 @@ internal class GalleryScreenViewModelFactory(
val galleryRepository = GalleryRepositoryImpl(
dataSource = MediaStoreDataSource(appContext.contentResolver),
contentResolver = appContext.contentResolver,
+ allowVideo = config.allowVideo,
)
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 3ced59e..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
@@ -1,28 +1,22 @@
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,
+ val tappedImage: GalleryImage? = null,
)
internal fun editorDestinationSaver(): Saver = Saver(
@@ -31,17 +25,27 @@ internal fun editorDestinationSaver(): Saver = Saver(
listOf(
it.entryId.toString(),
it.imageId.toString(),
- it.originalUri.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])
+ originalUri = Uri.parse(values[2]),
+ initialIndex = values.getOrNull(3)?.toIntOrNull() ?: 0,
+ tappedImage = tappedImage,
)
}
)
@@ -49,41 +53,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/ImagePickerContract.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerContract.kt
index f34a3bd..0e56d6a 100644
--- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerContract.kt
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerContract.kt
@@ -1,14 +1,16 @@
package io.github.seunghee17.imagepicker.presentation.picker
+import androidx.compose.runtime.Stable
import io.github.seunghee17.imagepicker.domain.model.GalleryImage
import io.github.seunghee17.imagepicker.domain.model.PermissionStatus
import io.github.seunghee17.imagepicker.PickerResult
internal interface ImagePickerContract {
+ @Stable
data class State(
val permissionStatus: PermissionStatus = PermissionStatus.DENIED,
- val hasRequestedPermission: Boolean = false
+ val hasRequestedFullAccessAfterPartial: Boolean = false,
)
sealed interface Intent {
@@ -36,7 +38,6 @@ internal interface ImagePickerContract {
data class NavigateToEditor(val image: GalleryImage, val entryId: Long) : Effect
data class ReturnResult(val result: PickerResult) : Effect
data object Cancelled : Effect
- data class ShowToast(val message: String) : Effect
}
enum class PermissionCheckSource {
@@ -45,4 +46,4 @@ internal interface ImagePickerContract {
PERMISSION_RESULT,
RETRY_BUTTON
}
-}
\ No newline at end of file
+}
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..c8cfb3e 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
@@ -1,14 +1,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
@@ -19,6 +19,9 @@ 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.R
+import io.github.seunghee17.imagepicker.domain.model.GalleryImage
+import io.github.seunghee17.imagepicker.domain.model.MediaType
import io.github.seunghee17.imagepicker.domain.model.PermissionStatus
import io.github.seunghee17.imagepicker.presentation.gallery.GalleryContract
import io.github.seunghee17.imagepicker.presentation.gallery.GalleryScreen
@@ -37,6 +40,7 @@ internal fun ImagePickerScreen(
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
+ val snackbarHostState = remember { SnackbarHostState() }
val viewModel: ImagePickerViewModel = viewModel(
factory = ImagePickerViewModelFactory()
@@ -62,7 +66,8 @@ internal fun ImagePickerScreen(
ImagePickerContract.Intent.OnPermissionEvaluated(
status = resolvePermissionStatus(
context = context,
- hasRequestedPermission = hasRequestedPermission
+ hasRequestedPermission = hasRequestedPermission,
+ allowVideo = config.allowVideo
),
source = ImagePickerContract.PermissionCheckSource.PERMISSION_RESULT
)
@@ -85,6 +90,24 @@ internal fun ImagePickerScreen(
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
+ LaunchedEffect(Unit) {
+ galleryViewModel.effect.collectLatest { galleryEffect ->
+ when (galleryEffect) {
+ is GalleryContract.Effect.ShowSelectionLimitSnackbar ->
+ snackbarHostState.showSnackbar(
+ context.getString(
+ R.string.selection_limit,
+ galleryEffect.maxSelectionCount,
+ )
+ )
+ 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 ||
@@ -95,7 +118,7 @@ internal fun ImagePickerScreen(
}
// Effect 처리
- LaunchedEffect(viewModel, context, hasRequestedPermission) {
+ LaunchedEffect(Unit) {
viewModel.effect.collectLatest { effect ->
when (effect) {
is ImagePickerContract.Effect.CheckPermission -> {
@@ -103,7 +126,8 @@ internal fun ImagePickerScreen(
ImagePickerContract.Intent.OnPermissionEvaluated(
status = resolvePermissionStatus(
context = context,
- hasRequestedPermission = hasRequestedPermission
+ hasRequestedPermission = hasRequestedPermission,
+ allowVideo = config.allowVideo
),
source = effect.source
)
@@ -111,18 +135,21 @@ internal fun ImagePickerScreen(
}
is ImagePickerContract.Effect.RequestPermission -> {
hasRequestedPermission = true
- permissionLauncher.launch(requestedPermissionsForPicker())
+ permissionLauncher.launch(requestedPermissionsForPicker(config.allowVideo))
}
is ImagePickerContract.Effect.NavigateToSettings -> openAppSettings(context)
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()
is ImagePickerContract.Effect.NavigateToEditor -> {
+ 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,
)
}
}
@@ -138,30 +165,52 @@ internal fun ImagePickerScreen(
GalleryScreen(
modifier = modifier,
state = galleryState,
- effect = galleryViewModel.effect,
+ pagingFlow = galleryViewModel.pagingFlow,
+ snackbarHostState = snackbarHostState,
onIntent = galleryViewModel::handleIntent,
onOpenEditor = { image ->
- viewModel.handleIntent(ImagePickerContract.Intent.OpenEditor(image = image))
+ if (image.mediaType == MediaType.VIDEO) {
+ // Videos don't support editing; tap toggles selection instead
+ galleryViewModel.handleIntent(GalleryContract.Intent.ToggleImageSelection(image))
+ } else {
+ viewModel.handleIntent(ImagePickerContract.Intent.OpenEditor(image = image))
+ }
},
- onConfirm = { result ->
- viewModel.handleIntent(ImagePickerContract.Intent.ConfirmSelection(result))
- },
- onCancel = {
- viewModel.handleIntent(ImagePickerContract.Intent.Cancel)
- }
)
}
} else {
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!!,
+ destination = destination,
+ allImages = allImages,
+ selectedImages = selected,
+ snackbarHostState = snackbarHostState,
onEditApplied = { pickedImage ->
galleryViewModel.handleIntent(GalleryContract.Intent.OnEditResult(pickedImage))
editorDestination = null
},
onDismiss = { editorDestination = null },
+ onToggleSelection = { image ->
+ galleryViewModel.handleIntent(GalleryContract.Intent.ToggleImageSelection(image))
+ },
onError = onError,
modifier = modifier,
allowEditing = config.allowEditing,
diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerViewModel.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerViewModel.kt
index b0169f6..634e404 100644
--- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerViewModel.kt
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/ImagePickerViewModel.kt
@@ -51,22 +51,32 @@ internal class ImagePickerViewModel : ViewModel() {
status: PermissionStatus,
source: ImagePickerContract.PermissionCheckSource
) {
- _state.update { current ->
- current.copy(
- permissionStatus = status,
- hasRequestedPermission = current.hasRequestedPermission ||
- source == ImagePickerContract.PermissionCheckSource.PERMISSION_RESULT
- )
- }
-
when (status) {
- PermissionStatus.GRANTED -> Unit
+ PermissionStatus.GRANTED -> {
+ _state.update {
+ it.copy(
+ permissionStatus = status,
+ hasRequestedFullAccessAfterPartial = false,
+ )
+ }
+ }
PermissionStatus.PARTIALLY_GRANTED -> {
- if (source != ImagePickerContract.PermissionCheckSource.RESUME) {
+ val hasRequestedFullAccess = _state.value.hasRequestedFullAccessAfterPartial
+ _state.update { it.copy(permissionStatus = status) }
+ if (source != ImagePickerContract.PermissionCheckSource.RESUME &&
+ !hasRequestedFullAccess
+ ) {
+ _state.update { it.copy(hasRequestedFullAccessAfterPartial = true) }
sendEffect(ImagePickerContract.Effect.RequestPermission)
}
}
PermissionStatus.DENIED -> {
+ _state.update {
+ it.copy(
+ permissionStatus = status,
+ hasRequestedFullAccessAfterPartial = false,
+ )
+ }
if (source == ImagePickerContract.PermissionCheckSource.INITIAL ||
source == ImagePickerContract.PermissionCheckSource.RETRY_BUTTON
) {
@@ -74,6 +84,12 @@ internal class ImagePickerViewModel : ViewModel() {
}
}
PermissionStatus.PERMANENTLY_DENIED -> {
+ _state.update {
+ it.copy(
+ permissionStatus = status,
+ hasRequestedFullAccessAfterPartial = false,
+ )
+ }
if (source == ImagePickerContract.PermissionCheckSource.PERMISSION_RESULT ||
source == ImagePickerContract.PermissionCheckSource.RETRY_BUTTON
) {
diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/PermissionFallbackContent.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/PermissionFallbackContent.kt
index 259562c..de6701e 100644
--- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/PermissionFallbackContent.kt
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/PermissionFallbackContent.kt
@@ -10,7 +10,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import io.github.seunghee17.imagepicker.R
import io.github.seunghee17.imagepicker.domain.model.PermissionStatus
@Composable
@@ -19,21 +21,18 @@ internal fun PermissionFallbackContent(
state: ImagePickerContract.State,
onIntent: (ImagePickerContract.Intent) -> Unit,
) {
- val message = when (state.permissionStatus) {
- PermissionStatus.PARTIALLY_GRANTED ->
- "전체 갤러리를 표시하려면 전체 사진 접근 권한이 필요합니다."
- PermissionStatus.PERMANENTLY_DENIED ->
- "권한이 영구적으로 거부되었습니다. 앱 설정에서 사진 권한을 허용해 주세요."
- PermissionStatus.DENIED ->
- "갤러리를 불러오려면 사진 접근 권한이 필요합니다."
- PermissionStatus.GRANTED -> ""
+ val messageRes = when (state.permissionStatus) {
+ PermissionStatus.PARTIALLY_GRANTED -> R.string.permission_partially_granted
+ PermissionStatus.PERMANENTLY_DENIED -> R.string.permission_permanently_denied
+ PermissionStatus.DENIED -> R.string.permission_denied
+ PermissionStatus.GRANTED -> error("PermissionFallbackContent should not render in granted state")
}
- val buttonLabel = when (state.permissionStatus) {
- PermissionStatus.PERMANENTLY_DENIED -> "앱 설정 열기"
- PermissionStatus.PARTIALLY_GRANTED -> "전체 권한 요청"
- PermissionStatus.DENIED -> "권한 요청"
- PermissionStatus.GRANTED -> ""
+ val buttonLabelRes = when (state.permissionStatus) {
+ PermissionStatus.PERMANENTLY_DENIED -> R.string.open_settings
+ PermissionStatus.PARTIALLY_GRANTED -> R.string.request_full_permission
+ PermissionStatus.DENIED -> R.string.request_permission
+ PermissionStatus.GRANTED -> error("PermissionFallbackContent should not render in granted state")
}
Column(
@@ -44,7 +43,7 @@ internal fun PermissionFallbackContent(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
- text = message,
+ text = stringResource(messageRes),
style = MaterialTheme.typography.bodyLarge
)
@@ -58,7 +57,7 @@ internal fun PermissionFallbackContent(
},
modifier = Modifier.padding(top = 16.dp)
) {
- Text(buttonLabel)
+ Text(stringResource(buttonLabelRes))
}
}
-}
\ No newline at end of file
+}
diff --git a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/PickerPermissionHelper.kt b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/PickerPermissionHelper.kt
index 30daa99..91f76ed 100644
--- a/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/PickerPermissionHelper.kt
+++ b/imagepicker/src/main/java/io/github/seunghee17/imagepicker/presentation/picker/PickerPermissionHelper.kt
@@ -13,28 +13,35 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.github.seunghee17.imagepicker.domain.model.PermissionStatus
-internal fun requestedPermissionsForPicker(): Array = when {
+internal fun requestedPermissionsForPicker(allowVideo: Boolean = false): Array = when {
Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2 ->
+ // READ_EXTERNAL_STORAGE covers both images and videos on API <= 32
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE ->
- arrayOf(
- Manifest.permission.READ_MEDIA_IMAGES,
- Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
- )
+ buildList {
+ add(Manifest.permission.READ_MEDIA_IMAGES)
+ add(Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
+ if (allowVideo) add(Manifest.permission.READ_MEDIA_VIDEO)
+ }.toTypedArray()
else ->
- arrayOf(Manifest.permission.READ_MEDIA_IMAGES)
+ buildList {
+ add(Manifest.permission.READ_MEDIA_IMAGES)
+ if (allowVideo) add(Manifest.permission.READ_MEDIA_VIDEO)
+ }.toTypedArray()
}
internal fun resolvePermissionStatus(
context: Context,
- hasRequestedPermission: Boolean
+ hasRequestedPermission: Boolean,
+ allowVideo: Boolean = false
): PermissionStatus {
val activity = context.findActivity()
- val fullPermission = fullAccessPermission()
+ val fullPermissions = fullAccessPermissions(allowVideo)
- val hasFullAccess = ContextCompat.checkSelfPermission(
- context, fullPermission
- ) == PackageManager.PERMISSION_GRANTED
+ // Check if all required permissions are granted
+ val hasFullAccess = fullPermissions.all { permission ->
+ ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
+ }
if (hasFullAccess) return PermissionStatus.GRANTED
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
@@ -44,8 +51,13 @@ internal fun resolvePermissionStatus(
if (hasSelectedPhotoAccess) return PermissionStatus.PARTIALLY_GRANTED
}
- val shouldShowRationale = activity?.let {
- ActivityCompat.shouldShowRequestPermissionRationale(it, fullPermission)
+ val deniedPermissions = fullPermissions.filter { permission ->
+ ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
+ }
+ val shouldShowRationale = activity?.let { host ->
+ deniedPermissions.any { permission ->
+ ActivityCompat.shouldShowRequestPermissionRationale(host, permission)
+ }
} ?: false
return if (hasRequestedPermission && !shouldShowRationale) {
@@ -63,11 +75,20 @@ internal fun openAppSettings(context: Context) {
context.startActivity(intent)
}
-private fun fullAccessPermission(): String =
- if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2)
- Manifest.permission.READ_EXTERNAL_STORAGE
- else
- Manifest.permission.READ_MEDIA_IMAGES
+private fun fullAccessPermissions(allowVideo: Boolean = false): Array = when {
+ Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2 ->
+ arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE ->
+ buildList {
+ add(Manifest.permission.READ_MEDIA_IMAGES)
+ if (allowVideo) add(Manifest.permission.READ_MEDIA_VIDEO)
+ }.toTypedArray()
+ else ->
+ buildList {
+ add(Manifest.permission.READ_MEDIA_IMAGES)
+ if (allowVideo) add(Manifest.permission.READ_MEDIA_VIDEO)
+ }.toTypedArray()
+}
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
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..aae35de 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
@@ -15,6 +15,11 @@ import androidx.compose.ui.composed
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import io.github.seunghee17.imagepicker.domain.model.GalleryImage
+internal data class DragSelectionState(
+ val offset: Offset,
+ val lastProcessedKey: Long?,
+)
+
// 현재 터치한 좌표를 매개변수로 받는다
// 이 아이템 영역 안에 현재 터치좌표가 들어가 있는가? 만약 찾은 아이템이 있다면 key 반환 없으면 null
internal fun LazyGridState.gridItemKeyAtPosition(hitPoint: Offset): Long? =
@@ -30,7 +35,8 @@ internal fun Modifier.photoGridDragHandler(
selectedImages: List,
onSelect: (Long) -> Unit, // viewmodel에 정의한 사진 선택 콜백 주입하도록 수정
autoScrollSpeed: MutableState,
- autoScrollThreshold: Float
+ autoScrollThreshold: Float,
+ currentDragState: MutableState // 자동 스크롤 중 중복 토글 방지를 위해 좌표와 마지막 처리 key를 함께 노출
): Modifier = composed {
val currentSelectedImages by rememberUpdatedState(selectedImages)
val currentOnSelect by rememberUpdatedState(onSelect)
@@ -48,19 +54,31 @@ internal fun Modifier.photoGridDragHandler(
initialKey = key
currentKey = key
currentOnSelect(key)
+ currentDragState.value = DragSelectionState(
+ offset = offset,
+ lastProcessedKey = key,
+ )
}
}
},
onDragCancel = {
initialKey = null
+ currentKey = null
autoScrollSpeed.value = 0f
+ currentDragState.value = null
},
onDragEnd = {
initialKey = null
+ currentKey = null
autoScrollSpeed.value = 0f
+ currentDragState.value = null
},
onDrag = { change, _ ->
if (initialKey != null) {
+ currentDragState.value = DragSelectionState(
+ offset = change.position,
+ lastProcessedKey = currentDragState.value?.lastProcessedKey,
+ )
val distFromBottom =
lazyGridState.layoutInfo.viewportSize.height - change.position.y
val distFromTop = change.position.y
@@ -74,10 +92,14 @@ internal fun Modifier.photoGridDragHandler(
if (currentKey != key && currentSelectedImages.none { it.id == key }) {
currentOnSelect(key)
currentKey = key
+ currentDragState.value = DragSelectionState(
+ offset = change.position,
+ lastProcessedKey = key,
+ )
}
}
}
}
)
}
-}
\ No newline at end of file
+}
diff --git a/imagepicker/src/main/res/values-ko/strings.xml b/imagepicker/src/main/res/values-ko/strings.xml
new file mode 100644
index 0000000..40d3ea0
--- /dev/null
+++ b/imagepicker/src/main/res/values-ko/strings.xml
@@ -0,0 +1,38 @@
+
+
+ 이미지 피커
+
+
+ 표시할 이미지가 없습니다.
+
+
+ 사진 선택
+ 취소
+ 완료
+
+
+ 편집
+ 크롭 영역 선택
+ 회전
+ 크롭
+
+
+ %1$d / %2$d
+ 최대 %d개의 미디어를 선택할 수 있습니다.
+
+
+ 사진에 대한 접근 권한 허용
+ 사진과 동영상에 대한 접근 권한 허용
+ 미디어에 접근하기 위해 권한이 필요합니다.
+ 전체 갤러리를 표시하려면 전체 미디어 접근 권한이 필요합니다.
+ 권한이 영구적으로 거부되었습니다. 앱 설정에서 미디어 권한을 허용해 주세요.
+ 갤러리를 불러오려면 미디어 접근 권한이 필요합니다.
+ 설정
+ 앱 설정 열기
+ 전체 권한 요청
+ 권한 요청
+
+
+ 미디어 로드 실패
+ 이미지 편집 실패
+
diff --git a/imagepicker/src/main/res/values/strings.xml b/imagepicker/src/main/res/values/strings.xml
index 9c4b527..fefb715 100644
--- a/imagepicker/src/main/res/values/strings.xml
+++ b/imagepicker/src/main/res/values/strings.xml
@@ -1,3 +1,37 @@
ImagePicker
-
\ No newline at end of file
+
+
+ No images available
+
+
+ Select Photo
+ Cancel
+ Done
+
+
+ Edit
+ Select crop area
+ Rotate
+ Crop
+
+
+ %1$d / %2$d
+ You can select up to %d media items.
+
+
+ Grant access to your photos
+ Grant access to your photos and videos
+ Permission is required to access your media.
+ Full media access is required to show the complete gallery.
+ Permission has been permanently denied. Please allow media access in the app settings.
+ Media access permission is required to load the gallery.
+ Settings
+ Open App Settings
+ Request Full Permission
+ Request Permission
+
+
+ Failed to load media
+ Failed to edit image
+
diff --git a/imagepicker/stability_config.conf b/imagepicker/stability_config.conf
new file mode 100644
index 0000000..af113a9
--- /dev/null
+++ b/imagepicker/stability_config.conf
@@ -0,0 +1 @@
+android.net.Uri