diff --git a/app/build.gradle b/app/build.gradle index 0ab674e..b757107 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -52,6 +52,7 @@ dependencies { def pagingVersion = "3.1.1" def navigationVersion= "2.5.3" def retrofitVersion = "2.9.0" + def glideVersion = "4.13.2" implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.appcompat:appcompat:1.6.1' @@ -96,4 +97,8 @@ dependencies { // logging implementation "com.squareup.okhttp3:logging-interceptor:4.9.0" + // glide + implementation "com.github.bumptech.glide:glide:$glideVersion" + annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion" + } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6e421d9..25bfe0a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -52,6 +52,10 @@ + + \ No newline at end of file diff --git a/app/src/main/java/com/dining/coach/ui/gallery/GalleryActivity.kt b/app/src/main/java/com/dining/coach/ui/gallery/GalleryActivity.kt new file mode 100644 index 0000000..f48b033 --- /dev/null +++ b/app/src/main/java/com/dining/coach/ui/gallery/GalleryActivity.kt @@ -0,0 +1,66 @@ +package com.dining.coach.ui.gallery + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Bundle +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.dining.coach.R +import com.dining.coach.base.BaseActivity +import com.dining.coach.databinding.ActivityGalleryBinding +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@AndroidEntryPoint +class GalleryActivity : BaseActivity(R.layout.activity_gallery) { + private val viewModel: GalleryViewModel by viewModels() + private val requiredPermissions = arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + private lateinit var galleryRecyclerViewAdapter:GalleryRecyclerViewAdapter + + override fun createActivity() = viewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setRecyclerView() + } + private fun setRecyclerView(){ + wrapGridRecyclerView(bind.galleryRv, 4) + galleryRecyclerViewAdapter = GalleryRecyclerViewAdapter() + bind.galleryRv.adapter = galleryRecyclerViewAdapter + + fetchGalleryAdapter() + } + + private fun fetchGalleryAdapter(){ + if (hasAllPermissions()) { + lifecycleScope.launch { + viewModel.galleryPager.collectLatest { pagingData -> + galleryRecyclerViewAdapter.submitData(pagingData) + } + } + } else { + registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { isGranted -> + if (isGranted.all { it.value }) { + fetchGalleryAdapter() + } else { + // TODO("replace permission denied process") + Toast.makeText(this, "permission error", Toast.LENGTH_SHORT).show() + finish() + } + }.launch(requiredPermissions) + } + } + + + private fun hasAllPermissions() = requiredPermissions.all { + ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_GRANTED + } + +} diff --git a/app/src/main/java/com/dining/coach/ui/gallery/GalleryRecyclerViewAdapter.kt b/app/src/main/java/com/dining/coach/ui/gallery/GalleryRecyclerViewAdapter.kt new file mode 100644 index 0000000..86f48da --- /dev/null +++ b/app/src/main/java/com/dining/coach/ui/gallery/GalleryRecyclerViewAdapter.kt @@ -0,0 +1,54 @@ +package com.dining.coach.ui.gallery + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.dining.coach.databinding.RowGalleryImageBinding +import com.diningcoach.domain.model.Photo + +class GalleryRecyclerViewAdapter : + PagingDataAdapter(diffCallback) { + + companion object { + val diffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Photo, newItem: Photo) = + oldItem.uri == newItem.uri + + override fun areContentsTheSame(oldItem: Photo, newItem: Photo) = + oldItem == newItem + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): ImageViewHolder = ImageViewHolder( + RowGalleryImageBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + + override fun onBindViewHolder(holder: ImageViewHolder, position: Int) { + val item = getItem(position) + item?.let { + holder.bind(it) + } + } + + inner class ImageViewHolder( + private val binding: RowGalleryImageBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: Photo) { + binding.run { + Glide.with(binding.root.context) + .load(item.uri) + .into(binding.rowGalleryImageView) + } + } + } +} diff --git a/app/src/main/java/com/dining/coach/ui/gallery/GalleryViewModel.kt b/app/src/main/java/com/dining/coach/ui/gallery/GalleryViewModel.kt new file mode 100644 index 0000000..69b5fd9 --- /dev/null +++ b/app/src/main/java/com/dining/coach/ui/gallery/GalleryViewModel.kt @@ -0,0 +1,23 @@ +package com.dining.coach.ui.gallery + +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import com.dining.coach.base.BaseViewModel +import com.diningcoach.domain.usecase.gallery.GalleryImageFetchUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class GalleryViewModel @Inject constructor( + private val galleryImageFetch: GalleryImageFetchUseCase +): BaseViewModel() { + val galleryPager = Pager( + config = PagingConfig(pageSize = 50) + ) { + galleryImageFetch() + }.flow.cachedIn(viewModelScope) + + +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_gallery.xml b/app/src/main/res/layout/activity_gallery.xml new file mode 100644 index 0000000..853885c --- /dev/null +++ b/app/src/main/res/layout/activity_gallery.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/app/src/main/res/layout/row_gallery_image.xml b/app/src/main/res/layout/row_gallery_image.xml new file mode 100644 index 0000000..bc38bb4 --- /dev/null +++ b/app/src/main/res/layout/row_gallery_image.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/data/build.gradle b/data/build.gradle index 23b7817..b95cb59 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -38,6 +38,7 @@ dependencies { def retrofitVersion = "2.9.0" def hiltVersion = "2.45" + def pagingVersion = "3.1.1" // Retrofit2 implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" @@ -51,4 +52,7 @@ dependencies { implementation "com.google.dagger:hilt-android:$hiltVersion" kapt "com.google.dagger:hilt-compiler:$hiltVersion" kapt "androidx.hilt:hilt-compiler:1.0.0" + + // paging + implementation "androidx.paging:paging-runtime-ktx:$pagingVersion" } \ No newline at end of file diff --git a/data/src/main/java/com/diningcoach/data/di/manager/local/MediaStoreManager.kt b/data/src/main/java/com/diningcoach/data/di/manager/local/MediaStoreManager.kt new file mode 100644 index 0000000..bb8e979 --- /dev/null +++ b/data/src/main/java/com/diningcoach/data/di/manager/local/MediaStoreManager.kt @@ -0,0 +1,58 @@ +package com.diningcoach.data.di.manager.local + +import android.content.ContentResolver +import android.content.Context +import android.database.Cursor +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class MediaStoreManager @Inject constructor( + @ApplicationContext context: Context +) { + private val contentResolver = context.contentResolver + + fun fetchImages(projection:Array, limit: Int, offset: Int): Cursor?{ + val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + val selection = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) MediaStore.Images.Media.SIZE + " > 0" + else null + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return contentResolver.query( + contentUri, + projection, + Bundle().apply { + // Limit & Offset + putInt(ContentResolver.QUERY_ARG_LIMIT, limit) + putInt(ContentResolver.QUERY_ARG_OFFSET, offset) + + // Sort function + putStringArray( + ContentResolver.QUERY_ARG_SORT_COLUMNS, + arrayOf(MediaStore.Images.Media.DATE_TAKEN) + ) + putInt( + ContentResolver.QUERY_ARG_SORT_DIRECTION, + ContentResolver.QUERY_SORT_DIRECTION_DESCENDING + ) + + // Selection + putString(ContentResolver.QUERY_ARG_SQL_SELECTION, selection) + }, null + ) + } else { + val sortOrder = + "${MediaStore.Images.Media.DATE_TAKEN} DESC, ${MediaStore.Images.Media._ID} DESC LIMIT $limit OFFSET $offset" + return contentResolver.query( + contentUri, + projection, + selection, + null, + sortOrder + ) + } + } +} \ No newline at end of file diff --git a/data/src/main/java/com/diningcoach/data/di/module/UseCaseModule.kt b/data/src/main/java/com/diningcoach/data/di/module/UseCaseModule.kt index 855f0e6..507a458 100644 --- a/data/src/main/java/com/diningcoach/data/di/module/UseCaseModule.kt +++ b/data/src/main/java/com/diningcoach/data/di/module/UseCaseModule.kt @@ -1,6 +1,8 @@ package com.diningcoach.data.di.module +import com.diningcoach.domain.repository.GalleryRepository import com.diningcoach.domain.repository.UserRepository +import com.diningcoach.domain.usecase.gallery.GalleryImageFetchUseCase import com.diningcoach.domain.usecase.user.CheckIsLoginUseCase import dagger.Module import dagger.Provides @@ -16,4 +18,10 @@ object UseCaseModule { @Singleton fun provideCheckIsLoginUseCase(repository: UserRepository): CheckIsLoginUseCase = CheckIsLoginUseCase(repository) + + @Provides + @Singleton + fun provideGalleryImageFetchUseCase(repository: GalleryRepository): GalleryImageFetchUseCase = + GalleryImageFetchUseCase(repository) + } \ No newline at end of file diff --git a/data/src/main/java/com/diningcoach/data/di/module/repository/LocalDataSourceModule.kt b/data/src/main/java/com/diningcoach/data/di/module/repository/LocalDataSourceModule.kt index 9f4be9b..c4a45e6 100644 --- a/data/src/main/java/com/diningcoach/data/di/module/repository/LocalDataSourceModule.kt +++ b/data/src/main/java/com/diningcoach/data/di/module/repository/LocalDataSourceModule.kt @@ -1,5 +1,7 @@ package com.diningcoach.data.di.module.repository +import com.diningcoach.data.repository.gallery.local.GalleryLocalDataSource +import com.diningcoach.data.repository.gallery.local.GalleryLocalDataSourceImpl import com.diningcoach.data.repository.user.local.UserLocalDataSource import com.diningcoach.data.repository.user.local.UserLocalDataSourceImpl import dagger.Binds @@ -15,4 +17,9 @@ interface LocalDataSourceModule { @Singleton @Binds fun bindsUserLocalDataSource(implements: UserLocalDataSourceImpl): UserLocalDataSource + + @Singleton + @Binds + fun bindsGalleryLocalDataSource(implements: GalleryLocalDataSourceImpl): GalleryLocalDataSource + } \ No newline at end of file diff --git a/data/src/main/java/com/diningcoach/data/di/module/repository/RepositoryModule.kt b/data/src/main/java/com/diningcoach/data/di/module/repository/RepositoryModule.kt index cb17815..9810caa 100644 --- a/data/src/main/java/com/diningcoach/data/di/module/repository/RepositoryModule.kt +++ b/data/src/main/java/com/diningcoach/data/di/module/repository/RepositoryModule.kt @@ -1,6 +1,8 @@ package com.diningcoach.data.di.module.repository +import com.diningcoach.data.repository.gallery.GalleryRepositoryImpl import com.diningcoach.data.repository.user.UserRepositoryImpl +import com.diningcoach.domain.repository.GalleryRepository import com.diningcoach.domain.repository.UserRepository import dagger.Binds import dagger.Module @@ -15,4 +17,8 @@ interface RepositoryModule { @Singleton @Binds fun bindsUserRepository(implements: UserRepositoryImpl): UserRepository + + @Singleton + @Binds + fun bindsGalleryRepository(implements: GalleryRepositoryImpl): GalleryRepository } \ No newline at end of file diff --git a/data/src/main/java/com/diningcoach/data/extensions/Extensions.kt b/data/src/main/java/com/diningcoach/data/extensions/Extensions.kt index 5d3c76b..5fa1606 100644 --- a/data/src/main/java/com/diningcoach/data/extensions/Extensions.kt +++ b/data/src/main/java/com/diningcoach/data/extensions/Extensions.kt @@ -1,10 +1,25 @@ package com.diningcoach.data.extensions +import com.diningcoach.data.model.PhotoModel import com.diningcoach.data.model.UserModel +import com.diningcoach.domain.model.Photo import com.diningcoach.domain.model.User +import java.util.* fun UserModel.toUser(): User { return User( - id, accessToken, refreshToken + id, accessToken, refreshToken ) } + +fun PhotoModel.toPhoto() = Photo( + uri, + name, + fullName, + mimeType, + Date(addedDate), + folder, + size, + width, + height, +) diff --git a/data/src/main/java/com/diningcoach/data/model/PhotoModel.kt b/data/src/main/java/com/diningcoach/data/model/PhotoModel.kt new file mode 100644 index 0000000..4ff5506 --- /dev/null +++ b/data/src/main/java/com/diningcoach/data/model/PhotoModel.kt @@ -0,0 +1,15 @@ +package com.diningcoach.data.model + +import android.net.Uri + +data class PhotoModel( + val uri: Uri, + val name: String, + val fullName: String, + val mimeType: String, + val addedDate: Long, + val folder: String, + val size: Long, + val width: Int, + val height: Int, +) diff --git a/data/src/main/java/com/diningcoach/data/repository/gallery/GalleryRepositoryImpl.kt b/data/src/main/java/com/diningcoach/data/repository/gallery/GalleryRepositoryImpl.kt new file mode 100644 index 0000000..0b1de67 --- /dev/null +++ b/data/src/main/java/com/diningcoach/data/repository/gallery/GalleryRepositoryImpl.kt @@ -0,0 +1,38 @@ +package com.diningcoach.data.repository.gallery + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.diningcoach.data.extensions.toPhoto +import com.diningcoach.data.repository.gallery.local.GalleryLocalDataSource +import com.diningcoach.domain.model.Photo +import com.diningcoach.domain.repository.GalleryRepository +import javax.inject.Inject + +class GalleryRepositoryImpl @Inject constructor( + private val galleryLocalDataSource: GalleryLocalDataSource, +): GalleryRepository { + override fun getGalleryImagePagingSource():PagingSource { + return object: PagingSource() { + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) + } + } + override suspend fun load(params: LoadParams): LoadResult { + try { + val pageNumber = params.key ?: 0 + val response = galleryLocalDataSource.fetchGalleryImages(params.loadSize, pageNumber*params.loadSize) + return LoadResult.Page( + data = response.map { it.toPhoto() }, + prevKey = if(pageNumber==0) null else pageNumber-1, + nextKey = if(response.isEmpty()) null else pageNumber+1 + ) + } catch (e: Exception) { + // TODO("error process") + throw e + } + } + } + } +} \ No newline at end of file diff --git a/data/src/main/java/com/diningcoach/data/repository/gallery/local/GalleryLocalDataSource.kt b/data/src/main/java/com/diningcoach/data/repository/gallery/local/GalleryLocalDataSource.kt new file mode 100644 index 0000000..e8158f8 --- /dev/null +++ b/data/src/main/java/com/diningcoach/data/repository/gallery/local/GalleryLocalDataSource.kt @@ -0,0 +1,7 @@ +package com.diningcoach.data.repository.gallery.local + +import com.diningcoach.data.model.PhotoModel + +interface GalleryLocalDataSource { + fun fetchGalleryImages(limit: Int, offset: Int): List +} \ No newline at end of file diff --git a/data/src/main/java/com/diningcoach/data/repository/gallery/local/GalleryLocalDataSourceImpl.kt b/data/src/main/java/com/diningcoach/data/repository/gallery/local/GalleryLocalDataSourceImpl.kt new file mode 100644 index 0000000..8e6a789 --- /dev/null +++ b/data/src/main/java/com/diningcoach/data/repository/gallery/local/GalleryLocalDataSourceImpl.kt @@ -0,0 +1,49 @@ +package com.diningcoach.data.repository.gallery.local + +import android.net.Uri +import android.provider.MediaStore.Images.Media +import com.diningcoach.data.di.manager.local.MediaStoreManager +import com.diningcoach.data.model.PhotoModel +import javax.inject.Inject + +class GalleryLocalDataSourceImpl @Inject constructor( + private val mediaStoreManager: MediaStoreManager +) : GalleryLocalDataSource { + + override fun fetchGalleryImages(limit: Int, offset: Int): List { + val contentUri = Media.EXTERNAL_CONTENT_URI + val projection = arrayOf( + Media._ID, + Media.TITLE, + Media.DISPLAY_NAME, + Media.MIME_TYPE, + Media.DATE_TAKEN, + Media.BUCKET_DISPLAY_NAME, + Media.SIZE, + Media.WIDTH, + Media.HEIGHT, + ) + val galleryImage = mutableListOf() + mediaStoreManager.fetchImages(projection, limit, offset)?.use{ cursor -> + while (cursor.moveToNext()) { + galleryImage.add( + PhotoModel( + uri = Uri.withAppendedPath( + contentUri, + cursor.getLong(cursor.getColumnIndexOrThrow(Media._ID)).toString() + ), + name = cursor.getString(cursor.getColumnIndexOrThrow(Media.TITLE)), + fullName = cursor.getString(cursor.getColumnIndexOrThrow(Media.DISPLAY_NAME)), + mimeType = cursor.getString(cursor.getColumnIndexOrThrow(Media.MIME_TYPE)), + addedDate = cursor.getLong(cursor.getColumnIndexOrThrow(Media.DATE_TAKEN)), + folder = cursor.getString(cursor.getColumnIndexOrThrow(Media.BUCKET_DISPLAY_NAME)), + size = cursor.getLong(cursor.getColumnIndexOrThrow(Media.SIZE)), + width = cursor.getInt(cursor.getColumnIndexOrThrow(Media.WIDTH)), + height = cursor.getInt(cursor.getColumnIndexOrThrow(Media.HEIGHT)), + ) + ) + } + } + return galleryImage + } +} \ No newline at end of file diff --git a/domain/build.gradle b/domain/build.gradle index 4acb4ca..e3b9830 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -37,6 +37,7 @@ dependencies { def lifecycleVersion = "2.6.1" def retrofitVersion = "2.9.0" def hiltVersion = "2.45" + def pagingVersion = "3.1.1" // Coroutine implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion" @@ -61,4 +62,8 @@ dependencies { implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion" // Retrofit2 - log implementation "com.squareup.okhttp3:logging-interceptor:4.9.0" + + // paging + implementation "androidx.paging:paging-common:$pagingVersion" + } \ No newline at end of file diff --git a/domain/src/main/java/com/diningcoach/domain/model/Photo.kt b/domain/src/main/java/com/diningcoach/domain/model/Photo.kt new file mode 100644 index 0000000..b5fcb84 --- /dev/null +++ b/domain/src/main/java/com/diningcoach/domain/model/Photo.kt @@ -0,0 +1,16 @@ +package com.diningcoach.domain.model + +import android.net.Uri +import java.util.Date + +data class Photo( + val uri: Uri, + val name: String, + val fullName: String, + val mimeType: String, + val addedDate: Date, + val folder: String, + val size: Long, + val width: Int, + val height: Int, +) diff --git a/domain/src/main/java/com/diningcoach/domain/repository/GalleryRepository.kt b/domain/src/main/java/com/diningcoach/domain/repository/GalleryRepository.kt new file mode 100644 index 0000000..c1b5440 --- /dev/null +++ b/domain/src/main/java/com/diningcoach/domain/repository/GalleryRepository.kt @@ -0,0 +1,8 @@ +package com.diningcoach.domain.repository + +import androidx.paging.PagingSource +import com.diningcoach.domain.model.Photo + +interface GalleryRepository { + fun getGalleryImagePagingSource(): PagingSource +} \ No newline at end of file diff --git a/domain/src/main/java/com/diningcoach/domain/usecase/gallery/GalleryImageFetchUseCase.kt b/domain/src/main/java/com/diningcoach/domain/usecase/gallery/GalleryImageFetchUseCase.kt new file mode 100644 index 0000000..60dd804 --- /dev/null +++ b/domain/src/main/java/com/diningcoach/domain/usecase/gallery/GalleryImageFetchUseCase.kt @@ -0,0 +1,12 @@ +package com.diningcoach.domain.usecase.gallery + +import androidx.paging.PagingSource +import com.diningcoach.domain.model.Photo +import com.diningcoach.domain.repository.GalleryRepository +import javax.inject.Inject + +class GalleryImageFetchUseCase @Inject constructor( + private val repository: GalleryRepository +) { + operator fun invoke(): PagingSource =repository.getGalleryImagePagingSource() +} \ No newline at end of file