From 778c6fea682af901e00868a6bec2a800fc464824 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EA=B9=80=EC=A7=84=EC=9A=B0?=
<85734140+jinuemong@users.noreply.github.com>
Date: Tue, 6 Feb 2024 14:32:39 +0900
Subject: [PATCH] =?UTF-8?q?[Setting]=20=EA=B0=A4=EB=9F=AC=EB=A6=AC=20ui=20?=
=?UTF-8?q?&=20=EB=B6=99=EC=9D=B4=EA=B8=B0=20(#51)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* [Chore]: 갤러리 관련 업무
* [Feat]: 갤러리 ui 구현 & 기초 세팅
* [Chore]: 라이브러리,권한 추가
* [Feat]: 갤러리 페이징 기능 구현
* [Fix]: 오류 수정
* [Style]: 패키지명 수정, 코드 포맷 변경
* [Chore]: 코드 포맷 변경
* [Fix]: 오류 수정
---
app/src/main/AndroidManifest.xml | 2 +
.../android/data/di/RepositoryModule.kt | 22 +-
.../gallery/GalleryImageRepositoryImpl.kt | 126 ++++++++++
.../local/gallery/GalleryPagingSource.kt | 43 ++++
.../gallery/GalleryRepositoryImpl.kt | 35 +++
.../domain/model/gallery/GalleryFolder.kt | 6 +
.../domain/model/gallery/GalleryImage.kt | 9 +
.../repository/GalleryImageRepository.kt | 15 ++
.../domain/repository/GalleryRepository.kt | 16 ++
.../usecase/gallery/GetFolderListUseCase.kt | 13 ++
.../usecase/gallery/GetPhotoListUseCase.kt | 18 ++
gradle/libs.versions.toml | 9 +-
presentation/build.gradle.kts | 2 +
.../ui/main/common/gallery/GalleryIntent.kt | 10 +
.../ui/main/common/gallery/GalleryModel.kt | 12 +
.../ui/main/common/gallery/GalleryScreen.kt | 219 ++++++++++++++++++
.../ui/main/common/gallery/GalleryState.kt | 5 +
.../main/common/gallery/GalleryViewModel.kt | 69 ++++++
.../common/gallery/item/GalleryItemContent.kt | 79 +++++++
.../presentation/ui/main/home/HomeScreen.kt | 4 +
20 files changed, 707 insertions(+), 7 deletions(-)
create mode 100644 data/src/main/kotlin/ac/dnd/bookkeeping/android/data/remote/local/gallery/GalleryImageRepositoryImpl.kt
create mode 100644 data/src/main/kotlin/ac/dnd/bookkeeping/android/data/remote/local/gallery/GalleryPagingSource.kt
create mode 100644 data/src/main/kotlin/ac/dnd/bookkeeping/android/data/repository/gallery/GalleryRepositoryImpl.kt
create mode 100644 domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/model/gallery/GalleryFolder.kt
create mode 100644 domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/model/gallery/GalleryImage.kt
create mode 100644 domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/repository/GalleryImageRepository.kt
create mode 100644 domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/repository/GalleryRepository.kt
create mode 100644 domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/usecase/gallery/GetFolderListUseCase.kt
create mode 100644 domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/usecase/gallery/GetPhotoListUseCase.kt
create mode 100644 presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryIntent.kt
create mode 100644 presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryModel.kt
create mode 100644 presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryScreen.kt
create mode 100644 presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryState.kt
create mode 100644 presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryViewModel.kt
create mode 100644 presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/item/GalleryItemContent.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cb2182da..990fa0ec 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,8 @@
+
+
{
+ val galleryImageList = mutableListOf()
+
+ var selection: String? = null
+ var selectionArgs: Array? = null
+ if (currentLocation != null) {
+ selection = "${MediaStore.Images.Media.DATA} LIKE ?"
+ selectionArgs = arrayOf("%$currentLocation%")
+ }
+
+ val offset = (page - 1) * loadSize
+ val query = getQuery(offset, loadSize, selection, selectionArgs)
+
+ query?.use { cursor ->
+ while (cursor.moveToNext()) {
+ val id =
+ cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID))
+ val name =
+ cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DISPLAY_NAME))
+ val filePath =
+ cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATA))
+ val date =
+ cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN))
+ val image = GalleryImage(
+ id = id,
+ filePath = filePath,
+ name = name,
+ date = date ?: "",
+ size = 0,
+ )
+ galleryImageList.add(image)
+ }
+ }
+ return galleryImageList
+ }
+
+ override fun getFolderList(): List {
+ val folderList: ArrayList = arrayListOf(GalleryFolder("최근 항목", null))
+ val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ val projection = arrayOf(MediaStore.Images.Media.DATA)
+ val cursor = context.contentResolver.query(uri, projection, null, null, null)
+ if (cursor != null) {
+ while (cursor.moveToNext()) {
+ val columnIndex = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
+ val filePath = File(cursor.getString(columnIndex)).parent
+ filePath?.let {
+ val folder = GalleryFolder(filePath.split("/").last(), filePath)
+ folderList.find {
+ it.location == filePath
+ } ?: folderList.add(folder)
+ }
+ }
+ cursor.close()
+ }
+ return folderList
+ }
+
+ @SuppressLint("Recycle")
+ private fun getQuery(
+ offset: Int,
+ limit: Int,
+ selection: String?,
+ selectionArgs: Array?,
+ ): Cursor? {
+ return if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
+ val bundle = bundleOf(
+ ContentResolver.QUERY_ARG_OFFSET to offset,
+ ContentResolver.QUERY_ARG_LIMIT to limit,
+ ContentResolver.QUERY_ARG_SORT_COLUMNS to arrayOf(MediaStore.Files.FileColumns.DATE_MODIFIED),
+ ContentResolver.QUERY_ARG_SORT_DIRECTION to ContentResolver.QUERY_SORT_DIRECTION_DESCENDING,
+ ContentResolver.QUERY_ARG_SQL_SELECTION to selection,
+ ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS to selectionArgs,
+ )
+ contentResolver.query(uriExternal, projection, bundle, null)
+ } else {
+ contentResolver.query(
+ uriExternal,
+ projection,
+ selection,
+ selectionArgs,
+ "$sortedOrder DESC LIMIT $limit OFFSET $offset",
+ )
+ }
+ }
+}
diff --git a/data/src/main/kotlin/ac/dnd/bookkeeping/android/data/remote/local/gallery/GalleryPagingSource.kt b/data/src/main/kotlin/ac/dnd/bookkeeping/android/data/remote/local/gallery/GalleryPagingSource.kt
new file mode 100644
index 00000000..04d9c2ee
--- /dev/null
+++ b/data/src/main/kotlin/ac/dnd/bookkeeping/android/data/remote/local/gallery/GalleryPagingSource.kt
@@ -0,0 +1,43 @@
+package ac.dnd.bookkeeping.android.data.remote.local.gallery
+
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryImage
+import ac.dnd.bookkeeping.android.domain.repository.GalleryImageRepository
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+
+class GalleryPagingSource(
+ private val imageRepository: GalleryImageRepository,
+ private val currentLocation: String?
+) : PagingSource() {
+
+ override fun getRefreshKey(state: PagingState): Int? {
+ return state.anchorPosition?.let { anchorPosition ->
+ state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
+ ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
+ }
+ }
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ return try {
+ val position = params.key ?: STARTING_PAGE_IDX
+ val data = imageRepository.getPhotoList(
+ page = position,
+ loadSize = params.loadSize,
+ currentLocation = currentLocation
+ )
+ val endOfPaginationReached = data.isEmpty()
+ val prevKey = if (position == STARTING_PAGE_IDX) null else position - 1
+ val nextKey =
+ if (endOfPaginationReached) null else position + (params.loadSize / PAGING_SIZE)
+ LoadResult.Page(data, prevKey, nextKey)
+
+ } catch (e: Exception) {
+ LoadResult.Error(e)
+ }
+ }
+
+ companion object {
+ const val STARTING_PAGE_IDX = 1
+ const val PAGING_SIZE = 30
+ }
+}
diff --git a/data/src/main/kotlin/ac/dnd/bookkeeping/android/data/repository/gallery/GalleryRepositoryImpl.kt b/data/src/main/kotlin/ac/dnd/bookkeeping/android/data/repository/gallery/GalleryRepositoryImpl.kt
new file mode 100644
index 00000000..99d3f61c
--- /dev/null
+++ b/data/src/main/kotlin/ac/dnd/bookkeeping/android/data/repository/gallery/GalleryRepositoryImpl.kt
@@ -0,0 +1,35 @@
+package ac.dnd.bookkeeping.android.data.repository.gallery
+
+import ac.dnd.bookkeeping.android.data.remote.local.gallery.GalleryPagingSource
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryFolder
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryImage
+import ac.dnd.bookkeeping.android.domain.repository.GalleryImageRepository
+import ac.dnd.bookkeeping.android.domain.repository.GalleryRepository
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class GalleryRepositoryImpl @Inject constructor(
+ private val galleryImageRepository: GalleryImageRepository
+) : GalleryRepository {
+ override fun getPagingGalleryList(folder: GalleryFolder): Flow> {
+ return Pager(
+ config = PagingConfig(
+ pageSize = GalleryPagingSource.PAGING_SIZE,
+ enablePlaceholders = true
+ ),
+ pagingSourceFactory = {
+ GalleryPagingSource(
+ imageRepository = galleryImageRepository,
+ currentLocation = folder.location,
+ )
+ },
+ ).flow
+ }
+
+ override fun getFolderList(): List {
+ return galleryImageRepository.getFolderList()
+ }
+}
diff --git a/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/model/gallery/GalleryFolder.kt b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/model/gallery/GalleryFolder.kt
new file mode 100644
index 00000000..59263112
--- /dev/null
+++ b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/model/gallery/GalleryFolder.kt
@@ -0,0 +1,6 @@
+package ac.dnd.bookkeeping.android.domain.model.gallery
+
+class GalleryFolder(
+ val name: String,
+ val location: String?,
+)
diff --git a/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/model/gallery/GalleryImage.kt b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/model/gallery/GalleryImage.kt
new file mode 100644
index 00000000..a394a8eb
--- /dev/null
+++ b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/model/gallery/GalleryImage.kt
@@ -0,0 +1,9 @@
+package ac.dnd.bookkeeping.android.domain.model.gallery
+
+data class GalleryImage(
+ val id: Long,
+ val filePath: String,
+ val name: String,
+ val date: String,
+ val size: Int,
+)
diff --git a/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/repository/GalleryImageRepository.kt b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/repository/GalleryImageRepository.kt
new file mode 100644
index 00000000..0d1262e5
--- /dev/null
+++ b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/repository/GalleryImageRepository.kt
@@ -0,0 +1,15 @@
+package ac.dnd.bookkeeping.android.domain.repository
+
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryFolder
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryImage
+
+interface GalleryImageRepository {
+
+ fun getPhotoList(
+ page: Int,
+ loadSize: Int,
+ currentLocation: String? = null
+ ): List
+
+ fun getFolderList(): List
+}
diff --git a/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/repository/GalleryRepository.kt b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/repository/GalleryRepository.kt
new file mode 100644
index 00000000..37f281bc
--- /dev/null
+++ b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/repository/GalleryRepository.kt
@@ -0,0 +1,16 @@
+package ac.dnd.bookkeeping.android.domain.repository
+
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryFolder
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryImage
+import androidx.paging.PagingData
+import kotlinx.coroutines.flow.Flow
+
+interface GalleryRepository {
+
+ fun getPagingGalleryList(
+ folder: GalleryFolder
+ ): Flow>
+
+ fun getFolderList(): List
+
+}
diff --git a/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/usecase/gallery/GetFolderListUseCase.kt b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/usecase/gallery/GetFolderListUseCase.kt
new file mode 100644
index 00000000..419fc7bf
--- /dev/null
+++ b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/usecase/gallery/GetFolderListUseCase.kt
@@ -0,0 +1,13 @@
+package ac.dnd.bookkeeping.android.domain.usecase.gallery
+
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryFolder
+import ac.dnd.bookkeeping.android.domain.repository.GalleryRepository
+import javax.inject.Inject
+
+class GetFolderListUseCase @Inject constructor(
+ private val galleryRepository: GalleryRepository
+) {
+ operator fun invoke(): List {
+ return galleryRepository.getFolderList()
+ }
+}
diff --git a/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/usecase/gallery/GetPhotoListUseCase.kt b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/usecase/gallery/GetPhotoListUseCase.kt
new file mode 100644
index 00000000..c03609c9
--- /dev/null
+++ b/domain/src/main/kotlin/ac/dnd/bookkeeping/android/domain/usecase/gallery/GetPhotoListUseCase.kt
@@ -0,0 +1,18 @@
+package ac.dnd.bookkeeping.android.domain.usecase.gallery
+
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryFolder
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryImage
+import ac.dnd.bookkeeping.android.domain.repository.GalleryRepository
+import androidx.paging.PagingData
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+class GetPhotoListUseCase @Inject constructor(
+ private val galleryRepository: GalleryRepository
+) {
+ operator fun invoke(
+ currentFolder: GalleryFolder
+ ): Flow> {
+ return galleryRepository.getPagingGalleryList(currentFolder)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3e434834..dc905aae 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -22,7 +22,7 @@ hilt-compose = "1.1.0"
androidx-core = "1.12.0"
androidx-appcompat = "1.6.1"
androidx-room = "2.6.1"
-androidx-paging = "3.1.1"
+androidx-paging = "3.2.1"
androidx-compose = "1.5.4"
androidx-compose-navigation = "2.7.0"
androidx-compose-lifecycle = "2.7.0"
@@ -35,7 +35,7 @@ kakao = "2.12.1"
ktor = "2.3.7"
kotlinx-serialization = "1.6.1"
# UI
-coil-compose = "1.3.2"
+coil-compose = "2.4.0"
lottie = "5.2.0"
holix-bottomsheet-compose = "1.3.1"
# Logging
@@ -65,6 +65,7 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androi
androidx-compose-ui-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose" }
androidx-compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-compose-navigation" }
androidx-compose-lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-compose-lifecycle" }
+androidx-compose-paging = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
# AndroidX Data
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
@@ -74,7 +75,7 @@ androidx-paging-common = { module = "androidx.paging:paging-common-ktx", version
# Google
google-system-contoller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "google-system-contoller" }
# Kakao
-kakao-user = { module = "com.kakao.sdk:v2-user", version.ref = "kakao"}
+kakao-user = { module = "com.kakao.sdk:v2-user", version.ref = "kakao" }
# Network
ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
@@ -112,7 +113,7 @@ sentry = { id = "io.sentry.android.gradle", version.ref = "plugin-sentry" }
kotlin = ["kotlin", "kotlinx-coroutines-android", "kotlinx-coroutines-core", "kotlinx-datetime"]
androidx-data = ["androidx-room-runtime", "androidx-room-coroutine", "androidx-paging-runtime"]
androidx-presentation = ["androidx-core", "androidx-appcompat", "androidx-compose-material", "androidx-compose-ui",
- "androidx-compose-ui-preview", "androidx-compose-navigation", "androidx-compose-lifecycle"]
+ "androidx-compose-ui-preview", "androidx-compose-navigation", "androidx-compose-lifecycle", "coil-compose"]
network = ["ktor-core", "ktor-okhttp", "ktor-resources", "ktor-content-negotiation", "ktor-kotlinx-serialization",
"ktor-auth", "kotlinx-serialization"]
logging = ["timber", "sentry"]
diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts
index 22e77997..e19858f4 100644
--- a/presentation/build.gradle.kts
+++ b/presentation/build.gradle.kts
@@ -69,6 +69,8 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
+
+ implementation(libs.androidx.compose.paging)
}
fun getLocalProperty(propertyKey: String): String {
diff --git a/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryIntent.kt b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryIntent.kt
new file mode 100644
index 00000000..f4b51b1d
--- /dev/null
+++ b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryIntent.kt
@@ -0,0 +1,10 @@
+package ac.dnd.bookkeeping.android.presentation.ui.main.common.gallery
+
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryFolder
+
+sealed interface GalleryIntent {
+ data object OnGrantPermission : GalleryIntent
+ data class OnChangeFolder(
+ val location: GalleryFolder
+ ) : GalleryIntent
+}
diff --git a/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryModel.kt b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryModel.kt
new file mode 100644
index 00000000..18280e9c
--- /dev/null
+++ b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryModel.kt
@@ -0,0 +1,12 @@
+package ac.dnd.bookkeeping.android.presentation.ui.main.common.gallery
+
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryFolder
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryImage
+import androidx.paging.compose.LazyPagingItems
+
+class GalleryModel(
+ val state: GalleryState,
+ val folders: List,
+ val currentFolder: GalleryFolder,
+ val galleryImages: LazyPagingItems
+)
diff --git a/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryScreen.kt b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryScreen.kt
new file mode 100644
index 00000000..e22b339b
--- /dev/null
+++ b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryScreen.kt
@@ -0,0 +1,219 @@
+package ac.dnd.bookkeeping.android.presentation.ui.main.common.gallery
+
+import ac.dnd.bookkeeping.android.presentation.R
+import ac.dnd.bookkeeping.android.presentation.common.theme.Gray000
+import ac.dnd.bookkeeping.android.presentation.common.theme.Gray300
+import ac.dnd.bookkeeping.android.presentation.common.theme.Gray600
+import ac.dnd.bookkeeping.android.presentation.common.theme.Gray800
+import ac.dnd.bookkeeping.android.presentation.common.theme.Headline1
+import ac.dnd.bookkeeping.android.presentation.common.theme.Headline3
+import ac.dnd.bookkeeping.android.presentation.common.theme.Primary4
+import ac.dnd.bookkeeping.android.presentation.common.theme.Space16
+import ac.dnd.bookkeeping.android.presentation.common.theme.Space4
+import ac.dnd.bookkeeping.android.presentation.ui.main.ApplicationState
+import ac.dnd.bookkeeping.android.presentation.ui.main.common.gallery.item.GalleryItemContent
+import android.Manifest
+import android.os.Build
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.material.DropdownMenu
+import androidx.compose.material.DropdownMenuItem
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.paging.compose.collectAsLazyPagingItems
+
+@Composable
+fun GalleryScreen(
+ appState: ApplicationState,
+ viewModel: GalleryViewModel = hiltViewModel()
+) {
+ val model: GalleryModel = Unit.let {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val folders by viewModel.folders.collectAsStateWithLifecycle()
+ val currentFolder by viewModel.currentFolder.collectAsStateWithLifecycle()
+ val images = viewModel.galleryPhotoList.collectAsLazyPagingItems()
+ GalleryModel(
+ state = state,
+ folders = folders,
+ currentFolder = currentFolder,
+ galleryImages = images
+ )
+ }
+ val perMissionAlbumLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) {
+ if (it) {
+ viewModel.onIntent(GalleryIntent.OnGrantPermission)
+ }
+ }
+ var currentSelectedId by remember { mutableLongStateOf(-1) }
+ var isDropDownMenuExpanded by remember { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ perMissionAlbumLauncher.launch(
+ Manifest.permission.READ_MEDIA_IMAGES
+ )
+ } else {
+ perMissionAlbumLauncher.launch(
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ )
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .background(Gray000)
+ .fillMaxSize()
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .padding(
+ horizontal = 20.dp,
+ vertical = 13.dp
+ )
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.ic_close),
+ contentDescription = null,
+ modifier = Modifier
+ .clickable {
+ appState.navController.popBackStack()
+ }
+ .align(Alignment.CenterStart)
+ )
+
+ Row(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .clickable {
+ isDropDownMenuExpanded = !isDropDownMenuExpanded
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = model.currentFolder.name,
+ style = Headline1.merge(
+ color = Gray800,
+ fontWeight = FontWeight.SemiBold
+ )
+ )
+ Spacer(modifier = Modifier.width(Space4))
+ Image(
+ painter = painterResource(id = R.drawable.ic_chevron_down),
+ contentDescription = null,
+ modifier = Modifier
+ .rotate(if (isDropDownMenuExpanded) 180f else 0f)
+ .size(Space16)
+ )
+ if (isDropDownMenuExpanded) {
+ DropdownMenu(
+ modifier = Modifier
+ .wrapContentSize()
+ .background(Gray300),
+ expanded = isDropDownMenuExpanded,
+ onDismissRequest = { isDropDownMenuExpanded = false },
+ ) {
+ model.folders.map { folder ->
+ DropdownMenuItem(
+ onClick = {
+ viewModel.onIntent(GalleryIntent.OnChangeFolder(folder))
+ isDropDownMenuExpanded = false
+ }
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = folder.name,
+ style = Headline3
+ )
+ }
+ }
+ }
+ }
+ }
+
+ }
+
+ Box(
+ modifier = Modifier
+ .wrapContentSize()
+ .align(Alignment.CenterEnd)
+ .clickable {
+ if (currentSelectedId > 0) {
+ // TODO next
+ }
+ }
+ ) {
+ Text(
+ text = "확인",
+ style = Headline3.merge(
+ color = if (currentSelectedId > 0) Primary4 else Gray600,
+ fontWeight = FontWeight.SemiBold
+ )
+ )
+ }
+
+ }
+
+ Box(Modifier.fillMaxSize()) {
+ LazyVerticalGrid(
+ modifier = Modifier.fillMaxSize(),
+ columns = GridCells.Fixed(3),
+ verticalArrangement = Arrangement.spacedBy(1.dp),
+ horizontalArrangement = Arrangement.spacedBy(1.dp)
+ ) {
+ items(model.galleryImages.itemCount) { index ->
+ model.galleryImages[index]?.let { gallery ->
+ GalleryItemContent(
+ galleryImage = gallery,
+ currentSelectedImageId = currentSelectedId,
+ onSelectImage = {
+ currentSelectedId = gallery.id
+ },
+ onDeleteImage = {
+ currentSelectedId = -1
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryState.kt b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryState.kt
new file mode 100644
index 00000000..e01e6cb0
--- /dev/null
+++ b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryState.kt
@@ -0,0 +1,5 @@
+package ac.dnd.bookkeeping.android.presentation.ui.main.common.gallery
+
+sealed interface GalleryState {
+ data object Init : GalleryState
+}
diff --git a/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryViewModel.kt b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryViewModel.kt
new file mode 100644
index 00000000..cfee24d9
--- /dev/null
+++ b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/GalleryViewModel.kt
@@ -0,0 +1,69 @@
+package ac.dnd.bookkeeping.android.presentation.ui.main.common.gallery
+
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryFolder
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryImage
+import ac.dnd.bookkeeping.android.domain.usecase.gallery.GetFolderListUseCase
+import ac.dnd.bookkeeping.android.domain.usecase.gallery.GetPhotoListUseCase
+import ac.dnd.bookkeeping.android.presentation.common.base.BaseViewModel
+import androidx.lifecycle.SavedStateHandle
+import androidx.paging.PagingData
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import javax.inject.Inject
+
+@HiltViewModel
+class GalleryViewModel @Inject constructor(
+ private val savedStateHandle: SavedStateHandle,
+ private val getFolderListUseCase: GetFolderListUseCase,
+ private val getPhotoListUseCase: GetPhotoListUseCase
+) : BaseViewModel() {
+
+ private val _state: MutableStateFlow =
+ MutableStateFlow(GalleryState.Init)
+ val state: StateFlow = _state.asStateFlow()
+
+ private val _galleryPhotoList = MutableStateFlow>(PagingData.empty())
+ val galleryPhotoList: StateFlow> = _galleryPhotoList.asStateFlow()
+
+ private val _folders: MutableStateFlow> =
+ MutableStateFlow(listOf(GalleryFolder("최근 항목", null)))
+ val folders: StateFlow> = _folders.asStateFlow()
+
+ private val _currentFolder = MutableStateFlow(GalleryFolder("최근 항목", null))
+ val currentFolder: StateFlow = _currentFolder
+
+ fun onIntent(intent: GalleryIntent) {
+ when (intent) {
+ is GalleryIntent.OnGrantPermission -> loadData()
+ is GalleryIntent.OnChangeFolder -> {
+ setCurrentFolder(intent.location)
+ getGalleryPagingImages()
+ }
+ }
+ }
+
+ private fun loadData() {
+ getFolders()
+ getGalleryPagingImages()
+ }
+
+ private fun getGalleryPagingImages() {
+ launch {
+ getPhotoListUseCase(currentFolder.value)
+ .collectLatest {
+ _galleryPhotoList.value = it
+ }
+ }
+ }
+
+ private fun getFolders() {
+ _folders.value = getFolderListUseCase()
+ }
+
+ private fun setCurrentFolder(foloder: GalleryFolder) {
+ _currentFolder.value = foloder
+ }
+}
diff --git a/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/item/GalleryItemContent.kt b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/item/GalleryItemContent.kt
new file mode 100644
index 00000000..8048139a
--- /dev/null
+++ b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/common/gallery/item/GalleryItemContent.kt
@@ -0,0 +1,79 @@
+package ac.dnd.bookkeeping.android.presentation.ui.main.common.gallery.item
+
+import ac.dnd.bookkeeping.android.domain.model.gallery.GalleryImage
+import ac.dnd.bookkeeping.android.presentation.R
+import ac.dnd.bookkeeping.android.presentation.common.theme.Gray150
+import ac.dnd.bookkeeping.android.presentation.common.theme.Primary1
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import coil.compose.SubcomposeAsyncImage
+import coil.request.ImageRequest
+
+@Composable
+fun GalleryItemContent(
+ galleryImage: GalleryImage,
+ currentSelectedImageId: Long,
+ onSelectImage: (GalleryImage) -> Unit,
+ onDeleteImage: () -> Unit
+) {
+ val selected = currentSelectedImageId == galleryImage.id
+ Box {
+ SubcomposeAsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(galleryImage.filePath)
+ .crossfade(true)
+ .build(),
+ loading = {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Gray150),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ color = Primary1,
+ modifier = Modifier.fillMaxSize(0.5f)
+ )
+
+ }
+ },
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .aspectRatio(1f)
+ .animateContentSize()
+ .clickable {
+ if (selected) {
+ onDeleteImage()
+ } else {
+ onSelectImage(galleryImage)
+ }
+ },
+ alpha = if (selected || currentSelectedImageId == -1L) 1f else 0.4f
+ )
+ if (selected) {
+ Box(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_check_circle),
+ contentDescription = null
+ )
+ }
+ }
+ }
+}
diff --git a/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/home/HomeScreen.kt b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/home/HomeScreen.kt
index 4bb8f6ec..ecd19ec1 100644
--- a/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/home/HomeScreen.kt
+++ b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/ui/main/home/HomeScreen.kt
@@ -3,6 +3,7 @@ package ac.dnd.bookkeeping.android.presentation.ui.main.home
import ac.dnd.bookkeeping.android.presentation.common.util.ErrorObserver
import ac.dnd.bookkeeping.android.presentation.common.view.CustomSnackBarHost
import ac.dnd.bookkeeping.android.presentation.ui.main.ApplicationState
+import ac.dnd.bookkeeping.android.presentation.ui.main.common.gallery.GalleryScreen
import ac.dnd.bookkeeping.android.presentation.ui.main.home.history.HistoryScreen
import ac.dnd.bookkeeping.android.presentation.ui.main.home.setting.SettingScreen
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -70,6 +71,9 @@ fun HomeScreen(
HistoryScreen(
appState = appState
)
+ GalleryScreen(
+ appState = appState
+ )
}
1 -> {