Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,6 @@ dependencies {
// Glide
implementation("com.github.bumptech.glide:glide:4.15.1")

// Image Compression
implementation("id.zelory:compressor:3.0.1")

testImplementation("junit:junit:4.+")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
package com.wafflestudio.siksha2.ui.menuDetail

import android.Manifest
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.text.InputFilter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ProgressBar
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.forEachIndexed
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
Expand All @@ -35,21 +31,16 @@ class LeaveReviewFragment : Fragment() {

private val vm: MenuDetailViewModel by activityViewModels()

private val galleryLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let {
vm.addImageUri(it, onFailure = {
private val pickMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(3)) { uris ->
val context = context ?: return@registerForActivityResult
uris.forEach { uri ->
vm.addImageUri(
context = context,
imageUri = uri,
onFailure = {
requireContext().showToast(getString(R.string.leave_review_max_image_toast))
})
}
}
}

private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
launchGalleryIntent()
} else {
showToast("사진 업로드를 위해 사진 권한을 허용해 주세요.")
}
)
}
}

Expand Down Expand Up @@ -108,25 +99,42 @@ class LeaveReviewFragment : Fragment() {
}
)

vm.imageUriList.observe(viewLifecycleOwner) { imageUriList ->
binding.imageLayout.forEachIndexed { index, view ->
(view as ReviewImageView).run {
if (index < imageUriList.size) {
setImage(imageUriList[index])
visibility = View.VISIBLE
setOnDeleteClickListener(
object : ReviewImageView.OnDeleteClickListener {
override fun onClick() {
vm.deleteImageUri(index)
}
viewLifecycleOwner.lifecycleScope.launch {
vm.images.collect { imageUriList ->
binding.imageLayout.forEachIndexed { index, view ->
val frameLayout = view as? FrameLayout ?: return@forEachIndexed
val reviewImageView = frameLayout.getChildAt(0) as? ReviewImageView ?: return@forEachIndexed
val progressBar = frameLayout.getChildAt(1) as? ProgressBar ?: return@forEachIndexed

if (index >= imageUriList.size) {
reviewImageView.visibility = View.GONE
return@forEachIndexed
}

reviewImageView.visibility = View.VISIBLE
reviewImageView.setOnDeleteClickListener(
object : ReviewImageView.OnDeleteClickListener {
override fun onClick() {
vm.deleteImageUri(index)
}
)
} else {
visibility = View.GONE
}
)

when (val imageState = imageUriList[index]) {
is CompressedImageUiState.Compressing -> {
reviewImageView.setImage(imageState.originalImageUri)
progressBar.visibility = View.VISIBLE
}

is CompressedImageUiState.Completed -> {
reviewImageView.setImage(imageState.compressedImageUri)
progressBar.visibility = View.GONE
}
}

}
binding.imageLayout.setVisibleOrGone(imageUriList.isNotEmpty())
}
binding.imageLayout.setVisibleOrGone(imageUriList.isNotEmpty())
}

vm.leaveReviewState.observe(viewLifecycleOwner) {
Expand All @@ -141,10 +149,13 @@ class LeaveReviewFragment : Fragment() {
lifecycleScope.launch {
try {
vm.leaveReview(
context = requireContext(),
score = binding.rating.rating.toDouble(),
comment = binding.commentEdit.text.toString().ifEmpty {
binding.commentEdit.hint.toString()
},
onFailure = {
showToast("이미지 압축 중입니다.")
return@leaveReview
}
)
showToast("평가가 등록되었습니다.")
Expand All @@ -164,29 +175,7 @@ class LeaveReviewFragment : Fragment() {
}

binding.addImageButton.setOnClickListener {
requestPermission(onGranted = {
launchGalleryIntent()
})
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
}

private fun requestPermission(onGranted: () -> Unit) {
val permission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.READ_MEDIA_IMAGES else Manifest.permission.READ_EXTERNAL_STORAGE
if (ContextCompat.checkSelfPermission(requireActivity(), permission) == PackageManager.PERMISSION_GRANTED) {
onGranted()
} else {
requestPermissionLauncher.launch(permission)
}
}

private fun launchGalleryIntent() {
val intent = Intent(Intent.ACTION_PICK)
.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*")
galleryLauncher.launch(intent)
}

companion object {
private const val GET_GALLERY_IMAGE = 1126
private const val REQUEST_STORAGE_PERMISSION = 555
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.wafflestudio.siksha2.ui.menuDetail

import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
Expand All @@ -11,15 +10,14 @@ import androidx.paging.PagingData
import com.wafflestudio.siksha2.models.Menu
import com.wafflestudio.siksha2.models.Review
import com.wafflestudio.siksha2.repositories.MenuRepository
import com.wafflestudio.siksha2.utils.PathUtil
import com.wafflestudio.siksha2.utils.showToast
import com.wafflestudio.siksha2.utils.ImageUtil
import dagger.hilt.android.lifecycle.HiltViewModel
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.format
import id.zelory.compressor.constraint.resolution
import id.zelory.compressor.constraint.size
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
Expand All @@ -31,6 +29,11 @@ import javax.inject.Inject
class MenuDetailViewModel @Inject constructor(
private val menuRepository: MenuRepository
) : ViewModel() {

companion object {
private const val MAX_IMAGE_COUNT = 3
}

private val _menu = MutableLiveData<Menu>()
val menu: LiveData<Menu>
get() = _menu
Expand All @@ -47,9 +50,8 @@ class MenuDetailViewModel @Inject constructor(
val reviewDistribution: LiveData<List<Long>>
get() = _reviewDistribution

private val _imageUriList = MutableLiveData<List<Uri>>()
val imageUriList: LiveData<List<Uri>>
get() = _imageUriList
private val _images = MutableStateFlow(emptyList<CompressedImageUiState>())
val images: StateFlow<List<CompressedImageUiState>> = _images

private val _imageUrlList = MutableLiveData<List<String>>()
val imageUrlList: LiveData<List<String>>
Expand Down Expand Up @@ -125,28 +127,51 @@ class MenuDetailViewModel @Inject constructor(
}
}

fun addImageUri(uri: Uri, onFailure: () -> Unit) {
val list = _imageUriList.value?.toMutableList() ?: mutableListOf()
if (list.size < 3) {
list.add(uri)
_imageUriList.value = list.toList()
} else {
fun addImageUri(context: Context, imageUri: Uri, onFailure: () -> Unit) {
if (images.value.size >= MAX_IMAGE_COUNT) {
onFailure()
return
}

viewModelScope.launch {
val compressing = CompressedImageUiState.Compressing(imageUri)

_images.emit(
_images.value.toMutableList().apply {
add(compressing)
}
)

val compressedImageFile = withContext(Dispatchers.IO) {
ImageUtil.getCompressedImage(context, imageUri)
}

_images.emit(
_images.value.toMutableList().apply {
set(
indexOf(compressing),
CompressedImageUiState.Completed(
compressedImageUri = Uri.fromFile(compressedImageFile),
compressedImageFile = compressedImageFile
)
)
}
)
}
}

fun deleteImageUri(index: Int, onFailure: () -> Unit = {}) {
val list = _imageUriList.value?.toMutableList() ?: mutableListOf()
if (index < list.size) {
list.removeAt(index)
_imageUriList.value = list.toList()
} else {
if (index >= images.value.size) {
onFailure()
return
}
_images.value = images.value.toMutableList().apply {
removeAt(index)
}
}

fun refreshUriList() {
_imageUriList.value = listOf()
_images.value = emptyList()
}

fun notifySendReviewEnd() {
Expand All @@ -161,33 +186,33 @@ class MenuDetailViewModel @Inject constructor(
_menu.postValue(updatedMenu)
}

suspend fun leaveReview(context: Context, score: Double, comment: String) {
val menuId = _menu.value?.id ?: return
if (_imageUriList.value?.isNotEmpty() == true) {
context.showToast("이미지 압축 중입니다.")
_leaveReviewState.value = ReviewState.COMPRESSING
val imageList = _imageUriList.value?.map {
getCompressedImage(context, it)
}
val commentBody = MultipartBody.Part.createFormData("comment", comment)
imageList?.let {
menuRepository.leaveMenuReviewImage(menuId, score.toLong(), commentBody, imageList)
}
suspend fun leaveReview(score: Double, comment: String, onFailure: () -> Unit) {
val menuId = menu.value?.id ?: return
if (images.value.isEmpty()) {
leaveReviewWithoutImage(menuId, score, comment)
} else {
menuRepository.leaveMenuReview(menuId, score, comment)
if (images.value.all { it is CompressedImageUiState.Completed }.not()) {
onFailure()
return
}
leaveReviewWithImage(menuId, score, comment, images.value)
}
}

private suspend fun getCompressedImage(context: Context, uri: Uri): MultipartBody.Part {
val path = PathUtil.getPath(context, uri)
var file = File(path)
file = Compressor.compress(context, file) {
resolution(300, 300)
size(100000)
format(Bitmap.CompressFormat.JPEG)
private suspend fun leaveReviewWithoutImage(menuId: Long, score: Double, comment: String) {
menuRepository.leaveMenuReview(menuId, score, comment)
}

private suspend fun leaveReviewWithImage(menuId: Long, score: Double, comment: String, images: List<CompressedImageUiState>) {
val commentBody = MultipartBody.Part.createFormData("comment", comment)
val imagesBody = withContext(Dispatchers.Default) {
images.map {
val compressedImageFile = (it as CompressedImageUiState.Completed).compressedImageFile
val requestBody = compressedImageFile.asRequestBody("image/jpeg".toMediaTypeOrNull())
MultipartBody.Part.createFormData("images", compressedImageFile.name, requestBody)
}
}
val requestBody = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
return MultipartBody.Part.createFormData("images", file.name, requestBody)
menuRepository.leaveMenuReviewImage(menuId, score.toLong(), commentBody, imagesBody)
}

enum class State {
Expand All @@ -201,3 +226,11 @@ class MenuDetailViewModel @Inject constructor(
COMPRESSING
}
}

sealed interface CompressedImageUiState {
class Compressing(val originalImageUri: Uri) : CompressedImageUiState
class Completed(
val compressedImageUri: Uri,
val compressedImageFile: File,
) : CompressedImageUiState
}
20 changes: 20 additions & 0 deletions app/src/main/java/com/wafflestudio/siksha2/utils/ImageUtil.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.wafflestudio.siksha2.utils

import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import com.wafflestudio.siksha2.utils.compressor.Compressor
import com.wafflestudio.siksha2.utils.compressor.format
import com.wafflestudio.siksha2.utils.compressor.resolution
import com.wafflestudio.siksha2.utils.compressor.size
import java.io.File

object ImageUtil {
suspend fun getCompressedImage(context: Context, imageUri: Uri): File {
return Compressor.compress(context, imageUri) {
resolution(300, 300)
size(100000)
format(Bitmap.CompressFormat.JPEG)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.wafflestudio.siksha2.utils.compressor

class Compression {
internal val constraints: MutableList<Constraint> = mutableListOf()

fun constraint(constraint: Constraint) {
constraints.add(constraint)
}
}
Loading