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 -> {