diff --git a/core-ui/src/main/java/com/teamdontbe/core_ui/util/context/ContextExt.kt b/core-ui/src/main/java/com/teamdontbe/core_ui/util/context/ContextExt.kt index b3803f940..782c62998 100644 --- a/core-ui/src/main/java/com/teamdontbe/core_ui/util/context/ContextExt.kt +++ b/core-ui/src/main/java/com/teamdontbe/core_ui/util/context/ContextExt.kt @@ -1,10 +1,14 @@ package com.teamdontbe.core_ui.util.context import android.app.Activity +import android.app.AlertDialog import android.content.Context +import android.content.Intent import android.content.res.Resources import android.graphics.Color import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.provider.Settings import android.util.TypedValue import android.view.View import android.view.WindowManager @@ -83,3 +87,24 @@ fun Context.statusBarColorOf( window?.statusBarColor = colorOf(resId) } } + +fun Context.showPermissionAppSettingsDialog() { + AlertDialog.Builder(this) + .setTitle("권한이 필요해요") + .setMessage("이 앱은 파일 및 미디어 접근 권한이 필요해요.\n앱 세팅으로 이동해서 권한을 부여 할 수 있어요.") + .setPositiveButton("이동하기") { dialog, _ -> + navigateToAppSettings() + dialog.dismiss() + } + .setNegativeButton("취소하기") { dialog, _ -> + dialog.dismiss() + } + .show() +} + +fun Context.navigateToAppSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", packageName, null) + intent.data = uri + startActivity(intent) +} diff --git a/data-local/src/main/java/com/teamdontbe/data_local/di/ContentResolverModule.kt b/data-local/src/main/java/com/teamdontbe/data_local/di/ContentResolverModule.kt new file mode 100644 index 000000000..80ba26d00 --- /dev/null +++ b/data-local/src/main/java/com/teamdontbe/data_local/di/ContentResolverModule.kt @@ -0,0 +1,20 @@ +package com.teamdontbe.data_local.di + +import android.content.ContentResolver +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ContentResolverModule { + @Provides + @Singleton + fun providesContentResolver( + @ApplicationContext context: Context, + ): ContentResolver = context.contentResolver +} diff --git a/data-remote/src/main/java/com/teamdontbe/data_remote/api/HomeApiService.kt b/data-remote/src/main/java/com/teamdontbe/data_remote/api/HomeApiService.kt index f4eca031b..5ca5a2764 100644 --- a/data-remote/src/main/java/com/teamdontbe/data_remote/api/HomeApiService.kt +++ b/data-remote/src/main/java/com/teamdontbe/data_remote/api/HomeApiService.kt @@ -2,17 +2,20 @@ package com.teamdontbe.data_remote.api import com.teamdontbe.data.dto.BaseResponse import com.teamdontbe.data.dto.request.RequestCommentLikedDto -import com.teamdontbe.data.dto.request.RequestCommentPostingDto import com.teamdontbe.data.dto.request.RequestFeedLikedDto import com.teamdontbe.data.dto.request.RequestTransparentDto import com.teamdontbe.data.dto.response.ResponseCommentDto import com.teamdontbe.data.dto.response.ResponseFeedDto import com.teamdontbe.data_remote.api.LoginApiService.Companion.API import com.teamdontbe.data_remote.api.LoginApiService.Companion.V1 +import okhttp3.MultipartBody +import okhttp3.RequestBody import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query @@ -23,7 +26,6 @@ interface HomeApiService { const val CURSOR = "cursor" const val COMMENT = "comment" const val COMMENTS = "comments" - const val DETAIL = "detail" const val CONTENT_ID = "contentId" const val LIKED = "liked" const val UNLIKED = "unliked" @@ -64,10 +66,12 @@ interface HomeApiService { @Path(value = CONTENT_ID) contentId: Int, ): BaseResponse - @POST("/$API/$V1/$CONTENT/{$CONTENT_ID}/$COMMENT") + @Multipart + @POST("/$API/$V2/$CONTENT/{$CONTENT_ID}/$COMMENT") suspend fun postCommentPosting( @Path(value = CONTENT_ID) contentId: Int, - @Body request: RequestCommentPostingDto, + @Part("text") request: RequestBody, + @Part file: MultipartBody.Part?, ): BaseResponse @DELETE("/$API/$V1/$COMMENT/{$COMMENT_ID}") diff --git a/data-remote/src/main/java/com/teamdontbe/data_remote/api/PostingApiService.kt b/data-remote/src/main/java/com/teamdontbe/data_remote/api/PostingApiService.kt index c5ddbade5..897a4217b 100644 --- a/data-remote/src/main/java/com/teamdontbe/data_remote/api/PostingApiService.kt +++ b/data-remote/src/main/java/com/teamdontbe/data_remote/api/PostingApiService.kt @@ -2,13 +2,18 @@ package com.teamdontbe.data_remote.api import com.teamdontbe.data.dto.BaseResponse import com.teamdontbe.data.dto.request.RequestPostingDto +import com.teamdontbe.data_remote.api.HomeApiService.Companion.V2 +import com.teamdontbe.data_remote.api.LoginApiService.Companion.V1 +import okhttp3.MultipartBody +import okhttp3.RequestBody import retrofit2.http.Body +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part interface PostingApiService { companion object { const val API = "api" - const val V1 = "v1" const val CONTENT = "content" } @@ -16,4 +21,11 @@ interface PostingApiService { suspend fun posting( @Body requestPostingDto: RequestPostingDto, ): BaseResponse + + @Multipart + @POST("/$API/$V2/$CONTENT") + suspend fun postingMultiPart( + @Part("text") text: RequestBody, + @Part image: MultipartBody.Part?, + ): BaseResponse } diff --git a/data-remote/src/main/java/com/teamdontbe/data_remote/datasourceimpl/HomeDataSourceImpl.kt b/data-remote/src/main/java/com/teamdontbe/data_remote/datasourceimpl/HomeDataSourceImpl.kt index e5f3c4127..00f8468a6 100644 --- a/data-remote/src/main/java/com/teamdontbe/data_remote/datasourceimpl/HomeDataSourceImpl.kt +++ b/data-remote/src/main/java/com/teamdontbe/data_remote/datasourceimpl/HomeDataSourceImpl.kt @@ -6,7 +6,6 @@ import androidx.paging.PagingData import com.teamdontbe.data.datasource.HomeDataSource import com.teamdontbe.data.dto.BaseResponse import com.teamdontbe.data.dto.request.RequestCommentLikedDto -import com.teamdontbe.data.dto.request.RequestCommentPostingDto import com.teamdontbe.data.dto.request.RequestTransparentDto import com.teamdontbe.data.dto.response.ResponseFeedDto import com.teamdontbe.data_remote.api.HomeApiService @@ -15,6 +14,8 @@ import com.teamdontbe.data_remote.pagingsourceimpl.HomeFeedPagingSourceImpl import com.teamdontbe.domain.entity.CommentEntity import com.teamdontbe.domain.entity.FeedEntity import kotlinx.coroutines.flow.Flow +import okhttp3.MultipartBody +import okhttp3.RequestBody import javax.inject.Inject class HomeDataSourceImpl @@ -54,9 +55,10 @@ constructor( override suspend fun postCommentPosting( contentId: Int, - commentText: RequestCommentPostingDto, + commentText: RequestBody, + image: MultipartBody.Part? ): BaseResponse { - return homeApiService.postCommentPosting(contentId, commentText) + return homeApiService.postCommentPosting(contentId, commentText, image) } override suspend fun deleteComment(commentId: Int): BaseResponse { diff --git a/data-remote/src/main/java/com/teamdontbe/data_remote/datasourceimpl/PostingDataSourceImpl.kt b/data-remote/src/main/java/com/teamdontbe/data_remote/datasourceimpl/PostingDataSourceImpl.kt index b2831aeb3..24a9aeee5 100644 --- a/data-remote/src/main/java/com/teamdontbe/data_remote/datasourceimpl/PostingDataSourceImpl.kt +++ b/data-remote/src/main/java/com/teamdontbe/data_remote/datasourceimpl/PostingDataSourceImpl.kt @@ -4,11 +4,20 @@ import com.teamdontbe.data.datasource.PostingDataSource import com.teamdontbe.data.dto.BaseResponse import com.teamdontbe.data.dto.request.RequestPostingDto import com.teamdontbe.data_remote.api.PostingApiService +import okhttp3.MultipartBody +import okhttp3.RequestBody import javax.inject.Inject -class PostingDataSourceImpl - @Inject - constructor(private val postingApiService: PostingApiService) : - PostingDataSource { - override suspend fun posting(requestPosting: RequestPostingDto): BaseResponse = postingApiService.posting(requestPosting) +class PostingDataSourceImpl @Inject constructor( + private val postingApiService: PostingApiService +) : PostingDataSource { + override suspend fun posting(requestPosting: RequestPostingDto): BaseResponse = + postingApiService.posting(requestPosting) + + override suspend fun postingMultiPart( + text: RequestBody, + image: MultipartBody.Part? + ): BaseResponse { + return postingApiService.postingMultiPart(text, image) } +} diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 1ea6a6416..a5e30524f 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { // android implementation(libs.bundles.room) implementation(libs.paging) + implementation(libs.androidx.exifinterface) // Third Party implementation(libs.bundles.retrofit) diff --git a/data/src/main/java/com/teamdontbe/data/datasource/HomeDataSource.kt b/data/src/main/java/com/teamdontbe/data/datasource/HomeDataSource.kt index f8c37cb34..a08871a36 100644 --- a/data/src/main/java/com/teamdontbe/data/datasource/HomeDataSource.kt +++ b/data/src/main/java/com/teamdontbe/data/datasource/HomeDataSource.kt @@ -2,11 +2,12 @@ package com.teamdontbe.data.datasource import androidx.paging.PagingData import com.teamdontbe.data.dto.BaseResponse -import com.teamdontbe.data.dto.request.RequestCommentPostingDto import com.teamdontbe.data.dto.response.ResponseFeedDto import com.teamdontbe.domain.entity.CommentEntity import com.teamdontbe.domain.entity.FeedEntity import kotlinx.coroutines.flow.Flow +import okhttp3.MultipartBody +import okhttp3.RequestBody interface HomeDataSource { fun getFeedList(): Flow> @@ -23,7 +24,8 @@ interface HomeDataSource { suspend fun postCommentPosting( contentId: Int, - commentText: RequestCommentPostingDto, + commentText: RequestBody, + image: MultipartBody.Part? ): BaseResponse suspend fun deleteComment(commentId: Int): BaseResponse diff --git a/data/src/main/java/com/teamdontbe/data/datasource/PostingDataSource.kt b/data/src/main/java/com/teamdontbe/data/datasource/PostingDataSource.kt index 5c6d4da4b..258eacc76 100644 --- a/data/src/main/java/com/teamdontbe/data/datasource/PostingDataSource.kt +++ b/data/src/main/java/com/teamdontbe/data/datasource/PostingDataSource.kt @@ -2,7 +2,13 @@ package com.teamdontbe.data.datasource import com.teamdontbe.data.dto.BaseResponse import com.teamdontbe.data.dto.request.RequestPostingDto +import okhttp3.MultipartBody +import okhttp3.RequestBody interface PostingDataSource { suspend fun posting(requestPostingDto: RequestPostingDto): BaseResponse + suspend fun postingMultiPart( + text: RequestBody, + image: MultipartBody.Part?, + ): BaseResponse } diff --git a/data/src/main/java/com/teamdontbe/data/repositoryimpl/HomeRepositoryImpl.kt b/data/src/main/java/com/teamdontbe/data/repositoryimpl/HomeRepositoryImpl.kt index b76f7ebba..0052ffd00 100644 --- a/data/src/main/java/com/teamdontbe/data/repositoryimpl/HomeRepositoryImpl.kt +++ b/data/src/main/java/com/teamdontbe/data/repositoryimpl/HomeRepositoryImpl.kt @@ -1,16 +1,27 @@ package com.teamdontbe.data.repositoryimpl +import android.content.ContentResolver import androidx.paging.PagingData import com.teamdontbe.data.datasource.HomeDataSource -import com.teamdontbe.data.dto.request.RequestCommentPostingDto +import com.teamdontbe.data.repositoryimpl.utils.createImagePart +import com.teamdontbe.data.repositoryimpl.utils.extractErrorMessage import com.teamdontbe.domain.entity.CommentEntity import com.teamdontbe.domain.entity.FeedEntity import com.teamdontbe.domain.repository.HomeRepository +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import retrofit2.HttpException +import java.io.IOException import javax.inject.Inject class HomeRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, private val homeDataSource: HomeDataSource, ) : HomeRepository { override fun getFeedList(): Flow> = homeDataSource.getFeedList() @@ -21,7 +32,8 @@ class HomeRepositoryImpl } } - override fun getCommentList(contentId: Int): Flow> = homeDataSource.getCommentList(contentId) + override fun getCommentList(contentId: Int): Flow> = + homeDataSource.getCommentList(contentId) override suspend fun deleteFeed(contentId: Int): Result { return runCatching { @@ -41,18 +53,6 @@ class HomeRepositoryImpl } } - override suspend fun postCommentPosting( - contentId: Int, - commentText: String, - ): Result { - return runCatching { - homeDataSource.postCommentPosting( - contentId, - RequestCommentPostingDto(commentText, "comment"), - ).success - } - } - override suspend fun deleteComment(commentId: Int): Result { return runCatching { homeDataSource.deleteComment( @@ -88,4 +88,42 @@ class HomeRepositoryImpl ).success } } + + override suspend fun postCommentPosting( + contentId: Int, + commentText: String, + uriString: String? + ): Result { + return runCatching { + + val infoRequestBody = createCommentRequestBody(commentText) + val imagePart = withContext(Dispatchers.IO) { + createImagePart(contentResolver, uriString) + } + + homeDataSource.postCommentPosting( + contentId, + infoRequestBody, + imagePart + ).success + }.onFailure { throwable -> + return when (throwable) { + is HttpException -> Result.failure(IOException(throwable.extractErrorMessage())) + else -> Result.failure(throwable) + } + } + } + + private fun createCommentRequestBody(commentText: String): RequestBody { + val infoJson = JSONObject().apply { + put("commentText", commentText) + put("notificationTriggerType", COMMENT_VALUE) + }.toString() + + return infoJson.toRequestBody("application/json".toMediaTypeOrNull()) + } + + companion object { + private const val COMMENT_VALUE = "comment" + } } diff --git a/data/src/main/java/com/teamdontbe/data/repositoryimpl/PostingRepositoryImpl.kt b/data/src/main/java/com/teamdontbe/data/repositoryimpl/PostingRepositoryImpl.kt index 4b27d5292..d4da6c093 100644 --- a/data/src/main/java/com/teamdontbe/data/repositoryimpl/PostingRepositoryImpl.kt +++ b/data/src/main/java/com/teamdontbe/data/repositoryimpl/PostingRepositoryImpl.kt @@ -1,26 +1,57 @@ package com.teamdontbe.data.repositoryimpl +import android.content.ContentResolver import com.teamdontbe.data.datasource.PostingDataSource import com.teamdontbe.data.dto.request.RequestPostingDto +import com.teamdontbe.data.repositoryimpl.utils.createImagePart +import com.teamdontbe.data.repositoryimpl.utils.extractErrorMessage import com.teamdontbe.domain.repository.PostingRepository +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -import timber.log.Timber +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import retrofit2.HttpException +import java.io.IOException import javax.inject.Inject -class PostingRepositoryImpl - @Inject - constructor( - private val postingDataSource: PostingDataSource, - ) : PostingRepository { - override suspend fun posting(contentText: String): Flow { - return flow { - val result = - runCatching { - postingDataSource.posting(RequestPostingDto(contentText)).success - } - Timber.d(result.toString()) - emit(result.getOrDefault(false)) +class PostingRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + private val postingDataSource: PostingDataSource, +) : PostingRepository { + override suspend fun posting(requestPosting: String): Flow { + return flow { + val result = + runCatching { + postingDataSource.posting(RequestPostingDto(requestPosting)).success + } + emit(result.getOrDefault(false)) + } + } + + override suspend fun postingMultiPart(content: String, uriString: String?): Result { + return runCatching { + val textRequestBody = createContentRequestBody(content) + + val imagePart = withContext(Dispatchers.IO) { + createImagePart(contentResolver, uriString) + } + + postingDataSource.postingMultiPart(textRequestBody, imagePart).success + + }.onFailure { throwable -> + return when (throwable) { + is HttpException -> Result.failure(IOException(throwable.extractErrorMessage())) + else -> Result.failure(throwable) } } } + + private fun createContentRequestBody(content: String): RequestBody { + val contentJson = JSONObject().apply { put("contentText", content) }.toString() + return contentJson.toRequestBody("application/json".toMediaTypeOrNull()) + } +} diff --git a/data/src/main/java/com/teamdontbe/data/repositoryimpl/utils/ContentUriRequestBody.kt b/data/src/main/java/com/teamdontbe/data/repositoryimpl/utils/ContentUriRequestBody.kt new file mode 100644 index 000000000..c51655a0c --- /dev/null +++ b/data/src/main/java/com/teamdontbe/data/repositoryimpl/utils/ContentUriRequestBody.kt @@ -0,0 +1,138 @@ +package com.teamdontbe.data.repositoryimpl.utils + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import android.provider.MediaStore +import androidx.exifinterface.media.ExifInterface +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okio.BufferedSink +import java.io.ByteArrayOutputStream + +class ContentUriRequestBody( + private val contentResolver: ContentResolver, + private val uri: Uri, +) : RequestBody() { + + private var fileName = "" + private var size = -1L + private var compressedImage: ByteArray? = null + + init { + contentResolver.query( + uri, + arrayOf(MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DISPLAY_NAME), + null, + null, + null, + )?.use { cursor -> + if (cursor.moveToFirst()) { + size = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)) + fileName = + cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) + } + } + + compressBitmap() + } + + private fun compressBitmap() { + var originalBitmap: Bitmap? = null + val exif: ExifInterface + + contentResolver.openInputStream(uri).use { inputStream -> + if (inputStream == null) return + exif = ExifInterface(inputStream) + } + + // 이미지 크기를 계산하여 imageSizeMb 선언 + val imageSizeMb = size / (1024.0 * 1024.0) + + contentResolver.openInputStream(uri).use { inputStream -> + if (inputStream == null) return + // 이미지 크기를 계산하여 3MB를 초과하는 경우에만 inSampleSize 설정 + val option = BitmapFactory.Options().apply { + if (imageSizeMb >= 3) { + inSampleSize = calculateInSampleSize(this, MAX_WIDTH, MAX_HEIGHT) + } + } + originalBitmap = BitmapFactory.decodeStream(inputStream, null, option) + } + + originalBitmap?.let { bitmap -> + val orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + val rotatedBitmap = when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> rotateBitmap(bitmap, 90f) + ExifInterface.ORIENTATION_ROTATE_180 -> rotateBitmap(bitmap, 180f) + ExifInterface.ORIENTATION_ROTATE_270 -> rotateBitmap(bitmap, 270f) + else -> bitmap + } + + val outputStream = ByteArrayOutputStream() + val compressRate = if (imageSizeMb >= 3) { + (300 / imageSizeMb).toInt() + } else { + 75 // 기본 압축률을 더 낮게 설정 + } + + outputStream.use { stream -> + rotatedBitmap.compress(Bitmap.CompressFormat.JPEG, compressRate, stream) + } + compressedImage = outputStream.toByteArray() + size = compressedImage?.size?.toLong() ?: -1L + } + } + + private fun rotateBitmap(bitmap: Bitmap, degrees: Float): Bitmap { + val matrix = Matrix().apply { + postRotate(degrees) + } + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } + + private fun calculateInSampleSize( + options: BitmapFactory.Options, + reqWidth: Int, + reqHeight: Int + ): Int { + val (height: Int, width: Int) = options.run { outHeight to outWidth } + var inSampleSize = 1 + + if (height > reqHeight || width > reqWidth) { + val halfHeight: Int = height / 2 + val halfWidth: Int = width / 2 + + while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 + } + } + + return inSampleSize + } + + private fun getFileName() = fileName + + override fun contentLength(): Long = size + + override fun contentType(): MediaType? = + uri.let { contentResolver.getType(it)?.toMediaTypeOrNull() } + + override fun writeTo(sink: BufferedSink) { + compressedImage?.let(sink::write) + } + + fun toMultiPartData(name: String) = MultipartBody.Part.createFormData(name, getFileName(), this) + + companion object { + const val MAX_WIDTH = 1024 + const val MAX_HEIGHT = 1024 + } +} diff --git a/data/src/main/java/com/teamdontbe/data/repositoryimpl/utils/extractErrorMessage.kt b/data/src/main/java/com/teamdontbe/data/repositoryimpl/utils/extractErrorMessage.kt new file mode 100644 index 000000000..f7cd58773 --- /dev/null +++ b/data/src/main/java/com/teamdontbe/data/repositoryimpl/utils/extractErrorMessage.kt @@ -0,0 +1,37 @@ +package com.teamdontbe.data.repositoryimpl.utils + +import android.content.ContentResolver +import android.net.Uri +import okhttp3.MultipartBody +import okhttp3.ResponseBody +import org.json.JSONException +import org.json.JSONObject +import retrofit2.HttpException + +fun HttpException.extractErrorMessage(): String { + if (response()?.code() == 413) return "이미지는 3mb를 넘을 수 없습니다" + + val errorBody: ResponseBody? = response()?.errorBody() + if (errorBody != null) { + val error = errorBody.string() + return try { + val jsonObject = JSONObject(error) + jsonObject.getString("message") + } catch (e: JSONException) { + "알수 없는 오류가 발생 했습니다" + } + } + return "알수 없는 오류가 발생 했습니다" +} + +fun createImagePart(contentResolver: ContentResolver, uriString: String?): MultipartBody.Part? { + return when (uriString) { + null -> null + else -> { + val uri = Uri.parse(uriString) + val imageRequestBody = ContentUriRequestBody(contentResolver, uri) + + imageRequestBody.toMultiPartData("image") + } + } +} diff --git a/domain/src/main/java/com/teamdontbe/domain/repository/HomeRepository.kt b/domain/src/main/java/com/teamdontbe/domain/repository/HomeRepository.kt index 4a80cf523..4fbcf8b5a 100644 --- a/domain/src/main/java/com/teamdontbe/domain/repository/HomeRepository.kt +++ b/domain/src/main/java/com/teamdontbe/domain/repository/HomeRepository.kt @@ -21,6 +21,7 @@ interface HomeRepository { suspend fun postCommentPosting( contentId: Int, commentText: String, + uriString: String? ): Result suspend fun deleteComment(commentId: Int): Result diff --git a/domain/src/main/java/com/teamdontbe/domain/repository/PostingRepository.kt b/domain/src/main/java/com/teamdontbe/domain/repository/PostingRepository.kt index 27a9e9210..1aa0b1a99 100644 --- a/domain/src/main/java/com/teamdontbe/domain/repository/PostingRepository.kt +++ b/domain/src/main/java/com/teamdontbe/domain/repository/PostingRepository.kt @@ -4,4 +4,6 @@ import kotlinx.coroutines.flow.Flow interface PostingRepository { suspend fun posting(requestPosting: String): Flow + + suspend fun postingMultiPart(content: String, uriString: String?): Result } diff --git a/feature/src/main/java/com/teamdontbe/feature/MainActivity.kt b/feature/src/main/java/com/teamdontbe/feature/MainActivity.kt index 421a17d4b..066731ac3 100644 --- a/feature/src/main/java/com/teamdontbe/feature/MainActivity.kt +++ b/feature/src/main/java/com/teamdontbe/feature/MainActivity.kt @@ -119,11 +119,12 @@ class MainActivity : BindingActivity(R.layout.activity_main binding.bnvMain.visibility = if (destination.id in listOf( - R.id.fragment_home, - R.id.fragment_notification, - R.id.fragment_my_page, - R.id.fragment_home_detail, - ) + R.id.fragment_home, + R.id.fragment_notification, + R.id.fragment_my_page, + R.id.fragment_home_detail, + R.id.fragment_image_detail, + ) ) { View.VISIBLE } else { diff --git a/feature/src/main/java/com/teamdontbe/feature/home/HomeFeedAdapter.kt b/feature/src/main/java/com/teamdontbe/feature/home/HomeFeedAdapter.kt index c13956ea7..e617bddac 100644 --- a/feature/src/main/java/com/teamdontbe/feature/home/HomeFeedAdapter.kt +++ b/feature/src/main/java/com/teamdontbe/feature/home/HomeFeedAdapter.kt @@ -17,6 +17,7 @@ class HomeFeedAdapter( private val onClickUserProfileBtn: (Int) -> Unit, private val onClickKebabBtn: (FeedEntity, Int) -> Unit, private val onClickTransparentBtn: (FeedEntity) -> Unit, + private val onClickFeedImage: (String) -> Unit, ) : ListAdapter( HomeAdapterDiffCallback, ) { @@ -34,7 +35,8 @@ class HomeFeedAdapter( onClickLikedBtn, onClickUserProfileBtn, onClickKebabBtn, - onClickTransparentBtn + onClickTransparentBtn, + onClickFeedImage, ) } diff --git a/feature/src/main/java/com/teamdontbe/feature/home/HomeFragment.kt b/feature/src/main/java/com/teamdontbe/feature/home/HomeFragment.kt index 8bbebc98d..dad7051cd 100644 --- a/feature/src/main/java/com/teamdontbe/feature/home/HomeFragment.kt +++ b/feature/src/main/java/com/teamdontbe/feature/home/HomeFragment.kt @@ -26,6 +26,7 @@ import com.teamdontbe.feature.util.AmplitudeTag.CLICK_POST_LIKE import com.teamdontbe.feature.util.AmplitudeTag.CLICK_POST_VIEW import com.teamdontbe.feature.util.EventObserver import com.teamdontbe.feature.util.FeedItemDecorator +import com.teamdontbe.feature.util.KeyStorage import com.teamdontbe.feature.util.KeyStorage.DELETE_POSTING import com.teamdontbe.feature.util.PagingLoadingAdapter import com.teamdontbe.feature.util.pagingSubmitData @@ -61,6 +62,7 @@ class HomeFragment : BindingFragment(R.layout.fragment_home homeViewModel.openHomeDetail(feedData) }, userId = homeViewModel.getMemberId(), + onClickFeedImage = { navigateToImageDetailFragment(it) }, ) homeFeedAdapter.apply { pagingSubmitData( @@ -230,6 +232,13 @@ class HomeFragment : BindingFragment(R.layout.fragment_home } } + private fun navigateToImageDetailFragment(it: String) { + findNavController().navigate( + R.id.fragment_image_detail, + bundleOf(KeyStorage.KEY_NOTI_DATA to it), + ) + } + companion object { const val HOME_BOTTOM_SHEET = "home_bottom_sheet" const val HOME_TRANSPARENT_DIALOG = "home_transparent_dialog" diff --git a/feature/src/main/java/com/teamdontbe/feature/home/HomePagingFeedAdapter.kt b/feature/src/main/java/com/teamdontbe/feature/home/HomePagingFeedAdapter.kt index e575d62c0..98fabb6e6 100644 --- a/feature/src/main/java/com/teamdontbe/feature/home/HomePagingFeedAdapter.kt +++ b/feature/src/main/java/com/teamdontbe/feature/home/HomePagingFeedAdapter.kt @@ -10,13 +10,14 @@ import com.teamdontbe.feature.home.HomeFeedAdapter.Companion.HomeAdapterDiffCall import com.teamdontbe.feature.home.viewholder.HomeFeedViewHolder class HomePagingFeedAdapter( - private val context : Context, + private val context: Context, private val userId: Int, private val onClickToNavigateToHomeDetail: (FeedEntity) -> Unit, private val onClickLikedBtn: (Int, Boolean) -> Unit, private val onClickUserProfileBtn: (Int) -> Unit, private val onClickKebabBtn: (FeedEntity, Int) -> Unit, private val onClickTransparentBtn: (FeedEntity) -> Unit, + private val onClickFeedImage: (String) -> Unit, ) : PagingDataAdapter(HomeAdapterDiffCallback) { override fun onCreateViewHolder( @@ -33,9 +34,9 @@ class HomePagingFeedAdapter( onClickLikedBtn, onClickUserProfileBtn, onClickKebabBtn, - onClickTransparentBtn + onClickTransparentBtn, + onClickFeedImage, ) - } override fun onBindViewHolder( @@ -48,4 +49,4 @@ class HomePagingFeedAdapter( fun deleteItem(position: Int) { notifyItemRemoved(position) } -} \ No newline at end of file +} diff --git a/feature/src/main/java/com/teamdontbe/feature/home/HomeViewModel.kt b/feature/src/main/java/com/teamdontbe/feature/home/HomeViewModel.kt index 020fd2865..4e4950b55 100644 --- a/feature/src/main/java/com/teamdontbe/feature/home/HomeViewModel.kt +++ b/feature/src/main/java/com/teamdontbe/feature/home/HomeViewModel.kt @@ -11,7 +11,9 @@ import com.teamdontbe.domain.repository.UserInfoRepository import com.teamdontbe.feature.util.Event import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -46,6 +48,13 @@ class HomeViewModel private val _openHomeDetail = MutableLiveData>() val openHomeDetail: LiveData> = _openHomeDetail + private val _photoUri = MutableStateFlow(null) + val photoUri: StateFlow = _photoUri + + fun setPhotoUri(uri: String?) { + _photoUri.value = uri + } + fun openHomeDetail(feedEntity: FeedEntity) { _openHomeDetail.value = Event(feedEntity) } @@ -88,12 +97,18 @@ class HomeViewModel fun postCommentPosting( contentId: Int, commentText: String, - ) = viewModelScope.launch { - homeRepository.postCommentPosting(contentId, commentText) - .fold( - { _postCommentPosting.emit(UiState.Success(it)) }, - { _postCommentPosting.emit(UiState.Failure(it.message.toString())) } - ) + uriString: String? + ) { + viewModelScope.launch { + homeRepository.postCommentPosting(contentId, commentText, uriString) + .onSuccess { + if (it) _postCommentPosting.emit(UiState.Success(it)) + else _postCommentPosting.emit(UiState.Failure("포스팅 실패")) + } + .onFailure { + _postCommentPosting.emit(UiState.Failure(it.message.toString())) + } + } } fun deleteComment(commentId: Int) = viewModelScope.launch { diff --git a/feature/src/main/java/com/teamdontbe/feature/home/viewholder/HomeFeedViewHolder.kt b/feature/src/main/java/com/teamdontbe/feature/home/viewholder/HomeFeedViewHolder.kt index 654e9b2e3..fb92b182d 100644 --- a/feature/src/main/java/com/teamdontbe/feature/home/viewholder/HomeFeedViewHolder.kt +++ b/feature/src/main/java/com/teamdontbe/feature/home/viewholder/HomeFeedViewHolder.kt @@ -21,6 +21,7 @@ class HomeFeedViewHolder( private val onClickUserProfileBtn: (Int) -> Unit, private val onClickKebabBtn: (FeedEntity, Int) -> Unit, private val onClickTransparentBtn: (FeedEntity) -> Unit, + private val onClickFeedImage: (String) -> Unit, ) : RecyclerView.ViewHolder(binding.root) { fun bind(data: FeedEntity) { with(binding) { @@ -46,6 +47,9 @@ class HomeFeedViewHolder( binding.tvHomeFeedContent.setOnShortClickListener { onClickToNavigateToHomeDetail(data) } + binding.ivHomeFeedImg.setOnClickListener { + data.contentImageUrl?.run { onClickFeedImage(this) } + } initLikedBtnCLickListener(data) initProfileBtnClickListener(data) initKebabBtnClickListener(data) diff --git a/feature/src/main/java/com/teamdontbe/feature/homedetail/CommentBottomSheet.kt b/feature/src/main/java/com/teamdontbe/feature/homedetail/CommentBottomSheet.kt index 69ad634a6..b789703c8 100644 --- a/feature/src/main/java/com/teamdontbe/feature/homedetail/CommentBottomSheet.kt +++ b/feature/src/main/java/com/teamdontbe/feature/homedetail/CommentBottomSheet.kt @@ -1,23 +1,36 @@ package com.teamdontbe.feature.homedetail +import android.Manifest import android.app.Dialog import android.content.Context import android.content.res.ColorStateList +import android.net.Uri +import android.os.Build import android.os.Bundle import android.text.InputFilter import android.view.View import android.view.WindowManager import android.view.inputmethod.InputMethodManager +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.activityViewModels +import androidx.lifecycle.flowWithLifecycle +import coil.load import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.teamdontbe.core_ui.base.BindingBottomSheetFragment import com.teamdontbe.core_ui.util.AmplitudeUtil.trackEvent +import com.teamdontbe.core_ui.util.context.showPermissionAppSettingsDialog import com.teamdontbe.core_ui.util.fragment.colorOf +import com.teamdontbe.core_ui.util.fragment.viewLifeCycle +import com.teamdontbe.core_ui.util.fragment.viewLifeCycleScope import com.teamdontbe.core_ui.view.setOnDuplicateBlockClick import com.teamdontbe.domain.entity.FeedEntity +import com.teamdontbe.feature.ErrorActivity import com.teamdontbe.feature.R import com.teamdontbe.feature.databinding.BottomsheetCommentBinding import com.teamdontbe.feature.dialog.DeleteDialogFragment @@ -32,6 +45,8 @@ import com.teamdontbe.feature.util.AmplitudeTag.CLICK_REPLY_UPLOAD import com.teamdontbe.feature.util.Debouncer import com.teamdontbe.feature.util.DialogTag.DELETE_COMMENT import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach @AndroidEntryPoint class CommentBottomSheet( @@ -44,15 +59,45 @@ class CommentBottomSheet( private var totalCommentLength = 0 private var linkValidity = true + private lateinit var getGalleryLauncher: ActivityResultLauncher + private lateinit var getPhotoPickerLauncher: ActivityResultLauncher + private val requestPermissions = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + when (isGranted) { + true -> { + try { + selectImage() + } catch (e: Exception) { + ErrorActivity.navigateToErrorPage(requireContext()) + } + } + + false -> handlePermissionDenied() + } + } + + private fun handlePermissionDenied() { + if (!shouldShowRequestPermissionRationale(Manifest.permission.READ_MEDIA_IMAGES)) { + requireContext().showPermissionAppSettingsDialog() + } + } + override fun initView() { binding.vm = homeViewModel binding.feed = feed + initPhotoPickerLauncher() + initGalleryLauncher() + setShowKeyboard() initEditText() initAppbarCancelClickListener() initLinkBtnClickListener() initCancelLinkBtnClickListener() checkLinkValidity() + + // 이미지 업로드 + initImageUploadBtnClickListener() + observePhotoUri() } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { @@ -220,7 +265,8 @@ class CommentBottomSheet( homeViewModel.postCommentPosting( contentId, binding.etCommentContent.text.toString() + binding.etCommentLink.text.takeIf { it.isNotEmpty() } - ?.let { "\n$it" }.orEmpty() + ?.let { "\n$it" }.orEmpty(), + homeViewModel.photoUri.value ) dismiss() } @@ -239,7 +285,78 @@ class CommentBottomSheet( } } + private fun initImageUploadBtnClickListener() = with(binding) { + layoutUploadBar.ivUploadImage.setOnClickListener { + getGalleryPermission() + } + } + + private fun getGalleryPermission() { + // api 34 이상인 경우 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + selectImage() + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissions.launch(Manifest.permission.READ_MEDIA_IMAGES) + } else { + requestPermissions.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + private fun selectImage() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + getGalleryLauncher.launch("image/*") + } else { + getPhotoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + } + + private fun initPhotoPickerLauncher() { + getPhotoPickerLauncher = + registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { imageUri -> + imageUri?.let { homeViewModel.setPhotoUri(it.toString()) } + } + } + + private fun initGalleryLauncher() { + getGalleryLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> + imageUri?.let { homeViewModel.setPhotoUri(it.toString()) } + } + } + + + private fun observePhotoUri() { + homeViewModel.photoUri.flowWithLifecycle(viewLifeCycle).onEach { getUri -> + getUri?.let { uri -> + handleUploadImageClick(Uri.parse(uri)) + } + }.launchIn(viewLifeCycleScope) + } + + private fun handleUploadImageClick(uri: Uri) = with(binding) { + ivPostingImg.isVisible = true + ivPostingImg.load(uri) + ivPostingCancelImage.isVisible = true + cancelImageBtnClickListener() + } + + private fun cancelImageBtnClickListener() = with(binding) { + ivPostingCancelImage.setOnClickListener { + homeViewModel.setPhotoUri(null) + ivPostingImg.load(null) + ivPostingImg.isGone = true + ivPostingCancelImage.isGone = true + } + } + override fun onDeleteDialogDismissed() { dismiss() } + + override fun onDestroyView() { + super.onDestroyView() + homeViewModel.setPhotoUri(null) + } } diff --git a/feature/src/main/java/com/teamdontbe/feature/homedetail/HomeDetailFragment.kt b/feature/src/main/java/com/teamdontbe/feature/homedetail/HomeDetailFragment.kt index 98d7c564e..2057702de 100644 --- a/feature/src/main/java/com/teamdontbe/feature/homedetail/HomeDetailFragment.kt +++ b/feature/src/main/java/com/teamdontbe/feature/homedetail/HomeDetailFragment.kt @@ -29,6 +29,7 @@ import com.teamdontbe.feature.home.HomeFeedAdapter import com.teamdontbe.feature.home.HomeFragment import com.teamdontbe.feature.home.HomeFragment.Companion.KEY_HOME_DETAIL_FEED import com.teamdontbe.feature.home.HomeViewModel +import com.teamdontbe.feature.snackbar.LinkCountErrorSnackBar import com.teamdontbe.feature.snackbar.TransparentIsGhostSnackBar import com.teamdontbe.feature.snackbar.UploadingSnackBar import com.teamdontbe.feature.util.AmplitudeTag.CLICK_POST_LIKE @@ -153,6 +154,7 @@ class HomeDetailFragment : onClickUserProfileBtn = { memberId -> navigateToMyPageFragment(memberId) }, userId = homeViewModel.getMemberId(), onClickToNavigateToHomeDetail = {}, + onClickFeedImage = { navigateToImageDetailFragment(it) }, ).apply { submitList(feedListData) } @@ -292,7 +294,13 @@ class HomeDetailFragment : homeViewModel.postCommentPosting.flowWithLifecycle(viewLifeCycle).onEach { result -> when (result) { is UiState.Success -> handleCommentPostingSuccess() - is UiState.Failure -> navigateToErrorPage() + is UiState.Failure -> { + LinkCountErrorSnackBar.make(binding.root).apply { + setText(result.msg) + show() + } + } + else -> Unit } }.launchIn(viewLifeCycleScope) @@ -371,6 +379,13 @@ class HomeDetailFragment : ) } + private fun navigateToImageDetailFragment(it: String) { + findNavController().navigate( + R.id.fragment_image_detail, + bundleOf(KEY_NOTI_DATA to it), + ) + } + companion object { const val COMMENT_DEBOUNCE_DELAY = 1000L const val ALARM_TRIGGER_TYPE_COMMENT = "commentGhost" diff --git a/feature/src/main/java/com/teamdontbe/feature/homedetail/imagedetail/ImageDialogFragment.kt b/feature/src/main/java/com/teamdontbe/feature/homedetail/imagedetail/ImageDialogFragment.kt new file mode 100644 index 000000000..8e35c64ef --- /dev/null +++ b/feature/src/main/java/com/teamdontbe/feature/homedetail/imagedetail/ImageDialogFragment.kt @@ -0,0 +1,49 @@ +package com.teamdontbe.feature.homedetail.imagedetail + +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import coil.load +import com.teamdontbe.core_ui.base.BindingDialogFragment +import com.teamdontbe.core_ui.util.context.dialogFragmentResize +import com.teamdontbe.feature.R +import com.teamdontbe.feature.databinding.FragmentImageDetailBinding +import com.teamdontbe.feature.util.KeyStorage + +class ImageDialogFragment : + BindingDialogFragment(R.layout.fragment_image_detail) { + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + } + + override fun initView() { + initLoadImageUri() + initCancelButtonClick() + } + + private fun initLoadImageUri() { + val imageUri = arguments?.getString(KeyStorage.KEY_NOTI_DATA).orEmpty() + if (imageUri.isNotEmpty()) { + binding.ivImageDetail.load(imageUri) + } + } + + override fun onResume() { + super.onResume() + context?.dialogFragmentResize(this, 25.0f) + } + + private fun initCancelButtonClick() { + binding.ivPostingCancelImage.setOnClickListener { + dismiss() + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + } +} diff --git a/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentAdapter.kt b/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentAdapter.kt index 602d414cb..ca8f595a0 100644 --- a/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentAdapter.kt +++ b/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentAdapter.kt @@ -15,6 +15,7 @@ class MyPageCommentAdapter( private val onItemClicked: (CommentEntity) -> Unit, private val onClickLikedBtn: (Int, Boolean) -> Unit, private val onClickTransparentBtn: (CommentEntity) -> Unit, + private val onClickFeedImage: (String) -> Unit, ) : PagingDataAdapter(ExampleDiffCallback) { private val inflater by lazy { LayoutInflater.from(context) } @@ -31,6 +32,7 @@ class MyPageCommentAdapter( onClickLikedBtn = onClickLikedBtn, idFlag = idFlag, onClickTransparentBtn = onClickTransparentBtn, + onClickFeedImage = onClickFeedImage, ) } diff --git a/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentFragment.kt b/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentFragment.kt index 13e7c86ee..6b633f094 100644 --- a/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentFragment.kt +++ b/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentFragment.kt @@ -83,6 +83,7 @@ class MyPageCommentFragment : }, onClickLikedBtn = ::onLikedBtnClick, onClickTransparentBtn = ::onTransparentBtnClick, + onClickFeedImage = { navigateToImageDetailFragment(it) }, ).apply { pagingSubmitData( viewLifecycleOwner, @@ -181,7 +182,7 @@ class MyPageCommentFragment : private fun stateCommentItemNull() { myPageCommentAdapter.addLoadStateListener { combinedLoadStates -> val isEmpty = combinedLoadStates.source.refresh is - LoadState.NotLoading && combinedLoadStates.append.endOfPaginationReached && myPageCommentAdapter.itemCount < 1 + LoadState.NotLoading && combinedLoadStates.append.endOfPaginationReached && myPageCommentAdapter.itemCount < 1 if (isEmpty) { updateNoCommentUI() } else { @@ -257,6 +258,13 @@ class MyPageCommentFragment : } } + private fun navigateToImageDetailFragment(it: String) { + findNavController().navigate( + R.id.fragment_image_detail, + bundleOf(KEY_NOTI_DATA to it), + ) + } + override fun onResume() { super.onResume() binding.root.requestLayout() diff --git a/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentViewHolder.kt b/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentViewHolder.kt index e21a7abdf..3784270b1 100644 --- a/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentViewHolder.kt +++ b/feature/src/main/java/com/teamdontbe/feature/mypage/comment/MyPageCommentViewHolder.kt @@ -3,6 +3,7 @@ package com.teamdontbe.feature.mypage.comment import android.graphics.Color import android.view.View import androidx.recyclerview.widget.RecyclerView +import com.teamdontbe.core_ui.view.setOnShortClickListener import com.teamdontbe.domain.entity.CommentEntity import com.teamdontbe.feature.databinding.ItemMyPageCommentBinding import com.teamdontbe.feature.util.Transparent @@ -14,6 +15,7 @@ class MyPageCommentViewHolder( private val onClickLikedBtn: (Int, Boolean) -> Unit, private val onClickKebabBtn: (CommentEntity, Int) -> Unit, private val onClickTransparentBtn: (CommentEntity) -> Unit, + private val onClickFeedImage: (String) -> Unit, ) : RecyclerView.ViewHolder(binding.root) { private var item: CommentEntity? = null @@ -27,6 +29,12 @@ class MyPageCommentViewHolder( binding.ivCommentGhostFillGreen.setOnClickListener { item?.let { onClickTransparentBtn(it) } } + binding.tvCommentContent.setOnShortClickListener { + item?.run { onItemClicked(this) } + } + binding.ivHomeFeedImg.setOnClickListener { + item?.commentImageUrl?.run { onClickFeedImage(this) } + } } fun onBind(data: CommentEntity) = with(binding) { diff --git a/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedAdapter.kt b/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedAdapter.kt index c36d8aedb..aec8122e1 100644 --- a/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedAdapter.kt +++ b/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedAdapter.kt @@ -15,8 +15,8 @@ class MyPageFeedAdapter( private val onItemClicked: (FeedEntity) -> Unit, private val onClickLikedBtn: (Int, Boolean) -> Unit, private val onClickTransparentBtn: (FeedEntity) -> Unit, -) : - PagingDataAdapter(myPageFeedItemDiffCallback) { + private val onClickFeedImage: (String) -> Unit, +) : PagingDataAdapter(myPageFeedItemDiffCallback) { private val inflater by lazy { LayoutInflater.from(context) } override fun onCreateViewHolder( @@ -31,6 +31,7 @@ class MyPageFeedAdapter( onItemClicked = onItemClicked, onClickLikedBtn = onClickLikedBtn, onClickTransparentBtn = onClickTransparentBtn, + onClickFeedImage = onClickFeedImage, ) } diff --git a/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedFragment.kt b/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedFragment.kt index e9ede6492..d09d84300 100644 --- a/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedFragment.kt +++ b/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedFragment.kt @@ -27,7 +27,6 @@ import com.teamdontbe.feature.mypage.MyPageViewModel import com.teamdontbe.feature.mypage.bottomsheet.MyPageAnotherUserBottomSheet import com.teamdontbe.feature.mypage.bottomsheet.MyPageTransparentDialogFragment import com.teamdontbe.feature.snackbar.TransparentIsGhostSnackBar -import com.teamdontbe.feature.util.AmplitudeTag import com.teamdontbe.feature.util.AmplitudeTag.CLICK_POST_LIKE import com.teamdontbe.feature.util.AmplitudeTag.CLICK_POST_VIEW import com.teamdontbe.feature.util.FeedItemDecorator @@ -85,7 +84,7 @@ class MyPageFeedFragment : }, onClickLikedBtn = ::onLikedBtnClick, onClickTransparentBtn = ::onTransparentBtnClick, - + onClickFeedImage = { navigateToImageDetailFragment(it) }, ).apply { pagingSubmitData( viewLifecycleOwner, @@ -251,7 +250,7 @@ class MyPageFeedFragment : myPageFeedAdapter.addLoadStateListener { combinedLoadStates -> val isEmpty = combinedLoadStates.source.refresh is - LoadState.NotLoading && combinedLoadStates.append.endOfPaginationReached && myPageFeedAdapter.itemCount < 1 + LoadState.NotLoading && combinedLoadStates.append.endOfPaginationReached && myPageFeedAdapter.itemCount < 1 when { memberProfile.idFlag && isEmpty -> updateNoFeedUI() !memberProfile.idFlag && isEmpty -> updateOtherUserNoFeedUI() @@ -269,6 +268,13 @@ class MyPageFeedFragment : ) } + private fun navigateToImageDetailFragment(it: String) { + findNavController().navigate( + R.id.fragment_image_detail, + bundleOf(KEY_NOTI_DATA to it), + ) + } + companion object { const val ARG_MEMBER_PROFILE = "arg_member_profile" const val FROM_FEED = "feed" diff --git a/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedViewHolder.kt b/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedViewHolder.kt index 590665809..69e8167ef 100644 --- a/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedViewHolder.kt +++ b/feature/src/main/java/com/teamdontbe/feature/mypage/feed/MyPageFeedViewHolder.kt @@ -3,6 +3,7 @@ package com.teamdontbe.feature.mypage.feed import android.graphics.Color import android.view.View import androidx.recyclerview.widget.RecyclerView +import com.teamdontbe.core_ui.view.setOnShortClickListener import com.teamdontbe.domain.entity.FeedEntity import com.teamdontbe.feature.databinding.ItemHomeFeedBinding import com.teamdontbe.feature.util.Transparent @@ -14,6 +15,7 @@ class MyPageFeedViewHolder( private val onClickLikedBtn: (Int, Boolean) -> Unit, private val onClickKebabBtn: (FeedEntity, Int) -> Unit, private val onClickTransparentBtn: (FeedEntity) -> Unit, + private val onClickFeedImage: (String) -> Unit, ) : RecyclerView.ViewHolder(binding.root) { private var item: FeedEntity? = null @@ -27,6 +29,12 @@ class MyPageFeedViewHolder( binding.ivHomeGhostFillGreen.setOnClickListener { item?.let { onClickTransparentBtn(it) } } + binding.tvHomeFeedContent.setOnShortClickListener() { + item?.run { onItemClicked(this) } + } + binding.ivHomeFeedImg.setOnClickListener { + item?.contentImageUrl?.run { onClickFeedImage(this) } + } } fun onBind(data: FeedEntity) = with(binding) { diff --git a/feature/src/main/java/com/teamdontbe/feature/posting/PostingFragment.kt b/feature/src/main/java/com/teamdontbe/feature/posting/PostingFragment.kt index 63b3f7e5d..bb91919e8 100644 --- a/feature/src/main/java/com/teamdontbe/feature/posting/PostingFragment.kt +++ b/feature/src/main/java/com/teamdontbe/feature/posting/PostingFragment.kt @@ -1,25 +1,35 @@ package com.teamdontbe.feature.posting +import android.Manifest import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList +import android.net.Uri +import android.os.Build import android.text.InputFilter import android.util.Patterns.WEB_URL import android.view.animation.AnimationUtils import android.view.inputmethod.InputMethodManager +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.viewModels import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import coil.load import com.teamdontbe.core_ui.base.BindingFragment import com.teamdontbe.core_ui.util.AmplitudeUtil.trackEvent import com.teamdontbe.core_ui.util.context.pxToDp +import com.teamdontbe.core_ui.util.context.showPermissionAppSettingsDialog import com.teamdontbe.core_ui.util.fragment.colorOf import com.teamdontbe.core_ui.util.fragment.statusBarColorOf +import com.teamdontbe.core_ui.util.fragment.viewLifeCycle +import com.teamdontbe.core_ui.util.fragment.viewLifeCycleScope import com.teamdontbe.core_ui.view.UiState +import com.teamdontbe.feature.ErrorActivity.Companion.navigateToErrorPage import com.teamdontbe.feature.R import com.teamdontbe.feature.databinding.FragmentPostingBinding import com.teamdontbe.feature.dialog.DeleteDialogFragment @@ -43,8 +53,34 @@ class PostingFragment : BindingFragment(R.layout.fragmen private var totalContentLength = 0 private var linkValidity = true + private lateinit var getGalleryLauncher: ActivityResultLauncher + private lateinit var getPhotoPickerLauncher: ActivityResultLauncher + private val requestPermissions = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + when (isGranted) { + true -> { + try { + selectImage() + } catch (e: Exception) { + navigateToErrorPage(requireContext()) + } + } + + false -> handlePermissionDenied() + } + } + + private fun handlePermissionDenied() { + if (!shouldShowRequestPermissionRationale(Manifest.permission.READ_MEDIA_IMAGES)) { + requireContext().showPermissionAppSettingsDialog() + } + } + override fun initView() { statusBarColorOf(R.color.white) + initGalleryLauncher() + initPhotoPickerLauncher() + showKeyboard() initAnimation() @@ -58,11 +94,15 @@ class PostingFragment : BindingFragment(R.layout.fragmen initLinkBtnClickListener() initCancelLinkBtnClickListener() checkLinkValidity() + + // 이미지 업로드 + initImageUploadBtnClickListener() + observePhotoUri() } private fun initObserveUser() { postingViewModel.getMyPageUserProfileInfo(postingViewModel.getMemberId()) - postingViewModel.getMyPageUserProfileState.flowWithLifecycle(lifecycle).onEach { + postingViewModel.getMyPageUserProfileState.flowWithLifecycle(viewLifeCycle).onEach { when (it) { is UiState.Loading -> Unit is UiState.Success -> { @@ -78,7 +118,7 @@ class PostingFragment : BindingFragment(R.layout.fragmen is UiState.Empty -> Unit is UiState.Failure -> Unit } - }.launchIn(lifecycleScope) + }.launchIn(viewLifeCycleScope) } private fun hideKeyboard() { @@ -118,7 +158,7 @@ class PostingFragment : BindingFragment(R.layout.fragmen } private fun initObservePost() { - postingViewModel.postPosting.flowWithLifecycle(lifecycle).onEach { + postingViewModel.postPosting.flowWithLifecycle(viewLifeCycle).onEach { when (it) { is UiState.Loading -> Unit is UiState.Success -> { @@ -130,9 +170,14 @@ class PostingFragment : BindingFragment(R.layout.fragmen } is UiState.Empty -> Unit - is UiState.Failure -> Unit + is UiState.Failure -> { + LinkCountErrorSnackBar.make(binding.root).apply { + setText(it.msg) + show() + } + } } - }.launchIn(lifecycleScope) + }.launchIn(viewLifeCycleScope) } private fun initLinkBtnClickListener() = with(binding) { @@ -234,9 +279,10 @@ class PostingFragment : BindingFragment(R.layout.fragmen trackEvent(CLICK_POST_UPLOAD) postingViewModel.posting( binding.etPostingContent.text.toString() + ( - binding.etPostingLink.text.takeIf { it.isNotEmpty() } - ?.let { "\n$it" }.orEmpty() - ) + binding.etPostingLink.text.takeIf { it.isNotEmpty() } + ?.let { "\n$it" }.orEmpty() + ), + postingViewModel.photoUri.value ) } } @@ -325,6 +371,78 @@ class PostingFragment : BindingFragment(R.layout.fragmen ) } + private fun initImageUploadBtnClickListener() = with(binding) { + layoutUploadBar.ivUploadImage.setOnClickListener { + getGalleryPermission() + showKeyboard() + } + } + + private fun getGalleryPermission() { + // api 34 이상인 경우 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + selectImage() + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissions.launch(Manifest.permission.READ_MEDIA_IMAGES) + } else { + requestPermissions.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + private fun selectImage() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + getGalleryLauncher.launch("image/*") + } else { + getPhotoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + } + + private fun initPhotoPickerLauncher() { + getPhotoPickerLauncher = + registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { imageUri -> + imageUri?.let { postingViewModel.setPhotoUri(it.toString()) } + } + } + + private fun initGalleryLauncher() { + getGalleryLauncher = + registerForActivityResult(ActivityResultContracts.GetContent()) { imageUri -> + imageUri?.let { postingViewModel.setPhotoUri(it.toString()) } + } + } + + + private fun observePhotoUri() { + postingViewModel.photoUri.flowWithLifecycle(viewLifeCycle).onEach { getUri -> + getUri?.let { uri -> + handleUploadImageClick(Uri.parse(uri)) + } + }.launchIn(viewLifeCycleScope) + } + + private fun handleUploadImageClick(uri: Uri) = with(binding) { + ivPostingImg.isVisible = true + ivPostingImg.load(uri) + ivPostingCancelImage.isVisible = true + cancelImageBtnClickListener() + } + + private fun cancelImageBtnClickListener() = with(binding) { + ivPostingCancelImage.setOnClickListener { + postingViewModel.setPhotoUri(null) + ivPostingImg.load(null) + ivPostingImg.isGone = true + ivPostingCancelImage.isGone = true + } + } + + override fun onDestroyView() { + super.onDestroyView() + postingViewModel.setPhotoUri(null) + } + companion object { const val POSTING_MIN = 1 const val POSTING_MAX = 499 diff --git a/feature/src/main/java/com/teamdontbe/feature/posting/PostingViewModel.kt b/feature/src/main/java/com/teamdontbe/feature/posting/PostingViewModel.kt index 4392e21f4..ec9208dfa 100644 --- a/feature/src/main/java/com/teamdontbe/feature/posting/PostingViewModel.kt +++ b/feature/src/main/java/com/teamdontbe/feature/posting/PostingViewModel.kt @@ -8,9 +8,11 @@ import com.teamdontbe.domain.repository.MyPageRepository import com.teamdontbe.domain.repository.PostingRepository import com.teamdontbe.domain.repository.UserInfoRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -22,20 +24,31 @@ constructor( private val userInfoRepository: UserInfoRepository, private val myPageRepository: MyPageRepository, ) : ViewModel() { - private val _postPosting = MutableStateFlow>(UiState.Empty) - val postPosting: StateFlow> = _postPosting + private val _postPosting = MutableSharedFlow>() + val postPosting: SharedFlow> get() = _postPosting.asSharedFlow() private val _getMyPageUserProfileState = MutableStateFlow>(UiState.Empty) val getMyPageUserProfileState: StateFlow> = _getMyPageUserProfileState - fun posting(contentText: String) = + private val _photoUri = MutableStateFlow(null) + val photoUri: StateFlow = _photoUri + + fun setPhotoUri(uri: String?) { + _photoUri.value = uri + } + + fun posting(contentText: String, imageString: String?) = viewModelScope.launch { - postingRepository.posting(contentText).collectLatest { - _postPosting.value = UiState.Success(it) - } - _postPosting.value = UiState.Loading + _postPosting.emit(UiState.Loading) + postingRepository.postingMultiPart(contentText, imageString) + .onSuccess { isSuccess -> + if (isSuccess) _postPosting.emit(UiState.Success(isSuccess)) + else _postPosting.emit(UiState.Failure("포스팅 실패")) + }.onFailure { + _postPosting.emit(UiState.Failure(it.message.orEmpty())) + } } fun getNickName() = userInfoRepository.getNickName() diff --git a/feature/src/main/java/com/teamdontbe/feature/signup/SignUpProfileActivity.kt b/feature/src/main/java/com/teamdontbe/feature/signup/SignUpProfileActivity.kt index 5580af4db..7e46567ae 100644 --- a/feature/src/main/java/com/teamdontbe/feature/signup/SignUpProfileActivity.kt +++ b/feature/src/main/java/com/teamdontbe/feature/signup/SignUpProfileActivity.kt @@ -2,13 +2,11 @@ package com.teamdontbe.feature.signup import android.Manifest.permission.READ_EXTERNAL_STORAGE import android.Manifest.permission.READ_MEDIA_IMAGES -import android.app.AlertDialog import android.content.Intent import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.net.Uri import android.os.Build -import android.provider.Settings import android.view.View import android.view.WindowManager import androidx.activity.result.PickVisualMediaRequest @@ -25,6 +23,7 @@ import com.teamdontbe.core_ui.util.AmplitudeUtil.trackEvent import com.teamdontbe.core_ui.util.context.colorOf import com.teamdontbe.core_ui.util.context.hideKeyboard import com.teamdontbe.core_ui.util.context.openKeyboard +import com.teamdontbe.core_ui.util.context.showPermissionAppSettingsDialog import com.teamdontbe.core_ui.util.intent.navigateTo import com.teamdontbe.core_ui.view.UiState import com.teamdontbe.domain.entity.ProfileEditInfoEntity @@ -69,7 +68,7 @@ class SignUpProfileActivity : } } else { Timber.tag("permission").d("권한 거부") - requestPermissionAppSettings() + showPermissionAppSettingsDialog() } } @@ -127,28 +126,6 @@ class SignUpProfileActivity : } } - // 앱 설정으로 이동하여 사용자에게 권한을 다시 요청하는 함수 - private fun requestPermissionAppSettings() { - AlertDialog.Builder(this) - .setTitle(getString(R.string.sign_up_profile_permission_title)) - .setMessage(getString(R.string.sign_up_profile_permission_description)) - .setPositiveButton(getString(R.string.sign_up_profile_permission_move)) { dialog, _ -> - navigateToAppSettings() - dialog.dismiss() - } - .setNegativeButton(getString(R.string.sign_up_profile_permission_cancle)) { dialog, _ -> - dialog.dismiss() - } - .show() - } - - private fun navigateToAppSettings() { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - val uri = Uri.fromParts("package", packageName, null) - intent.data = uri - startActivity(intent) - } - private fun getGalleryPermission() { // api 34 이상인 경우 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -352,7 +329,7 @@ class SignUpProfileActivity : nickName: String, optionalAgreement: Boolean, introduce: String, - imgUrl: File? + imgUrl: File?, ) { viewModel.patchUserProfileUri( ProfileEditInfoEntity( diff --git a/feature/src/main/java/com/teamdontbe/feature/snackbar/LinkCountErrorSnackBar.kt b/feature/src/main/java/com/teamdontbe/feature/snackbar/LinkCountErrorSnackBar.kt index d0680f2de..984802826 100644 --- a/feature/src/main/java/com/teamdontbe/feature/snackbar/LinkCountErrorSnackBar.kt +++ b/feature/src/main/java/com/teamdontbe/feature/snackbar/LinkCountErrorSnackBar.kt @@ -34,6 +34,10 @@ class LinkCountErrorSnackBar(view: View) { } } + fun setText(text: String) { + binding.tvCommentLinkCountError.text = text + } + fun show() { snackbar.show() } diff --git a/feature/src/main/res/drawable/ic_image.xml b/feature/src/main/res/drawable/ic_image.xml new file mode 100644 index 000000000..8d007fffd --- /dev/null +++ b/feature/src/main/res/drawable/ic_image.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/feature/src/main/res/layout/bottomsheet_comment.xml b/feature/src/main/res/layout/bottomsheet_comment.xml index d4be1cdf6..efc45ba70 100644 --- a/feature/src/main/res/layout/bottomsheet_comment.xml +++ b/feature/src/main/res/layout/bottomsheet_comment.xml @@ -255,6 +255,34 @@ android:layout_width="match_parent" android:layout_height="10dp" app:layout_constraintTop_toBottomOf="@id/tv_comment_link_error" /> + + + + + @@ -268,4 +296,4 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> - \ No newline at end of file + diff --git a/feature/src/main/res/layout/fragment_image_detail.xml b/feature/src/main/res/layout/fragment_image_detail.xml new file mode 100644 index 000000000..75073e09a --- /dev/null +++ b/feature/src/main/res/layout/fragment_image_detail.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + diff --git a/feature/src/main/res/layout/fragment_posting.xml b/feature/src/main/res/layout/fragment_posting.xml index 77ac6ca0c..2d2257a4d 100644 --- a/feature/src/main/res/layout/fragment_posting.xml +++ b/feature/src/main/res/layout/fragment_posting.xml @@ -124,8 +124,35 @@ android:textColor="@color/gray_12" android:visibility="gone" app:layout_constraintEnd_toEndOf="@id/iv_posting_cancel_link" - app:layout_constraintStart_toStartOf="@id/et_posting_link" + app:layout_constraintStart_toStartOf="@id/et_posting_content" app:layout_constraintTop_toBottomOf="@id/et_posting_link" /> + + + + diff --git a/feature/src/main/res/layout/view_upload_bar.xml b/feature/src/main/res/layout/view_upload_bar.xml index 714de4e21..4ab20b676 100644 --- a/feature/src/main/res/layout/view_upload_bar.xml +++ b/feature/src/main/res/layout/view_upload_bar.xml @@ -30,6 +30,15 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + - \ No newline at end of file + diff --git a/feature/src/main/res/navigation/nav_main.xml b/feature/src/main/res/navigation/nav_main.xml index c61c5be14..e8df64d0c 100644 --- a/feature/src/main/res/navigation/nav_main.xml +++ b/feature/src/main/res/navigation/nav_main.xml @@ -116,4 +116,9 @@ android:id="@+id/action_fragment_home_detail_to_fragment_my_page" app:destination="@id/fragment_my_page" /> + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7d3ffebf4..6f83a1acc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ paging = "3.1.1" room = "2.5.0" security = "1.1.0-alpha06" swipe-refresh-layout = "1.1.0" +exifinterface = "1.3.7" # material + google material = "1.11.0" @@ -82,6 +83,7 @@ room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } shared-security = { module = "androidx.security:security-crypto-ktx", version.ref = "security" } swipe-refresh-layout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swipe-refresh-layout" } +androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" } # dagger-hilt hilt = { module = "com.google.dagger:hilt-android", version.ref = "dagger-hilt" } @@ -139,4 +141,4 @@ kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", vers lifecycle = ["lifecycle", "lifecycle-viewmodel", "lifecycle-livedata"] retrofit = ["retrofit", "retrofit-kotlin-serialization-converter", "okhttp-bom", "okhttp", "okhttp-logging-interceptor"] androidx-android-test = ["androidx-test-espresso", "androidx-test-junit"] -room = ["room-runtime", "room-compiler", "room-ktx"] \ No newline at end of file +room = ["room-runtime", "room-compiler", "room-ktx"]