diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 547e7f99b..08fb01b13 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,15 @@ android { namespace = "com.eatssu.android" compileSdk = 35 - // S8: API 28 - // S21: API 33 + /** + * 현재 팀 내 안드로이드 OS 버전 + * 진 S8: 9 (sdk 28) + * 진 S21: 14 (sdk 33) + * 윤소: 9 + * 유리: 10 + * 제훈: 14 + */ + defaultConfig { applicationId = "com.eatssu.android" minSdk = 23 @@ -114,6 +121,7 @@ android { dependencies { implementation(project(":core:design-system")) + implementation(project(":core:design-system")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) @@ -126,6 +134,27 @@ dependencies { implementation(libs.androidx.activity.ktx) implementation(libs.androidx.fragment.ktx) + // Compose + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.animation) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.lifecycle.viewmodel) + implementation(libs.androidx.compose.lifecycle.runtime) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.compose.theme.adapter) + implementation(libs.accompanist.appcompat.theme) + androidTestImplementation(libs.androidx.compose.bom) + debugImplementation(libs.androidx.compose.ui.test.manifest) + + // navigation + implementation(libs.androidx.navigation.fragment) + implementation(libs.androidx.navigation.ui) + implementation(libs.androidx.navigation.compose) + //glance implementation(libs.androidx.glance) implementation(libs.androidx.glance.preview) @@ -153,6 +182,9 @@ dependencies { implementation(libs.glide) kapt(libs.glide.compiler) + //coil: 이미지 로딩 + implementation(libs.coil.compose) + //compressor: 이미지 압축 implementation(libs.compressor) @@ -170,6 +202,7 @@ dependencies { kapt(libs.androidx.hilt.compiler) implementation(libs.androidx.hilt.common) implementation(libs.androidx.hilt.work) + implementation(libs.hilt.navigation.compose) // ViewModel and LiveData implementation(libs.androidx.lifecycle.viewmodel.ktx) @@ -189,27 +222,6 @@ dependencies { // OSS implementation(libs.oss.licenses) - // Compose - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.compose.animation) - implementation(libs.androidx.compose.ui.tooling) - implementation(libs.androidx.compose.lifecycle.viewmodel) - implementation(libs.androidx.compose.lifecycle.runtime) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) -// androidTestImplementation(libs.androidx.compose.ui.test.junit4) - implementation(libs.compose.theme.adapter) - implementation(libs.accompanist.appcompat.theme) - androidTestImplementation(libs.androidx.compose.bom) - debugImplementation(libs.androidx.compose.ui.test.manifest) - - // navigation - implementation(libs.androidx.navigation.fragment) - implementation(libs.androidx.navigation.ui) - // worker (Kotlin + coroutines) implementation(libs.androidx.work.runtime.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1207ded2f..2b032f7b4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -133,15 +133,12 @@ - @@ -186,14 +183,7 @@ android:value="" /> - - - - - - \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/dto/request/ModifyReviewRequest.kt b/app/src/main/java/com/eatssu/android/data/dto/request/ModifyReviewRequest.kt index 4fc581aa9..4be5eb3cc 100644 --- a/app/src/main/java/com/eatssu/android/data/dto/request/ModifyReviewRequest.kt +++ b/app/src/main/java/com/eatssu/android/data/dto/request/ModifyReviewRequest.kt @@ -1,10 +1,18 @@ package com.eatssu.android.data.dto.request +import com.eatssu.android.data.dto.request.WriteMenuReviewRequest.MenuLike import com.google.gson.annotations.SerializedName data class ModifyReviewRequest( - @SerializedName("mainRating") var mainRating: Int? = null, - @SerializedName("amountRating") var amountRating: Int? = null, - @SerializedName("tasteRating") var tasteRating: Int? = null, - @SerializedName("content") var content: String? = null, -) \ No newline at end of file + + @SerializedName("rating") var rating: Int? = null, + @SerializedName("menuLikes") var menuLikes: MenuLike? = MenuLike(), + @SerializedName("content") var content: String? = null +) { + data class MenuLikes( + + @SerializedName("menuId") var menuId: Long? = null, + @SerializedName("isLike") var isLike: Boolean? = null + + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/dto/request/ReportRequest.kt b/app/src/main/java/com/eatssu/android/data/dto/request/ReportRequest.kt index 30d5ab84b..462c279ce 100644 --- a/app/src/main/java/com/eatssu/android/data/dto/request/ReportRequest.kt +++ b/app/src/main/java/com/eatssu/android/data/dto/request/ReportRequest.kt @@ -3,12 +3,7 @@ package com.eatssu.android.data.dto.request import com.google.gson.annotations.SerializedName data class ReportRequest( - @SerializedName("reviewId") - val reviewId: Long, - - @SerializedName("reportType") - val reportType: String, - - @SerializedName("content") - val content: String, + @SerializedName("reviewId") var reviewId: Long? = null, + @SerializedName("reportType") var reportType: String? = null, + @SerializedName("content") var content: String? = null ) diff --git a/app/src/main/java/com/eatssu/android/data/dto/request/WriteReviewRequest.kt b/app/src/main/java/com/eatssu/android/data/dto/request/WriteMealReviewRequest.kt similarity index 56% rename from app/src/main/java/com/eatssu/android/data/dto/request/WriteReviewRequest.kt rename to app/src/main/java/com/eatssu/android/data/dto/request/WriteMealReviewRequest.kt index 1042fd6ce..1f26dca66 100644 --- a/app/src/main/java/com/eatssu/android/data/dto/request/WriteReviewRequest.kt +++ b/app/src/main/java/com/eatssu/android/data/dto/request/WriteMealReviewRequest.kt @@ -2,12 +2,20 @@ package com.eatssu.android.data.dto.request import com.google.gson.annotations.SerializedName -data class WriteReviewRequest( +data class WriteMenuReviewRequest( + @SerializedName("menuId") var menuId: Int? = null, @SerializedName("mainRating") var mainRating: Int? = null, @SerializedName("amountRating") var amountRating: Int? = null, @SerializedName("tasteRating") var tasteRating: Int? = null, @SerializedName("content") var content: String? = null, @SerializedName("imageUrl") var imageUrl: String? = null, + @SerializedName("menuLike") var menuLike: MenuLike? = MenuLike() +) { + data class MenuLike( - ) \ No newline at end of file + @SerializedName("menuId") var menuId: Long? = null, + @SerializedName("isLike") var isLike: Boolean? = null + + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/dto/request/WriteMenuReviewRequest.kt b/app/src/main/java/com/eatssu/android/data/dto/request/WriteMenuReviewRequest.kt new file mode 100644 index 000000000..6893dd269 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/dto/request/WriteMenuReviewRequest.kt @@ -0,0 +1,18 @@ +package com.eatssu.android.data.dto.request + +import com.google.gson.annotations.SerializedName + + +data class WriteMealReviewRequest( + @SerializedName("mealId") var mealId: Int? = null, + @SerializedName("rating") var rating: Int? = null, + @SerializedName("menuLikes") var menuLikes: List = arrayListOf(), + @SerializedName("content") var content: String? = null, + @SerializedName("imageUrls") var imageUrls: List = arrayListOf() + +) { + data class MenuLikes( + @SerializedName("menuId") var menuId: Long? = null, + @SerializedName("isLike") var isLike: Boolean? = null + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/dto/response/MealReviewInfoResponse.kt b/app/src/main/java/com/eatssu/android/data/dto/response/MealReviewInfoResponse.kt index b58c0b51a..8a682367e 100644 --- a/app/src/main/java/com/eatssu/android/data/dto/response/MealReviewInfoResponse.kt +++ b/app/src/main/java/com/eatssu/android/data/dto/response/MealReviewInfoResponse.kt @@ -3,38 +3,29 @@ package com.eatssu.android.data.dto.response import com.eatssu.android.domain.model.ReviewInfo import com.google.gson.annotations.SerializedName -data class GetMealReviewInfoResponse( - - @SerializedName("menuNames") var menuNames: ArrayList = arrayListOf(), +data class MealReviewInfoResponse( + @SerializedName("menuNames") var menuNames: List? = null, @SerializedName("totalReviewCount") var totalReviewCount: Int? = null, @SerializedName("mainRating") var mainRating: Double? = null, - @SerializedName("amountRating") var amountRating: Double? = null, - @SerializedName("tasteRating") var tasteRating: Double? = null, - @SerializedName("reviewRatingCount") var reviewRatingCount: ReviewRatingCount = ReviewRatingCount(), - - ) { + @SerializedName("likeCount") var likeCount: Int? = null, + @SerializedName("reviewRatingCount") var reviewRatingCount: ReviewRatingCount? = ReviewRatingCount() +) { data class ReviewRatingCount( - @SerializedName("oneStarCount") var oneStarCount: Int? = null, @SerializedName("twoStarCount") var twoStarCount: Int? = null, @SerializedName("threeStarCount") var threeStarCount: Int? = null, @SerializedName("fourStarCount") var fourStarCount: Int? = null, - @SerializedName("fiveStarCount") var fiveStarCount: Int? = null, - - ) - + @SerializedName("fiveStarCount") var fiveStarCount: Int? = null + ) } -fun GetMealReviewInfoResponse.asReviewInfo() = ReviewInfo( - - name = menuNames.joinToString(separator = "+"), +fun MealReviewInfoResponse.toDomain() = ReviewInfo( + name = menuNames?.joinToString(separator = " + ") ?: "", reviewCnt = totalReviewCount ?: 0, mainRating = mainRating ?: 0.0, - amountRating = amountRating ?: 0.0, - tasteRating = tasteRating ?: 0.0, - one = reviewRatingCount.oneStarCount ?: 0, - two = reviewRatingCount.twoStarCount ?: 0, - three = reviewRatingCount.threeStarCount ?: 0, - four = reviewRatingCount.fourStarCount ?: 0, - five = reviewRatingCount.fiveStarCount ?: 0, + one = reviewRatingCount?.oneStarCount ?: 0, + two = reviewRatingCount?.twoStarCount ?: 0, + three = reviewRatingCount?.threeStarCount ?: 0, + four = reviewRatingCount?.fourStarCount ?: 0, + five = reviewRatingCount?.fiveStarCount ?: 0, ) diff --git a/app/src/main/java/com/eatssu/android/data/dto/response/MealReviewListResponse.kt b/app/src/main/java/com/eatssu/android/data/dto/response/MealReviewListResponse.kt new file mode 100644 index 000000000..74e047b2c --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/dto/response/MealReviewListResponse.kt @@ -0,0 +1,51 @@ +package com.eatssu.android.data.dto.response + +import com.eatssu.android.domain.model.Review +import com.google.gson.annotations.SerializedName + +data class MealReviewListResponse( + @SerializedName("numberOfElements") var numberOfElements: Int? = null, + @SerializedName("hasNext") var hasNext: Boolean? = null, + @SerializedName("dataList") var dataList: ArrayList = arrayListOf() +) { + data class DataList( + @SerializedName("reviewId") var reviewId: Long? = null, + @SerializedName("writerId") var writerId: Long? = null, + @SerializedName("isWriter") var isWriter: Boolean? = null, + @SerializedName("writerNickname") var writerNickname: String? = null, + @SerializedName("rating") var rating: Int? = null, + @SerializedName("writtenAt") var writtenAt: String? = null, + @SerializedName("content") var content: String? = null, + @SerializedName("imageUrls") var imageUrls: ArrayList = arrayListOf(), + @SerializedName("menuList") var menuList: ArrayList = arrayListOf() + ) { + data class MenuList( + @SerializedName("id") var id: Long? = null, + @SerializedName("name") var name: String? = null, + @SerializedName("isLike") var isLike: Boolean? = null + ) + } +} + + +fun MealReviewListResponse?.toDomain(): List { + // MealReviewListResponse 객체 자체가 null이면 emptyList() 반환 + return this?.dataList?.map { data -> + Review( + reviewId = data.reviewId ?: 0, + isWriter = data.isWriter ?: false, + menuList = data.menuList.map { + Review.Menu( + menuId = it.id ?: -1L, + name = it.name ?: "", + isLike = it.isLike ?: false + ) + }, + writerNickname = data.writerNickname ?: "유저", + mainGrade = data.rating ?: 0, + writeDate = data.writtenAt ?: "", + content = data.content ?: "", + imgUrl = data.imageUrls.firstOrNull(), + ) + } ?: emptyList() +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/dto/response/MenuOfMealResponse.kt b/app/src/main/java/com/eatssu/android/data/dto/response/MenuOfMealResponse.kt index 78d69a721..7d7d5689c 100644 --- a/app/src/main/java/com/eatssu/android/data/dto/response/MenuOfMealResponse.kt +++ b/app/src/main/java/com/eatssu/android/data/dto/response/MenuOfMealResponse.kt @@ -1,26 +1,20 @@ package com.eatssu.android.data.dto.response -import com.eatssu.android.domain.model.MenuMini import com.google.gson.annotations.SerializedName data class MenuOfMealResponse( - @SerializedName("briefMenus") var briefMenus: ArrayList = arrayListOf(), + @SerializedName("menuList") var menuList: ArrayList = arrayListOf() ) -data class MenusInformation( +data class MenuList( - @SerializedName("menuId") var menuId: Long, - @SerializedName("name") var name: String, + @SerializedName("menuId") var menuId: Long? = null, + @SerializedName("name") var name: String? = null - ) - -fun MenuOfMealResponse.toMenuMiniList(): List { - return briefMenus.map { it.toMenuMini() } -} +) -fun MenusInformation.toMenuMini(): MenuMini { - return MenuMini( - id = this.menuId, - name = this.name - ) +fun MenuOfMealResponse.toDomain(): List> { + return menuList.map { + (it.menuId ?: -1L) to (it.name ?: "") + } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/dto/response/MenuReviewInfoResponse.kt b/app/src/main/java/com/eatssu/android/data/dto/response/MenuReviewInfoResponse.kt index 6489dabad..ce4e337bf 100644 --- a/app/src/main/java/com/eatssu/android/data/dto/response/MenuReviewInfoResponse.kt +++ b/app/src/main/java/com/eatssu/android/data/dto/response/MenuReviewInfoResponse.kt @@ -3,38 +3,29 @@ package com.eatssu.android.data.dto.response import com.eatssu.android.domain.model.ReviewInfo import com.google.gson.annotations.SerializedName -data class GetMenuReviewInfoResponse( - - @SerializedName("menuName") var menuName: String, - @SerializedName("totalReviewCount") var totalReviewCount: Int, +data class MenuReviewInfoResponse( + @SerializedName("menuName") var menuName: String? = null, + @SerializedName("totalReviewCount") var totalReviewCount: Int? = null, @SerializedName("mainRating") var mainRating: Double? = null, - @SerializedName("amountRating") var amountRating: Double? = null, - @SerializedName("tasteRating") var tasteRating: Double? = null, - @SerializedName("reviewRatingCount") var reviewRatingCount: ReviewRatingCount, + @SerializedName("likeCount") var likeCount: Int? = null, + @SerializedName("reviewRatingCount") var reviewRatingCount: ReviewRatingCount? = ReviewRatingCount() ) { data class ReviewRatingCount( - - @SerializedName("oneStarCount") var oneStarCount: Int, - @SerializedName("twoStarCount") var twoStarCount: Int, - @SerializedName("threeStarCount") var threeStarCount: Int, - @SerializedName("fourStarCount") var fourStarCount: Int, - @SerializedName("fiveStarCount") var fiveStarCount: Int, - - ) - + @SerializedName("oneStarCount") var oneStarCount: Int? = null, + @SerializedName("twoStarCount") var twoStarCount: Int? = null, + @SerializedName("threeStarCount") var threeStarCount: Int? = null, + @SerializedName("fourStarCount") var fourStarCount: Int? = null, + @SerializedName("fiveStarCount") var fiveStarCount: Int? = null + ) } -fun GetMenuReviewInfoResponse.asReviewInfo() = ReviewInfo( - - name = menuName, - reviewCnt = totalReviewCount, +fun MenuReviewInfoResponse.toDomain() = ReviewInfo( + name = menuName ?: "", + reviewCnt = totalReviewCount ?: 0, mainRating = mainRating ?: 0.0, - amountRating = amountRating ?: 0.0, - tasteRating = tasteRating ?: 0.0, - one = reviewRatingCount.oneStarCount, - two = reviewRatingCount.twoStarCount, - three = reviewRatingCount.threeStarCount, - four = reviewRatingCount.fourStarCount, - five = reviewRatingCount.fiveStarCount, - - ) + one = reviewRatingCount?.oneStarCount ?: 0, + two = reviewRatingCount?.twoStarCount ?: 0, + three = reviewRatingCount?.threeStarCount ?: 0, + four = reviewRatingCount?.fourStarCount ?: 0, + five = reviewRatingCount?.fiveStarCount ?: 0, +) diff --git a/app/src/main/java/com/eatssu/android/data/dto/response/MenuReviewListResponse.kt b/app/src/main/java/com/eatssu/android/data/dto/response/MenuReviewListResponse.kt new file mode 100644 index 000000000..a909aacac --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/dto/response/MenuReviewListResponse.kt @@ -0,0 +1,45 @@ +package com.eatssu.android.data.dto.response + +import com.eatssu.android.domain.model.Review +import com.google.gson.annotations.SerializedName + +data class MenuReviewListResponse( + @SerializedName("numberOfElements") var numberOfElements: Int? = null, + @SerializedName("hasNext") var hasNext: Boolean? = null, + @SerializedName("dataList") var dataList: ArrayList = arrayListOf() +) { + data class DataList( //todo 변경 + @SerializedName("reviewId") var reviewId: Long? = null, + @SerializedName("menu") var menu: String? = null, + @SerializedName("writerId") var writerId: Long? = null, + @SerializedName("isWriter") var isWriter: Boolean? = null, + @SerializedName("writerNickname") var writerNickname: String? = null, + @SerializedName("mainRating") var mainRating: Int? = null, + @SerializedName("amountRating") var amountRating: String? = null, + @SerializedName("tasteRating") var tasteRating: String? = null, + @SerializedName("writedAt") var writtenAt: String? = null, + @SerializedName("content") var content: String? = null, + @SerializedName("imageUrls") var imageUrls: ArrayList = arrayListOf() + ) +} + +fun MenuReviewListResponse.toDomain(): List { + return this.dataList.map { data -> + Review( + reviewId = data.reviewId ?: 0, + isWriter = data.isWriter ?: false, + menuList = listOf( + Review.Menu( + 0, + data.menu ?: "", + false + ), + ), + writerNickname = data.writerNickname ?: "유저", + mainGrade = data.mainRating ?: 0, + writeDate = data.writtenAt ?: "", + content = data.content ?: "", + imgUrl = data.imageUrls.firstOrNull(), + ) + } ?: emptyList() +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/dto/response/MyReviewListResponse.kt b/app/src/main/java/com/eatssu/android/data/dto/response/MyReviewListResponse.kt new file mode 100644 index 000000000..de5dc5691 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/dto/response/MyReviewListResponse.kt @@ -0,0 +1,47 @@ +package com.eatssu.android.data.dto.response + +import com.eatssu.android.domain.model.Review +import com.google.gson.annotations.SerializedName + +data class MyReviewListResponse( + @SerializedName("numberOfElements") var numberOfElements: Int? = null, + @SerializedName("hasNext") var hasNext: Boolean? = null, + @SerializedName("dataList") var dataList: ArrayList = arrayListOf() +) { + data class DataList( + + @SerializedName("reviewId") var reviewId: Long? = null, + @SerializedName("rating") var rating: Int? = null, + @SerializedName("writtenAt") var writtenAt: String? = null, + @SerializedName("content") var content: String? = null, + @SerializedName("imageUrls") var imageUrls: ArrayList = arrayListOf(), + @SerializedName("menuList") var menuList: ArrayList = arrayListOf() + ) { + data class MenuList( + @SerializedName("id") var id: Long? = null, + @SerializedName("name") var name: String? = null, + @SerializedName("isLike") var isLike: Boolean? = null + ) + } +} + +fun MyReviewListResponse.toDomain(): List { + return dataList.map { data -> + Review( + reviewId = data.reviewId ?: 0, + isWriter = true, + menuList = data.menuList.map { menu -> + Review.Menu( + menuId = menu.id ?: -1L, + name = menu.name ?: "", + isLike = menu.isLike ?: false + ) + }, + writerNickname = "", + mainGrade = data.rating ?: 0, + writeDate = data.writtenAt ?: "", + content = data.content ?: "", + imgUrl = data.imageUrls.firstOrNull(), + ) + } ?: emptyList() +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/dto/response/MyReviewResponse.kt b/app/src/main/java/com/eatssu/android/data/dto/response/MyReviewResponse.kt deleted file mode 100644 index 793e335ea..000000000 --- a/app/src/main/java/com/eatssu/android/data/dto/response/MyReviewResponse.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.eatssu.android.data.dto.response - -import com.eatssu.android.domain.model.Review -import com.google.gson.annotations.SerializedName - -data class MyReviewResponse( - @SerializedName("numberOfElements") var numberOfElements: Int? = null, - @SerializedName("hasNext") var hasNext: Boolean? = null, - @SerializedName("dataList") var dataList: List, - - ) { - data class DataList( - @SerializedName("reviewId") var reviewId: Long, - @SerializedName("mainRating") var mainRating: Int, - @SerializedName("amountRating") var amountRating: Int, - @SerializedName("tasteRating") var tasteRating: Int, - @SerializedName("writeDate") var writeDate: String, - @SerializedName("menuName") var menuName: String, - @SerializedName("content") var content: String, - @SerializedName("imgUrlList") var imgUrlList: ArrayList? = arrayListOf(), - - ) -} - -fun MyReviewResponse.toReviewList(): List { - return dataList.map { data -> - Review( - reviewId = data.reviewId, - isWriter = true, - menu = data.menuName, - writerNickname = "", - mainGrade = data.mainRating, - amountGrade = data.amountRating, - tasteGrade = data.tasteRating, - writeDate = data.writeDate, - content = data.content, - imgUrl = data.imgUrlList - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/dto/response/ReviewListResponse.kt b/app/src/main/java/com/eatssu/android/data/dto/response/ReviewListResponse.kt deleted file mode 100644 index 165b7adb1..000000000 --- a/app/src/main/java/com/eatssu/android/data/dto/response/ReviewListResponse.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.eatssu.android.data.dto.response - -import com.eatssu.android.domain.model.Review -import com.google.gson.annotations.SerializedName - -data class GetReviewListResponse( - @SerializedName("numberOfElements") var numberOfElements: Int? = null, - @SerializedName("hasNext") var hasNext: Boolean? = null, - @SerializedName("dataList") var dataList: ArrayList? = arrayListOf(), -) { - - data class DataList( - - @SerializedName("reviewId") var reviewId: Long, - @SerializedName("menu") var menu: String, - @SerializedName("writerId") var writerId: Long, - @SerializedName("isWriter") var isWriter: Boolean, - @SerializedName("writerNickname") var writerNickname: String?, - @SerializedName("mainRating") var mainRating: Int, - @SerializedName("amountRating") var amountRating: Int, - @SerializedName("tasteRating") var tasteRating: Int, - @SerializedName("writedAt") var writedAt: String, - @SerializedName("content") var content: String, - @SerializedName("imageUrls") var imageUrls: ArrayList? = arrayListOf(), - - ) -} - -fun GetReviewListResponse.toReviewList(): List { - return dataList!!.map { data -> - Review( - reviewId = data.reviewId, - isWriter = data.isWriter, - menu = data.menu, - writerNickname = data.writerNickname ?: "유저", - mainGrade = data.mainRating, - amountGrade = data.amountRating, - tasteGrade = data.tasteRating, - writeDate = data.writedAt, - content = data.content, - imgUrl = data.imageUrls - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/repository/MealRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/repository/MealRepositoryImpl.kt index 660e76e93..2c9479178 100644 --- a/app/src/main/java/com/eatssu/android/data/repository/MealRepositoryImpl.kt +++ b/app/src/main/java/com/eatssu/android/data/repository/MealRepositoryImpl.kt @@ -1,7 +1,5 @@ package com.eatssu.android.data.repository -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.MenuOfMealResponse import com.eatssu.android.data.dto.response.toDomain import com.eatssu.android.data.service.MealService import com.eatssu.android.domain.repository.MealRepository @@ -35,10 +33,5 @@ class MealRepositoryImpl @Inject constructor( } } } - - - override suspend fun getMenuInfoByMealId(mealId: Long): Flow> = - flow { - emit(mealService.getMenuInfoByMealId(mealId)) - } } + diff --git a/app/src/main/java/com/eatssu/android/data/repository/ReviewRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/repository/ReviewRepositoryImpl.kt index 3d1b7a1d5..629c3396e 100644 --- a/app/src/main/java/com/eatssu/android/data/repository/ReviewRepositoryImpl.kt +++ b/app/src/main/java/com/eatssu/android/data/repository/ReviewRepositoryImpl.kt @@ -1,68 +1,107 @@ package com.eatssu.android.data.repository import com.eatssu.android.data.dto.request.ModifyReviewRequest -import com.eatssu.android.data.dto.request.WriteReviewRequest -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.GetMealReviewInfoResponse -import com.eatssu.android.data.dto.response.GetMenuReviewInfoResponse -import com.eatssu.android.data.dto.response.GetReviewListResponse -import com.eatssu.android.data.dto.response.ImageResponse +import com.eatssu.android.data.dto.request.WriteMealReviewRequest +import com.eatssu.android.data.dto.request.WriteMenuReviewRequest +import com.eatssu.android.data.dto.response.toDomain import com.eatssu.android.data.service.ReviewService +import com.eatssu.android.domain.model.Review +import com.eatssu.android.domain.model.ReviewInfo import com.eatssu.android.domain.repository.ReviewRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import java.io.File import javax.inject.Inject -class ReviewRepositoryImpl @Inject constructor(private val reviewService: ReviewService) : - ReviewRepository { +class ReviewRepositoryImpl @Inject constructor( + private val reviewService: ReviewService +) : ReviewRepository { - override suspend fun writeReview( - menuId: Long, - body: WriteReviewRequest, - ): Flow> = - flow { - emit(reviewService.writeReview(menuId, body)) + override suspend fun writeMealReview(body: WriteMealReviewRequest) { + val result = runCatching { + reviewService.writeMealReview(body) } - override suspend fun deleteReview(reviewId: Long): Flow> = - flow { - emit(reviewService.deleteReview(reviewId)) + result.onSuccess { response -> + if (response.isSuccess == true) { + // API가 2xx 코드를 반환하는 등 성공적으로 처리된 경우 + // 여기에 성공 로직을 작성합니다. + // 예: Unit 반환 또는 로그 기록 + } else { + // API가 4xx, 5xx 등 실패 코드를 반환한 경우 + // 예외를 던져 onFailure 블록에서 처리하도록 유도합니다. + throw Exception("리뷰 작성 실패: ${response.code}") + } + }.onFailure { e -> + // 실패: 예외가 발생하면 이곳에서 처리 + // 예: throw Exception("리뷰 작성 실패: ${e.message}") + throw e // 예외를 상위 계층으로 다시 던져서 처리 + } + } + + + override suspend fun writeMenuReview(body: WriteMenuReviewRequest) { + val result = runCatching { + reviewService.writeMenuReview(body) } + result.onSuccess { response -> + if (response.isSuccess == true) { + // API가 2xx 코드를 반환하는 등 성공적으로 처리된 경우 + // 여기에 성공 로직을 작성합니다. + // 예: Unit 반환 또는 로그 기록 + } else { + // API가 4xx, 5xx 등 실패 코드를 반환한 경우 + // 예외를 던져 onFailure 블록에서 처리하도록 유도합니다. + throw Exception("리뷰 작성 실패: ${response.code}") + } + }.onFailure { e -> + // 실패: 예외가 발생하면 이곳에서 처리 + // 예: throw Exception("리뷰 작성 실패: ${e.message}") + throw e // 예외를 상위 계층으로 다시 던져서 처리 + } + } + + override suspend fun deleteReview(reviewId: Long) { + reviewService.deleteReview(reviewId) + } + override suspend fun modifyReview( reviewId: Long, body: ModifyReviewRequest, - ): Flow> = - flow { - emit(reviewService.modifyReview(reviewId, body)) - } + ) { + reviewService.modifyReview(reviewId, body) + } - override suspend fun getReviewList( - menuType: String, - mealId: Long?, - menuId: Long?, - ): Flow> = flow { - emit(reviewService.getReviewList(menuType, mealId, menuId)) + override suspend fun getMenuReviewList(menuId: Long?): List { + return reviewService.getMenuReviewList(menuId).result?.toDomain() ?: emptyList() } - override suspend fun getMenuReviewInfo(menuId: Long): Flow> = - flow { - emit(reviewService.getMenuReviewInfo(menuId)) - } + override suspend fun getMealReviewList(mealId: Long?): List { + return reviewService.getMealReviewList(mealId).result?.toDomain() ?: emptyList() + } - override suspend fun getMealReviewInfo(mealId: Long): Flow> = - flow { - emit(reviewService.getMealReviewInfo(mealId)) - } - override suspend fun getImageString(file: File): Flow> = flow { + override suspend fun getMenuReviewInfo(menuId: Long): ReviewInfo { + return reviewService.getMenuReviewInfo(menuId).result?.toDomain()!! //non null 하면 안될 것 같은데 + } + + override suspend fun getMealReviewInfo(mealId: Long): ReviewInfo { + return reviewService.getMealReviewInfo(mealId).result?.toDomain()!! + } + + override suspend fun getImageString(file: File): String { val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) val multipart = MultipartBody.Part.createFormData("image", file.name, requestFile) - val response = reviewService.uploadImage(multipart) - emit(response) + return reviewService.uploadImage(multipart).result?.url ?: "" + } + + override suspend fun getMenuInfoByMealId(mealId: Long): List> { + return reviewService.getMenuInfoByMealId(mealId).result?.toDomain() ?: emptyList() + } + + override suspend fun getUserReviews(): List { + return reviewService.getMyReviews().result?.toDomain() ?: emptyList() } } diff --git a/app/src/main/java/com/eatssu/android/data/repository/UserRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/repository/UserRepositoryImpl.kt index 235527c6e..bfd718ec2 100644 --- a/app/src/main/java/com/eatssu/android/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/com/eatssu/android/data/repository/UserRepositoryImpl.kt @@ -4,7 +4,6 @@ import com.eatssu.android.data.dto.request.ChangeNicknameRequest import com.eatssu.android.data.dto.request.UserDepartmentRequest import com.eatssu.android.data.dto.response.BaseResponse import com.eatssu.android.data.dto.response.MyNickNameResponse -import com.eatssu.android.data.dto.response.MyReviewResponse import com.eatssu.android.data.dto.response.toDomain import com.eatssu.android.data.service.UserService import com.eatssu.android.domain.model.College @@ -28,11 +27,6 @@ class UserRepositoryImpl @Inject constructor(private val userService: UserServic emit(userService.checkNickname(nickname)) } - override suspend fun getUserReviews(): Flow> = - flow { - emit(userService.getMyReviews()) - } - override suspend fun getUserNickName(): Flow> = flow { emit(userService.getMyInfo()) diff --git a/app/src/main/java/com/eatssu/android/data/service/MealService.kt b/app/src/main/java/com/eatssu/android/data/service/MealService.kt index fabe0a657..38416f174 100644 --- a/app/src/main/java/com/eatssu/android/data/service/MealService.kt +++ b/app/src/main/java/com/eatssu/android/data/service/MealService.kt @@ -2,10 +2,8 @@ package com.eatssu.android.data.service import com.eatssu.android.data.dto.response.BaseResponse import com.eatssu.android.data.dto.response.GetMealResponse -import com.eatssu.android.data.dto.response.MenuOfMealResponse import retrofit2.Call import retrofit2.http.GET -import retrofit2.http.Path import retrofit2.http.Query interface MealService { @@ -27,12 +25,4 @@ interface MealService { @Query("time") time: String, ): BaseResponse> - /** - * 메뉴 정보 리스트 조회 - */ - @GET("meals/{mealId}/menus-info") - suspend fun getMenuInfoByMealId( - @Path("mealId") mealId: Long, - ): BaseResponse - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/data/service/ReviewService.kt b/app/src/main/java/com/eatssu/android/data/service/ReviewService.kt index d33bb2204..8c84095f3 100644 --- a/app/src/main/java/com/eatssu/android/data/service/ReviewService.kt +++ b/app/src/main/java/com/eatssu/android/data/service/ReviewService.kt @@ -2,12 +2,16 @@ package com.eatssu.android.data.service import com.eatssu.android.data.dto.request.ModifyReviewRequest -import com.eatssu.android.data.dto.request.WriteReviewRequest +import com.eatssu.android.data.dto.request.WriteMealReviewRequest +import com.eatssu.android.data.dto.request.WriteMenuReviewRequest import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.GetMealReviewInfoResponse -import com.eatssu.android.data.dto.response.GetMenuReviewInfoResponse -import com.eatssu.android.data.dto.response.GetReviewListResponse import com.eatssu.android.data.dto.response.ImageResponse +import com.eatssu.android.data.dto.response.MealReviewInfoResponse +import com.eatssu.android.data.dto.response.MealReviewListResponse +import com.eatssu.android.data.dto.response.MenuOfMealResponse +import com.eatssu.android.data.dto.response.MenuReviewInfoResponse +import com.eatssu.android.data.dto.response.MenuReviewListResponse +import com.eatssu.android.data.dto.response.MyReviewListResponse import okhttp3.MultipartBody import retrofit2.http.Body import retrofit2.http.DELETE @@ -21,44 +25,46 @@ import retrofit2.http.Query interface ReviewService { - @POST("/reviews/write/{menuId}") //리뷰 작성 - suspend fun writeReview( - @Path("menuId") menuId: Long, - @Body request: WriteReviewRequest, + @POST("/v2/reviews/menu") //리뷰 작성 + suspend fun writeMenuReview( + @Body request: WriteMenuReviewRequest, + ): BaseResponse + + @POST("/v2/reviews/meal") //리뷰 작성 + suspend fun writeMealReview( + @Body request: WriteMealReviewRequest, ): BaseResponse - @DELETE("/reviews/{reviewId}") //리뷰 삭제 + @DELETE("/v2/reviews/{reviewId}") //리뷰 삭제 suspend fun deleteReview( @Path("reviewId") reviewId: Long, ): BaseResponse - @PATCH("/reviews/{reviewId}") //리뷰 수정(글 수정) + @PATCH("/v2/reviews/{reviewId}") //리뷰 수정(글 수정) suspend fun modifyReview( @Path("reviewId") reviewId: Long, @Body request: ModifyReviewRequest, ): BaseResponse - //Todo paging 라이브러리 써보기 - @GET("/reviews") //리뷰 리스트 조회 - suspend fun getReviewList( - @Query("menuType") menuType: String, + @GET("/v2/reviews/list/meal") //리뷰 리스트 조회 + suspend fun getMealReviewList( @Query("mealId") mealId: Long?, + ): BaseResponse + + @GET("/v2/reviews/list/menu") //리뷰 리스트 조회 + suspend fun getMenuReviewList( @Query("menuId") menuId: Long?, -// @Query("lastReviewId") lastReviewId: Long?, - @Query("page") page: Int? = 0, - @Query("size") size: Int? = 20, - @Query("sort") sort: List? = arrayListOf("date", "DESC"), - ): BaseResponse + ): BaseResponse - @GET("/reviews/menus/{menuId}") //고정 메뉴 리뷰 정보 조회(메뉴명, 평점 등등) + @GET("/v2/reviews/statistics/menus/{menuId}") //고정 메뉴 리뷰 정보 조회(메뉴명, 평점 등등) suspend fun getMenuReviewInfo( @Path("menuId") menuId: Long, - ): BaseResponse + ): BaseResponse - @GET("/reviews/meals/{mealId}") //식단(변동 메뉴) 리뷰 정보 조회(메뉴명, 평점 등등) + @GET("/v2/reviews/statistics/meals/{mealId}") //식단(변동 메뉴) 리뷰 정보 조회(메뉴명, 평점 등등) suspend fun getMealReviewInfo( @Path("mealId") mealId: Long, - ): BaseResponse + ): BaseResponse @Multipart @POST("/reviews/upload/image") //리뷰 이미지 업로드 @@ -66,4 +72,19 @@ interface ReviewService { @Part image: MultipartBody.Part, ): BaseResponse + /** + * 메뉴 정보 리스트 조회 + */ + @GET("v2/reviews/meal/valid-for-review/{mealId}") + suspend fun getMenuInfoByMealId( + @Path("mealId") mealId: Long, + ): BaseResponse + + /** + * 내가 쓴 리뷰 + */ + @GET("users/v2/reviews") + suspend fun getMyReviews(): BaseResponse + + } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/service/UserService.kt b/app/src/main/java/com/eatssu/android/data/service/UserService.kt index 20ea1f995..4669fe0fe 100644 --- a/app/src/main/java/com/eatssu/android/data/service/UserService.kt +++ b/app/src/main/java/com/eatssu/android/data/service/UserService.kt @@ -6,7 +6,6 @@ import com.eatssu.android.data.dto.response.BaseResponse import com.eatssu.android.data.dto.response.CollegeResponse import com.eatssu.android.data.dto.response.DepartmentResponse import com.eatssu.android.data.dto.response.MyNickNameResponse -import com.eatssu.android.data.dto.response.MyReviewResponse import com.eatssu.android.data.dto.response.PartnershipResponse import com.eatssu.android.data.dto.response.UserCollegeDepartmentResponse import retrofit2.http.Body @@ -28,8 +27,6 @@ interface UserService { @Query("nickname") nickname: String, ): BaseResponse - @GET("users/reviews") //내가 쓴 리뷰 모아보기 - suspend fun getMyReviews(): BaseResponse @GET("users/mypage") //내 정보 모아보기 suspend fun getMyInfo(): BaseResponse diff --git a/app/src/main/java/com/eatssu/android/di/ServiceModule.kt b/app/src/main/java/com/eatssu/android/di/ServiceModule.kt index ef0d223b6..c06f7ded0 100644 --- a/app/src/main/java/com/eatssu/android/di/ServiceModule.kt +++ b/app/src/main/java/com/eatssu/android/di/ServiceModule.kt @@ -17,11 +17,13 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object ServiceModule { + @Provides @Singleton fun provideOauthService(@NoToken noTokenRetrofit: Retrofit): OauthService { return noTokenRetrofit.create(OauthService::class.java) } + @Provides @Singleton fun provideUserService(retrofit: Retrofit): UserService { @@ -42,8 +44,8 @@ object ServiceModule { @Provides @Singleton - fun provideMealService(@NoToken noTokenRetrofit: Retrofit): MealService { - return noTokenRetrofit.create(MealService::class.java) + fun provideMealService(retrofit: Retrofit): MealService { + return retrofit.create(MealService::class.java) } @Provides @@ -57,4 +59,4 @@ object ServiceModule { fun providePartnershipService(retrofit: Retrofit): PartnershipService { return retrofit.create(PartnershipService::class.java) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/domain/model/Result.kt b/app/src/main/java/com/eatssu/android/domain/model/Result.kt new file mode 100644 index 000000000..6c2863737 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/model/Result.kt @@ -0,0 +1,6 @@ +package com.eatssu.android.domain.model + +sealed class Result { + object Success : Result() + data class Failure(val message: String) : Result() +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/model/Review.kt b/app/src/main/java/com/eatssu/android/domain/model/Review.kt index 818b4c9b0..4c3673348 100644 --- a/app/src/main/java/com/eatssu/android/domain/model/Review.kt +++ b/app/src/main/java/com/eatssu/android/domain/model/Review.kt @@ -3,16 +3,16 @@ package com.eatssu.android.domain.model data class Review( val isWriter: Boolean, val reviewId: Long, - - val menu: String, + val menuList: List, val writerNickname: String, - val mainGrade: Int, - val amountGrade: Int, - val tasteGrade: Int, - val writeDate: String, - val content: String, - val imgUrl: ArrayList?, -) \ No newline at end of file + val imgUrl: String?, +) { + data class Menu( + val menuId: Long, + val name: String, + val isLike: Boolean, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/model/ReviewInfo.kt b/app/src/main/java/com/eatssu/android/domain/model/ReviewInfo.kt index 089af51c4..097eb4d16 100644 --- a/app/src/main/java/com/eatssu/android/domain/model/ReviewInfo.kt +++ b/app/src/main/java/com/eatssu/android/domain/model/ReviewInfo.kt @@ -4,8 +4,6 @@ data class ReviewInfo( var name: String, var reviewCnt: Int, var mainRating: Double, - var amountRating: Double, - var tasteRating: Double, var one: Int, var two: Int, var three: Int, diff --git a/app/src/main/java/com/eatssu/android/domain/model/ReviewWriteData.kt b/app/src/main/java/com/eatssu/android/domain/model/ReviewWriteData.kt new file mode 100644 index 000000000..cf2c5dadd --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/model/ReviewWriteData.kt @@ -0,0 +1,8 @@ +package com.eatssu.android.domain.model + +data class ReviewWriteData( + val rating: Int, + val content: String, + val menuLikes: List, + val imageUrl: String? = null +) diff --git a/app/src/main/java/com/eatssu/android/domain/repository/MealRepository.kt b/app/src/main/java/com/eatssu/android/domain/repository/MealRepository.kt index 1aac5a02d..fd9c4f006 100644 --- a/app/src/main/java/com/eatssu/android/domain/repository/MealRepository.kt +++ b/app/src/main/java/com/eatssu/android/domain/repository/MealRepository.kt @@ -1,7 +1,5 @@ package com.eatssu.android.domain.repository -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.MenuOfMealResponse import kotlinx.coroutines.flow.Flow interface MealRepository { @@ -15,11 +13,4 @@ interface MealRepository { time: String, ): Flow>> - - /** - * MealId를 이용해서 Menu를 찾기 api - */ - suspend fun getMenuInfoByMealId( - mealId: Long, - ): Flow> -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/domain/repository/ReviewRepository.kt b/app/src/main/java/com/eatssu/android/domain/repository/ReviewRepository.kt index 93289d82c..9b6325bf7 100644 --- a/app/src/main/java/com/eatssu/android/domain/repository/ReviewRepository.kt +++ b/app/src/main/java/com/eatssu/android/domain/repository/ReviewRepository.kt @@ -1,47 +1,58 @@ package com.eatssu.android.domain.repository import com.eatssu.android.data.dto.request.ModifyReviewRequest -import com.eatssu.android.data.dto.request.WriteReviewRequest -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.GetMealReviewInfoResponse -import com.eatssu.android.data.dto.response.GetMenuReviewInfoResponse -import com.eatssu.android.data.dto.response.GetReviewListResponse -import com.eatssu.android.data.dto.response.ImageResponse -import kotlinx.coroutines.flow.Flow +import com.eatssu.android.data.dto.request.WriteMealReviewRequest +import com.eatssu.android.data.dto.request.WriteMenuReviewRequest +import com.eatssu.android.domain.model.Review +import com.eatssu.android.domain.model.ReviewInfo import java.io.File interface ReviewRepository { - suspend fun writeReview( - menuId: Long, - body: WriteReviewRequest, - ): Flow> + suspend fun writeMenuReview( + body: WriteMenuReviewRequest, + ) + + suspend fun writeMealReview( + body: WriteMealReviewRequest, + ) suspend fun deleteReview( reviewId: Long, - ): Flow> + ) suspend fun modifyReview( reviewId: Long, body: ModifyReviewRequest, - ): Flow> + ) - suspend fun getReviewList( - menuType: String, - mealId: Long?, + suspend fun getMenuReviewList( menuId: Long?, - ): Flow> + ): List + + suspend fun getMealReviewList( + menuId: Long?, + ): List suspend fun getMenuReviewInfo( menuId: Long, - ): Flow> - + ): ReviewInfo suspend fun getMealReviewInfo( mealId: Long, - ): Flow> + ): ReviewInfo suspend fun getImageString( file: File - ): Flow> -} \ No newline at end of file + ): String + + /** + * MealId를 이용해서 Menu를 찾기 api (+ 리뷰 작성 가능한 메뉴 조회 v2) + */ + suspend fun getMenuInfoByMealId( + mealId: Long, + ): List> + + suspend fun getUserReviews(): List + +} diff --git a/app/src/main/java/com/eatssu/android/domain/repository/UserRepository.kt b/app/src/main/java/com/eatssu/android/domain/repository/UserRepository.kt index e87157ab2..6af0ac740 100644 --- a/app/src/main/java/com/eatssu/android/domain/repository/UserRepository.kt +++ b/app/src/main/java/com/eatssu/android/domain/repository/UserRepository.kt @@ -3,7 +3,6 @@ package com.eatssu.android.domain.repository import com.eatssu.android.data.dto.request.ChangeNicknameRequest import com.eatssu.android.data.dto.response.BaseResponse import com.eatssu.android.data.dto.response.MyNickNameResponse -import com.eatssu.android.data.dto.response.MyReviewResponse import com.eatssu.android.domain.model.College import com.eatssu.android.domain.model.Department import kotlinx.coroutines.flow.Flow @@ -18,7 +17,6 @@ interface UserRepository { nickname: String, ): Flow> - suspend fun getUserReviews(): Flow> suspend fun getUserNickName(): Flow> suspend fun signOut(): Flow> diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/menu/GetMenuNameListOfMealUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/menu/GetMenuNameListOfMealUseCase.kt index 694d5fae9..8a398bfcd 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/menu/GetMenuNameListOfMealUseCase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/menu/GetMenuNameListOfMealUseCase.kt @@ -1,14 +1,12 @@ package com.eatssu.android.domain.usecase.menu -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.MenuOfMealResponse -import com.eatssu.android.domain.repository.MealRepository -import kotlinx.coroutines.flow.Flow +import com.eatssu.android.domain.repository.ReviewRepository import javax.inject.Inject class GetMenuNameListOfMealUseCase @Inject constructor( - private val mealRepository: MealRepository, + private val reviewRepository: ReviewRepository ) { - suspend operator fun invoke(menuId: Long): Flow> = - mealRepository.getMenuInfoByMealId(menuId) + suspend operator fun invoke(menuId: Long): List> { + return reviewRepository.getMenuInfoByMealId(menuId) + } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/review/DeleteReviewUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/review/DeleteReviewUseCase.kt index dc0e863a8..b1a46184a 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/DeleteReviewUseCase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/review/DeleteReviewUseCase.kt @@ -1,13 +1,12 @@ package com.eatssu.android.domain.usecase.review -import com.eatssu.android.data.dto.response.BaseResponse import com.eatssu.android.domain.repository.ReviewRepository -import kotlinx.coroutines.flow.Flow import javax.inject.Inject class DeleteReviewUseCase @Inject constructor( private val reviewRepository: ReviewRepository, ) { - suspend operator fun invoke(reviewId: Long): Flow> = + suspend operator fun invoke(reviewId: Long) { reviewRepository.deleteReview(reviewId) + } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetImageUrlUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/review/GetImageUrlUseCase.kt index 4561a8998..d6bf6d00a 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetImageUrlUseCase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/review/GetImageUrlUseCase.kt @@ -1,9 +1,6 @@ package com.eatssu.android.domain.usecase.review -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.ImageResponse import com.eatssu.android.domain.repository.ReviewRepository -import kotlinx.coroutines.flow.Flow import java.io.File import javax.inject.Inject @@ -12,6 +9,6 @@ class GetImageUrlUseCase @Inject constructor( ) { suspend operator fun invoke( file: File - ): Flow> = + ): String = reviewRepository.getImageString(file) } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMealReviewInfoUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMealReviewInfoUseCase.kt deleted file mode 100644 index 9c270732f..000000000 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMealReviewInfoUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.eatssu.android.domain.usecase.review - -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.GetMealReviewInfoResponse -import com.eatssu.android.domain.repository.ReviewRepository -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class GetMealReviewInfoUseCase @Inject constructor( - private val reviewRepository: ReviewRepository, -) { - suspend operator fun invoke(mealId: Long): Flow> = - reviewRepository.getMealReviewInfo(mealId) -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMealReviewListUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMealReviewListUseCase.kt deleted file mode 100644 index 157b36c6b..000000000 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMealReviewListUseCase.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.eatssu.android.domain.usecase.review - -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.GetReviewListResponse -import com.eatssu.android.data.enums.MenuType -import com.eatssu.android.domain.repository.ReviewRepository -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class GetMealReviewListUseCase @Inject constructor( - private val reviewRepository: ReviewRepository, -) { - suspend operator fun invoke( - mealId: Long?, - ): Flow> = - reviewRepository.getReviewList(MenuType.VARIABLE.toString(), mealId, 0) -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMenuReviewInfoUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMenuReviewInfoUseCase.kt deleted file mode 100644 index 6664e4e7f..000000000 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMenuReviewInfoUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.eatssu.android.domain.usecase.review - -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.GetMenuReviewInfoResponse -import com.eatssu.android.domain.repository.ReviewRepository -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class GetMenuReviewInfoUseCase @Inject constructor( - private val reviewRepository: ReviewRepository, -) { - suspend operator fun invoke(menuId: Long): Flow> = - reviewRepository.getMenuReviewInfo(menuId) -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMenuReviewListUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMenuReviewListUseCase.kt deleted file mode 100644 index 5ed6df593..000000000 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMenuReviewListUseCase.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.eatssu.android.domain.usecase.review - -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.GetReviewListResponse -import com.eatssu.android.data.enums.MenuType -import com.eatssu.android.domain.repository.ReviewRepository -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class GetMenuReviewListUseCase @Inject constructor( - private val reviewRepository: ReviewRepository, -) { - suspend operator fun invoke( - menuId: Long?, - ): Flow> = - reviewRepository.getReviewList(MenuType.FIXED.toString(), 0, menuId) -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMyReviewsUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMyReviewsUseCase.kt deleted file mode 100644 index f8bb6c37b..000000000 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMyReviewsUseCase.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.eatssu.android.domain.usecase.review - -import com.eatssu.android.data.dto.response.BaseResponse -import com.eatssu.android.data.dto.response.MyReviewResponse -import com.eatssu.android.domain.repository.UserRepository -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class GetMyReviewsUseCase @Inject constructor( - private val userRepository: UserRepository, -) { - suspend operator fun invoke(): Flow> = - userRepository.getUserReviews() -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetReviewInfoUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/review/GetReviewInfoUseCase.kt new file mode 100644 index 000000000..08d68e040 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/usecase/review/GetReviewInfoUseCase.kt @@ -0,0 +1,16 @@ +package com.eatssu.android.domain.usecase.review + +import com.eatssu.android.data.enums.MenuType +import com.eatssu.android.domain.model.ReviewInfo +import com.eatssu.android.domain.repository.ReviewRepository +import javax.inject.Inject + +class GetReviewInfoUseCase @Inject constructor( + private val reviewRepository: ReviewRepository, +) { + suspend operator fun invoke(menuType: MenuType, itemId: Long): ReviewInfo = + when (menuType) { + MenuType.FIXED -> reviewRepository.getMenuReviewInfo(itemId) + MenuType.VARIABLE -> reviewRepository.getMealReviewInfo(itemId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetReviewListUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/review/GetReviewListUseCase.kt new file mode 100644 index 000000000..c42d10bea --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/usecase/review/GetReviewListUseCase.kt @@ -0,0 +1,16 @@ +package com.eatssu.android.domain.usecase.review + +import com.eatssu.android.data.enums.MenuType +import com.eatssu.android.domain.model.Review +import com.eatssu.android.domain.repository.ReviewRepository +import javax.inject.Inject + +class GetReviewListUseCase @Inject constructor( + private val reviewRepository: ReviewRepository, +) { + suspend operator fun invoke(menuType: MenuType, itemId: Long): List = + when (menuType) { + MenuType.FIXED -> reviewRepository.getMenuReviewList(itemId) + MenuType.VARIABLE -> reviewRepository.getMealReviewList(itemId) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/review/ModifyReviewUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/review/ModifyReviewUseCase.kt index a6a3e3de8..9a87e706e 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/ModifyReviewUseCase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/review/ModifyReviewUseCase.kt @@ -1,9 +1,9 @@ package com.eatssu.android.domain.usecase.review import com.eatssu.android.data.dto.request.ModifyReviewRequest -import com.eatssu.android.data.dto.response.BaseResponse +import com.eatssu.android.data.dto.request.WriteMenuReviewRequest +import com.eatssu.android.domain.model.ReviewWriteData import com.eatssu.android.domain.repository.ReviewRepository -import kotlinx.coroutines.flow.Flow import javax.inject.Inject class ModifyReviewUseCase @Inject constructor( @@ -11,7 +11,16 @@ class ModifyReviewUseCase @Inject constructor( ) { suspend operator fun invoke( reviewId: Long, - body: ModifyReviewRequest, - ): Flow> = - reviewRepository.modifyReview(reviewId, body) + reviewData: ReviewWriteData, + ) { + val request = ModifyReviewRequest( + content = reviewData.content, + rating = reviewData.rating, + menuLikes = WriteMenuReviewRequest.MenuLike( + menuId = reviewData.menuLikes.firstOrNull(), + isLike = true, + ) + ) + reviewRepository.modifyReview(reviewId, request) + } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/review/WriteReviewUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/review/WriteReviewUseCase.kt index 98c33ad04..d52b619d0 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/WriteReviewUseCase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/review/WriteReviewUseCase.kt @@ -1,14 +1,56 @@ package com.eatssu.android.domain.usecase.review -import com.eatssu.android.data.dto.request.WriteReviewRequest -import com.eatssu.android.data.dto.response.BaseResponse +import com.eatssu.android.data.dto.request.WriteMealReviewRequest +import com.eatssu.android.data.dto.request.WriteMenuReviewRequest +import com.eatssu.android.data.enums.MenuType +import com.eatssu.android.domain.model.Result +import com.eatssu.android.domain.model.ReviewWriteData import com.eatssu.android.domain.repository.ReviewRepository -import kotlinx.coroutines.flow.Flow import javax.inject.Inject class WriteReviewUseCase @Inject constructor( private val reviewRepository: ReviewRepository, ) { - suspend operator fun invoke(menuId: Long, body: WriteReviewRequest): Flow> = - reviewRepository.writeReview(menuId, body) + suspend operator fun invoke( + menuType: MenuType, + itemId: Long, + reviewData: ReviewWriteData + ): Result { + return try { + when (menuType) { + MenuType.FIXED -> { + val request = WriteMenuReviewRequest( + mainRating = reviewData.rating, + content = reviewData.content, + imageUrl = reviewData.imageUrl, + menuLike = WriteMenuReviewRequest.MenuLike( + menuId = reviewData.menuLikes.firstOrNull(), + isLike = true, + ) + ) + reviewRepository.writeMenuReview(request) + Result.Success + } + + MenuType.VARIABLE -> { + val request = WriteMealReviewRequest( + mealId = itemId.toInt(), + rating = reviewData.rating, + content = reviewData.content, + imageUrls = if (reviewData.imageUrl != null) arrayListOf(reviewData.imageUrl) else arrayListOf(), + menuLikes = reviewData.menuLikes.map { menuLike -> + WriteMealReviewRequest.MenuLikes( + menuId = menuLike, + isLike = true, + ) + } + ) + reviewRepository.writeMealReview(request) + Result.Success + } + } + } catch (e: Exception) { + Result.Failure(e.message ?: "리뷰 작성에 실패했습니다.") + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/user/GetMyReviewsUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/user/GetMyReviewsUseCase.kt new file mode 100644 index 000000000..1594a099b --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/usecase/user/GetMyReviewsUseCase.kt @@ -0,0 +1,12 @@ +package com.eatssu.android.domain.usecase.user + +import com.eatssu.android.domain.model.Review +import com.eatssu.android.domain.repository.UserRepository +import javax.inject.Inject + +class GetMyReviewsUseCase @Inject constructor( + private val userRepository: UserRepository, +) { + suspend operator fun invoke(): List = + userRepository.getUserReviews() +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuSubAdapter.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuSubAdapter.kt index 72984efd5..1c5c50ce4 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuSubAdapter.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuSubAdapter.kt @@ -1,6 +1,7 @@ package com.eatssu.android.presentation.cafeteria.menu import android.content.Intent +import android.os.SystemClock import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup @@ -9,7 +10,7 @@ import androidx.recyclerview.widget.RecyclerView import com.eatssu.android.data.enums.MenuType import com.eatssu.android.databinding.ItemMenuBinding import com.eatssu.android.domain.model.Menu -import com.eatssu.android.presentation.cafeteria.review.list.ReviewActivity +import com.eatssu.android.presentation.cafeteria.review.ReviewComposeActivity class MenuSubAdapter( @@ -21,6 +22,46 @@ class MenuSubAdapter( inner class ViewHolder(private val binding: ItemMenuBinding) : RecyclerView.ViewHolder(binding.root) { + private var lastClickTimeMs: Long = 0L + + init { + binding.root.setOnClickListener { + val position = bindingAdapterPosition + if (position == RecyclerView.NO_POSITION) return@setOnClickListener + + val now = SystemClock.elapsedRealtime() + if (now - lastClickTimeMs < 600) return@setOnClickListener + lastClickTimeMs = now + + // Prevent duplicate clicks at the View level + if (!binding.root.isEnabled) return@setOnClickListener + binding.root.isEnabled = false + + val item = dataList[position] + val intent = Intent(binding.root.context, ReviewComposeActivity::class.java) + + when (menuType) { + MenuType.FIXED -> { + Log.d("SubMenuAdapter", "고정메뉴${item.name}") + intent.putExtra("itemId", item.id) + intent.putExtra("itemName", item.name) + intent.putExtra("menuType", MenuType.FIXED.toString()) + } + + MenuType.VARIABLE -> { + Log.d("SubMenuAdapter", "변동메뉴${item.name}") + intent.putExtra("itemId", item.id) + intent.putExtra("itemName", item.name) + intent.putExtra("menuType", MenuType.VARIABLE.toString()) + } + } + ContextCompat.startActivity(binding.root.context, intent, null) + + // Re-enable after short delay + binding.root.postDelayed({ binding.root.isEnabled = true }, 800) + } + } + fun bind(position: Int) { binding.tvMenu.text = dataList[position].name binding.tvPrice.text = dataList[position].price.toString() @@ -42,31 +83,6 @@ class MenuSubAdapter( override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.bind(position) - - //intent 사용 - holder.itemView.setOnClickListener { - val intent = Intent(holder.itemView.context, ReviewActivity::class.java) - - when (menuType) { - MenuType.FIXED -> { - Log.d("SubMenuAdapter", "고정메뉴${dataList[position].name}") - intent.putExtra("itemId", dataList[position].id) - intent.putExtra("itemName", dataList[position].name) - intent.putExtra("menuType", MenuType.FIXED.toString()) - } - - MenuType.VARIABLE -> { - Log.d("SubMenuAdapter", "변동메뉴${dataList[position].name}") - intent.putExtra("itemId", dataList[position].id) - intent.putExtra("itemName", dataList[position].name) - intent.putExtra("menuType", MenuType.VARIABLE.toString()) - } - } - ContextCompat.startActivity(holder.itemView.context, intent, null) - - } - - } override fun getItemCount(): Int = dataList.size diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewComposeActivity.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewComposeActivity.kt new file mode 100644 index 000000000..940fa6220 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewComposeActivity.kt @@ -0,0 +1,45 @@ +package com.eatssu.android.presentation.cafeteria.review + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.navigation.compose.rememberNavController +import com.eatssu.android.data.enums.MenuType +import com.eatssu.design_system.theme.EatssuTheme +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber +import kotlin.properties.Delegates + +@AndroidEntryPoint +class ReviewComposeActivity : ComponentActivity() { + + private lateinit var menuType: String + private var itemId by Delegates.notNull() + private lateinit var itemName: String + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + EatssuTheme { + val navHostController = rememberNavController() + + ReviewNav( + navHostController = navHostController, + menuType = MenuType.valueOf(menuType), + menuName = itemName, + id = itemId, + onExit = { finish() } + ) + } + } + getIntents() + } + + private fun getIntents() { //todo 추후 변경 + menuType = intent.getStringExtra("menuType").toString() + itemId = intent.getLongExtra("itemId", 0) + itemName = intent.getStringExtra("itemName").toString().replace(Regex("[\\[\\]]"), "") + + Timber.d("메뉴는 $itemName $menuType $itemId") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewNav.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewNav.kt new file mode 100644 index 000000000..8a66dff19 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewNav.kt @@ -0,0 +1,98 @@ +package com.eatssu.android.presentation.cafeteria.review + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.eatssu.android.data.enums.MenuType +import com.eatssu.android.presentation.cafeteria.review.list.ReviewListScreen +import com.eatssu.android.presentation.cafeteria.review.modify.ModifyReviewScreen +import com.eatssu.android.presentation.cafeteria.review.write.ReviewWriteScreen + +object ReviewNav { + const val List = "list" + const val Write = "write" + const val Modify = "modify" +} + +@Composable +fun ReviewNav( + navHostController: NavHostController = rememberNavController(), + menuName: String, + menuType: MenuType, + id: Long, + onExit: () -> Unit = {} +) { + + NavHost( + navController = navHostController, + startDestination = ReviewNav.List + ) { + // 리뷰 리스트 + composable(ReviewNav.List) { + ReviewListScreen( + menuName = menuName, + menuType = menuType, + id = id, + onBack = { onExit() }, + onModifyClick = { review -> + // 선택된 리뷰 데이터를 Modify 화면으로 전달 + navHostController.currentBackStackEntry?.savedStateHandle?.apply { + set("reviewId", review.reviewId) + set("initialRating", review.mainGrade) + set("initialContent", review.content) + // 메뉴는 (id, name) 쌍이 필요하므로 이름만 전달하는 경우, id 매핑은 서버/화면에서 보유하고 있어야 합니다. + // 여기서는 임시로 name만 전달. Modify에서 Pair로 이미 있는 경우 그걸 넣어주세요. + set("menuList", ArrayList(review.menuList)) +// set("likeMenuList", ArrayList(review.likeMenuList ?: emptyList())) + } + + navHostController.navigate(ReviewNav.Modify) { launchSingleTop = true } + }, + onWriteButtonClick = { menuName -> + // SavedStateHandle을 사용하여 menuName 전달 + navHostController.currentBackStackEntry?.savedStateHandle?.set( + "menuName", + menuName + ) + + navHostController.navigate(ReviewNav.Write) { + launchSingleTop = true + } + } + ) + } + + // 리뷰 작성 + composable(ReviewNav.Write) { backStackEntry -> + ReviewWriteScreen( + menuType = menuType, + menuName = menuName, + id = id, + navController = navHostController, + onBack = { navHostController.popBackStack() }, + ) + } + + // 리뷰 작성 + composable(ReviewNav.Modify) { backStackEntry -> + val prev = navHostController.previousBackStackEntry?.savedStateHandle + val reviewId = prev?.get("reviewId") ?: 0L + val initialRating = prev?.get("initialRating") ?: 0 + val initialContent = prev?.get("initialContent") ?: "" + val menuNames = prev?.get>("menuList") ?: arrayListOf() + val likeMenuList = prev?.get>("likeMenuList") ?: arrayListOf() + + ModifyReviewScreen( + reviewId = reviewId, + initialRating = initialRating, + initialContent = initialContent, + menuList = menuNames.mapIndexed { index, name -> index.toLong() to name }, + likedNames = likeMenuList, + onBack = { navHostController.popBackStack() }, + navController = navHostController + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/MyReviewBottomSheet.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/MyReviewBottomSheet.kt new file mode 100644 index 000000000..25fd0e9c0 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/MyReviewBottomSheet.kt @@ -0,0 +1,154 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.eatssu.android.presentation.cafeteria.review.list + + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.eatssu.android.R +import com.eatssu.design_system.theme.Black +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.Gray400 +import com.eatssu.design_system.theme.Gray500 +import com.eatssu.design_system.theme.Gray600 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MyReviewBottomSheet( + onDismiss: () -> Unit, + onModify: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + LaunchedEffect(Unit) { + sheetState.show() + } + + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + containerColor = White, + sheetState = sheetState, + dragHandle = null + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(White) + ) { + // 상단 회색 바 + Spacer(modifier = Modifier.height(14.dp)) + + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(30.dp) + .height(2.dp) + .background( + color = Gray400, + shape = RoundedCornerShape(10.dp) + ) + ) + + Text( + text = "리뷰 설정", + style = EatssuTheme.typography.body2, + color = Gray600, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 22.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onModify) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_pencil), + contentDescription = null, + tint = Gray500, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(20.dp)) + Text( + text = "수정하기", + style = EatssuTheme.typography.body2, + color = Black + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = onDelete) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_remove), + contentDescription = null, + tint = Gray500, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(20.dp)) + Text( + text = "삭제하기", + style = EatssuTheme.typography.body2, + color = Black + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ReviewActionsSheetPreview() { + EatssuTheme { + Surface { + MyReviewBottomSheet(onDismiss = {}, onModify = {}, onDelete = {}) + } + } +} + +@Preview( + showBackground = true, + name = "Dark", + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun ReviewActionsSheetDarkPreview() { + EatssuTheme { + Surface { + MyReviewBottomSheet(onDismiss = {}, onModify = {}, onDelete = {}) + } + } +} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/OthersReviewBottomSheet.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/OthersReviewBottomSheet.kt new file mode 100644 index 000000000..f497e53f3 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/OthersReviewBottomSheet.kt @@ -0,0 +1,132 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.eatssu.android.presentation.cafeteria.review.list + + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.eatssu.android.R +import com.eatssu.design_system.theme.Black +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.Gray400 +import com.eatssu.design_system.theme.Gray500 +import com.eatssu.design_system.theme.Gray600 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OthersReviewBottomSheet( + onDismiss: () -> Unit, + onReport: () -> Unit, + modifier: Modifier = Modifier +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + LaunchedEffect(Unit) { + sheetState.show() + } + + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + containerColor = White, + sheetState = sheetState, + dragHandle = null + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(White) + ) { + // 상단 회색 바 + Spacer(modifier = Modifier.height(14.dp)) + + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .width(30.dp) + .height(2.dp) + .background( + color = Gray400, + shape = RoundedCornerShape(10.dp) + ) + ) + + Text( + text = "리뷰 설정", + style = EatssuTheme.typography.body2, + color = Gray600, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 22.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.Button, onClick = { onReport() }) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_siren), + contentDescription = null, + tint = Gray500, + modifier = Modifier.size(12.dp) + ) + Spacer(modifier = Modifier.width(20.dp)) + Text( + text = "수정하기", + style = EatssuTheme.typography.body2, + color = Black + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ReviewActionsSheetPreview() { + EatssuTheme { + Surface { + OthersReviewBottomSheet(onDismiss = {}, onReport = {}) + } + } +} + +@Preview( + showBackground = true, + name = "Dark", + uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES +) +@Composable +private fun ReviewActionsSheetDarkPreview() { + EatssuTheme { + Surface { + OthersReviewBottomSheet(onDismiss = {}, onReport = {}) + } + } +} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewActivity.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewActivity.kt deleted file mode 100644 index b028a0960..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewActivity.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.list - -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.activity.viewModels -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.eatssu.android.R -import com.eatssu.android.data.enums.MenuType -import com.eatssu.android.databinding.ActivityReviewBinding -import com.eatssu.android.domain.model.Review -import com.eatssu.android.presentation.base.BaseActivity -import com.eatssu.android.presentation.common.MyReviewBottomSheetFragment -import com.eatssu.android.presentation.common.OthersBottomSheetFragment -import com.eatssu.android.presentation.cafeteria.review.write.ReviewWriteRateActivity -import com.eatssu.android.presentation.cafeteria.review.write.menu.ReviewWriteMenuActivity -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import timber.log.Timber -import kotlin.properties.Delegates - -@AndroidEntryPoint -class ReviewActivity : - BaseActivity(ActivityReviewBinding::inflate), - MyReviewBottomSheetFragment.OnReviewDeletedListener { - - private val reviewViewModel: ReviewViewModel by viewModels() - - private lateinit var menuType: String - private var itemId by Delegates.notNull() - - private lateinit var itemName: String - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - toolbarTitle.text = "리뷰" // 툴바 제목 설정 - - getIndex() - lodeData() - bindData() - setClickListener() - } - - override fun onResume() { - super.onResume() - - - //todo 이거 안하면 바로바로 갱신이 안되는디 - lodeData() - bindData() - } - - - private fun getIndex() { - //get menuId - menuType = intent.getStringExtra("menuType").toString() - itemId = intent.getLongExtra("itemId", 0) - itemName = intent.getStringExtra("itemName").toString().replace(Regex("[\\[\\]]"), "") - - Timber.d("메뉴는 $itemName $menuType $itemId") - } - - private fun lodeData() { - //Todo 여기서는 메뉴 타입이 뭔지 몰라도 됨. 추상화 해도 됨 - - reviewViewModel.loadReview(menuType, itemId) - } - - - private fun bindData() { - lifecycleScope.launch { - reviewViewModel.uiState.collectLatest { - if (!it.error && !it.loading) { - if (it.isEmpty) { - //리뷰 없어도 메뉴명은 있음 - Timber.d("리뷰가 없음") - binding.llNonReview.visibility = View.VISIBLE - binding.rvReview.visibility = View.INVISIBLE - - it.reviewInfo?.apply { - binding.tvMenu.text = name.replace(Regex("[\\[\\]]"), "") - } - - } else { //리뷰 있다. - Timber.d("리뷰가 있음") - binding.llNonReview.visibility = View.INVISIBLE - binding.rvReview.visibility = View.VISIBLE - - it.reviewList?.let { reviewList -> setAdapter(reviewList = reviewList) } - - it.reviewInfo?.apply { - - Timber.d(it.reviewInfo.toString()) - - binding.tvMenu.text = name.replace(Regex("[\\[\\]]"), "") - binding.tvReviewNumCount.text = reviewCnt.toString() - binding.tvRate.text = String.format("%.1f", mainRating) - - val totalReviewCount = reviewCnt - binding.progressBar1.max = totalReviewCount - binding.progressBar2.max = totalReviewCount - binding.progressBar3.max = totalReviewCount - binding.progressBar4.max = totalReviewCount - binding.progressBar5.max = totalReviewCount - - binding.progressBar1.progress = one - binding.progressBar2.progress = two - binding.progressBar3.progress = three - binding.progressBar4.progress = four - binding.progressBar5.progress = five - } - } - } - } - } - } - - - private fun setAdapter(reviewList: List) { - - val adapter = ReviewAdapter() - adapter.submitList(reviewList) - - val linearLayoutManager = LinearLayoutManager(this) - - adapter.setOnItemClickListener(object : - ReviewAdapter.OnItemClickListener { - - override fun onMyReviewClicked(view: View, reviewData: Review) { - onMyReviewClicked(review = reviewData) - } - - override fun onOthersReviewClicked(view: View, reviewData: Review) { - onOthersReviewClicked(reviewData = reviewData) - } - }) - - binding.rvReview.adapter = adapter - binding.rvReview.layoutManager = linearLayoutManager - binding.rvReview.setHasFixedSize(true) - } - - - private fun setClickListener() { - when (menuType) { - MenuType.FIXED.name -> { - binding.btnNextReview.setOnClickListener { - val intent = Intent(this, ReviewWriteRateActivity::class.java) - intent.putExtra("itemId", itemId) - intent.putExtra("itemName", itemName) - intent.putExtra("menuType", menuType) - startActivity(intent) - } - } - - MenuType.VARIABLE.name -> { - binding.btnNextReview.setOnClickListener { - val intent = Intent(this, ReviewWriteMenuActivity::class.java) - intent.putExtra("itemId", itemId) - intent.putExtra("menuType", menuType) - startActivity(intent) - } - } - - else -> { - Timber.d("잘못된 식당 정보입니다.") - } - } - } - - - fun onMyReviewClicked(review: Review) { - val modalBottomSheet = MyReviewBottomSheetFragment().apply { - arguments = Bundle().apply { - putLong("reviewId", review.reviewId) - putString("menu", review.menu) - putString("content", review.content) - putInt("mainGrade", review.mainGrade) - putInt("amountGrade", review.amountGrade) - putInt("tasteGrade", review.tasteGrade) - } - onReviewDeletedListener = this@ReviewActivity - } - modalBottomSheet.setStyle( - DialogFragment.STYLE_NORMAL, - R.style.RoundCornerBottomSheetDialogTheme - ) - modalBottomSheet.show(supportFragmentManager, "Open Bottom Sheet") - } - - fun onOthersReviewClicked(reviewData: Review) { - val modalBottomSheet = OthersBottomSheetFragment() - modalBottomSheet.setStyle( - DialogFragment.STYLE_NORMAL, - R.style.RoundCornerBottomSheetDialogTheme - ) - - modalBottomSheet.arguments = Bundle().apply { - putLong("reviewId", reviewData.reviewId) - putString("menu", reviewData.menu) - } - - modalBottomSheet.show(supportFragmentManager, "Open Bottom Sheet") - } - - - override fun onReviewDeleted() { - lodeData() - bindData() - } -} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewAdapter.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewAdapter.kt deleted file mode 100644 index 1810458e8..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewAdapter.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.list - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.eatssu.android.databinding.ItemReviewBinding -import com.eatssu.android.domain.model.Review - - -class ReviewAdapter : - ListAdapter(ReviewDiffCallback()) { - - interface OnItemClickListener { - fun onMyReviewClicked(view: View, reviewData: Review) - fun onOthersReviewClicked(view: View, reviewData: Review) - } - - private lateinit var mOnItemClickListener: OnItemClickListener - - fun setOnItemClickListener(onItemClickListener: OnItemClickListener) { - mOnItemClickListener = onItemClickListener - } - - inner class ViewHolder(private val binding: ItemReviewBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(data: Review) { - binding.tvWriterNickname.text = data.writerNickname - binding.tvReviewItemComment.text = data.content - binding.tvReviewItemDate.text = data.writeDate - binding.tvMenuName.text = data.menu - binding.rbRate.rating = data.mainGrade.toFloat() - - val firstImageUrl = data.imgUrl?.firstOrNull() - - if (!firstImageUrl.isNullOrEmpty()) { - Glide.with(itemView) - .load(firstImageUrl) - .into(binding.ivReviewPhoto) - binding.ivReviewPhoto.visibility = View.VISIBLE - binding.cvPhotoReview.visibility = View.VISIBLE - } else { - binding.ivReviewPhoto.visibility = View.GONE - binding.cvPhotoReview.visibility = View.GONE - } - - binding.btnDetail.setOnClickListener { v: View -> - if (data.isWriter) { - mOnItemClickListener.onMyReviewClicked(v, data) - } else { - mOnItemClickListener.onOthersReviewClicked(v, data) - } - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val binding = - ItemReviewBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ViewHolder(binding) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val review = getItem(position) // `ListAdapter`에서 제공 - holder.bind(review) - } -} - -class ReviewDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Review, newItem: Review): Boolean { - // 고유 식별자를 비교 (예: id) - return oldItem.reviewId == newItem.reviewId - } - - override fun areContentsTheSame(oldItem: Review, newItem: Review): Boolean { - // 객체의 내용 전체를 비교 - return oldItem == newItem - } -} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt new file mode 100644 index 000000000..4fe74e4df --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt @@ -0,0 +1,562 @@ +package com.eatssu.android.presentation.cafeteria.review.list + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.eatssu.android.R +import com.eatssu.android.data.enums.MenuType +import com.eatssu.android.domain.model.Review +import com.eatssu.android.domain.model.ReviewInfo +import com.eatssu.android.presentation.UiEvent +import com.eatssu.android.presentation.UiState +import com.eatssu.android.presentation.cafeteria.review.list.component.ReviewItem +import com.eatssu.android.presentation.cafeteria.review.list.component.ReviewProgressBar +import com.eatssu.android.presentation.util.showToast +import com.eatssu.design_system.component.EatSsuButton +import com.eatssu.design_system.component.EatSsuTopBar +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.Gray100 +import com.eatssu.design_system.theme.Gray600 +import com.eatssu.design_system.theme.Primary +import com.eatssu.design_system.theme.Star +import timber.log.Timber + +@Composable +fun ReviewListScreen( + modifier: Modifier = Modifier, + viewModel: ReviewListViewModel = hiltViewModel(), + menuType: MenuType, + menuName: String, + id: Long, + onBack: () -> Unit = {}, + onWriteButtonClick: (menuName: String) -> Unit, // menuName을 인자로 받도록 수정 + onModifyClick: (Review) -> Unit, +) { + val context = LocalContext.current + + LaunchedEffect(key1 = menuType, key2 = id) { + viewModel.getReview(menuType, id) + } + + val reviewListState by viewModel.uiState.collectAsStateWithLifecycle() + val uiEvent by viewModel.uiEvent.collectAsStateWithLifecycle(initialValue = null) + + when (uiEvent) { + is UiEvent.ShowToast -> { + context.showToast((uiEvent as UiEvent.ShowToast).message) + } + } + + ReviewListScreen( + uiState = reviewListState, + modifier = modifier, + menuName = menuName, + onBack = onBack, + onReviewWriteButtonClick = onWriteButtonClick, + onModifyClick = onModifyClick, + onDeleteClick = { reviewId -> viewModel.deleteReview(reviewId) } + ) +} + +@Composable +internal fun ReviewListScreen( + uiState: UiState, + modifier: Modifier = Modifier, + menuName: String, + onBack: () -> Unit = {}, + onReviewWriteButtonClick: (menuName: String) -> Unit, + onModifyClick: (Review) -> Unit, + onDeleteClick: (reviewId: Long) -> Unit, +) { + val context = LocalContext.current + + var showMyBottomSheet by remember { mutableStateOf(false) } + var showOthersBottomSheet by remember { mutableStateOf(false) } + + var selectedReviewId by remember { mutableStateOf(null) } + var selectedReview by remember { mutableStateOf(null) } + + if (showOthersBottomSheet && selectedReviewId != null) { + OthersReviewBottomSheet( + onDismiss = { showOthersBottomSheet = false; selectedReviewId = null }, + onReport = { + val intent = android.content.Intent( + context, + com.eatssu.android.presentation.cafeteria.review.report.ReportActivity::class.java + ) + intent.putExtra("reviewId", selectedReviewId) + context.startActivity(intent) + showOthersBottomSheet = false + selectedReviewId = null + } + ) + } + + if (showMyBottomSheet && selectedReviewId != null) { + MyReviewBottomSheet( + onDismiss = { showMyBottomSheet = false; selectedReviewId = null }, + onModify = { + selectedReview?.let { onModifyClick(it) } + showMyBottomSheet = false + selectedReviewId = null + selectedReview = null + }, + onDelete = { + selectedReviewId?.let { onDeleteClick(it) } + showMyBottomSheet = false + selectedReviewId = null + selectedReview = null + } + ) + } + + Scaffold( + topBar = { + EatSsuTopBar( + title = "리뷰", + onBack = onBack + ) + }, + bottomBar = { // 하단에 버튼을 고정하기 위함 + EatSsuButton( + text = "리뷰 작성하기", + onClick = { + // info.name을 전달 (메뉴명이 +로 합쳐진 값) + val menuName = (uiState as? UiState.Success)?.data?.reviewInfo?.name ?: "" + Timber.d("ReviewListScreen - info.name: '${(uiState as? UiState.Success)?.data?.reviewInfo?.name}', menuName: '$menuName'") + onReviewWriteButtonClick(menuName) + }, + modifier = Modifier + .padding(24.dp) + ) + }, + ) { innerPadding -> + Surface(modifier = modifier + .padding(innerPadding) + .fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + when (uiState) { + + is UiState.Init, UiState.Loading -> { + ReviewInfoContent( + ReviewInfo( + name = menuName, + reviewCnt = 0, + five = 0, + four = 0, + three = 0, + two = 0, + one = 0, + mainRating = 0.0, + ) + ) + Column(modifier = Modifier.fillMaxSize()) { + Spacer( + modifier = Modifier + .padding(vertical = 16.dp) + .fillMaxWidth() // 가로 전체 차지 + .height(16.dp) + .background(Gray100) // 배경색 적용 + ) + + Row(Modifier.padding(horizontal = 24.dp)) { + Text( + "리뷰", + style = EatssuTheme.typography.h2, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "0", + color = Primary, + style = EatssuTheme.typography.h2, + ) + } + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + + + is UiState.Success -> { + val info = uiState.data?.reviewInfo + val reviewList = uiState.data?.reviewList ?: emptyList() + + ReviewInfoContent(info) + + Column(modifier = Modifier.fillMaxSize()) { + Spacer( + modifier = Modifier + .padding(vertical = 16.dp) + .fillMaxWidth() // 가로 전체 차지 + .height(16.dp) + .background(Gray100) // 배경색 적용 + ) + + Row(Modifier.padding(horizontal = 24.dp)) { + Text( + "리뷰", + style = EatssuTheme.typography.h2, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "${info?.reviewCnt}", + color = Primary, + style = EatssuTheme.typography.h2, + ) + } + + if (uiState.data?.reviewList?.size == 0) { + EmptyReviewContent( + modifier = Modifier.fillMaxSize(), + ) + } else { + reviewList.forEach { item -> + ReviewItem( + modifier = Modifier.padding(horizontal = 24.dp), + isWriter = item.isWriter, + writeName = item.writerNickname, + writeDate = item.writeDate, + content = item.content, + rating = item.mainGrade, + menuList = item.menuList, + imgUrl = item.imgUrl, + onMoreClick = { + if (item.isWriter) { + showMyBottomSheet = true + selectedReviewId = item.reviewId + selectedReview = item + Timber.d("ReviewListScreen - onMoreClick: 내 리뷰") + } else { + showOthersBottomSheet = true + Timber.d("ReviewListScreen - onMoreClick: 다른 사람 리뷰") + selectedReviewId = item.reviewId + selectedReview = item + } + } + ) + } + } + } + } + + UiState.Error -> { + // TODO: 에러 UI + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } +} + +@Composable +fun ReviewInfoContent(info: ReviewInfo?) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Gray100) + .padding(horizontal = 16.dp, vertical = 13.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row { + Icon( + painter = painterResource(R.drawable.ic_cafeteria_menu_selected), + modifier = Modifier.size(24.dp), + tint = Primary, + contentDescription = "map restaurant icon" + ) + Spacer(Modifier.width(4.dp)) + Text( + "오늘의 메뉴", + style = EatssuTheme.typography.subtitle1 + ) + } + Spacer(modifier = Modifier.height(12.dp)) + + Text( + info?.name.toString(), + textAlign = TextAlign.Center, + style = EatssuTheme.typography.body1 + ) + } + } + + Box( + modifier = Modifier + .height(12.dp) + .background(Gray100) + .padding(vertical = 16.dp) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = com.eatssu.design_system.R.drawable.ic_star_24), + contentDescription = null, + modifier = Modifier + .size(24.dp), + tint = Star + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + info?.mainRating.toString(), + modifier = Modifier.align(Alignment.CenterVertically), + style = EatssuTheme.typography.rate + ) + } + + Spacer(modifier = Modifier.width(37.dp)) + + ReviewProgressBar( + reviewCount = info?.reviewCnt ?: 0, + fiveRatingCount = info?.five ?: 0, + fourRatingCount = info?.four ?: 0, + threeRatingCount = info?.three ?: 0, + twoRatingCount = info?.two ?: 0, + oneRatingCount = info?.one ?: 0, + modifier = Modifier.fillMaxWidth() + ) + } + + } +} + +@Composable +fun EmptyReviewContent(modifier: Modifier) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painterResource(R.drawable.ic_none_review), + "empty review", + tint = Gray600, + modifier = Modifier.size(48.dp) + ) + Spacer(Modifier.height(16.dp)) + Text( + "아직 작성된 리뷰가 없어요", + style = EatssuTheme.typography.subtitle2, + color = Gray600 + ) + Spacer(Modifier.height(8.dp)) + Text( + "메뉴에 가장 먼저 리뷰를 남겨주세요!", + style = EatssuTheme.typography.caption2, + color = Gray600 + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun ReviewListPreview() { + EatssuTheme { + ReviewListScreen( + menuName = "소고기+닭고기+돼지고기+양고기+오리고기", + onReviewWriteButtonClick = {}, + onModifyClick = {}, + onDeleteClick = {}, + uiState = UiState.Success( + ReviewListState( + reviewInfo = ReviewInfo( + name = "소고기+닭고기+돼지고기+양고기+오리고기", + reviewCnt = 123, + five = 80, + four = 20, + three = 10, + two = 5, + one = 8, + mainRating = 4.5, + ), + reviewList = listOf( + Review( + isWriter = false, + reviewId = 0, + menuList = listOf( + Review.Menu( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.Menu( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "숭실푸드파이터", + writeDate = "2024-12-31", + mainGrade = 4, + content = "맛있어요", + imgUrl = null, + ), + Review( + isWriter = false, + reviewId = 1, + menuList = listOf( + Review.Menu( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.Menu( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "맛있는리뷰어", + writeDate = "2024-12-30", + mainGrade = 5, + content = "정말 맛있어요! 다음에도 먹고 싶어요.", + imgUrl = null, + ), + Review( + isWriter = false, + reviewId = 2, + menuList = listOf( + Review.Menu( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.Menu( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "음식평론가", + writeDate = "2024-12-29", + mainGrade = 3, + content = "그럭저럭 괜찮아요", + imgUrl = null, + ), + Review( + isWriter = false, + reviewId = 2, + menuList = listOf( + Review.Menu( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.Menu( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "음식평론가", + writeDate = "2024-12-29", + mainGrade = 3, + content = "그럭저럭 괜찮아요", + imgUrl = "https://picsum.photos/400/301", // 실제 이미지 URL 사용 + ) + ) + ) + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ReviewListLoadingPreview() { + EatssuTheme { + ReviewListScreen( + menuName = "소고기+닭고기+돼지고기+양고기+오리고기", + onReviewWriteButtonClick = {}, + onModifyClick = {}, + onDeleteClick = {}, + uiState = UiState.Loading + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ReviewListEmptyPreview() { + EatssuTheme { + ReviewListScreen( + menuName = "소고기+닭고기+돼지고기+양고기+오리고기+닭고기+돼지고기+양고기", + onReviewWriteButtonClick = {}, + onModifyClick = {}, + onDeleteClick = {}, + uiState = UiState.Success( + ReviewListState( + reviewInfo = ReviewInfo( + name = "소고기+닭고기+돼지고기+양고기+오리고기+닭고기+돼지고기+양고기", + reviewCnt = 0, + five = 0, + four = 0, + three = 0, + two = 0, + one = 0, + mainRating = 0.0, + ), + reviewList = emptyList() + ) + ), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModel.kt new file mode 100644 index 000000000..29dd699cd --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListViewModel.kt @@ -0,0 +1,85 @@ +package com.eatssu.android.presentation.cafeteria.review.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.eatssu.android.data.enums.MenuType +import com.eatssu.android.domain.model.Review +import com.eatssu.android.domain.model.ReviewInfo +import com.eatssu.android.domain.usecase.review.DeleteReviewUseCase +import com.eatssu.android.domain.usecase.review.GetReviewInfoUseCase +import com.eatssu.android.domain.usecase.review.GetReviewListUseCase +import com.eatssu.android.presentation.UiEvent +import com.eatssu.android.presentation.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class ReviewListViewModel @Inject constructor( + private val getReviewInfoUseCase: GetReviewInfoUseCase, + private val getReviewListUseCase: GetReviewListUseCase, + private val deleteReviewUseCase: DeleteReviewUseCase, +) : ViewModel() { + + private val _uiState = MutableStateFlow>(UiState.Init) + val uiState: StateFlow> = _uiState.asStateFlow() + + private val _uiEvent: MutableSharedFlow = MutableSharedFlow() + val uiEvent = _uiEvent.asSharedFlow() + + // 마지막 조회 파라미터 저장하여 삭제 후 재조회에 사용 + private var lastMenuType: MenuType? = null + private var lastItemId: Long? = null + + fun getReview(menuType: MenuType, itemId: Long) { + lastMenuType = menuType + lastItemId = itemId + _uiState.value = UiState.Loading + + viewModelScope.launch { + try { + val reviewInfo = getReviewInfoUseCase(menuType, itemId) + val reviewList = getReviewListUseCase(menuType, itemId) + + _uiState.value = UiState.Success( + ReviewListState( + reviewInfo = reviewInfo, + reviewList = reviewList + ) + ) + } catch (e: Exception) { + _uiState.value = UiState.Error + _uiEvent.emit(UiEvent.ShowToast("Error: $e")) + } + } + } + + fun deleteReview(reviewId: Long) { + viewModelScope.launch { + try { + deleteReviewUseCase(reviewId) + _uiEvent.emit(UiEvent.ShowToast("리뷰를 삭제했습니다.")) + // 삭제 성공 시 목록 재조회 + val type = lastMenuType + val id = lastItemId + if (type != null && id != null) { + getReview(type, id) + } + } catch (e: Exception) { + _uiEvent.emit(UiEvent.ShowToast("Error: $e")) + Timber.d("deleteReview: ${e.message}") + } + } + } +} + +data class ReviewListState( + val reviewInfo: ReviewInfo? = null, + val reviewList: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewViewModel.kt deleted file mode 100644 index 01e136aea..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewViewModel.kt +++ /dev/null @@ -1,266 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.list - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.eatssu.android.data.dto.response.asReviewInfo -import com.eatssu.android.data.dto.response.toReviewList -import com.eatssu.android.data.enums.MenuType -import com.eatssu.android.domain.model.Review -import com.eatssu.android.domain.model.ReviewInfo -import com.eatssu.android.domain.usecase.review.DeleteReviewUseCase -import com.eatssu.android.domain.usecase.review.GetMealReviewInfoUseCase -import com.eatssu.android.domain.usecase.review.GetMealReviewListUseCase -import com.eatssu.android.domain.usecase.review.GetMenuReviewInfoUseCase -import com.eatssu.android.domain.usecase.review.GetMenuReviewListUseCase -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject - - -@HiltViewModel -class ReviewViewModel @Inject constructor( - private val getMenuReviewInfoUseCase: GetMenuReviewInfoUseCase, - private val getMenuReviewListUseCase: GetMenuReviewListUseCase, - private val getMealReviewInfoUseCase: GetMealReviewInfoUseCase, - private val getMealReviewListUseCase: GetMealReviewListUseCase, - private val deleteReviewUseCase: DeleteReviewUseCase, -) : ViewModel() { - - private val _uiState: MutableStateFlow = MutableStateFlow(ReviewState()) - val uiState: StateFlow = _uiState.asStateFlow() - - fun loadReview( - menuType: String, - itemId: Long, - ) { - when (menuType) { - MenuType.FIXED.name -> { - callMenuReviewInfo(itemId) - callMenuReviewList(itemId) - } - - MenuType.VARIABLE.name -> { - callMealReviewInfo(itemId) - callMealReviewList(itemId) - } - - else -> { - Timber.d("잘못된 식당 정보입니다.") - - } - } - - } - - private fun callMenuReviewInfo(menuId: Long) { - viewModelScope.launch { - getMenuReviewInfoUseCase(menuId).onStart { - _uiState.update { it.copy(loading = true) } - }.onCompletion { - _uiState.update { it.copy(loading = false, error = true) } - }.catch { e -> - _uiState.update { - it.copy( - loading = false, - error = false, - ) - } - Timber.d(e.toString()) - }.collectLatest { result -> - result.result?.apply { - if (mainRating == null) { - _uiState.update { - it.copy( - loading = false, - error = false, - reviewInfo = asReviewInfo(), - isEmpty = true - ) - } - } else { - _uiState.update { - it.copy( - loading = false, - error = false, - reviewInfo = asReviewInfo(), - isEmpty = false - ) - } - Timber.d("리뷰 있다") - } - } - } - } - } - - private fun callMealReviewInfo( - mealId: Long, - ) { - viewModelScope.launch { - getMealReviewInfoUseCase(mealId).onStart { - _uiState.update { it.copy(loading = true) } - }.onCompletion { - _uiState.update { it.copy(loading = false, error = true) } - }.catch { e -> - _uiState.update { - it.copy( - loading = false, - error = false, - ) - } - Timber.e(e.toString()) - }.collectLatest { result -> - result.result?.apply { - if (mainRating == null) { - _uiState.update { - it.copy( - loading = false, - error = false, - reviewInfo = asReviewInfo(), - isEmpty = true - ) - } - } else { - _uiState.update { - it.copy( - loading = false, - error = false, - reviewInfo = asReviewInfo(), - isEmpty = false - ) - } - Timber.d("리뷰 있다") - } - } - } - } - } - - private fun callMenuReviewList( - itemId: Long, - ) { - viewModelScope.launch { - getMenuReviewListUseCase(itemId).onStart { - _uiState.update { it.copy(loading = true) } - }.onCompletion { - _uiState.update { it.copy(loading = false, error = true) } - }.catch { e -> - _uiState.update { - it.copy( - loading = false, - error = true, - ) - } - Timber.e(e.toString()) - }.collectLatest { result -> - result.result?.apply { - if (numberOfElements == 0) { //리뷰 없음 - _uiState.update { - it.copy( - loading = false, - error = false, - isEmpty = true - ) - } - } else { //리뷰 있음 - _uiState.update { - it.copy( - loading = false, - error = false, - reviewList = this.toReviewList(), - isEmpty = false - ) - } - } - } - } - } - } - - private fun callMealReviewList( - itemId: Long, - ) { - viewModelScope.launch { - getMealReviewListUseCase(itemId).onStart { - _uiState.update { it.copy(loading = true) } - }.onCompletion { - _uiState.update { it.copy(loading = false, error = true) } - }.catch { e -> - _uiState.update { - it.copy( - loading = false, - error = false, - ) - } - Timber.e(e.toString()) - }.collectLatest { result -> - result.result?.apply { - if (numberOfElements == 0) { //리뷰 없음 - _uiState.update { - it.copy( - loading = false, - error = false, - isEmpty = true - ) - } - } else { //리뷰 있음 - _uiState.update { - it.copy( - loading = false, - error = false, - reviewList = this.toReviewList(), - isEmpty = false - ) - } - } - } - } - } - } - - fun deleteReview(reviewId: Long) { - viewModelScope.launch { - deleteReviewUseCase(reviewId).onStart { - _uiState.update { it.copy(loading = true) } - }.onCompletion { - _uiState.update { it.copy(loading = false, error = true) } - }.catch { e -> - _uiState.update { - it.copy( - error = true, -// toastMessage = context.getString(R.string.delete_not) - ) - } - Timber.e(e.toString()) - }.collectLatest { result -> - Timber.d(result.toString()) - - _uiState.update { - it.copy( -// isDeleted = true, -// toastMessage = context.getString(R.string.delete_done) - ) - } - } - } - } -} - -data class ReviewState( - var loading: Boolean = true, - var error: Boolean = false, - - var isEmpty: Boolean = true, //리뷰 없다~ - - var reviewInfo: ReviewInfo? = null, - var reviewList: List? = null, -) \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/ReviewItem.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/ReviewItem.kt new file mode 100644 index 000000000..79e8337d5 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/ReviewItem.kt @@ -0,0 +1,230 @@ +package com.eatssu.android.presentation.cafeteria.review.list.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.eatssu.android.domain.model.Review +import com.eatssu.design_system.R +import com.eatssu.design_system.component.Chip +import com.eatssu.design_system.component.RatingBarSmall +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.Gray400 + +@Composable +private fun SimpleFlowRow( + horizontalSpacing: androidx.compose.ui.unit.Dp, + verticalSpacing: androidx.compose.ui.unit.Dp, + content: @Composable () -> Unit +) { + Layout(content = content) { measurables, constraints -> + val placeables = measurables.map { measurable -> + measurable.measure(constraints) + } + + val maxWidth = constraints.maxWidth + var currentRowWidth = 0 + var currentRowHeight = 0 + var totalHeight = 0 + val positions = mutableListOf() + + var x = 0 + var y = 0 + + placeables.forEach { placeable -> + val itemWidth = placeable.width + val itemHeight = placeable.height + + if (x > 0 && x + itemWidth > maxWidth) { + // wrap to next line + y += currentRowHeight + verticalSpacing.roundToPx() + x = 0 + currentRowHeight = 0 + } + + positions.add(androidx.compose.ui.unit.IntOffset(x, y)) + x += itemWidth + horizontalSpacing.roundToPx() + currentRowHeight = maxOf(currentRowHeight, itemHeight) + currentRowWidth = maxOf(currentRowWidth, x) + } + + totalHeight = y + currentRowHeight + + layout(width = maxWidth, height = totalHeight) { + placeables.forEachIndexed { index, placeable -> + val pos = positions[index] + placeable.placeRelative(pos.x, pos.y) + } + } + } +} + +@Composable +fun ReviewItem( + isWriter: Boolean, + modifier: Modifier = Modifier, + writeName: String, + writeDate: String, + content: String, + rating: Int, + menuList: List? = null, + imgUrl: String? = null, + onMoreClick: () -> Unit = {}, // 바텀시트 열기 콜백 +) { + Column(modifier = modifier.padding(vertical = 24.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_profile_24), + contentDescription = "Profile Image", + modifier = Modifier.size(30.dp), + tint = Color.Unspecified, + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Column { + Text( + writeName, + style = EatssuTheme.typography.caption1 + ) + RatingBarSmall(rating = rating) + } + + Spacer( + modifier = Modifier.weight(1f) + ) + + Column(horizontalAlignment = Alignment.End) { + IconButton( + onClick = { + onMoreClick() + }, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_three_dot), + contentDescription = "etc", + modifier = Modifier + .size(24.dp), + tint = Color.Unspecified, + ) + } + + Text( + writeDate, + style = EatssuTheme.typography.caption3, + color = Gray400, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (menuList?.isNotEmpty() == true || menuList != null) { + Spacer(modifier = Modifier.height(4.dp)) + SimpleFlowRow(horizontalSpacing = 4.dp, verticalSpacing = 2.dp) { + menuList.forEach { + Chip( + menuName = it.name, + modifier = Modifier.padding(end = 4.dp, bottom = 2.dp), + isLike = it.isLike + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text(content, style = EatssuTheme.typography.body3) + + // 이미지가 있는 경우에만 표시 + if (!imgUrl.isNullOrBlank() && imgUrl != "null") { + Spacer(modifier = Modifier.height(8.dp)) + AsyncImage( + model = imgUrl, + contentDescription = "Review image", + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } + } +} + + +@Preview(showBackground = true) +@Composable +fun ReviewItemPreview() { + EatssuTheme { + ReviewItem( + modifier = Modifier, + isWriter = true, + writeName = "숭실푸드파이터", + writeDate = "2024-12-31", + content = "맛있어요", + rating = 4, + menuList = listOf( + Review.Menu( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.Menu( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + imgUrl = "https://www.adobe.com/kr/creativecloud/photography/hub/features/media_19243bf806dc1c5a3532f3e32f4c14d44f81cae9f.jpeg?width=1200&format=pjpg&optimize=medium" + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ReviewItemWithoutImagePreview() { + EatssuTheme { + ReviewItem( + modifier = Modifier, + isWriter = true, + writeName = "맛있는리뷰어", + writeDate = "2024-12-30", + content = "사진 없이 텍스트만 있는 리뷰입니다.", + rating = 5, + menuList = listOf( + Review.Menu( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.Menu( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + imgUrl = null + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/ReviewProgressBar.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/ReviewProgressBar.kt new file mode 100644 index 000000000..498edd533 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/ReviewProgressBar.kt @@ -0,0 +1,110 @@ +package com.eatssu.android.presentation.cafeteria.review.list.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.eatssu.android.R +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.Gray200 +import timber.log.Timber + +@Composable +fun ReviewProgressBar( + modifier: Modifier = Modifier, + reviewCount: Int = 0, + fiveRatingCount: Int = 0, + fourRatingCount: Int = 0, + threeRatingCount: Int = 0, + twoRatingCount: Int = 0, + oneRatingCount: Int = 0, +) { + val ratingList = listOf( + 5 to fiveRatingCount, + 4 to fourRatingCount, + 3 to threeRatingCount, + 2 to twoRatingCount, + 1 to oneRatingCount + ) + Timber.d(ratingList.toString() + "rating") + + Column(modifier = modifier) { + + ratingList.forEach { (rating, count) -> + val percent = if (reviewCount > 0) (count.toFloat() / reviewCount.toFloat()) else 0f + Timber.d("ReviewProgressBar - rating: $rating, count: $count, reviewCount: $reviewCount, percent: $percent") + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + ) { + Text( + text = stringResource( + id = when (rating) { + 5 -> R.string.rate_5 + 4 -> R.string.rate_4 + 3 -> R.string.rate_3 + 2 -> R.string.rate_2 + else -> R.string.rate_1 + } + ), + style = EatssuTheme.typography.caption2, + ) + Spacer(modifier = Modifier.width(8.dp)) + + LinearProgressIndicator( + progress = { percent.coerceIn(0f, 1f) }, + modifier = Modifier + .weight(1f) + .height(10.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = Gray200, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun ReviewProgressBarPreview() { + EatssuTheme { + ReviewProgressBar( + reviewCount = 100, + fiveRatingCount = 60, + fourRatingCount = 20, + threeRatingCount = 10, + twoRatingCount = 7, + oneRatingCount = 3 + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ReviewProgressBarEmptyPreview() { + EatssuTheme { + ReviewProgressBar( + reviewCount = 0, + fiveRatingCount = 0, + fourRatingCount = 0, + threeRatingCount = 0, + twoRatingCount = 0, + oneRatingCount = 0 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewActivity.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewActivity.kt deleted file mode 100644 index 521c38a00..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewActivity.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.modify - -import android.os.Bundle -import androidx.activity.viewModels -import androidx.lifecycle.lifecycleScope -import com.eatssu.android.data.dto.request.ModifyReviewRequest -import com.eatssu.android.databinding.ActivityFixMenuBinding -import com.eatssu.android.presentation.base.BaseActivity -import com.eatssu.android.presentation.util.showToast -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import timber.log.Timber - -@AndroidEntryPoint -class ModifyReviewActivity : BaseActivity(ActivityFixMenuBinding::inflate) { - - private val modifyViewModel: ModifyViewModel by viewModels() - - private var reviewId = -1L - private var menu = "" - - private var content = "" - - private var main = 0 - private var amount = 0 - private var taste = 0 - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - toolbarTitle.text = "리뷰 수정하기" // 툴바 제목 설정 - - getIndex() - setData() - setOnClickListener() - observeViewModel() - } - - fun setOnClickListener() { - binding.btnDone.setOnClickListener { - postData(reviewId) - } - } - - - private fun getIndex() { - - reviewId = intent.getLongExtra("reviewId", -1L) - menu = intent.getStringExtra("menu").toString() - content = intent.getStringExtra("content").toString() - - main = intent.getIntExtra("mainGrade", 0) - amount = intent.getIntExtra("amountGrade", 0) - taste = intent.getIntExtra("tasteGrade", 0) - - Timber.tag("ReviewFixedActivity") - .d("reviewID: %s, menu: %s, content: %s", reviewId.toString(), menu, content) - } - - private fun setData() { - binding.menu.text = menu - binding.etReview2Comment.setText(content) - binding.rbMain.rating = main.toFloat() - binding.rbAmount.rating = intent.getIntExtra("amountGrade", 0).toFloat() - binding.rbTaste.rating = intent.getIntExtra("tasteGrade", 0).toFloat() - } - - private fun postData(reviewId: Long) { - val comment = binding.etReview2Comment.text.toString() - val mainGrade = binding.rbMain.rating.toInt() - val amountGrade = binding.rbAmount.rating.toInt() - val tasteGrade = binding.rbTaste.rating.toInt() - - modifyViewModel.modifyMyReview( - reviewId, - ModifyReviewRequest(mainGrade, amountGrade, tasteGrade, comment) - ) - } - - private fun observeViewModel() { - - lifecycleScope.launch { - modifyViewModel.uiState.collectLatest { - if (it.isDone) { - showToast(it.toastMessage) - finish() - } - - if (it.error) { - showToast(it.toastMessage) - } - } - } - } - - //Todo 쓰다 뒤로 갔을 때 undo -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewScreen.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewScreen.kt new file mode 100644 index 000000000..a29d83b35 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewScreen.kt @@ -0,0 +1,337 @@ +package com.eatssu.android.presentation.cafeteria.review.modify + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.eatssu.android.presentation.UiState +import com.eatssu.design_system.component.CloseTopBar +import com.eatssu.design_system.component.EatSsuButton +import com.eatssu.design_system.component.LikeButton +import com.eatssu.design_system.component.RatingBarMedium +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.Gray100 +import com.eatssu.design_system.theme.Gray200 +import com.eatssu.design_system.theme.Gray400 +import com.eatssu.design_system.theme.Primary +import com.eatssu.design_system.theme.Secondary +import timber.log.Timber + +@Composable +fun ModifyReviewScreen( + modifier: Modifier = Modifier, + viewModel: ModifyViewModel = hiltViewModel(), + reviewId: Long, + initialRating: Int = 0, + initialContent: String = "", + menuList: List> = emptyList(), + likedNames: List = emptyList(), + onBack: () -> Unit = {}, + navController: NavController, +) { + + val reviewWriteState by viewModel.uiState.collectAsStateWithLifecycle() + + val context = LocalContext.current + + val initialLikedSet = likedNames.toSet() + + // 리뷰 작성 성공 시 이전 화면으로 돌아가기 + LaunchedEffect(reviewWriteState) { + when (reviewWriteState) { + is UiState.Error -> { + Timber.d("리뷰 작성 오류") + } + + UiState.Init, UiState.Loading -> { + } + + is UiState.Success -> { + navController.popBackStack() + + } + } + } + + ModifyReviewScreen( + menuList = menuList, + onBack = onBack, + initialRating = initialRating, + initialContent = initialContent, + initialLikedNames = likedNames, + uiState = reviewWriteState, + modifier = modifier, + writeReviewButtonClick = { rating, content, menuLikes -> + viewModel.modifyMyReview( + reviewId = reviewId, + rating = rating, + content = content, + menuLikes = menuLikes, + ) + } + ) +} + +@Composable +internal fun ModifyReviewScreen( + menuList: List>, + onBack: () -> Unit, + initialRating: Int = 0, + initialContent: String = "", + initialLikedNames: List = emptyList(), + uiState: UiState, + modifier: Modifier = Modifier, + writeReviewButtonClick: (rating: Int, content: String, menuLikes: List) -> Unit, +) { + + var rating by remember { mutableIntStateOf(initialRating) } + var text by remember { mutableStateOf(initialContent) } + var likedNameSet by remember { mutableStateOf(initialLikedNames.toSet()) } + + + Scaffold( + topBar = { + CloseTopBar("리뷰 수정하기", onClose = { onBack() }) + }, + bottomBar = { // 하단에 버튼을 고정하기 위함 + EatSsuButton( + text = "완료하기", + enabled = rating != 0, + onClick = { + val menuLikesList = menuList + .filter { likedNameSet.contains(it.second) } + .map { it.first } + writeReviewButtonClick( + rating, + text, + menuLikesList + ) + }, + modifier = Modifier + .padding(24.dp) + ) + } + ) { innerPadding -> + Surface( + modifier = modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Text( + "오늘의 식사는 어뗘셨나요?", + style = EatssuTheme.typography.subtitle1 + ) + + RatingBarMedium( + modifier = Modifier.padding(top = 16.dp, bottom = 12.dp), + rating = rating, // 현재 상태 값 전달 + onRatingChanged = { newRating -> + rating = newRating // 클릭 시 상태 값 업데이트 + }, + ) + + Text( + "추천하고 싶은 메뉴가 있나요?", + style = EatssuTheme.typography.subtitle1 + ) + + Spacer(modifier = Modifier.height(16.dp)) + + + SimpleFlowRow(horizontalSpacing = 4.dp, verticalSpacing = 8.dp) { + menuList.forEach { pair -> + val name = pair.second + val isLiked = likedNameSet.contains(name) + Surface( + shape = RoundedCornerShape(30.dp), + border = BorderStroke(0.5.dp, Primary), + color = Secondary, + contentColor = Primary, + modifier = Modifier + ) { + Row( + modifier = Modifier + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (isLiked) { + Icon( + painter = painterResource(id = com.eatssu.design_system.R.drawable.ic_thumb_up), + contentDescription = null, + tint = Primary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + Text( + name, + style = EatssuTheme.typography.caption3, + color = Primary + ) + } + } + } + } + + + // 최대 글자 수 + val maxChar = 300 + + + Column { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .height(160.dp), + value = text, + onValueChange = { newText -> + if (newText.length <= maxChar) { + text = newText + } + }, + label = { + Text( + "메뉴에 대한 상세한 리뷰를 작성해주세요", + style = EatssuTheme.typography.body2 + ) + }, + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = Gray100, + unfocusedContainerColor = Gray100, + unfocusedBorderColor = Gray200, + focusedBorderColor = Gray200, + unfocusedLabelColor = Gray400, + focusedLabelColor = Gray400, + cursorColor = Primary + ) + ) + + Text( + modifier = Modifier + .align(Alignment.End) + .padding(top = 8.dp), + text = "${text.length}/$maxChar", + color = Gray400, + style = EatssuTheme.typography.caption3 + ) + } + } + + } + } +} + +@Composable +private fun SimpleFlowRow( + horizontalSpacing: androidx.compose.ui.unit.Dp, + verticalSpacing: androidx.compose.ui.unit.Dp, + content: @Composable () -> Unit +) { + Layout(content = content) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } + val maxWidth = constraints.maxWidth + var x = 0 + var y = 0 + var rowHeight = 0 + val positions = mutableListOf() + + placeables.forEach { p -> + if (x > 0 && x + p.width > maxWidth) { + x = 0 + y += rowHeight + verticalSpacing.roundToPx() + rowHeight = 0 + } + positions.add(androidx.compose.ui.unit.IntOffset(x, y)) + x += p.width + horizontalSpacing.roundToPx() + rowHeight = maxOf(rowHeight, p.height) + } + + val height = y + rowHeight + layout(width = maxWidth, height = height) { + placeables.forEachIndexed { index, placeable -> + val pos = positions[index] + placeable.placeRelative(pos.x, pos.y) + } + } + } +} + + +@Composable +fun MenuItem( + modifier: Modifier, + mealName: String, + isLiked: Boolean, + onLikeChanged: (Boolean) -> Unit, +) { + + Row(modifier.padding(vertical = 6.dp)) { + Text( + mealName, + style = EatssuTheme.typography.body3 + ) + Spacer(modifier = Modifier.weight(1f)) + LikeButton( + isLiked = isLiked, + onClick = { + onLikeChanged(!isLiked) + } + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun ReviewListPreview() { + EatssuTheme { + ModifyReviewScreen( + onBack = {}, + menuList = listOf( + 1L to "맑은 미역국", + 2L to "연탄불맛돈불고기", + 3L to "김말이", + ), + uiState = UiState.Success(ModifyState.ModifyDone), + writeReviewButtonClick = { _, _, _ -> } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModel.kt index 04c3a85f0..ee9dddfc6 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyViewModel.kt @@ -2,21 +2,17 @@ package com.eatssu.android.presentation.cafeteria.review.modify import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.eatssu.android.App -import com.eatssu.android.R -import com.eatssu.android.data.dto.request.ModifyReviewRequest +import com.eatssu.android.domain.model.ReviewWriteData import com.eatssu.android.domain.usecase.review.ModifyReviewUseCase +import com.eatssu.android.presentation.UiEvent +import com.eatssu.android.presentation.UiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -24,48 +20,38 @@ class ModifyViewModel @Inject constructor( private val modifyReviewUseCase: ModifyReviewUseCase, ) : ViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(ModifyState()) - val uiState: StateFlow = _uiState.asStateFlow() + private val _uiState: MutableStateFlow> = MutableStateFlow(UiState.Init) + val uiState: StateFlow> = _uiState.asStateFlow() + + private val _uiEvent: MutableSharedFlow = MutableSharedFlow() + val uiEvent = _uiEvent.asSharedFlow() fun modifyMyReview( reviewId: Long, - body: ModifyReviewRequest, + rating: Int, + content: String, + menuLikes: List, ) { + _uiState.value = UiState.Loading + viewModelScope.launch { - modifyReviewUseCase(reviewId, body).onStart { - _uiState.update { it.copy(loading = true) } - }.onCompletion { - _uiState.update { it.copy(loading = false, error = true) } - }.catch { e -> - _uiState.update { - it.copy( - loading = false, - error = false, - isDone = true, - toastMessage = App.appContext.getString(R.string.modify_not) - ) - } - Timber.e(e.toString()) - }.collectLatest { result -> - Timber.d(result.toString()) - _uiState.update { - it.copy( - loading = false, - error = false, - isDone = true, - toastMessage = App.appContext.getString(R.string.modify_done) - ) - } + try { + val reviewData = ReviewWriteData( + rating = rating, + content = content, + menuLikes = menuLikes, + ) + + modifyReviewUseCase(reviewId, reviewData) + _uiState.value = UiState.Success(ModifyState.ModifyDone) + } catch (e: Exception) { + _uiState.value = UiState.Error + _uiEvent.emit(UiEvent.ShowToast("리뷰 수정에 실패했습니다: ${e.message}")) } } } } -data class ModifyState( - var loading: Boolean = true, - var error: Boolean = false, - var toastMessage: String = "", - - var isDone: Boolean = false, - - ) \ No newline at end of file +sealed class ModifyState { + data object ModifyDone : ModifyState() +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteRateActivity.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteRateActivity.kt deleted file mode 100644 index f98a26c9b..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteRateActivity.kt +++ /dev/null @@ -1,236 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.write - -import android.net.Uri -import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher -import android.view.View -import android.widget.Toast -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.bumptech.glide.Glide -import com.bumptech.glide.request.RequestOptions -import com.eatssu.android.data.dto.request.WriteReviewRequest -import com.eatssu.android.databinding.ActivityReviewWriteRateBinding -import com.eatssu.android.presentation.UiEvent -import com.eatssu.android.presentation.UiState -import com.eatssu.android.presentation.base.BaseActivity -import com.eatssu.android.presentation.util.showToast -import dagger.hilt.android.AndroidEntryPoint -import id.zelory.compressor.Compressor -import kotlinx.coroutines.launch -import timber.log.Timber -import java.io.File - -@AndroidEntryPoint -class ReviewWriteRateActivity : - BaseActivity(ActivityReviewWriteRateBinding::inflate) { - - private val viewModel: UploadReviewViewModel by viewModels() - - private var itemId: Long = 0 - private lateinit var itemName: String - private var comment: String? = "" - - private var imageFile: File? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - toolbarTitle.text = "리뷰 남기기" // 툴바 제목 설정 - binding.viewModel = viewModel - - itemName = intent.getStringExtra("itemName").toString() - Timber.d("고정메뉴 $itemName") - - itemId = intent.getLongExtra("itemId", -1) - - // 현재 메뉴명을 표시합니다. - binding.menu.text = itemName - - setupTextReviewInput() - setOnClickListener() - - observeState() - observeEvents() - } - - fun setOnClickListener() { - // 이미지 추가 버튼 클릭 리스너 설정 - binding.ibAddPic.setOnClickListener { - Timber.d("클릭") - imagePickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - } - - binding.btnNextReview2.setOnClickListener { - if (binding.rbMain.rating.toInt() == 0 || binding.rbAmount.rating.toInt() == 0 || binding.rbTaste.rating.toInt() == 0) { - showToast("별점을 모두 등록해주세요") - } - - if (imageFile?.exists() == true) { - - lifecycleScope.launch { - val compressed = compressImage() - if (compressed != null) { - val imageUrl = viewModel.saveS3(compressed) - if (imageUrl != null) { - postPhotoReview(imageUrl) - } else { - showToast("이미지 업로드에 실패했습니다.") - } - } else { - showToast("이미지 압축에 실패했습니다.") - } - } - } else { - postReview() - } - } - - binding.btnDelete.setOnClickListener { deleteImage() } - - } - - private fun observeState() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { state -> - when (state) { - is UiState.Loading -> showLoading(true) - is UiState.Success -> finish() - else -> { - showLoading(false) - } - } - } - } - } - } - - private fun observeEvents() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiEvent.collect { event -> - when (event) { - is UiEvent.ShowToast -> showToast(event.message) - } - } - } - } - } - - private fun postPhotoReview(imageUrl: String) { - - val photoReview = WriteReviewRequest( - mainRating = binding.rbMain.rating.toInt(), - amountRating = binding.rbAmount.rating.toInt(), - tasteRating = binding.rbTaste.rating.toInt(), - content = comment.toString(), - imageUrl = imageUrl - ) - - viewModel.postReview(itemId, photoReview) - Timber.d("사진있는 리뷰 전송") - } - - private fun postReview() { - val review = WriteReviewRequest( - mainRating = binding.rbMain.rating.toInt(), - amountRating = binding.rbAmount.rating.toInt(), - tasteRating = binding.rbTaste.rating.toInt(), - content = comment.toString(), - ) - - viewModel.postReview(itemId, review) - Timber.d("사진없는 리뷰 전송") - } - - private suspend fun compressImage(): File? { - return imageFile?.let { originalFile -> - Compressor.compress(this@ReviewWriteRateActivity, originalFile) - } - } - - private val imagePickerLauncher = registerForActivityResult( - ActivityResultContracts.PickVisualMedia() // 사진 단일 선택 - ) { uri -> - if (uri != null) { - Timber.d("선택된 이미지: $uri") - handleImageUri(uri) - } else { - Timber.d("이미지 선택 안함") - } - } - - private fun handleImageUri(uri: Uri) { - try { - Glide.with(this) - .load(uri) - .fitCenter() - .apply(RequestOptions().override(500, 500)) - .into(binding.ivImage) - - binding.ivImage.visibility = View.VISIBLE - binding.btnDelete.visibility = View.VISIBLE - - val inputStream = contentResolver.openInputStream(uri) - val tempFile = File(cacheDir, "temp_image_${System.currentTimeMillis()}.jpg") - inputStream?.use { input -> - tempFile.outputStream().use { output -> - input.copyTo(output) - } - } - imageFile = tempFile - Timber.d("임시 파일 저장 완료: ${tempFile.absolutePath}") - } catch (e: Exception) { - Timber.e(e, "이미지 처리 중 오류 발생") - showToast("이미지 처리 중 오류가 발생했습니다.") - } - } - - private fun setupTextReviewInput() { - binding.etReview2Comment.addTextChangedListener(object : TextWatcher { - override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {} - - override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) { - comment = binding.etReview2Comment.text.toString() - } - - override fun afterTextChanged(p0: Editable?) {} - }) - } - - @Deprecated("This method has been deprecated in favor of using the\n {@link OnBackPressedDispatcher} via {@link #getOnBackPressedDispatcher()}.\n The OnBackPressedDispatcher controls how back button events are dispatched\n to one or more {@link OnBackPressedCallback} objects.") - override fun onBackPressed() { - super.onBackPressed() - if (imageFile?.exists() == true) { - Toast.makeText(this, "리뷰 작성을 중지합니다.", Toast.LENGTH_SHORT).show() - binding.ivImage.setImageDrawable(null) - imageFile!!.delete() - - } - } - - private fun deleteImage() { - Timber.d("imageFile: " + imageFile.toString()) - if (imageFile?.exists() == true) { - showToast("이미지가 삭제되었습니다.") - binding.ivImage.setImageDrawable(null) - imageFile!!.delete() - - binding.ivImage.visibility = View.GONE - binding.btnDelete.visibility = View.GONE - - } else { - showToast("이미지를 삭제할 수 없습니다.") - } - } - - private fun showLoading(isLoading: Boolean) { - binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE - binding.btnNextReview2.visibility = if (isLoading) View.INVISIBLE else View.VISIBLE - } -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteScreen.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteScreen.kt new file mode 100644 index 000000000..4688007b6 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteScreen.kt @@ -0,0 +1,407 @@ +package com.eatssu.android.presentation.cafeteria.review.write + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.eatssu.android.R +import com.eatssu.android.data.enums.MenuType +import com.eatssu.android.presentation.UiState +import com.eatssu.design_system.component.CloseTopBar +import com.eatssu.design_system.component.EatSsuButton +import com.eatssu.design_system.component.LikeButton +import com.eatssu.design_system.component.RatingBarMedium +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.Gray100 +import com.eatssu.design_system.theme.Gray200 +import com.eatssu.design_system.theme.Gray300 +import com.eatssu.design_system.theme.Gray400 +import com.eatssu.design_system.theme.Gray500 +import com.eatssu.design_system.theme.Primary +import timber.log.Timber + +@Composable +fun ReviewWriteScreen( + modifier: Modifier = Modifier, + viewModel: ReviewWriteViewModel = hiltViewModel(), + menuName: String, + menuType: MenuType, + id: Long, + onBack: () -> Unit, + navController: NavController, +) { + Timber.d("넘어온 메뉴명: $menuName, 메뉴타입: $menuType, ID: $id") + + val reviewWriteState by viewModel.uiState.collectAsStateWithLifecycle() + val viewModelMenuList by viewModel.menuList.collectAsStateWithLifecycle() + val selectedImageUri by viewModel.selectedImageUri.collectAsStateWithLifecycle() + val uploadedImageUrl by viewModel.uploadedImageUrl.collectAsStateWithLifecycle() + + // 갤러리 선택을 위한 launcher + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + viewModel.setSelectedImage(uri) + } + + val context = LocalContext.current + + // menuList를 Pair 리스트로 통일 + val menuList = remember(menuName, menuType, viewModelMenuList) { + when (menuType) { + MenuType.FIXED -> { + // 고정 메뉴인 경우, Pair(id, menuName) 형태로 리스트를 만듭니다. + listOf(Pair(id, menuName)) + } + MenuType.VARIABLE -> { + // 변동 메뉴는 이미 Pair 리스트이므로 그대로 사용합니다. + viewModelMenuList + } + } + } + + LaunchedEffect(menuType, id) { + when (menuType) { + MenuType.FIXED -> { + Timber.d("고정 메뉴 - 원본 메뉴명: $menuName") + } + MenuType.VARIABLE -> { + viewModel.findMenuItemByMealId(id) + } + } + } + + // menuList가 변경될 때마다 로그 출력 + LaunchedEffect(menuList) { + Timber.d("최종 메뉴 목록: $menuList") + } + + // 리뷰 작성 성공 시 이전 화면으로 돌아가기 + LaunchedEffect(reviewWriteState) { + if (reviewWriteState is UiState.Success && (reviewWriteState as UiState.Success).data == WriteReviewState.WriteDone) { + navController.popBackStack() + } + } + + ReviewWriteScreen( + menuList = menuList, + uiState = reviewWriteState, + selectedImageUri = selectedImageUri, + uploadedImageUrl = uploadedImageUrl, + modifier = modifier, + onImageSelect = { + galleryLauncher.launch("image/*") + }, + onImageDelete = { + viewModel.setSelectedImage(null) + }, + writeReviewButtonClick = { rating, content, menuLikes -> + viewModel.postReview( + menuType = menuType, + itemId = id, + rating = rating, + content = content, + menuLikes = menuLikes, + context = context, + ) + }, + onBack = onBack + ) +} + +@Composable +internal fun ReviewWriteScreen( + menuList: List>, + uiState: UiState, + selectedImageUri: Uri?, + uploadedImageUrl: String?, + modifier: Modifier = Modifier, + onImageSelect: () -> Unit, + onImageDelete: () -> Unit, + writeReviewButtonClick: (rating: Int, content: String, menuLikes: List) -> Unit, + onBack: () -> Unit, +) { + + var rating by remember { mutableIntStateOf(0) } + var text by remember { mutableStateOf("") } + var likedMenus by remember { mutableStateOf(mutableListOf()) } + + // 갤러리 선택을 위한 launcher + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + // 콜백을 통해 메인 함수에서 처리 + } + + Scaffold( + topBar = { + CloseTopBar("리뷰 작성하기", onClose = { onBack() }) + }, + bottomBar = { // 하단에 버튼을 고정하기 위함 + EatSsuButton( + text = "완료하기", + enabled = rating != 0, + onClick = { + val menuLikesList = likedMenus.map { it } + writeReviewButtonClick( + rating, + text, + menuLikesList + ) + }, + modifier = Modifier + .padding(24.dp) + ) + } + ) { innerPadding -> + Surface( + modifier = modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + Column( + Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Text( + "오늘의 식사는 어뗘셨나요?", + style = EatssuTheme.typography.subtitle1 + ) + + RatingBarMedium( + modifier = Modifier.padding(top = 16.dp, bottom = 12.dp), + rating = rating, // 현재 상태 값 전달 + onRatingChanged = { newRating -> + rating = newRating // 클릭 시 상태 값 업데이트 + }, + ) + + Text( + "추천하고 싶은 메뉴가 있나요?", + style = EatssuTheme.typography.subtitle1 + ) + + Spacer(modifier = Modifier.height(16.dp)) + + + LazyColumn { + items(menuList) { menuPair -> // 매개변수 이름을 menuPair로 변경하여 혼동 방지 + MenuItem( + mealName = menuPair.second, + modifier = Modifier, + isLiked = likedMenus.contains(menuPair.first), + onLikeChanged = { isLiked -> + // Set을 사용하여 중복 제거 및 상태 변경 + val newSet = likedMenus.toSet() + val updatedList = if (isLiked) { + (newSet + menuPair.first).toList() + } else { + (newSet - menuPair.first).toList() + } + likedMenus = + updatedList.toMutableList() // mutableStateOf를 위해 MutableList로 다시 변환 + } + ) + } + } + + + // 최대 글자 수 + val maxChar = 300 + + + Column { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .height(160.dp), + value = text, + onValueChange = { newText -> + // 최대 글자 수를 초과하지 않도록 함 + if (newText.length <= maxChar) { + text = newText + } + }, + label = { + Text( + "메뉴에 대한 상세한 리뷰를 작성해주세요", + style = EatssuTheme.typography.body2 + ) + }, + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + // 배경색 + focusedContainerColor = Gray100, + unfocusedContainerColor = Gray100, + + // 테두리 색상 + unfocusedBorderColor = Gray200, + focusedBorderColor = Gray200, + + // 힌트 문구 색상 + unfocusedLabelColor = Gray400, + focusedLabelColor = Gray400, + cursorColor = Primary + ) + ) + + Text( + modifier = Modifier + .align(Alignment.End) + .padding(top = 8.dp), + text = "${text.length}/$maxChar", + color = Gray400, + style = EatssuTheme.typography.caption3 + ) + } + + //사진 + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + if (selectedImageUri != null) { + // 선택된 이미지가 있는 경우 + Column( + modifier = Modifier + .size(120.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { + onImageDelete() + } + ) { + AsyncImage( + model = selectedImageUri, + contentDescription = "Selected image", + modifier = Modifier.fillMaxSize() + ) + } + Text( + modifier = Modifier.padding(top = 8.dp), + text = "사진 클릭 시, 삭제됩니다.", + color = Gray500, + style = EatssuTheme.typography.caption3 + ) + } else { + // 이미지가 선택되지 않은 경우 + Column( + modifier = Modifier + .size(60.dp) + .clip(RoundedCornerShape(5.dp)) + .background(Gray100) + .border( + width = 1.dp, + color = Gray200, + shape = RoundedCornerShape(5.dp) + ) + .clickable { + onImageSelect() + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_camera_light), + "add photo", + tint = Gray300 + ) + Text( + "사진 0/1", + color = Gray400, + style = EatssuTheme.typography.caption3 + ) + } + } + } + } + + } + } +} + + +@Composable +fun MenuItem( + modifier: Modifier, + mealName: String, + isLiked: Boolean, + onLikeChanged: (Boolean) -> Unit, +) { + + Row(modifier.padding(vertical = 6.dp)) { + Text( + mealName, + style = EatssuTheme.typography.body3 + ) + Spacer(modifier = Modifier.weight(1f)) + LikeButton( + isLiked = isLiked, + onClick = { + onLikeChanged(!isLiked) // 클릭 시 상태를 반전 + } + ) + } +} + + +@Preview(showBackground = true) +@Composable +fun ReviewListPreview() { + EatssuTheme { + ReviewWriteScreen( + menuList = listOf( + 1L to "맑은 미역국", + 2L to "연탄불맛돈불고기", + 3L to "김말이", + ), + uiState = UiState.Success(WriteReviewState.WriteDone), + selectedImageUri = null, + uploadedImageUrl = null, + onImageSelect = {}, + onImageDelete = {}, + writeReviewButtonClick = { _, _, _ -> }, + onBack = {} + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteViewModel.kt index b896f56da..70767337f 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteViewModel.kt @@ -1,8 +1,12 @@ package com.eatssu.android.presentation.cafeteria.review.write +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.eatssu.android.data.dto.request.WriteReviewRequest +import com.eatssu.android.data.enums.MenuType +import com.eatssu.android.domain.model.Result +import com.eatssu.android.domain.model.ReviewWriteData +import com.eatssu.android.domain.usecase.menu.GetMenuNameListOfMealUseCase import com.eatssu.android.domain.usecase.review.GetImageUrlUseCase import com.eatssu.android.domain.usecase.review.WriteReviewUseCase import com.eatssu.android.presentation.UiEvent @@ -12,60 +16,149 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import timber.log.Timber import java.io.File +import java.io.FileOutputStream +import java.io.InputStream import javax.inject.Inject @HiltViewModel -class UploadReviewViewModel @Inject constructor( +class ReviewWriteViewModel @Inject constructor( private val writeReviewUseCase: WriteReviewUseCase, private val getImageUrlUseCase: GetImageUrlUseCase, + private val getMenuNameListOfMealUseCase: GetMenuNameListOfMealUseCase, ) : ViewModel() { - private val _uiState = MutableStateFlow>(UiState.Init) + private val _uiState = + MutableStateFlow>(UiState.Init) val uiState = _uiState.asStateFlow() private val _uiEvent: MutableSharedFlow = MutableSharedFlow() val uiEvent = _uiEvent.asSharedFlow() - fun postReview(menuId: Long, reviewData: WriteReviewRequest) { + // 메뉴 목록을 저장할 상태 추가 + private val _menuList = MutableStateFlow>>(emptyList()) + val menuList = _menuList.asStateFlow() + + // 이미지 관련 상태 + private val _selectedImageUri = MutableStateFlow(null) + val selectedImageUri = _selectedImageUri.asStateFlow() + + private val _uploadedImageUrl = MutableStateFlow(null) + val uploadedImageUrl = _uploadedImageUrl.asStateFlow() + + fun findMenuItemByMealId(mealId: Long) { viewModelScope.launch { - writeReviewUseCase(menuId, reviewData) - .onStart { - _uiState.value = UiState.Loading - } - .catch { e -> - _uiState.value = UiState.Error - _uiEvent.emit(UiEvent.ShowToast("리뷰 작성에 실패하였습니다.")) - Timber.e(e) - } - .collectLatest { - _uiState.value = UiState.Success() - _uiEvent.emit(UiEvent.ShowToast("리뷰가 작성되었습니다.")) - } + _menuList.value = getMenuNameListOfMealUseCase(mealId) } } - suspend fun saveS3(file: File): String? { - return getImageUrlUseCase(file) - .onStart { - _uiState.value = UiState.Loading + fun postReview( + menuType: MenuType, + itemId: Long, + rating: Int, + content: String, + menuLikes: List, + context: Context, + ) { + viewModelScope.launch { + Timber.d("postReview 시작 - rating: $rating, content: $content, menuLikes: $menuLikes") + _uiState.value = UiState.Loading + + var imageUrl: String? = null + + // 이미지가 선택된 경우 먼저 업로드 + val selectedUri = _selectedImageUri.value + Timber.d("선택된 이미지 URI: $selectedUri") + if (selectedUri != null) { + try { + Timber.d("이미지 업로드 시작") + // Uri를 File로 변환 (ContentResolver 사용) + val file = uriToFile(selectedUri, context) + Timber.d("변환된 파일 경로: ${file.absolutePath}, 파일 존재: ${file.exists()}") + if (file.exists()) { + Timber.d("S3 업로드 시작") + imageUrl = saveS3(file) + Timber.d("S3 업로드 결과: $imageUrl") + _uploadedImageUrl.value = imageUrl + _uiEvent.emit(UiEvent.ShowToast("이미지가 업로드되었습니다.")) + Timber.d("이미지 업로드 성공: $imageUrl") + } else { + _uiState.value = UiState.Error + _uiEvent.emit(UiEvent.ShowToast("이미지 파일을 찾을 수 없습니다.")) + Timber.e("이미지 파일이 존재하지 않음: ${file.absolutePath}") + return@launch + } + } catch (e: Exception) { + _uiState.value = UiState.Error + _uiEvent.emit(UiEvent.ShowToast("이미지 업로드에 실패하였습니다.")) + Timber.e(e, "이미지 업로드 중 예외 발생") + return@launch + } } - .catch { e -> + + val reviewData = ReviewWriteData( + rating = rating, + content = content, + menuLikes = menuLikes, + imageUrl = imageUrl + ) + Timber.d("리뷰 데이터 생성: $reviewData") + + try { + Timber.d("리뷰 작성 시작") + when (val result = writeReviewUseCase(menuType, itemId, reviewData)) { + is Result.Success -> { + _uiState.value = UiState.Success(WriteReviewState.WriteDone) + _uiEvent.emit(UiEvent.ShowToast("리뷰가 작성되었습니다.")) + // 성공 후 잠시 후 상태 초기화 +// kotlinx.coroutines.delay(1000) +// _uiState.value = UiState.Success(WriteReviewState.Init) + } + + is Result.Failure -> { + _uiState.value = UiState.Error + _uiEvent.emit(UiEvent.ShowToast(result.message)) + } + } + } catch (e: Exception) { _uiState.value = UiState.Error - _uiEvent.emit(UiEvent.ShowToast("이미지 업로드에 실패하였습니다.")) + _uiEvent.emit(UiEvent.ShowToast("리뷰 작성에 실패하였습니다.")) Timber.e(e) } - .map { it.result?.url } - .firstOrNull() + } + } + + fun setSelectedImage(uri: android.net.Uri?) { + _selectedImageUri.value = uri + } + + + private fun uriToFile(uri: android.net.Uri, context: Context): File { + val inputStream: InputStream = context.contentResolver.openInputStream(uri) + ?: throw IllegalArgumentException("Cannot open input stream for URI: $uri") + + val file = File(context.cacheDir, "temp_image_${System.currentTimeMillis()}.jpg") + val outputStream = FileOutputStream(file) + + inputStream.use { input -> + outputStream.use { output -> + input.copyTo(output) + } + } + + Timber.d("URI를 파일로 변환 완료: ${file.absolutePath}") + return file + } + + private suspend fun saveS3(file: File): String { + Timber.d("saveS3 시작 - 파일: ${file.absolutePath}") + return getImageUrlUseCase(file) } } -sealed class UploadReviewState +sealed class WriteReviewState { + data object WriteDone : WriteReviewState() +} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/ReviewWriteMenuActivity.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/ReviewWriteMenuActivity.kt deleted file mode 100644 index eb0511d1c..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/ReviewWriteMenuActivity.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.write.menu - -import android.content.Intent -import android.os.Build -import android.os.Bundle -import androidx.activity.viewModels -import androidx.annotation.RequiresApi -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.eatssu.android.databinding.ActivityReviewWriteMenuBinding -import com.eatssu.android.presentation.base.BaseActivity -import com.eatssu.android.presentation.cafeteria.review.write.ReviewWriteRateActivity -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import timber.log.Timber - -@AndroidEntryPoint -class ReviewWriteMenuActivity : - BaseActivity(ActivityReviewWriteMenuBinding::inflate) { - - private val viewModel: VariableMenuViewModel by viewModels() - private var mealId: Long = -1 - - private lateinit var variableMenuPickAdapter: VariableMenuPickAdapter - - @RequiresApi(Build.VERSION_CODES.O) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - toolbarTitle.text = "리뷰 남기기" // 툴바 제목 설정 - - getIndex() - loadData() - bindData() - setClickListener() - } - - fun getIndex() { - mealId = intent.getLongExtra("itemId", -1) - } - - fun loadData() { - viewModel.findMenuItemByMealId(mealId) - } - - private fun bindData() { - lifecycleScope.launch { - viewModel.uiState.collectLatest { - if (!it.error && !it.loading) { - Timber.d("받은" + it.menuOfMeal.toString()) - - variableMenuPickAdapter = VariableMenuPickAdapter(it.menuOfMeal!!) - binding.rvMenuPicker.apply { - adapter = variableMenuPickAdapter - layoutManager = LinearLayoutManager(this@ReviewWriteMenuActivity) - setHasFixedSize(true) - } - // 데이터 바인딩이 완료된 후 클릭 리스너 설정 -// setClickListener() - } - } - } - } - - private fun setClickListener() { - binding.btnNextReview.setOnClickListener { - sendNextItem(variableMenuPickAdapter.sendCheckedItem()) - } - } - - private fun sendNextItem(items: ArrayList>) { - for (i in 0 until items.size) { - Timber.d("sendNextItem: " + items.size.toString()) - // 현재 아이템을 가져옴 - - val currentItem = items[i] - - // 다음 아이템을 전달하기 위해 Intent 생성 - val intent = Intent(this, ReviewWriteRateActivity::class.java) - intent.putExtra("itemName", currentItem.first) - intent.putExtra("itemId", currentItem.second) - - // BActivity 실행 - startActivity(intent) - - // 만약 마지막 아이템이면 현재 액티비티 종료 - if (i == items.size - 1) { - finish() - } - } - } -} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/VariableMenuPickAdapter.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/VariableMenuPickAdapter.kt deleted file mode 100644 index 6dec55316..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/VariableMenuPickAdapter.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.write.menu - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.eatssu.android.databinding.ItemMenuPickBinding -import com.eatssu.android.domain.model.MenuMini - -class VariableMenuPickAdapter(private val menuList: List?) : - RecyclerView.Adapter() { - - private val checkedItems: ArrayList> = ArrayList() - - inner class ViewHolder(private val binding: ItemMenuPickBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(position: Int) { - val menuItem = menuList?.get(position) - with(binding) { - tvMenuName.text = menuItem?.name - checkBox.isChecked = checkedItems.contains(getItem(position)) - checkBox.setOnClickListener { onCheckBoxClick(position) } - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val binding = - ItemMenuPickBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ViewHolder(binding) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - holder.bind(position) - } - - override fun getItemCount(): Int = menuList?.size ?: 0 - - private fun getItem(position: Int): Pair { - val menuItem = menuList?.get(position) - return Pair(menuItem?.name, menuItem?.id) - } - - private fun onCheckBoxClick(position: Int) { - val item = getItem(position) - if (checkedItems.contains(item)) { - checkedItems.remove(item) - } else { - checkedItems.add(item as Pair) - } - } - - fun sendCheckedItem(): ArrayList> = checkedItems -} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/VariableMenuViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/VariableMenuViewModel.kt deleted file mode 100644 index 751523ce7..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/VariableMenuViewModel.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.write.menu - - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.eatssu.android.data.dto.response.toMenuMiniList -import com.eatssu.android.domain.model.MenuMini -import com.eatssu.android.domain.usecase.menu.GetMenuNameListOfMealUseCase -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import timber.log.Timber -import javax.inject.Inject - - -@HiltViewModel -class VariableMenuViewModel @Inject constructor( - private val getMenuNameListUseCase: GetMenuNameListOfMealUseCase, -) : ViewModel() { - - private val _uiState: MutableStateFlow = MutableStateFlow(MenuState()) - val uiState: StateFlow = _uiState.asStateFlow() - - fun findMenuItemByMealId(mealId: Long) { - Timber.d("findMenuItemByMealId: $mealId") - viewModelScope.launch { - getMenuNameListUseCase( - mealId - ).onStart { - Timber.d("findMenuItemByMealId: onStart") - - _uiState.update { it.copy(loading = true) } - }.onCompletion { - Timber.d("findMenuItemByMealId: onCompletion") - - _uiState.update { it.copy(loading = false, error = true) } - }.catch { e -> - Timber.d("findMenuItemByMealId: catch $e") - - _uiState.update { - it.copy( - loading = false, - error = true, - ) - } - }.collectLatest { result -> - Timber.d("findMenuItemByMealId: ${result.toString()}") - _uiState.update { - it.copy( - loading = false, - error = false, - menuOfMeal = result.result?.toMenuMiniList() - ) - } - } - } - } -} - -data class MenuState( - var loading: Boolean = true, - var error: Boolean = false, - var menuOfMeal: List? = null, -) \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/common/MyReviewBottomSheetFragment.kt b/app/src/main/java/com/eatssu/android/presentation/common/MyReviewBottomSheetFragment.kt deleted file mode 100644 index 2d4220c59..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/common/MyReviewBottomSheetFragment.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.eatssu.android.presentation.common - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import com.eatssu.android.App -import com.eatssu.android.R -import com.eatssu.android.databinding.FragmentBottomsheetMyReviewBinding -import com.eatssu.android.presentation.mypage.myreview.MyReviewViewModel -import com.eatssu.android.presentation.cafeteria.review.modify.ModifyReviewActivity -import com.eatssu.android.presentation.util.showToast -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import timber.log.Timber - -@AndroidEntryPoint -class MyReviewBottomSheetFragment : BottomSheetDialogFragment() { - private var _binding: FragmentBottomsheetMyReviewBinding? = null - private val binding get() = _binding!! - - interface OnReviewDeletedListener { - fun onReviewDeleted() - } - - var onReviewDeletedListener: OnReviewDeletedListener? = null - - private val viewModel: MyReviewViewModel by activityViewModels() - - var reviewId = -1L - var menu = "" - var content = "" - var mainGrade = -1 - var amountGrade = -1 - var tasteGrade = -1 - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentBottomsheetMyReviewBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - arguments?.let { - reviewId = it.getLong("reviewId") - menu = it.getString("menu").toString() - content = it.getString("content").toString() - mainGrade = it.getInt("mainGrade") - amountGrade = it.getInt("amountGrade") - tasteGrade = it.getInt("tasteGrade") - } - - Timber.d("넘겨받은 리뷰 정보: $reviewId $menu $content $reviewId") - - binding.llModify.setOnClickListener { - val intent = Intent(requireContext(), ModifyReviewActivity::class.java) - - intent.let { - it.putExtra("reviewId", reviewId) - it.putExtra("menu", menu) - it.putExtra("content", content) - it.putExtra("mainGrade", mainGrade) - it.putExtra("amountGrade", amountGrade) - it.putExtra("tasteGrade", tasteGrade) - } - - startActivity(intent) - dismiss() - } - - binding.llDelete.setOnClickListener { - AlertDialog.Builder(requireContext()).apply { - setTitle(R.string.delete) - setMessage(R.string.delete_description) - setNegativeButton("취소") { _, _ -> - activity?.showToast(App.appContext.getString(R.string.delete_undo)) - } - setPositiveButton("삭제") { _, _ -> - viewModel.deleteReview(reviewId) - lifecycleScope.launch { - viewModel.uiState.collectLatest { - if (it.isDeleted) { - onReviewDeletedListener?.onReviewDeleted() // 콜백 호출 - dismiss() - } - } - } - } - }.create().show() - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/common/OthersBottomSheetFragment.kt b/app/src/main/java/com/eatssu/android/presentation/common/OthersBottomSheetFragment.kt deleted file mode 100644 index 501482877..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/common/OthersBottomSheetFragment.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.eatssu.android.presentation.common - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.eatssu.android.databinding.FragmentBottomsheetOthersBinding -import com.eatssu.android.presentation.cafeteria.review.report.ReportActivity -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import dagger.hilt.android.AndroidEntryPoint -import timber.log.Timber - -@AndroidEntryPoint -class OthersBottomSheetFragment : BottomSheetDialogFragment() { - private var _binding: FragmentBottomsheetOthersBinding? = null - private val binding get() = _binding!! - - var reviewId = -1L - var menu = "" - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentBottomsheetOthersBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - arguments?.let { - reviewId = it.getLong("reviewId") - menu = it.getString("menu").toString() - } - - Timber.d("넘겨받은 리뷰 정보: $reviewId $menu") - - binding.llReport.setOnClickListener { - - val intent = Intent(requireContext(), ReportActivity::class.java) - intent.putExtra("reviewId", reviewId) - Timber.d("reviewId $reviewId") - startActivity(intent) - dismiss() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt index 122f34014..f3769dffe 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt @@ -20,10 +20,10 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.eatssu.android.R import com.eatssu.android.databinding.FragmentMyPageBinding -import com.eatssu.android.presentation.base.BaseFragment import com.eatssu.android.presentation.MainViewModel +import com.eatssu.android.presentation.base.BaseFragment import com.eatssu.android.presentation.login.LoginActivity -import com.eatssu.android.presentation.mypage.myreview.MyReviewListActivity +import com.eatssu.android.presentation.mypage.myreview.MyReviewListComposeActivity import com.eatssu.android.presentation.mypage.terms.WebViewActivity import com.eatssu.android.presentation.mypage.userinfo.UserInfoActivity import com.google.android.gms.oss.licenses.OssLicensesMenuActivity @@ -102,7 +102,7 @@ class MyPageFragment : BaseFragment() { } binding.llMyReview.setOnClickListener { - startActivity(Intent(requireContext(), MyReviewListActivity::class.java)) + startActivity(Intent(requireContext(), MyReviewListComposeActivity::class.java)) } binding.tvLogout.setOnClickListener { diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewAdapter.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewAdapter.kt deleted file mode 100644 index 7368874d1..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewAdapter.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.eatssu.android.presentation.mypage.myreview - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.bumptech.glide.Glide -import com.eatssu.android.data.MySharedPreferences -import com.eatssu.android.databinding.ItemReviewBinding -import com.eatssu.android.domain.model.Review -import timber.log.Timber - -class MyReviewAdapter : - ListAdapter(ReviewDiffCallback()) { - - interface OnItemClickListener { - fun onMyReviewClicked(view: View, reviewData: Review) - } - - // 객체 저장 변수 - private lateinit var mOnItemClickListener: OnItemClickListener - - // 객체 전달 메서드 - fun setOnItemClickListener(onItemClickListener: OnItemClickListener) { - mOnItemClickListener = onItemClickListener - } - - inner class ViewHolder(private val binding: ItemReviewBinding) : - androidx.recyclerview.widget.RecyclerView.ViewHolder(binding.root) { - - fun bind(data: Review) { - binding.tvWriterNickname.text = MySharedPreferences.getUserName(binding.root.context) - binding.tvReviewItemComment.text = data.content - binding.tvReviewItemDate.text = data.writeDate - binding.tvMenuName.text = data.menu - binding.rbRate.rating = data.mainGrade.toFloat() - - val imageView: ImageView = binding.ivReviewPhoto - if (data.imgUrl?.isEmpty() == true || data.imgUrl?.get(0).isNullOrEmpty()) { - imageView.visibility = View.GONE - } else { - Glide.with(itemView) - .load(data.imgUrl?.get(0)) - .into(imageView) - imageView.visibility = View.VISIBLE - } - - binding.btnDetail.setOnClickListener { v: View -> - mOnItemClickListener.onMyReviewClicked(v, data) - - Timber.d( - "리뷰 상세 정보 - ID: %d, 메뉴: %s, 내용: %s", - data.reviewId, - data.menu, - data.content - ) - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val viewBinding = - ItemReviewBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return ViewHolder(viewBinding) - } - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val item = getItem(position) - holder.bind(item) - } -} - -class ReviewDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Review, newItem: Review): Boolean { - return oldItem.reviewId == newItem.reviewId - } - - override fun areContentsTheSame(oldItem: Review, newItem: Review): Boolean { - return oldItem == newItem - } -} diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListActivity.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListActivity.kt index 8fa70c1db..dca2682df 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListActivity.kt @@ -1,105 +1,23 @@ package com.eatssu.android.presentation.mypage.myreview import android.os.Bundle -import android.view.View -import androidx.activity.viewModels -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import com.eatssu.android.R -import com.eatssu.android.databinding.ActivityMyReviewListBinding -import com.eatssu.android.domain.model.Review -import com.eatssu.android.presentation.base.BaseActivity -import com.eatssu.android.presentation.common.MyReviewBottomSheetFragment +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.eatssu.design_system.theme.EatssuTheme import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch @AndroidEntryPoint -class MyReviewListActivity : - BaseActivity(ActivityMyReviewListBinding::inflate), - MyReviewBottomSheetFragment.OnReviewDeletedListener { - - private val myReviewViewModel: MyReviewViewModel by viewModels() - - lateinit var menu: String +class MyReviewListComposeActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - toolbarTitle.text = "내가 쓴 리뷰" // 툴바 제목 설정 - - loadReview() - } - - private fun setAdapter(reviewList: List) { - - val adapter = MyReviewAdapter() - adapter.submitList(reviewList) - - val linearLayoutManager = LinearLayoutManager(this) - - adapter.setOnItemClickListener(object : - MyReviewAdapter.OnItemClickListener { - - override fun onMyReviewClicked(view: View, reviewData: Review) { - onMyReviewClicked(review = reviewData) - } - }) - - binding.rvReview.adapter = adapter - binding.rvReview.layoutManager = linearLayoutManager - binding.rvReview.setHasFixedSize(true) - } - - private fun loadReview() { - myReviewViewModel.getMyReviews() - lifecycleScope.launch { - myReviewViewModel.uiState.collectLatest { - if (it.isEmpty) { - binding.llNonReview.visibility = View.VISIBLE - binding.nestedScrollView.visibility = View.GONE - } else { - binding.llNonReview.visibility = View.GONE - binding.nestedScrollView.visibility = View.VISIBLE - it.myReviews?.let { reviews -> setAdapter(reviews) } - } + setContent { + EatssuTheme { + MyReviewListScreen( + onBack = { finish() } + ) } } } - - override fun onRestart() { - super.onRestart() - loadReview() - } - - override fun onResume() { - super.onResume() - loadReview() - } - - fun onMyReviewClicked(review: Review) { - - val modalBottomSheet = MyReviewBottomSheetFragment().apply { - arguments = Bundle().apply { - putLong("reviewId", review.reviewId) - putString("menu", review.menu) - putString("content", review.content) - putInt("mainGrade", review.mainGrade) - putInt("amountGrade", review.amountGrade) - putInt("tasteGrade", review.tasteGrade) - } - onReviewDeletedListener = this@MyReviewListActivity - } - modalBottomSheet.setStyle( - DialogFragment.STYLE_NORMAL, - R.style.RoundCornerBottomSheetDialogTheme - ) - modalBottomSheet.show(supportFragmentManager, "Open Bottom Sheet") - } - - override fun onReviewDeleted() { - loadReview() - } - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListScreen.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListScreen.kt new file mode 100644 index 000000000..d8f0ac2da --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListScreen.kt @@ -0,0 +1,311 @@ +package com.eatssu.android.presentation.mypage.myreview + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.eatssu.android.R +import com.eatssu.android.domain.model.Review +import com.eatssu.android.presentation.UiEvent +import com.eatssu.android.presentation.UiState +import com.eatssu.android.presentation.cafeteria.review.list.MyReviewBottomSheet +import com.eatssu.android.presentation.cafeteria.review.list.component.ReviewItem +import com.eatssu.android.presentation.util.showToast +import com.eatssu.design_system.component.EatSsuTopBar +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.Gray600 +import timber.log.Timber + +@Composable +fun MyReviewListScreen( + modifier: Modifier = Modifier, + viewModel: MyReviewViewModel = hiltViewModel(), + onBack: () -> Unit = {}, +// onModifyClick: () -> Unit, +) { + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.getMyReviewList() + } + + val reviewListState by viewModel.uiState.collectAsStateWithLifecycle() + val uiEvent by viewModel.uiEvent.collectAsStateWithLifecycle(initialValue = null) + + when (uiEvent) { + is UiEvent.ShowToast -> { + context.showToast((uiEvent as UiEvent.ShowToast).message) + } + } + + MyReviewListScreen( + uiState = reviewListState, + modifier = modifier, + onBack = onBack, + onDeleteClick = { reviewId -> viewModel.deleteReview(reviewId) }, +// onModifyClick = onModifyClick, + ) +} + +@Composable +internal fun MyReviewListScreen( + uiState: UiState, + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, +// onModifyClick: () -> Unit, + onDeleteClick: (reviewId: Long) -> Unit, +) { + var showBottomSheet by remember { mutableStateOf(false) } + var selectedReviewId by remember { mutableStateOf(null) } + + if (showBottomSheet && selectedReviewId != null) { + MyReviewBottomSheet( + onDismiss = { showBottomSheet = false; selectedReviewId = null }, + onModify = { }, + onDelete = { + selectedReviewId?.let { onDeleteClick(it) } + showBottomSheet = false + selectedReviewId = null + } + ) + } + + Scaffold( + topBar = { + EatSsuTopBar( + title = "내 리뷰", + onBack = onBack + ) + }, + ) { innerPadding -> + Surface(modifier = modifier.padding(innerPadding)) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + when (uiState) { + is UiState.Success -> { + when (val dataState = uiState.data) { + is MyReviewState.ReviewExists -> { + Timber.d("리뷰 존재") + val reviewList = dataState.myReviews ?: emptyList() + + LazyColumn( + modifier = Modifier.weight(1f) + ) { + items(reviewList) { item -> + ReviewItem( + modifier = Modifier, + isWriter = item.isWriter, + writeName = item.writerNickname, + writeDate = item.writeDate, + content = item.content, + rating = item.mainGrade, + menuList = item.menuList, + imgUrl = item.imgUrl, + onMoreClick = { + selectedReviewId = item.reviewId + showBottomSheet = true + } + ) + } + } + } + + is MyReviewState.NoReview -> { + Timber.d("리뷰 없음") + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painterResource(R.drawable.ic_none_review), + "empty review", + tint = Gray600, + modifier = Modifier.size(48.dp) + ) + Spacer(Modifier.height(16.dp)) + Text( + "아직 작성된 리뷰가 없어요", + style = EatssuTheme.typography.subtitle2, + color = Gray600 + ) + Spacer(Modifier.height(8.dp)) + Text( + "첫 리뷰를 남겨 주세요!", + style = EatssuTheme.typography.caption2, + color = Gray600 + ) + } + } + + null -> TODO() + } + } + + + UiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + UiState.Error -> { + // TODO: 에러 UI + Spacer(modifier = Modifier.weight(1f)) + } + + UiState.Init -> { + // TODO: 초기 상태 UI + Spacer(modifier = Modifier.weight(1f)) + } + } + + + } + } + } +} + + +@Preview(showBackground = true) +@Composable +fun ReviewListPreview() { + EatssuTheme { + MyReviewListScreen( + onDeleteClick = {}, + uiState = UiState.Success( + MyReviewState.ReviewExists( + myReviews = listOf( + Review( + isWriter = true, + reviewId = 0, + menuList = listOf( + Review.Menu( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.Menu( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "숭실푸드파이터", + writeDate = "2024-12-31", + mainGrade = 4, + content = "맛있어요", + imgUrl = null, + ), + Review( + isWriter = true, + reviewId = 1, + menuList = listOf( + Review.Menu( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.Menu( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "맛있는리뷰어", + writeDate = "2024-12-30", + mainGrade = 5, + content = "정말 맛있어요! 다음에도 먹고 싶어요.", + imgUrl = null, + ), + Review( + isWriter = true, + reviewId = 2, + menuList = listOf( + Review.Menu( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.Menu( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "음식평론가", + writeDate = "2024-12-29", + mainGrade = 3, + content = "그럭저럭 괜찮아요", + imgUrl = null, + ), + Review( + isWriter = false, + reviewId = 2, + menuList = listOf( + Review.Menu( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.Menu( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "음식평론가", + writeDate = "2024-12-29", + mainGrade = 3, + content = "그럭저럭 괜찮아요", + imgUrl = "https://picsum.photos/400/301", // 실제 이미지 URL 사용 + ) + ) + ) + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ReviewListEmptyPreview() { + EatssuTheme { + MyReviewListScreen( + onDeleteClick = {}, + uiState = UiState.Success( + MyReviewState.NoReview + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewViewModel.kt index 767e3b90f..4b57c2c2a 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewViewModel.kt @@ -1,23 +1,18 @@ package com.eatssu.android.presentation.mypage.myreview -import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.eatssu.android.R -import com.eatssu.android.data.dto.response.toReviewList import com.eatssu.android.domain.model.Review -import com.eatssu.android.domain.usecase.review.GetMyReviewsUseCase import com.eatssu.android.domain.usecase.review.DeleteReviewUseCase +import com.eatssu.android.domain.usecase.user.GetMyReviewsUseCase +import com.eatssu.android.presentation.UiEvent +import com.eatssu.android.presentation.UiState import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -26,80 +21,55 @@ import javax.inject.Inject class MyReviewViewModel @Inject constructor( private val getMyReviewsUseCase: GetMyReviewsUseCase, private val deleteReviewUseCase: DeleteReviewUseCase, - @ApplicationContext private val context: Context ) : ViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(MyReviewState()) - val uiState: StateFlow = _uiState.asStateFlow() + private val _uiState: MutableStateFlow> = MutableStateFlow(UiState.Init) + val uiState: StateFlow> = _uiState.asStateFlow() - init { - getMyReviews() - } + private val _uiEvent: MutableSharedFlow = MutableSharedFlow() + val uiEvent = _uiEvent.asSharedFlow() - fun getMyReviews() { - viewModelScope.launch { - getMyReviewsUseCase().onStart { - _uiState.update { it.copy(loading = true) } - }.onCompletion { - _uiState.update { it.copy(loading = false, error = true) } - }.catch { e -> - _uiState.update { it.copy(error = true, toastMessage = "정보를 불러올 수 없습니다.") } - Timber.e(e.toString()) - }.collectLatest { result -> - Timber.d(result.toString()) + fun getMyReviewList() { + _uiState.value = UiState.Loading - result.result?.apply { - if (dataList.isEmpty()) { - _uiState.update { it.copy(isEmpty = true) } + viewModelScope.launch { + try { + val myReviewList = getMyReviewsUseCase() + _uiState.value = UiState.Success( + if (myReviewList.isEmpty()) { + MyReviewState.NoReview } else { - //Todo 리뷰 바인딩을... - _uiState.update { it.copy(myReviews = this.toReviewList()) } + MyReviewState.ReviewExists(myReviews = myReviewList) } - - - } + ) + } catch (e: Exception) { + _uiState.value = UiState.Error + _uiEvent.emit(UiEvent.ShowToast("Error: $e")) + Timber.d("getMyReviewList: ${e.message}") } } } fun deleteReview(reviewId: Long) { viewModelScope.launch { - deleteReviewUseCase(reviewId).onStart { - _uiState.update { it.copy(loading = true) } - }.onCompletion { - _uiState.update { it.copy(loading = false, error = true) } - }.catch { e -> - _uiState.update { - it.copy( - error = true, - toastMessage = context.getString(R.string.delete_not) - ) - } - Timber.e(e.toString()) - }.collectLatest { result -> - Timber.d(result.toString()) - - _uiState.update { - it.copy( - isDeleted = true, - toastMessage = context.getString(R.string.delete_done) - ) - } + try { + deleteReviewUseCase(reviewId) + _uiEvent.emit(UiEvent.ShowToast("리뷰를 삭제했습니다.")) + // 삭제 성공 시 내 리뷰 목록 재조회 + getMyReviewList() + } catch (e: Exception) { + _uiEvent.emit(UiEvent.ShowToast("Error: $e")) + Timber.d("deleteReview: ${e.message}") } } } } -data class MyReviewState( - var loading: Boolean = true, - var error: Boolean = false, - - var toastMessage: String = "", - - var isEmpty: Boolean = false, - - var myReviews: List? = null, - var isDeleted: Boolean = false, +sealed class MyReviewState { + data class ReviewExists( + var myReviews: List? = null, + ) : MyReviewState() - ) \ No newline at end of file + data object NoReview : MyReviewState() +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_camera_light.xml b/app/src/main/res/drawable/ic_camera_light.xml new file mode 100644 index 000000000..ec1bed80c --- /dev/null +++ b/app/src/main/res/drawable/ic_camera_light.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_siren.xml b/app/src/main/res/drawable/ic_siren.xml new file mode 100644 index 000000000..c3afe32b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_siren.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_review.xml b/app/src/main/res/layout/activity_review.xml deleted file mode 100644 index 41a26b246..000000000 --- a/app/src/main/res/layout/activity_review.xml +++ /dev/null @@ -1,456 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -