diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e609cb456..b5005ed02 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 = 28 @@ -124,6 +131,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) @@ -151,6 +179,9 @@ dependencies { implementation(libs.glide) kapt(libs.glide.compiler) + //coil: 이미지 로딩 + implementation(libs.coil.compose) + //compressor: 이미지 압축 implementation(libs.compressor) @@ -168,6 +199,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) @@ -188,26 +220,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) - 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 8f1a8100e..93237e2b2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -156,15 +156,12 @@ - @@ -187,8 +184,8 @@ android:value="" /> + android:name=".presentation.error.ServerErrorActivity" + android:exported="false" /> @@ -212,14 +209,7 @@ android:value="" /> - - - - - - \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/request/ModifyReviewRequest.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/request/ModifyReviewRequest.kt index 26cbae6e0..3ed94c53f 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/dto/request/ModifyReviewRequest.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/request/ModifyReviewRequest.kt @@ -3,8 +3,14 @@ package com.eatssu.android.data.remote.dto.request 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") val rating: Int? = null, + @SerializedName("menuLikes") val menuLikes: List = arrayListOf(), + @SerializedName("content") val content: String? = null +) { + data class MenuLikes( + + @SerializedName("menuId") val menuId: Long? = null, + @SerializedName("isLike") val isLike: Boolean? = null + + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/request/WriteMealReviewRequest.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/request/WriteMealReviewRequest.kt new file mode 100644 index 000000000..608c8c64e --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/request/WriteMealReviewRequest.kt @@ -0,0 +1,17 @@ +package com.eatssu.android.data.remote.dto.request + +import com.google.gson.annotations.SerializedName + +//별점은 필수 값 나머지는 옵션 +data class WriteMealReviewRequest( + @SerializedName("mealId") val mealId: Long, + @SerializedName("rating") val rating: Int, + @SerializedName("menuLikes") val menuLikes: List?, + @SerializedName("content") val content: String, + @SerializedName("imageUrls") val imageUrls: List +) { + data class MenuLikes( + @SerializedName("menuId") val menuId: Long, + @SerializedName("isLike") val isLike: Boolean + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/request/WriteMenuReviewRequest.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/request/WriteMenuReviewRequest.kt new file mode 100644 index 000000000..d0f269f59 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/request/WriteMenuReviewRequest.kt @@ -0,0 +1,16 @@ +package com.eatssu.android.data.remote.dto.request + +import com.google.gson.annotations.SerializedName + +//별점은 필수 값 나머지는 옵션 +data class WriteMenuReviewRequest( + @SerializedName("rating") val rating: Int, + @SerializedName("menuLike") val menuLike: MenuLike?, + @SerializedName("content") val content: String, + @SerializedName("imageUrls") val imageUrls: List, +) { + data class MenuLike( + @SerializedName("menuId") val menuId: Long, + @SerializedName("isLike") val isLike: Boolean + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/request/WriteReviewRequest.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/request/WriteReviewRequest.kt deleted file mode 100644 index a6dabadae..000000000 --- a/app/src/main/java/com/eatssu/android/data/remote/dto/request/WriteReviewRequest.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.eatssu.android.data.remote.dto.request - -import com.google.gson.annotations.SerializedName - -data class WriteReviewRequest( - - @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, - - ) \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/ImageResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/ImageResponse.kt index c394db0f8..6b7b9d7f8 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/dto/response/ImageResponse.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/ImageResponse.kt @@ -3,5 +3,5 @@ package com.eatssu.android.data.remote.dto.response import com.google.gson.annotations.SerializedName data class ImageResponse( - @SerializedName("url") var url: String? = null, + @SerializedName("url") val url: String? = null, ) \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MealResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MealResponse.kt index b113fd4b5..bdd797f96 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MealResponse.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MealResponse.kt @@ -4,7 +4,6 @@ import com.eatssu.android.domain.model.Menu import com.google.gson.annotations.SerializedName data class GetMealResponse( - @SerializedName("mealId") var mealId: Long? = null, @SerializedName("price") var price: Int? = null, @SerializedName("rating") var rating: Double? = null, @@ -12,11 +11,9 @@ data class GetMealResponse( ) data class MenusInformationList( - @SerializedName("menuId") var menuId: Long? = null, @SerializedName("name") var name: String? = null, - - ) +) fun List.mapTodayMenuResponseToMenu(): List { val menuList = mutableListOf() diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MealReviewInfoResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MealReviewInfoResponse.kt index 919326dcf..263780c1b 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MealReviewInfoResponse.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MealReviewInfoResponse.kt @@ -3,38 +3,28 @@ package com.eatssu.android.data.remote.dto.response import com.eatssu.android.domain.model.ReviewInfo import com.google.gson.annotations.SerializedName -data class GetMealReviewInfoResponse( - - @SerializedName("menuNames") var menuNames: ArrayList = arrayListOf(), - @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(), - - ) { +data class MealReviewInfoResponse( + @SerializedName("menuNames") val menuNames: List? = null, + @SerializedName("totalReviewCount") val totalReviewCount: Int? = null, + @SerializedName("rating") val rating: Double? = null, + @SerializedName("likeCount") val likeCount: Int? = null, + @SerializedName("reviewRatingCount") val 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("oneStarCount") val oneStarCount: Int? = null, + @SerializedName("twoStarCount") val twoStarCount: Int? = null, + @SerializedName("threeStarCount") val threeStarCount: Int? = null, + @SerializedName("fourStarCount") val fourStarCount: Int? = null, + @SerializedName("fiveStarCount") val fiveStarCount: Int? = null, + ) } -fun GetMealReviewInfoResponse.asReviewInfo() = ReviewInfo( - - name = menuNames.joinToString(separator = "+"), +fun MealReviewInfoResponse.toDomain() = ReviewInfo( 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, + rating = rating ?: 0.0, + oneStarCount = reviewRatingCount?.oneStarCount ?: 0, + twoStarCount = reviewRatingCount?.twoStarCount ?: 0, + threeStarCount = reviewRatingCount?.threeStarCount ?: 0, + fourStarCount = reviewRatingCount?.fourStarCount ?: 0, + fiveStarCount = reviewRatingCount?.fiveStarCount ?: 0, ) diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MealReviewListResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MealReviewListResponse.kt new file mode 100644 index 000000000..f3560c176 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MealReviewListResponse.kt @@ -0,0 +1,51 @@ +package com.eatssu.android.data.remote.dto.response + +import com.eatssu.android.domain.model.Review +import com.google.gson.annotations.SerializedName + +data class MealReviewListResponse( + @SerializedName("numberOfElements") val numberOfElements: Int? = null, + @SerializedName("hasNext") val hasNext: Boolean? = null, + @SerializedName("dataList") val dataList: List = arrayListOf() +) { + data class DataList( + @SerializedName("reviewId") val reviewId: Long? = null, + @SerializedName("menuList") val menuList: List = arrayListOf(), + @SerializedName("writerId") val writerId: Long? = null, + @SerializedName("isWriter") val isWriter: Boolean? = null, + @SerializedName("writerNickname") val writerNickname: String? = null, + @SerializedName("rating") val rating: Int? = null, + @SerializedName("writtenAt") val writtenAt: String? = null, + @SerializedName("content") val content: String? = null, + @SerializedName("imageUrls") val imageUrls: List = arrayListOf(), + ) { + data class MenuList( + @SerializedName("id") val id: Long? = null, + @SerializedName("name") val name: String? = null, + @SerializedName("isLike") val isLike: Boolean? = null, + ) + } +} + + +fun MealReviewListResponse?.toDomain(): List { + // MealReviewListResponse 객체 자체가 null이면 emptyList() 반환 + return this?.dataList?.map { data -> + Review( + reviewId = data.reviewId ?: -1L, + isWriter = data.isWriter ?: false, + menuLikeInfoList = data.menuList.map { menu -> + Review.MenuLikeInfo( + menuId = menu.id ?: -1L, + name = menu.name ?: "", + isLike = menu.isLike ?: false + ) + }, + writerNickname = data.writerNickname ?: "", + rating = 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/remote/dto/response/MenuOfMealResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuOfMealResponse.kt index c9020d5bb..d637423e8 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuOfMealResponse.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuOfMealResponse.kt @@ -4,23 +4,21 @@ import com.eatssu.android.domain.model.MenuMini import com.google.gson.annotations.SerializedName data class MenuOfMealResponse( - @SerializedName("briefMenus") var briefMenus: ArrayList = arrayListOf(), + @SerializedName("menuList") val menuList: ArrayList = arrayListOf() ) -data class MenusInformation( +data class MenuList( - @SerializedName("menuId") var menuId: Long, - @SerializedName("name") var name: String, + @SerializedName("menuId") val menuId: Long? = null, + @SerializedName("name") val name: String? = null - ) +) -fun MenuOfMealResponse.toMenuMiniList(): List { - return briefMenus.map { it.toMenuMini() } +fun MenuOfMealResponse.toDomain(): List { + return menuList.map { + MenuMini( + id = it.menuId ?: -1L, + name = it.name ?: "" + ) + } } - -fun MenusInformation.toMenuMini(): MenuMini { - return MenuMini( - id = this.menuId, - name = this.name - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuResponse.kt index 1b6479c55..352f9cd58 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuResponse.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuResponse.kt @@ -7,23 +7,23 @@ import timber.log.Timber data class GetFixedMenuResponse( - @SerializedName("categoryMenuListCollection") var categoryMenuListCollection: List = emptyList(), + @SerializedName("categoryMenuListCollection") val categoryMenuListCollection: ArrayList = arrayListOf(), ) data class CategoryMenuListCollection( - @SerializedName("category") var category: String? = null, - @SerializedName("menus") var menus: List = emptyList(), + @SerializedName("category") val category: String? = null, + @SerializedName("menus") val menus: ArrayList = arrayListOf(), ) data class MenuInformationList( @SerializedName("menuId") var menuId: Long? = null, - @SerializedName("name") var name: String? = null, - @SerializedName("price") var price: Int? = null, - @SerializedName("rating") var rating: Double? = null, + @SerializedName("name") val name: String? = null, + @SerializedName("price") val price: Int? = null, + @SerializedName("rating") val rating: Double? = null, ) diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuReviewInfoResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuReviewInfoResponse.kt index e4f38dff8..bda768272 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuReviewInfoResponse.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuReviewInfoResponse.kt @@ -3,38 +3,28 @@ package com.eatssu.android.data.remote.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, - @SerializedName("mainRating") var mainRating: Double? = null, - @SerializedName("amountRating") var amountRating: Double? = null, - @SerializedName("tasteRating") var tasteRating: Double? = null, - @SerializedName("reviewRatingCount") var reviewRatingCount: ReviewRatingCount, +data class MenuReviewInfoResponse( + @SerializedName("menuName") val menuName: String? = null, + @SerializedName("totalReviewCount") val totalReviewCount: Int? = null, + @SerializedName("rating") val rating: Double? = null, + @SerializedName("likeCount") val likeCount: Int? = null, + @SerializedName("reviewRatingCount") val 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") val oneStarCount: Int? = null, + @SerializedName("twoStarCount") val twoStarCount: Int? = null, + @SerializedName("threeStarCount") val threeStarCount: Int? = null, + @SerializedName("fourStarCount") val fourStarCount: Int? = null, + @SerializedName("fiveStarCount") val fiveStarCount: Int? = null, + ) } -fun GetMenuReviewInfoResponse.asReviewInfo() = ReviewInfo( - - name = menuName, - reviewCnt = totalReviewCount, - 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, - - ) +fun MenuReviewInfoResponse.toDomain() = ReviewInfo( + reviewCnt = totalReviewCount ?: 0, + rating = rating ?: 0.0, + oneStarCount = reviewRatingCount?.oneStarCount ?: 0, + twoStarCount = reviewRatingCount?.twoStarCount ?: 0, + threeStarCount = reviewRatingCount?.threeStarCount ?: 0, + fourStarCount = reviewRatingCount?.fourStarCount ?: 0, + fiveStarCount = reviewRatingCount?.fiveStarCount ?: 0, +) diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuReviewListResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuReviewListResponse.kt new file mode 100644 index 000000000..34d758c2d --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MenuReviewListResponse.kt @@ -0,0 +1,49 @@ +package com.eatssu.android.data.remote.dto.response + +import com.eatssu.android.domain.model.Review +import com.google.gson.annotations.SerializedName + +data class MenuReviewListResponse( + @SerializedName("numberOfElements") val numberOfElements: Int? = null, + @SerializedName("hasNext") val hasNext: Boolean? = null, + @SerializedName("dataList") val dataList: List = arrayListOf(), +) { + data class DataList( + @SerializedName("reviewId") val reviewId: Long? = null, + @SerializedName("menu") val menu: Menu? = Menu(), + @SerializedName("writerId") val writerId: Long? = null, + @SerializedName("isWriter") val isWriter: Boolean? = null, + @SerializedName("writerNickname") val writerNickname: String? = null, + @SerializedName("rating") val rating: Int? = null, + @SerializedName("writtenAt") val writtenAt: String? = null, + @SerializedName("content") val content: String? = null, + @SerializedName("imageUrls") val imageUrls: List = arrayListOf(), + ) { + data class Menu( + @SerializedName("id") val id: Long? = null, + @SerializedName("name") val name: String? = null, + @SerializedName("isLike") val isLike: Boolean? = null + ) + } +} + +fun MenuReviewListResponse?.toDomain(): List { + return this?.dataList?.map { data -> + Review( + reviewId = data.reviewId ?: -1L, + isWriter = data.isWriter ?: false, + menuLikeInfoList = listOf( + Review.MenuLikeInfo( + menuId = data.menu?.id ?: -1L, + name = data.menu?.name ?: "", + isLike = data.menu?.isLike ?: false + ), + ), + writerNickname = data.writerNickname ?: "", + rating = 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/remote/dto/response/MyReviewListResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MyReviewListResponse.kt new file mode 100644 index 000000000..8b04a8f40 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MyReviewListResponse.kt @@ -0,0 +1,47 @@ +package com.eatssu.android.data.remote.dto.response + +import com.eatssu.android.domain.model.Review +import com.google.gson.annotations.SerializedName + +data class MyReviewListResponse( + @SerializedName("numberOfElements") val numberOfElements: Int? = null, + @SerializedName("hasNext") val hasNext: Boolean? = null, + @SerializedName("dataList") val dataList: ArrayList? = arrayListOf() +) { + data class DataList( + + @SerializedName("reviewId") val reviewId: Long? = null, + @SerializedName("rating") val rating: Int? = null, + @SerializedName("writtenAt") val writtenAt: String? = null, + @SerializedName("content") val content: String? = null, + @SerializedName("imageUrls") val imageUrls: ArrayList = arrayListOf(), + @SerializedName("menuList") val menuList: ArrayList = arrayListOf() + ) { + data class MenuList( + @SerializedName("id") val id: Long? = null, + @SerializedName("name") val name: String? = null, + @SerializedName("isLike") val isLike: Boolean? = null + ) + } +} + +fun MyReviewListResponse?.toDomain(): List { + return this?.dataList?.map { data -> + Review( + reviewId = data.reviewId ?: -1L, + isWriter = true, + menuLikeInfoList = data.menuList.map { menu -> + Review.MenuLikeInfo( + menuId = menu.id ?: -1L, + name = menu.name ?: "", + isLike = menu.isLike ?: false + ) + }, + writerNickname = "", + rating = 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/remote/dto/response/MyReviewResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/MyReviewResponse.kt deleted file mode 100644 index d2c9c0d79..000000000 --- a/app/src/main/java/com/eatssu/android/data/remote/dto/response/MyReviewResponse.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.eatssu.android.data.remote.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 ?: -1L, - isWriter = true, - menu = data.menuName ?: "", - writerNickname = "", - mainGrade = data.mainRating ?: 0, - amountGrade = data.amountRating ?: 0, - tasteGrade = data.tasteRating ?: 0, - writeDate = data.writeDate ?: "", - content = data.content ?: "", - imgUrl = data.imgUrlList ?: arrayListOf(), - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/ReviewListResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/ReviewListResponse.kt deleted file mode 100644 index ba8139590..000000000 --- a/app/src/main/java/com/eatssu/android/data/remote/dto/response/ReviewListResponse.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.eatssu.android.data.remote.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/remote/repository/MealRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/remote/repository/MealRepositoryImpl.kt index 3ad0352cf..8119f5075 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/repository/MealRepositoryImpl.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/repository/MealRepositoryImpl.kt @@ -2,7 +2,6 @@ package com.eatssu.android.data.remote.repository import com.eatssu.android.data.model.map import com.eatssu.android.data.model.orEmptyList -import com.eatssu.android.data.remote.dto.response.MenusInformation import com.eatssu.android.data.remote.dto.response.mapTodayMenuResponseToMenu import com.eatssu.android.data.remote.dto.response.toDomain import com.eatssu.android.data.remote.service.MealService @@ -34,6 +33,4 @@ class MealRepositoryImpl @Inject constructor( .orEmptyList() } - override suspend fun getMenuInfoByMealId(mealId: Long): List = - mealService.getMenuInfoByMealId(mealId).map { it.briefMenus }.orEmptyList() } diff --git a/app/src/main/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImpl.kt index 64c0282a7..568372e82 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImpl.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/repository/ReviewRepositoryImpl.kt @@ -5,13 +5,13 @@ import com.eatssu.android.data.model.map import com.eatssu.android.data.model.orEmptyList import com.eatssu.android.data.model.orNull import com.eatssu.android.data.remote.dto.request.ModifyReviewRequest -import com.eatssu.android.data.remote.dto.request.WriteReviewRequest -import com.eatssu.android.data.remote.dto.response.GetMealReviewInfoResponse -import com.eatssu.android.data.remote.dto.response.GetMenuReviewInfoResponse -import com.eatssu.android.data.remote.dto.response.ImageResponse -import com.eatssu.android.data.remote.dto.response.toReviewList +import com.eatssu.android.data.remote.dto.request.WriteMealReviewRequest +import com.eatssu.android.data.remote.dto.request.WriteMenuReviewRequest +import com.eatssu.android.data.remote.dto.response.toDomain import com.eatssu.android.data.remote.service.ReviewService +import com.eatssu.android.domain.model.MenuMini import com.eatssu.android.domain.model.Review +import com.eatssu.android.domain.model.ReviewInfo import com.eatssu.android.domain.repository.ReviewRepository import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody @@ -22,40 +22,97 @@ import javax.inject.Inject class ReviewRepositoryImpl @Inject constructor(private val reviewService: ReviewService) : ReviewRepository { - override suspend fun writeReview( - menuId: Long, - body: WriteReviewRequest, - ): Boolean = - reviewService.writeReview(menuId, body).isSuccess() + override suspend fun writeMealReview( + mealId: Long, + rating: Int, + content: String, + imageUrls: List, + likeMenuIdList: List?, + ): Boolean { + val request = WriteMealReviewRequest( + mealId = mealId, + rating = rating, + content = content, + imageUrls = imageUrls, + menuLikes = likeMenuIdList?.map { + WriteMealReviewRequest.MenuLikes( + menuId = it, + isLike = true, + ) + }, + ) + return reviewService.writeMealReview(request).isSuccess() + } + + override suspend fun writeMenuReview( + rating: Int, + content: String, + imageUrls: List, + likeMenuIdList: List?, + ): Boolean { + val request = WriteMenuReviewRequest( + rating = rating, + content = content, + imageUrls = imageUrls, + menuLike = likeMenuIdList?.let { + WriteMenuReviewRequest.MenuLike( + menuId = it.first(), + isLike = true, + ) + } + ) + return reviewService.writeMenuReview(request).isSuccess() + } override suspend fun deleteReview(reviewId: Long): Boolean = reviewService.deleteReview(reviewId).isSuccess() override suspend fun modifyReview( reviewId: Long, - body: ModifyReviewRequest, - ): Boolean = - reviewService.modifyReview(reviewId, body).isSuccess() + rating: Int, + content: String, + menuLikeInfoList: List, + ): Boolean { - override suspend fun getReviewList( - menuType: String, - mealId: Long?, - menuId: Long?, - ): List = - reviewService.getReviewList(menuType, mealId, menuId).map { it.toReviewList() } - .orEmptyList() + val request = ModifyReviewRequest( + rating = rating, + content = content, + menuLikes = menuLikeInfoList.map { + ModifyReviewRequest.MenuLikes( + menuId = it.menuId, + isLike = it.isLike, + ) + }, + ) + return reviewService.modifyReview(reviewId, request).isSuccess() + } - override suspend fun getMenuReviewInfo(menuId: Long): GetMenuReviewInfoResponse? = - reviewService.getMenuReviewInfo(menuId).orNull() + override suspend fun getMealReviewList(mealId: Long?): List { + return reviewService.getMealReviewList(mealId).map { it.toDomain() }.orEmptyList() + } - override suspend fun getMealReviewInfo(mealId: Long): GetMealReviewInfoResponse? = - reviewService.getMealReviewInfo(mealId).orNull() + override suspend fun getMenuReviewList(menuId: Long?): List { + return reviewService.getMenuReviewList(menuId).map { it.toDomain() }.orEmptyList() + } - override suspend fun getImageString(file: File): ImageResponse? { + override suspend fun getMealReviewInfo(mealId: Long): ReviewInfo? = + reviewService.getMealReviewInfo(mealId).map { it.toDomain() }.orNull() + + override suspend fun getMenuReviewInfo(menuId: Long): ReviewInfo? = + reviewService.getMenuReviewInfo(menuId).map { it.toDomain() }.orNull() + + override suspend fun getImageString(file: File): String? { val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) val multipart = MultipartBody.Part.createFormData("image", file.name, requestFile) + return reviewService.uploadImage(multipart).map { it.url }.orNull() + } + + override suspend fun getValidMenusByMealId(mealId: Long): List { + return reviewService.getMenuInfoByMealId(mealId).map { it.toDomain() }.orEmptyList() + } - return reviewService.uploadImage(multipart).orNull() + override suspend fun getMyReviews(): List { + return reviewService.getMyReviews().map { it.toDomain() }.orEmptyList() } } diff --git a/app/src/main/java/com/eatssu/android/data/remote/repository/UserRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/remote/repository/UserRepositoryImpl.kt index c2017bb41..37e134131 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/repository/UserRepositoryImpl.kt @@ -9,11 +9,9 @@ import com.eatssu.android.data.model.orNull import com.eatssu.android.data.remote.dto.request.ChangeNicknameRequest import com.eatssu.android.data.remote.dto.request.UserDepartmentRequest import com.eatssu.android.data.remote.dto.response.toDomain -import com.eatssu.android.data.remote.dto.response.toReviewList import com.eatssu.android.data.remote.service.UserService import com.eatssu.android.domain.model.College import com.eatssu.android.domain.model.Department -import com.eatssu.android.domain.model.Review import com.eatssu.android.domain.repository.UserRepository import javax.inject.Inject @@ -45,9 +43,6 @@ class UserRepositoryImpl @Inject constructor( } } - override suspend fun getUserReviews(): List = - userService.getMyReviews().map { it.toReviewList() }.orEmptyList() - override suspend fun getUserNickName(): String = userService.getMyInfo().map { it.nickname }.orNull() ?: "" diff --git a/app/src/main/java/com/eatssu/android/data/remote/service/MealService.kt b/app/src/main/java/com/eatssu/android/data/remote/service/MealService.kt index d43708ade..e75e2c3a3 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/service/MealService.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/service/MealService.kt @@ -2,9 +2,7 @@ package com.eatssu.android.data.remote.service import com.eatssu.android.data.model.ApiResult import com.eatssu.android.data.remote.dto.response.GetMealResponse -import com.eatssu.android.data.remote.dto.response.MenuOfMealResponse import retrofit2.http.GET -import retrofit2.http.Path import retrofit2.http.Query interface MealService { @@ -18,12 +16,4 @@ interface MealService { @Query("time") time: String, ): ApiResult> - /** - * 메뉴 정보 리스트 조회 - */ - @GET("meals/{mealId}/menus-info") - suspend fun getMenuInfoByMealId( - @Path("mealId") mealId: Long, - ): ApiResult - -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/data/remote/service/PartnershipService.kt b/app/src/main/java/com/eatssu/android/data/remote/service/PartnershipService.kt index 5fafb25f1..80c650fc0 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/service/PartnershipService.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/service/PartnershipService.kt @@ -5,7 +5,7 @@ import com.eatssu.android.data.remote.dto.response.PartnershipResponse import com.eatssu.android.data.remote.dto.response.PartnershipRestaurantResponse import retrofit2.http.GET -interface PartnershipService{ +interface PartnershipService { // 전체 제휴 조회 @GET("partnerships") diff --git a/app/src/main/java/com/eatssu/android/data/remote/service/ReviewService.kt b/app/src/main/java/com/eatssu/android/data/remote/service/ReviewService.kt index 2950a7657..f09924e25 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/service/ReviewService.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/service/ReviewService.kt @@ -3,11 +3,15 @@ package com.eatssu.android.data.remote.service import com.eatssu.android.data.model.ApiResult import com.eatssu.android.data.remote.dto.request.ModifyReviewRequest -import com.eatssu.android.data.remote.dto.request.WriteReviewRequest -import com.eatssu.android.data.remote.dto.response.GetMealReviewInfoResponse -import com.eatssu.android.data.remote.dto.response.GetMenuReviewInfoResponse -import com.eatssu.android.data.remote.dto.response.GetReviewListResponse +import com.eatssu.android.data.remote.dto.request.WriteMealReviewRequest +import com.eatssu.android.data.remote.dto.request.WriteMenuReviewRequest import com.eatssu.android.data.remote.dto.response.ImageResponse +import com.eatssu.android.data.remote.dto.response.MealReviewInfoResponse +import com.eatssu.android.data.remote.dto.response.MealReviewListResponse +import com.eatssu.android.data.remote.dto.response.MenuOfMealResponse +import com.eatssu.android.data.remote.dto.response.MenuReviewInfoResponse +import com.eatssu.android.data.remote.dto.response.MenuReviewListResponse +import com.eatssu.android.data.remote.dto.response.MyReviewListResponse import okhttp3.MultipartBody import retrofit2.http.Body import retrofit2.http.DELETE @@ -21,44 +25,51 @@ 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, + ): ApiResult + + @POST("/v2/reviews/meal") //리뷰 작성 + suspend fun writeMealReview( + @Body request: WriteMealReviewRequest, ): ApiResult - @DELETE("/reviews/{reviewId}") //리뷰 삭제 + @DELETE("/v2/reviews/{reviewId}") //리뷰 삭제 suspend fun deleteReview( @Path("reviewId") reviewId: Long, ): ApiResult - @PATCH("/reviews/{reviewId}") //리뷰 수정(글 수정) + @PATCH("/v2/reviews/{reviewId}") //리뷰 수정(글 수정) suspend fun modifyReview( @Path("reviewId") reviewId: Long, @Body request: ModifyReviewRequest, ): ApiResult //Todo paging 라이브러리 써보기 - @GET("/reviews") //리뷰 리스트 조회 - suspend fun getReviewList( - @Query("menuType") menuType: String, + @GET("/v2/reviews/list/meal") //리뷰 리스트 조회 + suspend fun getMealReviewList( @Query("mealId") mealId: Long?, + ): ApiResult + + @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"), - ): ApiResult +//// @Query("lastReviewId") lastReviewId: Long?, +// @Query("page") page: Int? = 0, +// @Query("size") size: Int? = 20, +// @Query("sort") sort: List? = arrayListOf("date", "DESC"), + ): ApiResult - @GET("/reviews/menus/{menuId}") //고정 메뉴 리뷰 정보 조회(메뉴명, 평점 등등) + @GET("/v2/reviews/statistics/menus/{menuId}") //고정 메뉴 리뷰 정보 조회(메뉴명, 평점 등등) suspend fun getMenuReviewInfo( @Path("menuId") menuId: Long, - ): ApiResult + ): ApiResult - @GET("/reviews/meals/{mealId}") //식단(변동 메뉴) 리뷰 정보 조회(메뉴명, 평점 등등) + @GET("/v2/reviews/statistics/meals/{mealId}") //식단(변동 메뉴) 리뷰 정보 조회(메뉴명, 평점 등등) suspend fun getMealReviewInfo( @Path("mealId") mealId: Long, - ): ApiResult + ): ApiResult @Multipart @POST("/reviews/upload/image") //리뷰 이미지 업로드 @@ -66,4 +77,12 @@ interface ReviewService { @Part image: MultipartBody.Part, ): ApiResult -} \ No newline at end of file + @GET("v2/reviews/meal/valid-for-review/{mealId}") //메뉴 정보 리스트 조회 + suspend fun getMenuInfoByMealId( + @Path("mealId") mealId: Long, + ): ApiResult + + @GET("users/v2/reviews") // 내가 쓴 리뷰 + suspend fun getMyReviews(): ApiResult + +} diff --git a/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt b/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt index f68516aa8..311a4cbb2 100644 --- a/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt +++ b/app/src/main/java/com/eatssu/android/data/remote/service/UserService.kt @@ -6,7 +6,6 @@ import com.eatssu.android.data.remote.dto.request.UserDepartmentRequest import com.eatssu.android.data.remote.dto.response.CollegeResponse import com.eatssu.android.data.remote.dto.response.DepartmentResponse import com.eatssu.android.data.remote.dto.response.MyNickNameResponse -import com.eatssu.android.data.remote.dto.response.MyReviewResponse import com.eatssu.android.data.remote.dto.response.PartnershipResponse import com.eatssu.android.data.remote.dto.response.UserCollegeDepartmentResponse import retrofit2.http.Body @@ -28,9 +27,6 @@ interface UserService { @Query("nickname") nickname: String, ): ApiResult - @GET("users/reviews") //내가 쓴 리뷰 모아보기 - suspend fun getMyReviews(): ApiResult - @GET("users/mypage") //내 정보 모아보기 suspend fun getMyInfo(): ApiResult @@ -56,4 +52,4 @@ interface UserService { @GET("users/department/partnerships") // 유저 학과의 제휴 조회 suspend fun getUserDepartmentPartnerships(): ApiResult> -} \ No newline at end of file +} 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 764bf3d71..7e3ff57b2 100644 --- a/app/src/main/java/com/eatssu/android/di/ServiceModule.kt +++ b/app/src/main/java/com/eatssu/android/di/ServiceModule.kt @@ -18,6 +18,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object ServiceModule { + @Provides @Singleton fun provideOauthService(@NoToken noTokenRetrofit: Retrofit): OauthService { @@ -44,8 +45,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 @@ -66,4 +67,4 @@ object ServiceModule { return noTokenRetrofit.create(HealthCheckService::class.java) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/domain/model/CalendarData.kt b/app/src/main/java/com/eatssu/android/domain/model/CalendarData.kt deleted file mode 100644 index fe9f6e259..000000000 --- a/app/src/main/java/com/eatssu/android/domain/model/CalendarData.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.eatssu.android.domain.model - -data class CalendarData( - var cl_date: String = "", // 날짜 - var cl_day: String = "" // 요일 -) 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..6421900cd 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 @@ -1,18 +1,23 @@ package com.eatssu.android.domain.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + data class Review( val isWriter: Boolean, val reviewId: Long, - - val menu: String, + val menuLikeInfoList: List, val writerNickname: String, - - val mainGrade: Int, - val amountGrade: Int, - val tasteGrade: Int, - + val rating: Int, val writeDate: String, - val content: String, - val imgUrl: ArrayList?, -) \ No newline at end of file + val imgUrl: String?, +) { + @Parcelize + data class MenuLikeInfo( + val menuId: Long, + val name: String, + val isLike: Boolean, + ) : Parcelable + +} 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..2e2177257 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 @@ -1,14 +1,11 @@ package com.eatssu.android.domain.model 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, - var four: Int, - var five: Int, + var rating: Double, + var oneStarCount: Int, + var twoStarCount: Int, + var threeStarCount: Int, + var fourStarCount: Int, + var fiveStarCount: Int, ) \ No newline at end of file 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 60ffd516c..d34c623c5 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,6 +1,5 @@ package com.eatssu.android.domain.repository -import com.eatssu.android.data.remote.dto.response.MenusInformation import com.eatssu.android.domain.model.Menu import com.eatssu.common.enums.Restaurant import com.eatssu.common.enums.Time @@ -24,11 +23,4 @@ interface MealRepository { restaurant: Restaurant, time: Time, ): List - - /** - * MealId를 이용해서 Menu를 찾기 api - */ - suspend fun getMenuInfoByMealId( - mealId: Long, - ): List -} \ 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 af34fe1ac..27ec4a0f9 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,18 +1,25 @@ package com.eatssu.android.domain.repository -import com.eatssu.android.data.remote.dto.request.ModifyReviewRequest -import com.eatssu.android.data.remote.dto.request.WriteReviewRequest -import com.eatssu.android.data.remote.dto.response.GetMealReviewInfoResponse -import com.eatssu.android.data.remote.dto.response.GetMenuReviewInfoResponse -import com.eatssu.android.data.remote.dto.response.ImageResponse +import com.eatssu.android.domain.model.MenuMini 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, + suspend fun writeMealReview( + mealId: Long, + rating: Int, + content: String, + imageUrls: List, + likeMenuIdList: List?, + ): Boolean + + suspend fun writeMenuReview( + rating: Int, + content: String, + imageUrls: List, + likeMenuIdList: List?, ): Boolean suspend fun deleteReview( @@ -21,25 +28,35 @@ interface ReviewRepository { suspend fun modifyReview( reviewId: Long, - body: ModifyReviewRequest, + rating: Int, + content: String, + menuLikeInfoList: List, ): Boolean - suspend fun getReviewList( - menuType: String, - mealId: Long?, + suspend fun getMenuReviewList( menuId: Long?, ): List + suspend fun getMealReviewList( + mealId: Long?, + ): List + suspend fun getMenuReviewInfo( menuId: Long, - ): GetMenuReviewInfoResponse? - + ): ReviewInfo? suspend fun getMealReviewInfo( mealId: Long, - ): GetMealReviewInfoResponse? + ): ReviewInfo? suspend fun getImageString( file: File - ): ImageResponse? -} \ No newline at end of file + ): String? + + suspend fun getValidMenusByMealId( + mealId: Long, + ): List + + suspend fun getMyReviews(): 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 17f687854..4423038cf 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,20 +3,23 @@ package com.eatssu.android.domain.repository import com.eatssu.android.data.remote.dto.request.ChangeNicknameRequest import com.eatssu.android.domain.model.College import com.eatssu.android.domain.model.Department -import com.eatssu.android.domain.model.Review interface UserRepository { + // 닉네임 변경 suspend fun updateUserName( body: ChangeNicknameRequest, ): Result + // 유저 닉네임 중복 검사 suspend fun checkUserNameValidation( nickname: String, ): Result - suspend fun getUserReviews(): List + // 유저 닉네임 조회 suspend fun getUserNickName(): String + + // 회원 탈퇴 suspend fun signOut(): Boolean // 모든 단과대 조회 @@ -33,4 +36,3 @@ interface UserRepository { departmentId: Int, ): Boolean } - 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 deleted file mode 100644 index 1bc78eb64..000000000 --- a/app/src/main/java/com/eatssu/android/domain/usecase/menu/GetMenuNameListOfMealUseCase.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.eatssu.android.domain.usecase.menu - -import com.eatssu.android.data.remote.dto.response.MenusInformation -import com.eatssu.android.domain.repository.MealRepository -import javax.inject.Inject - -class GetMenuNameListOfMealUseCase @Inject constructor( - private val mealRepository: MealRepository, -) { - suspend operator fun invoke(menuId: Long): List = - mealRepository.getMenuInfoByMealId(menuId) -} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/menu/GetValidMenusOfMealUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/menu/GetValidMenusOfMealUseCase.kt new file mode 100644 index 000000000..91ff842a2 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/usecase/menu/GetValidMenusOfMealUseCase.kt @@ -0,0 +1,13 @@ +package com.eatssu.android.domain.usecase.menu + +import com.eatssu.android.domain.model.MenuMini +import com.eatssu.android.domain.repository.ReviewRepository +import javax.inject.Inject + +class GetValidMenusOfMealUseCase @Inject constructor( + private val reviewRepository: ReviewRepository +) { + suspend operator fun invoke(menuId: Long): List { + return reviewRepository.getValidMenusByMealId(menuId) + } +} \ 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 185368493..fd4545287 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 @@ -10,5 +10,5 @@ class GetImageUrlUseCase @Inject constructor( suspend operator fun invoke( file: File ): String? = - reviewRepository.getImageString(file)?.url + 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 819bbd9c6..000000000 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMealReviewInfoUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.eatssu.android.domain.usecase.review - -import com.eatssu.android.data.remote.dto.response.asReviewInfo -import com.eatssu.android.domain.model.ReviewInfo -import com.eatssu.android.domain.repository.ReviewRepository -import javax.inject.Inject - -class GetMealReviewInfoUseCase @Inject constructor( - private val reviewRepository: ReviewRepository, -) { - suspend operator fun invoke(mealId: Long): ReviewInfo? = - reviewRepository.getMealReviewInfo(mealId)?.asReviewInfo() -} \ 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 ea7fada8c..000000000 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMealReviewListUseCase.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.eatssu.android.domain.usecase.review - -import com.eatssu.android.domain.model.Review -import com.eatssu.android.domain.repository.ReviewRepository -import com.eatssu.common.enums.MenuType -import javax.inject.Inject - -class GetMealReviewListUseCase @Inject constructor( - private val reviewRepository: ReviewRepository, -) { - suspend operator fun invoke( - mealId: Long?, - ): List = - 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 90630489b..000000000 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMenuReviewInfoUseCase.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.eatssu.android.domain.usecase.review - -import com.eatssu.android.data.remote.dto.response.asReviewInfo -import com.eatssu.android.domain.model.ReviewInfo -import com.eatssu.android.domain.repository.ReviewRepository -import javax.inject.Inject - -class GetMenuReviewInfoUseCase @Inject constructor( - private val reviewRepository: ReviewRepository, -) { - suspend operator fun invoke(menuId: Long): ReviewInfo? = - reviewRepository.getMenuReviewInfo(menuId)?.asReviewInfo() -} \ 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 5cdff3d1a..000000000 --- a/app/src/main/java/com/eatssu/android/domain/usecase/review/GetMenuReviewListUseCase.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.eatssu.android.domain.usecase.review - -import com.eatssu.android.domain.model.Review -import com.eatssu.android.domain.repository.ReviewRepository -import com.eatssu.common.enums.MenuType -import javax.inject.Inject - -class GetMenuReviewListUseCase @Inject constructor( - private val reviewRepository: ReviewRepository, -) { - suspend operator fun invoke( - menuId: Long?, - ): List = - 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 index c51cd8dd3..41fb70561 100644 --- 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 @@ -1,12 +1,12 @@ package com.eatssu.android.domain.usecase.review import com.eatssu.android.domain.model.Review -import com.eatssu.android.domain.repository.UserRepository +import com.eatssu.android.domain.repository.ReviewRepository import javax.inject.Inject class GetMyReviewsUseCase @Inject constructor( - private val userRepository: UserRepository, + private val reviewRepository: ReviewRepository, ) { suspend operator fun invoke(): List = - userRepository.getUserReviews() -} \ No newline at end of file + reviewRepository.getMyReviews() +} 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..85a345dd5 --- /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.domain.model.ReviewInfo +import com.eatssu.android.domain.repository.ReviewRepository +import com.eatssu.common.enums.MenuType +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..32b02a661 --- /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.domain.model.Review +import com.eatssu.android.domain.repository.ReviewRepository +import com.eatssu.common.enums.MenuType +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 cbd098d65..774b02831 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,6 +1,6 @@ package com.eatssu.android.domain.usecase.review -import com.eatssu.android.data.remote.dto.request.ModifyReviewRequest +import com.eatssu.android.domain.model.Review import com.eatssu.android.domain.repository.ReviewRepository import javax.inject.Inject @@ -9,7 +9,15 @@ class ModifyReviewUseCase @Inject constructor( ) { suspend operator fun invoke( reviewId: Long, - body: ModifyReviewRequest, - ): Boolean = - reviewRepository.modifyReview(reviewId, body) + rating: Int, + content: String, + menuLikeInfoList: List, + ): Boolean { + return reviewRepository.modifyReview( + reviewId, + rating, + content, + menuLikeInfoList + ) + } } \ 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 221f43f02..3019a62f7 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,12 +1,39 @@ package com.eatssu.android.domain.usecase.review -import com.eatssu.android.data.remote.dto.request.WriteReviewRequest import com.eatssu.android.domain.repository.ReviewRepository +import com.eatssu.common.enums.MenuType import javax.inject.Inject class WriteReviewUseCase @Inject constructor( private val reviewRepository: ReviewRepository, ) { - suspend operator fun invoke(menuId: Long, body: WriteReviewRequest): Boolean = - reviewRepository.writeReview(menuId, body) -} \ No newline at end of file + suspend operator fun invoke( + menuType: MenuType, + itemId: Long, + rating: Int, + content: String, + imageUrl: String?, + likeMenuIdList: List?, + ): Boolean { + when (menuType) { + MenuType.FIXED -> { + return reviewRepository.writeMenuReview( + rating = rating, + content = content, + imageUrls = if (imageUrl != null) listOf(imageUrl) else emptyList(), + likeMenuIdList = likeMenuIdList, + ) + } + + MenuType.VARIABLE -> { + return reviewRepository.writeMealReview( + mealId = itemId, + rating = rating, + content = content, + imageUrls = if (imageUrl != null) listOf(imageUrl) else emptyList(), + likeMenuIdList = likeMenuIdList, + ) + } + } + } +} diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/user/ValidateNicknameLocalUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/user/ValidateNicknameLocalUseCase.kt index 6f88f5c05..fad8f67b5 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/user/ValidateNicknameLocalUseCase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/user/ValidateNicknameLocalUseCase.kt @@ -5,7 +5,11 @@ import javax.inject.Inject // 정규식을 사용해 로컬에서 닉네임 검증 class ValidateNicknameLocalUseCase @Inject constructor() { - operator fun invoke(nickname: String, minLength: Int, maxLength: Int): NicknameValidationResult { + operator fun invoke( + nickname: String, + minLength: Int, + maxLength: Int + ): NicknameValidationResult { // 길이 제한 if (nickname.length !in minLength..maxLength) { return Invalid("${minLength}~${maxLength}글자를 입력해 주세요.") diff --git a/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt b/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt index d5fab44bf..758d5cf7a 100644 --- a/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt @@ -22,6 +22,8 @@ import com.eatssu.android.presentation.mypage.MyPageViewModel import com.eatssu.android.presentation.mypage.userinfo.UserInfoActivity import com.eatssu.android.presentation.util.showToast import com.eatssu.android.presentation.util.startActivity +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import com.eatssu.common.enums.ScreenId import com.google.android.material.bottomnavigation.BottomNavigationView import dagger.hilt.android.AndroidEntryPoint diff --git a/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt index a9c1e0878..8e00f5519 100644 --- a/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt @@ -11,6 +11,8 @@ import com.eatssu.android.domain.usecase.auth.LogoutUseCase import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableSharedFlow diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt index ea8cd3c7d..8e9f3d374 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt @@ -16,8 +16,9 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.eatssu.android.databinding.FragmentMenuBinding import com.eatssu.android.domain.model.Section import com.eatssu.android.presentation.MainViewModel -import com.eatssu.android.presentation.UiState import com.eatssu.android.presentation.cafeteria.info.InfoViewModel +import com.eatssu.common.UiState +import com.eatssu.common.enums.MenuType import com.eatssu.common.enums.Restaurant import com.eatssu.common.enums.Time import dagger.hilt.android.AndroidEntryPoint 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 ef2092677..d64b3edb8 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 @@ -8,7 +9,7 @@ import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView 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 import com.eatssu.common.EventLogger import com.eatssu.common.enums.MenuType import com.eatssu.common.enums.Restaurant @@ -23,6 +24,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 (restaurant.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) + EventLogger.clickMenu(restaurant) + // 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() @@ -44,29 +85,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 (restaurant.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) - EventLogger.clickMenu(restaurant) - } } override fun getItemCount(): Int = dataList.size diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModel.kt index 5d0bae676..d45d25593 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.eatssu.android.domain.model.Menu import com.eatssu.android.domain.usecase.menu.GetMenuListUseCase -import com.eatssu.android.presentation.UiState +import com.eatssu.common.UiState import com.eatssu.common.enums.Restaurant import com.eatssu.common.enums.Time import dagger.hilt.android.lifecycle.HiltViewModel 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..23f8eb9fd --- /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.common.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..f6395f49c --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewNav.kt @@ -0,0 +1,87 @@ +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.domain.model.Review +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.WriteReviewScreen +import com.eatssu.common.enums.MenuType + +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.rating) + set("initialContent", review.content) + set("menuList", review.menuLikeInfoList) + } + + navHostController.navigate(ReviewNav.Modify) { launchSingleTop = true } + }, + onWriteButtonClick = { + navHostController.navigate(ReviewNav.Write) { + launchSingleTop = true + } + } + ) + } + + // 리뷰 작성 + composable(ReviewNav.Write) { backStackEntry -> + WriteReviewScreen( + menuType = menuType, + menuName = menuName, + id = id, + 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 menuLikeInfoNames = + prev?.get>("menuList") ?: arrayListOf() + + ModifyReviewScreen( + reviewId = reviewId, + initialRating = initialRating, + initialContent = initialContent, + menuLikeInfoList = menuLikeInfoNames, + onBack = { navHostController.popBackStack() }, + ) + } + } +} \ No newline at end of file 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 f6f6f38a5..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewActivity.kt +++ /dev/null @@ -1,218 +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.databinding.ActivityReviewBinding -import com.eatssu.android.domain.model.Review -import com.eatssu.android.presentation.base.BaseActivity -import com.eatssu.android.presentation.cafeteria.review.write.ReviewWriteRateActivity -import com.eatssu.android.presentation.cafeteria.review.write.menu.ReviewWriteMenuActivity -import com.eatssu.android.presentation.common.MyReviewBottomSheetFragment -import com.eatssu.android.presentation.common.OthersBottomSheetFragment -import com.eatssu.common.EventLogger -import com.eatssu.common.enums.MenuType -import com.eatssu.common.enums.ScreenId -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, ScreenId.REVIEW_V1_VIEW), - 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) - EventLogger.writeReview() - } - } - - MenuType.VARIABLE.name -> { - binding.btnNextReview.setOnClickListener { - val intent = Intent(this, ReviewWriteMenuActivity::class.java) - intent.putExtra("itemId", itemId) - intent.putExtra("menuType", menuType) - startActivity(intent) - EventLogger.writeReview() - } - } - - 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..ed7224819 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt @@ -0,0 +1,617 @@ +package com.eatssu.android.presentation.cafeteria.review.list + +import android.content.Intent +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.fillMaxHeight +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.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.domain.model.Review +import com.eatssu.android.domain.model.ReviewInfo +import com.eatssu.android.presentation.cafeteria.review.list.component.MyReviewBottomSheet +import com.eatssu.android.presentation.cafeteria.review.list.component.OthersReviewBottomSheet +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.cafeteria.review.report.ReportActivity +import com.eatssu.android.presentation.util.showToast +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState +import com.eatssu.common.enums.MenuType +import com.eatssu.design_system.component.DelayedLoadingIndicator +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 + +@Composable +fun ReviewListScreen( + modifier: Modifier = Modifier, + viewModel: ReviewListViewModel = hiltViewModel(), + menuType: MenuType, + menuName: String, + id: Long, + onBack: () -> Unit = {}, + onWriteButtonClick: () -> Unit, + 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: () -> 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 selectedReview by remember { mutableStateOf(null) } + + if (showOthersBottomSheet && selectedReview != null) { + OthersReviewBottomSheet( + onDismiss = { showOthersBottomSheet = false; selectedReview = null }, + onReport = { + val intent = Intent(context, ReportActivity::class.java) + intent.putExtra("reviewId", selectedReview?.reviewId) + context.startActivity(intent) + showOthersBottomSheet = false + selectedReview = null + } + ) + } + + if (showMyBottomSheet && selectedReview != null) { + MyReviewBottomSheet( + onDismiss = { showMyBottomSheet = false; selectedReview = null }, + onModify = { + selectedReview?.let { onModifyClick(it) } + showMyBottomSheet = false + selectedReview = null + }, + onDelete = { + selectedReview?.let { onDeleteClick(it.reviewId) } + showMyBottomSheet = false + selectedReview = null + } + ) + } + + Scaffold( + topBar = { + EatSsuTopBar( + title = "리뷰", + onBack = onBack + ) + }, + bottomBar = { // 하단에 버튼을 고정하기 위함 + EatSsuButton( + text = "리뷰 작성하기", + onClick = { + onReviewWriteButtonClick() + }, + 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( + menuName, ReviewInfo( + reviewCnt = 0, + fiveStarCount = 0, + fourStarCount = 0, + threeStarCount = 0, + twoStarCount = 0, + oneStarCount = 0, + rating = 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 + .align(Alignment.CenterHorizontally) + .fillMaxHeight() + .padding(top = 100.dp) + ) { + DelayedLoadingIndicator(modifier = Modifier) + } + } + } + + + is UiState.Success -> { + val info = uiState.data?.reviewInfo + val reviewList = uiState.data?.reviewList ?: emptyList() + + ReviewInfoContent(menuName, 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 + .fillMaxHeight() + .padding(top = 100.dp), + ) + } else { + reviewList.forEach { item -> + ReviewItem( + modifier = Modifier.padding(horizontal = 24.dp), + writeName = item.writerNickname, + writeDate = item.writeDate, + content = item.content, + rating = item.rating, + menuLikeInfoList = item.menuLikeInfoList, + imgUrl = item.imgUrl, + onMoreClick = { + if (item.isWriter) { + showMyBottomSheet = true + selectedReview = item + } else { + showOthersBottomSheet = true + selectedReview = item + } + } + ) + } + } + } + } + + UiState.Error -> { + // TODO: 에러 UI + ReviewInfoContent( + menuName, + ReviewInfo( + reviewCnt = 0, + fiveStarCount = 0, + fourStarCount = 0, + threeStarCount = 0, + twoStarCount = 0, + oneStarCount = 0, + rating = 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 + .align(Alignment.CenterHorizontally) + .fillMaxHeight() + .padding(top = 100.dp) + ) { + Text( + "에러가 발생했습니다.", + style = EatssuTheme.typography.body1, + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + } + } + } + } +} + +@Composable +fun ReviewInfoContent( + menuName: String, + 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( + menuName, + 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( + if (info?.reviewCnt == 0) "-" else info?.rating.toString(), + modifier = Modifier.align(Alignment.CenterVertically), + style = EatssuTheme.typography.rate + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + ReviewProgressBar( + reviewCount = info?.reviewCnt ?: 0, + fiveRatingCount = info?.fiveStarCount ?: 0, + fourRatingCount = info?.fourStarCount ?: 0, + threeRatingCount = info?.threeStarCount ?: 0, + twoRatingCount = info?.twoStarCount ?: 0, + oneRatingCount = info?.oneStarCount ?: 0, + modifier = Modifier.width(150.dp) + ) + } + + } +} + +@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( + reviewCnt = 123, + fiveStarCount = 80, + fourStarCount = 20, + threeStarCount = 10, + twoStarCount = 5, + oneStarCount = 8, + rating = 4.5, + ), + reviewList = listOf( + Review( + isWriter = false, + reviewId = 0, + menuLikeInfoList = listOf( + Review.MenuLikeInfo( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.MenuLikeInfo( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "숭실푸드파이터", + writeDate = "2024-12-31", + rating = 4, + content = "맛있어요", + imgUrl = null, + ), + Review( + isWriter = false, + reviewId = 1, + menuLikeInfoList = listOf( + Review.MenuLikeInfo( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.MenuLikeInfo( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "맛있는리뷰어", + writeDate = "2024-12-30", + rating = 5, + content = "정말 맛있어요! 다음에도 먹고 싶어요.", + imgUrl = null, + ), + Review( + isWriter = false, + reviewId = 2, + menuLikeInfoList = listOf( + Review.MenuLikeInfo( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.MenuLikeInfo( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "음식평론가", + writeDate = "2024-12-29", + rating = 3, + content = "그럭저럭 괜찮아요", + imgUrl = null, + ), + Review( + isWriter = false, + reviewId = 2, + menuLikeInfoList = listOf( + Review.MenuLikeInfo( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.MenuLikeInfo( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "음식평론가", + writeDate = "2024-12-29", + rating = 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( + reviewCnt = 0, + fiveStarCount = 0, + fourStarCount = 0, + threeStarCount = 0, + twoStarCount = 0, + oneStarCount = 0, + rating = 0.0, + ), + reviewList = emptyList() + ) + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ReviewListErrorPreview() { + EatssuTheme { + ReviewListScreen( + menuName = "소고기+닭고기+돼지고기+양고기+오리고기", + onReviewWriteButtonClick = {}, + onModifyClick = {}, + onDeleteClick = {}, + uiState = UiState.Error + ) + } +} \ 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..a222b1adc --- /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.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.common.UiEvent +import com.eatssu.common.UiState +import com.eatssu.common.enums.MenuType +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 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) { + viewModelScope.launch { + loadReview(menuType, itemId) + } + } + + private suspend fun loadReview(menuType: MenuType, itemId: Long) { + lastMenuType = menuType + lastItemId = itemId + _uiState.value = UiState.Loading + + try { + val reviewInfo = getReviewInfoUseCase(menuType, itemId) + val reviewList = getReviewListUseCase(menuType, itemId) + _uiState.value = UiState.Success(ReviewListState(reviewInfo, reviewList)) + } catch (e: Exception) { + _uiState.value = UiState.Error + _uiEvent.emit(UiEvent.ShowToast("리뷰를 불러오지 못했습니다.")) + } + } + + fun deleteReview(reviewId: Long) { + viewModelScope.launch { + + val success = deleteReviewUseCase(reviewId) + + if (!success) { + _uiEvent.emit(UiEvent.ShowToast("리뷰 삭제에 실패했습니다.")) + return@launch + } + + // 삭제 성공 시 + _uiEvent.emit(UiEvent.ShowToast("리뷰를 삭제했습니다.")) + val type = lastMenuType + val id = lastItemId + if (type != null && id != null) { + // 같은 코루틴 안에서 suspend로 연속 실행 + loadReview(type, id) + } + } + } +} + +data class ReviewListState( + val reviewInfo: ReviewInfo? = null, + val reviewList: List = emptyList() +) 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 d84a320cb..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewViewModel.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.list - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -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 com.eatssu.common.enums.MenuType -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.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 { - _uiState.update { it.copy(loading = true) } - - val menuReviewInfo = getMenuReviewInfoUseCase(menuId) - _uiState.update { - it.copy( - loading = false, - error = false, - reviewInfo = menuReviewInfo, - isEmpty = menuReviewInfo == null, - ) - } - } - } - - private fun callMealReviewInfo(mealId: Long) { - viewModelScope.launch { - _uiState.update { it.copy(loading = true) } - - val mealReviewInfo = getMealReviewInfoUseCase(mealId) - _uiState.update { - it.copy( - loading = false, - error = false, - reviewInfo = mealReviewInfo, - isEmpty = mealReviewInfo == null, - ) - } - } - } - - private fun callMenuReviewList(itemId: Long) { - viewModelScope.launch { - val menuReviewList = getMenuReviewListUseCase(itemId) - _uiState.update { - it.copy( - loading = false, - error = false, - reviewList = menuReviewList, - isEmpty = menuReviewList.isEmpty() - ) - } - } - } - - private fun callMealReviewList(itemId: Long) { - viewModelScope.launch { - val reviewList = getMealReviewListUseCase(itemId) - _uiState.update { - it.copy( - loading = false, - error = false, - reviewList = reviewList, - isEmpty = reviewList.isEmpty() - ) - } - } - } - - fun deleteReview(reviewId: Long) { - viewModelScope.launch { - _uiState.update { it.copy(loading = true) } - - val success = deleteReviewUseCase(reviewId) - if (!success) { - _uiState.update { - it.copy( - error = true, - ) - } - return@launch - } - - _uiState.update { - it.copy( - loading = false, - error = false, - ) - } - } - } -} - -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/MyReviewBottomSheet.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/MyReviewBottomSheet.kt new file mode 100644 index 000000000..832c502c0 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/MyReviewBottomSheet.kt @@ -0,0 +1,153 @@ +package com.eatssu.android.presentation.cafeteria.review.list.component + + +import android.content.res.Configuration +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 = 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/component/OthersReviewBottomSheet.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/OthersReviewBottomSheet.kt new file mode 100644 index 000000000..d8f8c74f3 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/OthersReviewBottomSheet.kt @@ -0,0 +1,131 @@ +package com.eatssu.android.presentation.cafeteria.review.list.component + + +import android.content.res.Configuration +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 = 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/component/ReviewItem.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/ReviewItem.kt new file mode 100644 index 000000000..2e1ae4672 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/ReviewItem.kt @@ -0,0 +1,187 @@ +package com.eatssu.android.presentation.cafeteria.review.list.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.eatssu.android.R +import com.eatssu.android.domain.model.Review +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 +fun ReviewItem( + writeName: String, + writeDate: String, + content: String, + rating: Int, + modifier: Modifier = Modifier, + menuLikeInfoList: 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 = com.eatssu.design_system.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 + ) + Spacer(modifier = Modifier.height(2.dp)) + RatingBarSmall(rating = rating) + } + + Spacer( + modifier = Modifier.weight(1f) + ) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End + ) { + IconButton( + onClick = { onMoreClick() } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_menu_12), + contentDescription = "etc", + modifier = Modifier.size(12.dp), + tint = Color.Unspecified, + ) + } + + Text( + writeDate, + style = EatssuTheme.typography.caption3, + color = Gray400, + modifier = Modifier.padding(end = 20.dp) //20인 이유는 없음 IconButton에 넣으면서 padding 생겨서 끝점을 맞추려고 조절한 것임 + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (!menuLikeInfoList.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + FlowRow( + modifier = Modifier.padding(horizontal = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + menuLikeInfoList.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, + writeName = "숭실푸드파이터", + writeDate = "2024-12-31", + content = "맛있어요", + rating = 4, + menuLikeInfoList = listOf( + Review.MenuLikeInfo( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.MenuLikeInfo( + 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, + writeName = "맛있는리뷰어", + writeDate = "2024-12-30", + content = "사진 없이 텍스트만 있는 리뷰입니다.", + rating = 5, + menuLikeInfoList = listOf( + Review.MenuLikeInfo( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.MenuLikeInfo( + 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..83f8f9fb1 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/component/ReviewProgressBar.kt @@ -0,0 +1,141 @@ +package com.eatssu.android.presentation.cafeteria.review.list.component + +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.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 + +@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 + ) + Column(modifier = modifier) { + + ratingList.forEach { (rating, count) -> + val percent = if (reviewCount > 0) (count.toFloat() / reviewCount.toFloat()) else 0f + + 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)) + + Box( + modifier = Modifier + .weight(1f) + ) { // LinearProgressIndicator의 구현 자체가 progress와 track 중간에 여백이 있어서 + // 이를 커버하기 위해 Box로 감싸서 두개를 겹쳐놓음 + // 첫번째 LinearProgressIndicator는 그레이색 배경만 + // 두번째 LinearProgressIndicator가 진행 + LinearProgressIndicator( + progress = { 0f }, + modifier = Modifier + .height(5.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = Gray200, + ) + LinearProgressIndicator( + progress = { percent.coerceIn(0f, 1f) }, + modifier = Modifier + .matchParentSize() + .height(5.dp), + color = MaterialTheme.colorScheme.primary, + drawStopIndicator = {}, + 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 ReviewProgressBar1Preview() { + EatssuTheme { + + ReviewProgressBar( + reviewCount = 100, + fiveRatingCount = 100, + fourRatingCount = 0, + threeRatingCount = 0, + twoRatingCount = 0, + oneRatingCount = 0 + ) + } +} + +@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 207e0cefd..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewActivity.kt +++ /dev/null @@ -1,101 +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.remote.dto.request.ModifyReviewRequest -import com.eatssu.android.databinding.ActivityFixMenuBinding -import com.eatssu.android.presentation.base.BaseActivity -import com.eatssu.android.presentation.util.showToast -import com.eatssu.common.enums.ScreenId -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import timber.log.Timber - -@AndroidEntryPoint -class ModifyReviewActivity : BaseActivity( - ActivityFixMenuBinding::inflate, - ScreenId.REVIEW_V1_MODIFY -) { - - 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..637fb8b98 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/modify/ModifyReviewScreen.kt @@ -0,0 +1,266 @@ +package com.eatssu.android.presentation.cafeteria.review.modify + +import androidx.compose.foundation.layout.Column +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.domain.model.Review +import com.eatssu.android.presentation.cafeteria.review.write.component.MenuLikeButtonItem +import com.eatssu.android.presentation.util.showToast +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState +import com.eatssu.design_system.component.CloseTopBar +import com.eatssu.design_system.component.EatSsuButton +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 + +const val MAX_TEXT_COUNT = 300 + +@Composable +fun ModifyReviewScreen( + reviewId: Long, + initialRating: Int, + modifier: Modifier = Modifier, + viewModel: ModifyViewModel = hiltViewModel(), + initialContent: String = "", + menuLikeInfoList: List = emptyList(), + onBack: () -> Unit = {}, +) { + val context = LocalContext.current + val ui by viewModel.uiState.collectAsStateWithLifecycle() + + // 최초 1회 초기화 + LaunchedEffect(Unit) { + viewModel.init(initialRating, initialContent, menuLikeInfoList) + } + + // 완료 이펙트 처리 + LaunchedEffect(Unit) { + viewModel.uiEvent.collect { event -> + when (event) { + is UiEvent.NavigateBack -> onBack() + is UiEvent.ShowToast -> { + context.showToast(event.message) + } + } + } + } + + when (val data = (ui as? UiState.Success)?.data) { + is ModifyState.Editing -> { + ModifyReviewScreen( + modifier = modifier, + title = "리뷰 수정하기", + rating = data.rating, + content = data.content, + menuLikeInfos = data.menuLikeInfos, + isSubmitting = false, + canSubmit = data.canSubmit, + onBack = onBack, + onRatingChanged = viewModel::onRatingChanged, + onContentChanged = { new -> + if (new.length <= MAX_TEXT_COUNT) viewModel.onContentChanged(new) + }, + onToggleLike = viewModel::toggleLike, + onSubmit = { viewModel.submit(reviewId) } + ) + } + + is ModifyState.Modifying -> { + // 통신 중에도 폼은 유지, 버튼/입력 제한만 + ModifyReviewScreen( + modifier = modifier, + title = "리뷰 수정하기", + rating = data.rating, + content = data.content, + menuLikeInfos = data.menuLikeInfos, + isSubmitting = true, + canSubmit = false, + onBack = onBack, + onRatingChanged = {}, // 수정 불가 + onContentChanged = {}, // 수정 불가 + onToggleLike = {}, // 수정 불가 + onSubmit = {} // 중복 제출 방지 + ) + } + + else -> { + // 에러나 초기 로딩 등: 최소 로딩 UI + Surface(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(24.dp)) + CircularProgressIndicator() + Spacer(Modifier.height(8.dp)) + Text("화면을 준비하는 중입니다.", style = EatssuTheme.typography.body2) + } + } + } + } +} + +@Composable +internal fun ModifyReviewScreen( + modifier: Modifier = Modifier, + title: String, + rating: Int, + content: String, + menuLikeInfos: List, + isSubmitting: Boolean, + canSubmit: Boolean, + onBack: () -> Unit, + onRatingChanged: (Int) -> Unit, + onContentChanged: (String) -> Unit, + onToggleLike: (Long) -> Unit, + onSubmit: () -> Unit, +) { + Scaffold( + topBar = { CloseTopBar(title, onClose = onBack) }, + bottomBar = { + EatSsuButton( + text = if (isSubmitting) "수정 중..." else "완료하기", + enabled = canSubmit && rating > 0 && !isSubmitting, + onClick = onSubmit, + modifier = Modifier.padding(24.dp) + ) + } + ) { innerPadding -> + Surface( + modifier = modifier + .padding(innerPadding) + .fillMaxSize() + ) { + Column( + modifier = 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 = { if (!isSubmitting) onRatingChanged(it) } + ) + + Text("추천하고 싶은 메뉴가 있나요?", style = EatssuTheme.typography.subtitle1) + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) // 본문 스크롤, 버튼 고정 + ) { + items(items = menuLikeInfos, key = { it.menuId }) { menu -> + MenuLikeButtonItem( + modifier = Modifier.fillMaxWidth(), + mealName = menu.name, + isLiked = menu.isLike, + onLikeChanged = { + if (!isSubmitting) onToggleLike(menu.menuId) + } + ) + } + + item { + Spacer(Modifier.height(16.dp)) + Column { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .height(160.dp), + value = content, + onValueChange = { new -> + if (!isSubmitting && new.length <= MAX_TEXT_COUNT) { + onContentChanged(new) + } + }, + placeholder = { + Text( + "메뉴에 대한 상세한 리뷰를 작성해주세요", + style = EatssuTheme.typography.body2, + color = Gray400 + ) + }, + 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 = "${content.length}/$MAX_TEXT_COUNT", + color = Gray400, + style = EatssuTheme.typography.caption3 + ) + } + } + } + } + } + } +} + + +@Preview(showBackground = true) +@Composable +private fun ModifyReviewPreview() { + EatssuTheme { + ModifyReviewScreen( + title = "리뷰 수정하기", + rating = 3, + content = "국밥 맛있음!", + menuLikeInfos = listOf( + Review.MenuLikeInfo(1, "된장찌개", true), + Review.MenuLikeInfo(2, "김치찌개", false), + Review.MenuLikeInfo(3, "계란말이", true), + Review.MenuLikeInfo(4, "돈까스", false), + ), + isSubmitting = false, + canSubmit = false, + onBack = {}, + onRatingChanged = {}, + onContentChanged = {}, + onToggleLike = {}, + onSubmit = {} + ) + } +} 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 579c464f1..f408f122c 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,13 +2,16 @@ package com.eatssu.android.presentation.cafeteria.review.modify import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.eatssu.android.data.remote.dto.request.ModifyReviewRequest +import com.eatssu.android.domain.model.Review import com.eatssu.android.domain.usecase.review.ModifyReviewUseCase +import com.eatssu.common.UiEvent +import com.eatssu.common.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.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -17,46 +20,89 @@ class ModifyViewModel @Inject constructor( private val modifyReviewUseCase: ModifyReviewUseCase, ) : ViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(ModifyState()) - val uiState: StateFlow = _uiState.asStateFlow() + private val _uiState = MutableStateFlow>(UiState.Init) + val uiState: StateFlow> = _uiState.asStateFlow() - fun modifyMyReview( - reviewId: Long, - body: ModifyReviewRequest, - ) { - viewModelScope.launch { - _uiState.update { it.copy(loading = true) } + private val _uiEvent: MutableSharedFlow = MutableSharedFlow() + val uiEvent = _uiEvent.asSharedFlow() + + fun init(rating: Int, content: String, menuLikeInfos: List) { + val base = ModifyState.Baseline(rating, content, menuLikeInfos) + _uiState.value = UiState.Success( + ModifyState.Editing( + rating = rating, content = content, menuLikeInfos = menuLikeInfos, baseline = base + ) + ) + } + + fun onRatingChanged(new: Int) = updateEditing { it.copy(rating = new) } + fun onContentChanged(new: String) = updateEditing { it.copy(content = new) } + fun toggleLike(id: Long) = updateEditing { + it.copy(menuLikeInfos = it.menuLikeInfos.map { m -> if (m.menuId == id) m.copy(isLike = !m.isLike) else m }) + } + + private inline fun updateEditing(block: (ModifyState.Editing) -> ModifyState.Editing) { + val cur = (_uiState.value as? UiState.Success)?.data as? ModifyState.Editing ?: return + _uiState.value = UiState.Success(block(cur)) + } + + fun submit(reviewId: Long) { + val editing = (_uiState.value as? UiState.Success)?.data as? ModifyState.Editing ?: return + if (!editing.canSubmit) return - val success = modifyReviewUseCase(reviewId, body) + _uiState.value = UiState.Success( + ModifyState.Modifying( + editing.rating, + editing.content, + editing.menuLikeInfos, + editing.baseline + ) + ) + + viewModelScope.launch { + val success = modifyReviewUseCase( + reviewId, editing.rating, editing.content, editing.menuLikeInfos + ) if (!success) { - _uiState.update { - it.copy( - loading = false, - error = true, - isDone = false, - toastMessage = "리뷰 수정이 실패하였습니다." - ) - } - return@launch + _uiState.value = UiState.Success(editing) + _uiEvent.emit(UiEvent.ShowToast("리뷰 수정이 실패했습니다.")) } - _uiState.update { - it.copy( - loading = false, - error = false, - isDone = true, - toastMessage = "리뷰가 수정되었습니다." - ) - } + _uiEvent.emit(UiEvent.NavigateBack) + _uiEvent.emit(UiEvent.ShowToast("리뷰를 수정했습니다.")) } } } -data class ModifyState( - var loading: Boolean = true, - var error: Boolean = false, - var toastMessage: String = "", +sealed class ModifyState { + + data class Baseline( + val rating: Int, + val content: String, + val menuLikeInfos: List, + ) - var isDone: Boolean = false, + data class Editing( + val rating: Int = 0, + val content: String = "", + val menuLikeInfos: List = emptyList(), + val baseline: Baseline, // 초기 스냅샷 + ) : ModifyState() { + val hasChanges: Boolean + get() = rating != baseline.rating || + content != baseline.content || + menuLikeInfos != baseline.menuLikeInfos - ) \ No newline at end of file + val canSubmit: Boolean + get() = rating > 0 && hasChanges + + val contentCount: Int get() = content.length + } + + data class Modifying( + val rating: Int, + val content: String, + val menuLikeInfos: List, + val baseline: Baseline, // 유지 + ) : ModifyState() +} 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 9debbf55a..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteRateActivity.kt +++ /dev/null @@ -1,253 +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.remote.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 com.eatssu.common.EventLogger -import com.eatssu.common.enums.ScreenId -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, - ScreenId.REVIEW_V1_WRITE_RATE - ) { - - private val viewModel: UploadReviewViewModel by viewModels() - - private var itemId: Long = 0 - private lateinit var itemName: String - private var itemCount = 1L - 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) - itemCount = intent.getLongExtra("itemCount", 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) - EventLogger.completeReviewV1( - rating = binding.rbMain.rating.toLong(), - selection = itemCount, - photoAttached = true - ) - 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) - EventLogger.completeReviewV1( - rating = binding.rbMain.rating.toLong(), - selection = itemCount, - photoAttached = false - ) - 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/ReviewWriteViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteViewModel.kt deleted file mode 100644 index a3e4d7fe8..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/ReviewWriteViewModel.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.write - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.eatssu.android.data.remote.dto.request.WriteReviewRequest -import com.eatssu.android.domain.usecase.review.GetImageUrlUseCase -import com.eatssu.android.domain.usecase.review.WriteReviewUseCase -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.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.io.File -import javax.inject.Inject - - -@HiltViewModel -class UploadReviewViewModel @Inject constructor( - private val writeReviewUseCase: WriteReviewUseCase, - private val getImageUrlUseCase: GetImageUrlUseCase, -) : ViewModel() { - - 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) { - viewModelScope.launch { - _uiState.value = UiState.Loading - val success = writeReviewUseCase(menuId, reviewData) - - if (!success) { - _uiState.value = UiState.Error - _uiEvent.emit(UiEvent.ShowToast("리뷰 작성에 실패하였습니다.")) - return@launch - } - - _uiState.value = UiState.Success(Unit) - _uiEvent.emit(UiEvent.ShowToast("리뷰가 작성되었습니다.")) - } - } - - suspend fun saveS3(file: File): String? { - _uiState.value = UiState.Loading - val url = getImageUrlUseCase(file) - - if (url == null) { - _uiState.value = UiState.Error - _uiEvent.emit(UiEvent.ShowToast("이미지 업로드에 실패하였습니다.")) - return null - } - - // Success에 대한 정의는 한 곳에서만 관리 - return url - } -} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewScreen.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewScreen.kt new file mode 100644 index 000000000..5a718f610 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewScreen.kt @@ -0,0 +1,337 @@ +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.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.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.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 coil.compose.AsyncImage +import com.eatssu.android.R +import com.eatssu.android.domain.model.MenuMini +import com.eatssu.android.presentation.cafeteria.review.write.component.MenuLikeButtonItem +import com.eatssu.android.presentation.util.showToast +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState +import com.eatssu.common.enums.MenuType +import com.eatssu.design_system.component.CloseTopBar +import com.eatssu.design_system.component.EatSsuButton +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 + +const val MAX_TEXT_COUNT = 300 + +@Composable +fun WriteReviewScreen( + modifier: Modifier = Modifier, + viewModel: WriteReviewViewModel = hiltViewModel(), + menuName: String, + menuType: MenuType, + id: Long, + onBack: () -> Unit, +) { + val context = LocalContext.current + val ui by viewModel.uiState.collectAsStateWithLifecycle() + + // 갤러리 런처 + val galleryLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri: Uri? -> viewModel.setSelectedImage(uri) } + + // 처음 진입 시, 메뉴 불러오기: 기본찬(김치, 단무지, 밥) 등을 거르기 위함 + LaunchedEffect(menuType, id, menuName) { + viewModel.loadMenuList(menuType, id, menuName) + } + + LaunchedEffect(Unit) { + viewModel.uiEvent.collect { event -> + when (event) { + is UiEvent.NavigateBack -> onBack() + is UiEvent.ShowToast -> { + context.showToast(event.message) + } + } + } + } + + when (val data = (ui as? UiState.Success)?.data) { + is WriteReviewState.Editing -> { + WriteReviewScreen( + modifier = modifier, + title = "리뷰 작성하기", + menuList = data.menuList, + rating = data.rating, + content = data.content, + likedMenuIds = data.likedMenuIds, + selectedImageUri = data.selectedImageUri, + isPosting = false, + onBack = onBack, + onRatingChanged = viewModel::onRatingChanged, + onContentChanged = { new -> + if (new.length <= MAX_TEXT_COUNT) viewModel.onContentChanged(new) + }, + onToggleLike = viewModel::toggleLike, + onImageSelect = { galleryLauncher.launch("image/*") }, + onImageDelete = { viewModel.setSelectedImage(null) }, + onSubmit = { viewModel.postReview(menuType, id, context) } + ) + } + + is WriteReviewState.Posting -> { + WriteReviewScreen( + modifier = modifier, + title = "리뷰 작성하기", + menuList = data.menuList, + rating = data.rating, + content = data.content, + likedMenuIds = data.likedMenuIds, + selectedImageUri = data.selectedImageUri, + isPosting = true, + onBack = onBack, + onRatingChanged = {}, // 비활성 + onContentChanged = {}, // 비활성 + onToggleLike = {}, // 비활성 + onImageSelect = {}, // 비활성 + onImageDelete = {}, // 비활성 + onSubmit = {} // 중복 제출 방지 + ) + } + + else -> { + Surface(modifier = modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(24.dp)) + Text("화면을 준비하는 중입니다.", style = EatssuTheme.typography.body2) + } + } + } + } +} + +@Composable +internal fun WriteReviewScreen( + modifier: Modifier = Modifier, + title: String, + menuList: List, + rating: Int, + content: String, + likedMenuIds: Set, + selectedImageUri: Uri?, + isPosting: Boolean, + onBack: () -> Unit, + onRatingChanged: (Int) -> Unit, + onContentChanged: (String) -> Unit, + onToggleLike: (Long) -> Unit, + onImageSelect: () -> Unit, + onImageDelete: () -> Unit, + onSubmit: () -> Unit, +) { + Scaffold( + topBar = { CloseTopBar(title, onClose = onBack) }, + bottomBar = { + EatSsuButton( + text = if (isPosting) "작성 중..." else "완료하기", + enabled = rating > 0 && !isPosting, + onClick = onSubmit, + modifier = Modifier.padding(24.dp) + ) + } + ) { innerPadding -> + Surface( + modifier = modifier + .padding(innerPadding) + .fillMaxSize() + ) { + Column( + modifier = 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 = { if (!isPosting) onRatingChanged(it) } + ) + + Text("추천하고 싶은 메뉴가 있나요?", style = EatssuTheme.typography.subtitle1) + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) // 본문 스크롤, 버튼 고정 + ) { + items(menuList, key = { it.id }) { (id, name) -> + MenuLikeButtonItem( + modifier = Modifier.fillMaxWidth(), + mealName = name, + isLiked = id in likedMenuIds, + onLikeChanged = { + if (!isPosting) onToggleLike(id) + } + ) + } + + item { + Spacer(Modifier.height(16.dp)) + // 텍스트 입력 + Column { + androidx.compose.material3.OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .height(160.dp), + value = content, + onValueChange = { new -> + if (!isPosting && new.length <= MAX_TEXT_COUNT) { + onContentChanged(new) + } + }, + placeholder = { + Text( + "메뉴에 대한 상세한 리뷰를 작성해주세요", + style = EatssuTheme.typography.body2, + color = Gray400 + ) + }, + shape = RoundedCornerShape(10.dp), + colors = androidx.compose.material3.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 = "${content.length}/$MAX_TEXT_COUNT", + color = Gray400, + style = EatssuTheme.typography.caption3 + ) + } + Spacer(Modifier.height(16.dp)) + + // 사진 + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start + ) { + if (selectedImageUri != null) { + Column( + modifier = Modifier + .size(120.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(enabled = !isPosting) { 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(1.dp, Gray200, RoundedCornerShape(5.dp)) + .clickable(enabled = !isPosting) { onImageSelect() }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_camera_light), + contentDescription = "add photo", + tint = Gray300 + ) + Text( + "사진 0/1", + color = Gray400, + style = EatssuTheme.typography.caption3 + ) + } + } + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ReviewWritePreview() { + EatssuTheme { + WriteReviewScreen( + title = "리뷰 작성하기", + menuList = listOf( + MenuMini(1, "김치"), MenuMini(2, "계란말이"), MenuMini(3, "닭볶음탕") + ), + rating = 3, + content = "맛있었습니다!", + likedMenuIds = setOf(1L), + selectedImageUri = null, + isPosting = false, + onBack = {}, + onRatingChanged = {}, + onContentChanged = {}, + onToggleLike = {}, + onImageSelect = {}, + onImageDelete = {}, + onSubmit = {} + ) + } +} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModel.kt new file mode 100644 index 000000000..10b524226 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModel.kt @@ -0,0 +1,201 @@ +package com.eatssu.android.presentation.cafeteria.review.write + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.eatssu.android.domain.model.MenuMini +import com.eatssu.android.domain.usecase.menu.GetValidMenusOfMealUseCase +import com.eatssu.android.domain.usecase.review.GetImageUrlUseCase +import com.eatssu.android.domain.usecase.review.WriteReviewUseCase +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState +import com.eatssu.common.enums.MenuType +import dagger.hilt.android.lifecycle.HiltViewModel +import id.zelory.compressor.Compressor +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +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 WriteReviewViewModel @Inject constructor( + private val writeReviewUseCase: WriteReviewUseCase, + private val getImageUrlUseCase: GetImageUrlUseCase, + private val getValidMenusOfMealUseCase: GetValidMenusOfMealUseCase, +) : ViewModel() { + + private val _uiState = MutableStateFlow>(UiState.Init) + val uiState = _uiState.asStateFlow() + + private val _uiEvent: MutableSharedFlow = MutableSharedFlow() + val uiEvent = _uiEvent.asSharedFlow() + + + fun loadMenuList(menuType: MenuType, id: Long, menuName: String) { + viewModelScope.launch { + _uiState.value = UiState.Loading + val menuList: List = when (menuType) { + MenuType.FIXED -> listOf( + MenuMini( + id = id, + name = menuName + ) + ) + + MenuType.VARIABLE -> getValidMenusOfMealUseCase(id) + } + _uiState.value = UiState.Success( + WriteReviewState.Editing( + menuList = menuList, + rating = 0, + content = "", + likedMenuIds = emptySet(), + selectedImageUri = null + ) + ) + } + } + + fun onRatingChanged(new: Int) = updateEditing { it.copy(rating = new) } + + fun onContentChanged(new: String) = updateEditing { it.copy(content = new) } + + fun toggleLike(menuId: Long) = updateEditing { s -> + val next = + if (menuId in s.likedMenuIds) s.likedMenuIds - menuId else s.likedMenuIds + menuId + s.copy(likedMenuIds = next) + } + + fun setSelectedImage(uri: Uri?) = updateEditing { it.copy(selectedImageUri = uri) } + + private inline fun updateEditing(block: (WriteReviewState.Editing) -> WriteReviewState.Editing) { + val cur = (_uiState.value as? UiState.Success)?.data as? WriteReviewState.Editing ?: return + _uiState.value = UiState.Success(block(cur)) + } + + fun postReview( + menuType: MenuType, + itemId: Long, + context: Context, + ) { + val editing = + (_uiState.value as? UiState.Success)?.data as? WriteReviewState.Editing ?: return + if (!editing.canSubmit) return + + // Posting 단계로 전이 (폼 보존) + _uiState.value = UiState.Success( + WriteReviewState.Posting( + menuList = editing.menuList, + rating = editing.rating, + content = editing.content, + likedMenuIds = editing.likedMenuIds, + selectedImageUri = editing.selectedImageUri + ) + ) + + viewModelScope.launch { + // 1) 이미지 업로드(있으면) + var imageUrl: String? = null + editing.selectedImageUri?.let { uri -> + try { + val originalFile = uriToFile(uri, context) + if (originalFile.exists()) { + // 이미지 압축 + val compressedFile = compressImage(context, originalFile) + if (compressedFile != null && compressedFile.exists()) { + imageUrl = getImageUrlUseCase(compressedFile) + _uiEvent.emit(UiEvent.ShowToast("이미지가 업로드되었습니다.")) + + // 원본 파일 삭제 (압축된 파일만 유지) + originalFile.delete() + } else { + _uiState.value = UiState.Success(editing) // 되돌림 + _uiEvent.emit(UiEvent.ShowToast("이미지 압축에 실패하였습니다.")) + return@launch + } + } else { + _uiState.value = UiState.Success(editing) // 되돌림 + _uiEvent.emit(UiEvent.ShowToast("이미지 파일을 찾을 수 없습니다.")) + return@launch + } + } catch (e: Exception) { + Timber.e(e, "이미지 업로드 실패") + _uiState.value = UiState.Success(editing) // 되돌림 + _uiEvent.emit(UiEvent.ShowToast("이미지 업로드에 실패하였습니다.")) + return@launch + } + } + + // 2) 리뷰 작성 + val success = writeReviewUseCase( + menuType = menuType, + itemId = itemId, + rating = editing.rating, + content = editing.content, + imageUrl = imageUrl, + likeMenuIdList = editing.likedMenuIds.toList(), + ) + + if (!success) { + _uiState.value = UiState.Success(editing) // 되돌림 + _uiEvent.emit(UiEvent.ShowToast("리뷰 작성에 실패하였습니다.")) + return@launch + } + + _uiEvent.emit(UiEvent.ShowToast("리뷰가 작성되었습니다.")) + _uiEvent.emit(UiEvent.NavigateBack) + } + } +} + +private fun uriToFile(uri: 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) } } + return file +} + +private suspend fun compressImage(context: Context, originalFile: File): File? { + return try { + Compressor.compress(context, originalFile) + } catch (e: Exception) { + Timber.e(e, "이미지 압축 실패") + null + } +} + + +sealed class WriteReviewState { + data class Editing( + val menuList: List, + val rating: Int, + val content: String, + val likedMenuIds: Set, + val selectedImageUri: Uri?, + ) : WriteReviewState() { + val canSubmit: Boolean get() = rating > 0 + val contentCount: Int get() = content.length + } + + data class Posting( + val menuList: List, + val rating: Int, + val content: String, + val likedMenuIds: Set, + val selectedImageUri: Uri?, + ) : WriteReviewState() + + // 성공시 상태는 정의하지 않음. + // 성공시 네비게이트 이벤트 발생 +} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/component/MenuLikeButtonItem.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/component/MenuLikeButtonItem.kt new file mode 100644 index 000000000..45446b45d --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/component/MenuLikeButtonItem.kt @@ -0,0 +1,34 @@ +package com.eatssu.android.presentation.cafeteria.review.write.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.eatssu.design_system.component.LikeButton +import com.eatssu.design_system.theme.EatssuTheme + +@Composable +fun MenuLikeButtonItem( + 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) // 클릭 시 상태를 반전 + } + ) + } +} \ No newline at end of file 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 a1267f073..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/ReviewWriteMenuActivity.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.write.menu - -import android.content.Intent -import android.os.Bundle -import androidx.activity.viewModels -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 com.eatssu.common.enums.ScreenId -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import timber.log.Timber - -@AndroidEntryPoint -class ReviewWriteMenuActivity : - BaseActivity( - ActivityReviewWriteMenuBinding::inflate, - ScreenId.REVIEW_V1_WRITE - ) { - - private val viewModel: VariableMenuViewModel by viewModels() - private var mealId: Long = -1 - - private lateinit var variableMenuPickAdapter: VariableMenuPickAdapter - - 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) - intent.putExtra("itemCount", items.size.toLong()) - - // 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 a57848dcd..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/VariableMenuPickAdapter.kt +++ /dev/null @@ -1,55 +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 80f5bdfeb..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/menu/VariableMenuViewModel.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.eatssu.android.presentation.cafeteria.review.write.menu - - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.eatssu.android.data.remote.dto.response.toMenuMini -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.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 { - _uiState.update { it.copy(loading = true) } - val menuNameList = getMenuNameListUseCase(mealId) - _uiState.update { - it.copy( - loading = false, - error = false, - menuOfMeal = menuNameList.map { menuInfo -> menuInfo.toMenuMini() }) - } - - Timber.d("findMenuItemByMealId: $menuNameList") - } - } -} - -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 a53b5d30d..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/common/MyReviewBottomSheetFragment.kt +++ /dev/null @@ -1,103 +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.R -import com.eatssu.android.databinding.FragmentBottomsheetMyReviewBinding -import com.eatssu.android.presentation.cafeteria.review.modify.ModifyReviewActivity -import com.eatssu.android.presentation.mypage.myreview.MyReviewViewModel -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("리뷰 삭제를 취소하시겠습니까?") - } - 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/intro/IntroActivity.kt b/app/src/main/java/com/eatssu/android/presentation/intro/IntroActivity.kt index 3c7a277c9..cee801309 100644 --- a/app/src/main/java/com/eatssu/android/presentation/intro/IntroActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/intro/IntroActivity.kt @@ -8,14 +8,14 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.eatssu.android.databinding.ActivityIntroBinding import com.eatssu.android.presentation.MainActivity -import com.eatssu.android.presentation.UiEvent -import com.eatssu.android.presentation.UiState import com.eatssu.android.presentation.common.ForceUpdateDialogActivity import com.eatssu.android.presentation.login.LoginActivity import com.eatssu.android.presentation.util.observeNetworkError import com.eatssu.android.presentation.util.showToast import com.eatssu.android.presentation.util.startActivity import com.eatssu.common.EventLogger +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import com.eatssu.common.enums.LaunchPath import com.eatssu.common.enums.ScreenId import dagger.hilt.android.AndroidEntryPoint diff --git a/app/src/main/java/com/eatssu/android/presentation/intro/IntroViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/intro/IntroViewModel.kt index fffc9cb5e..8116f79b8 100644 --- a/app/src/main/java/com/eatssu/android/presentation/intro/IntroViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/intro/IntroViewModel.kt @@ -7,8 +7,8 @@ import com.eatssu.android.domain.repository.FirebaseRemoteConfigRepository import com.eatssu.android.domain.usecase.auth.GetAccessTokenUseCase import com.eatssu.android.domain.usecase.auth.GetIsAccessTokenValidUseCase import com.eatssu.android.domain.usecase.health.HealthCheckUseCase -import com.eatssu.android.presentation.UiEvent -import com.eatssu.android.presentation.UiState +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt b/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt index 71762b7e2..e38971482 100644 --- a/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt @@ -9,11 +9,11 @@ import androidx.lifecycle.repeatOnLifecycle import com.eatssu.android.R import com.eatssu.android.databinding.ActivityLoginBinding import com.eatssu.android.presentation.MainActivity -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 com.eatssu.android.presentation.util.startActivity +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import com.eatssu.common.enums.ScreenId import com.kakao.sdk.common.model.ClientError import com.kakao.sdk.common.model.ClientErrorCause diff --git a/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt index 084878be4..fd0d1f2cb 100644 --- a/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt @@ -10,8 +10,8 @@ import com.eatssu.android.domain.usecase.auth.LoginUseCase import com.eatssu.android.domain.usecase.auth.SetAccessTokenUseCase import com.eatssu.android.domain.usecase.auth.SetRefreshTokenUseCase import com.eatssu.android.domain.usecase.user.SetUserEmailUseCase -import com.eatssu.android.presentation.UiEvent -import com.eatssu.android.presentation.UiState +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt index 52dc9032a..c9bf86c38 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt @@ -45,8 +45,6 @@ import com.eatssu.android.R import com.eatssu.android.domain.model.RestaurantType import com.eatssu.android.presentation.MainState import com.eatssu.android.presentation.MainViewModel -import com.eatssu.android.presentation.UiEvent -import com.eatssu.android.presentation.UiState import com.eatssu.android.presentation.map.component.DepartmentBottomSheet import com.eatssu.android.presentation.map.component.FilterType import com.eatssu.android.presentation.map.component.MapRestaurantBottomSheet @@ -55,6 +53,8 @@ import com.eatssu.android.presentation.map.model.PlaceType import com.eatssu.android.presentation.mypage.userinfo.UserInfoActivity import com.eatssu.android.presentation.util.TrackScreenViewEvent import com.eatssu.common.EventLogger +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import com.eatssu.common.enums.ScreenId import com.eatssu.design_system.theme.Black import com.eatssu.design_system.theme.EatssuTheme diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt index 6e2933166..dfee98092 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt @@ -7,9 +7,9 @@ import com.eatssu.android.domain.model.PartnershipRestaurant import com.eatssu.android.domain.repository.PartnershipRepository import com.eatssu.android.domain.usecase.user.GetPartnershipDetailUseCase import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase -import com.eatssu.android.presentation.UiEvent -import com.eatssu.android.presentation.UiState import com.eatssu.android.presentation.map.model.RestaurantInfo +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow 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 79eb35bf4..aaea16688 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,14 +20,14 @@ import androidx.lifecycle.repeatOnLifecycle import com.eatssu.android.R import com.eatssu.android.databinding.FragmentMyPageBinding import com.eatssu.android.presentation.MainViewModel -import com.eatssu.android.presentation.UiEvent -import com.eatssu.android.presentation.UiState 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.eatssu.android.presentation.util.showToast +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import com.eatssu.common.enums.ScreenId import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import dagger.hilt.android.AndroidEntryPoint @@ -49,6 +49,7 @@ class MyPageFragment : BaseFragment(ScreenId.MYPAGE_MAIN) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.tvSignout.paintFlags = Paint.UNDERLINE_TEXT_FLAG setupObservers() setOnClickListener() @@ -145,7 +146,7 @@ class MyPageFragment : BaseFragment(ScreenId.MYPAGE_MAIN) } 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/MyPageViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageViewModel.kt index fb56a6345..8958e20b5 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageViewModel.kt @@ -7,8 +7,8 @@ import com.eatssu.android.data.local.SettingDataStore import com.eatssu.android.domain.usecase.alarm.AlarmUseCase import com.eatssu.android.domain.usecase.alarm.SetDailyNotificationStatusUseCase import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase -import com.eatssu.android.presentation.UiEvent -import com.eatssu.android.presentation.UiState +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/SignOutActivity.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/SignOutActivity.kt index 987327616..4f0ce55ca 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/SignOutActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/SignOutActivity.kt @@ -6,11 +6,11 @@ import androidx.activity.viewModels import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import com.eatssu.android.databinding.ActivitySignOutBinding -import com.eatssu.android.presentation.UiEvent -import com.eatssu.android.presentation.UiState import com.eatssu.android.presentation.base.BaseActivity import com.eatssu.android.presentation.login.LoginActivity import com.eatssu.android.presentation.util.showToast +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import com.eatssu.common.enums.ScreenId import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/SignOutViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/SignOutViewModel.kt index ad57a24fb..867782933 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/SignOutViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/SignOutViewModel.kt @@ -4,8 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.eatssu.android.domain.usecase.auth.LogoutUseCase import com.eatssu.android.domain.usecase.auth.SignOutUseCase -import com.eatssu.android.presentation.UiEvent -import com.eatssu.android.presentation.UiState +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow 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 8118e1ce1..000000000 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewAdapter.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.eatssu.android.presentation.mypage.myreview - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.bumptech.glide.Glide -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 = "" - 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 -> - 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 fa0e96307..bd9fe1a99 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,107 +1,27 @@ 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 com.eatssu.common.enums.ScreenId +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.navigation.compose.rememberNavController +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, - ScreenId.MYPAGE_REVIEWS, -), 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() + setContent { + EatssuTheme { + val navHostController = rememberNavController() - 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) } - } + MyReviewNav( + navHostController = navHostController, + onExit = { 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..b9b96302d --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListScreen.kt @@ -0,0 +1,322 @@ +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.cafeteria.review.list.component.MyReviewBottomSheet +import com.eatssu.android.presentation.cafeteria.review.list.component.ReviewItem +import com.eatssu.android.presentation.util.showToast +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState +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: (Review) -> Unit, +) { + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.getMyReviewList() + viewModel.loadUserNickname() + } + + val reviewListState by viewModel.uiState.collectAsStateWithLifecycle() + val userNickname by viewModel.nickname.collectAsStateWithLifecycle() + val uiEvent by viewModel.uiEvent.collectAsStateWithLifecycle(initialValue = null) + + when (uiEvent) { + is UiEvent.ShowToast -> { + context.showToast((uiEvent as UiEvent.ShowToast).message) + } + } + + MyReviewListScreen( + uiState = reviewListState, + userNickname = userNickname, + modifier = modifier, + onBack = onBack, + onDeleteClick = { reviewId -> viewModel.deleteReview(reviewId) }, + onModifyClick = onModifyClick, + ) +} + +@Composable +internal fun MyReviewListScreen( + uiState: UiState, + userNickname: String, + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + onModifyClick: (Review) -> Unit, + onDeleteClick: (reviewId: Long) -> Unit, +) { + var showBottomSheet by remember { mutableStateOf(false) } + var selectedReview by remember { mutableStateOf(null) } + + if (showBottomSheet && selectedReview != null) { + MyReviewBottomSheet( + onDismiss = { showBottomSheet = false; selectedReview = null }, + onModify = { + selectedReview?.let { onModifyClick(it) } + showBottomSheet = false + selectedReview = null + }, + onDelete = { + selectedReview?.let { onDeleteClick(it.reviewId) } + showBottomSheet = false + selectedReview = 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, + writeName = userNickname, + writeDate = item.writeDate, + content = item.content, + rating = item.rating, + menuLikeInfoList = item.menuLikeInfoList, + imgUrl = item.imgUrl, + onMoreClick = { + selectedReview = item + 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( + userNickname = "숭실푸드파이터", + onDeleteClick = {}, + onModifyClick = {}, + uiState = UiState.Success( + MyReviewState.ReviewExists( + myReviews = listOf( + Review( + isWriter = true, + reviewId = 0, + menuLikeInfoList = listOf( + Review.MenuLikeInfo( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.MenuLikeInfo( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "숭실푸드파이터", + writeDate = "2024-12-31", + rating = 4, + content = "맛있어요", + imgUrl = null, + ), + Review( + isWriter = true, + reviewId = 1, + menuLikeInfoList = listOf( + Review.MenuLikeInfo( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.MenuLikeInfo( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "맛있는리뷰어", + writeDate = "2024-12-30", + rating = 5, + content = "정말 맛있어요! 다음에도 먹고 싶어요.", + imgUrl = null, + ), + Review( + isWriter = true, + reviewId = 2, + menuLikeInfoList = listOf( + Review.MenuLikeInfo( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.MenuLikeInfo( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "음식평론가", + writeDate = "2024-12-29", + rating = 3, + content = "그럭저럭 괜찮아요", + imgUrl = null, + ), + Review( + isWriter = false, + reviewId = 2, + menuLikeInfoList = listOf( + Review.MenuLikeInfo( + menuId = 1L, + name = "소고기", + isLike = true + ), Review.MenuLikeInfo( + menuId = 2L, + name = "닭고기", + isLike = false + ) + ), + writerNickname = "음식평론가", + writeDate = "2024-12-29", + rating = 3, + content = "그럭저럭 괜찮아요", + imgUrl = "https://picsum.photos/400/301", // 실제 이미지 URL 사용 + ) + ) + ) + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun ReviewListEmptyPreview() { + EatssuTheme { + MyReviewListScreen( + userNickname = "숭실푸드파이터", + onDeleteClick = {}, + onModifyClick = {}, + uiState = UiState.Success( + MyReviewState.NoReview + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewNav.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewNav.kt new file mode 100644 index 000000000..1d338f1fc --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewNav.kt @@ -0,0 +1,62 @@ +package com.eatssu.android.presentation.mypage.myreview + +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.domain.model.Review +import com.eatssu.android.presentation.cafeteria.review.modify.ModifyReviewScreen + +object MyReviewNav { + const val List = "list" + const val Modify = "modify" +} + +@Composable +fun MyReviewNav( + navHostController: NavHostController = rememberNavController(), + onExit: () -> Unit = {} +) { + + NavHost( + navController = navHostController, + startDestination = MyReviewNav.List + ) { + // 리뷰 보기 + composable(MyReviewNav.List) { + MyReviewListScreen( + onBack = { onExit() }, + onModifyClick = { review -> + // 선택된 리뷰 데이터를 Modify 화면으로 전달 + navHostController.currentBackStackEntry?.savedStateHandle?.apply { + set("reviewId", review.reviewId) + set("initialRating", review.rating) + set("initialContent", review.content) + set("menuList", review.menuLikeInfoList) + } + + navHostController.navigate(MyReviewNav.Modify) { launchSingleTop = true } + }, + ) + } + + // 리뷰 수정 + composable(MyReviewNav.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 menuLikeInfoNames = + prev?.get>("menuList") ?: arrayListOf() + + ModifyReviewScreen( + reviewId = reviewId, + initialRating = initialRating, + initialContent = initialContent, + menuLikeInfoList = menuLikeInfoNames, + onBack = { navHostController.popBackStack() }, + ) + } + } +} \ 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 08c1b9705..d020619c2 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,89 +1,84 @@ 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.domain.model.Review import com.eatssu.android.domain.usecase.review.DeleteReviewUseCase import com.eatssu.android.domain.usecase.review.GetMyReviewsUseCase +import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase +import com.eatssu.common.UiEvent +import com.eatssu.common.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.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class MyReviewViewModel @Inject constructor( private val getMyReviewsUseCase: GetMyReviewsUseCase, + private val getUserNickNameUseCase: GetUserNickNameUseCase, 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() + + private val _uiEvent: MutableSharedFlow = MutableSharedFlow() + val uiEvent = _uiEvent.asSharedFlow() + + private val _nickname = MutableStateFlow("") + val nickname: StateFlow = _nickname init { - getMyReviews() + getMyReviewList() } - fun getMyReviews() { + fun loadUserNickname() { viewModelScope.launch { - _uiState.update { it.copy(loading = true) } + _nickname.value = getUserNickNameUseCase() + } + } + fun getMyReviewList() { + _uiState.value = UiState.Loading + + viewModelScope.launch { val myReviewList = getMyReviewsUseCase() - _uiState.update { - it.copy( - loading = false, - error = false, - myReviews = myReviewList, - isEmpty = myReviewList.isEmpty() - ) - } + + _uiState.value = UiState.Success( + if (myReviewList.isEmpty()) { + MyReviewState.NoReview + } else { + MyReviewState.ReviewExists(myReviews = myReviewList) + } + ) + // todo 에러처리 } } fun deleteReview(reviewId: Long) { viewModelScope.launch { - _uiState.update { it.copy(loading = true) } - val success = deleteReviewUseCase(reviewId) if (!success) { - _uiState.update { - it.copy( - loading = false, - error = true, - toastMessage = context.getString(R.string.delete_not) - ) - } + _uiEvent.emit(UiEvent.ShowToast("리뷰 삭제에 실패했습니다.")) return@launch } - - _uiState.update { - it.copy( - loading = false, - error = false, - isDeleted = true, - toastMessage = context.getString(R.string.delete_done) - ) - } + _uiEvent.emit(UiEvent.ShowToast("리뷰를 삭제했습니다.")) + // 삭제 성공 시 내 리뷰 목록 재조회 + getMyReviewList() } } } -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/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoActivity.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoActivity.kt index 2e616daf1..9804db0b5 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoActivity.kt @@ -14,11 +14,10 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.eatssu.android.R import com.eatssu.android.databinding.ActivityUserInfoBinding -import com.eatssu.android.domain.usecase.user.ValidateNicknameLocalUseCase -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 com.eatssu.common.UiEvent +import com.eatssu.common.UiState import com.eatssu.common.enums.ScreenId import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest @@ -112,11 +111,13 @@ class UserInfoActivity : binding.tvNicknameStatus.setTextColor(getColor(R.color.error)) binding.etChNickname.setBackgroundResource(R.drawable.shape_text_field_small_red) } + data.isDuplicationChecked -> { binding.tvNicknameStatus.text = getString(R.string.set_nickname_able) binding.tvNicknameStatus.setTextColor(getColor(R.color.gray600)) binding.etChNickname.setBackgroundResource(R.drawable.shape_text_field_small) } + else -> { binding.tvNicknameStatus.text = getString( R.string.set_nickname_length, @@ -132,14 +133,18 @@ class UserInfoActivity : private fun updateCollegeDepartmentUI(data: UserInfoData) { with(binding) { tvCollege.text = data.selectedCollege.collegeName - tvCollege.setTextColor(getColor( - if (data.selectedCollege.collegeId != -1) R.color.gray700 else R.color.gray400 - )) + tvCollege.setTextColor( + getColor( + if (data.selectedCollege.collegeId != -1) R.color.gray700 else R.color.gray400 + ) + ) tvDepartment.text = data.selectedDepartment.departmentName - tvDepartment.setTextColor(getColor( - if (data.selectedDepartment.departmentId != -1) R.color.gray700 else R.color.gray400 - )) + tvDepartment.setTextColor( + getColor( + if (data.selectedDepartment.departmentId != -1) R.color.gray700 else R.color.gray400 + ) + ) } } diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt index d00bf6f04..6c4260642 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt @@ -5,14 +5,14 @@ import androidx.lifecycle.viewModelScope import com.eatssu.android.domain.model.College import com.eatssu.android.domain.model.Department import com.eatssu.android.domain.repository.UserRepository -import com.eatssu.android.domain.usecase.user.ValidateNicknameServerUseCase import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase import com.eatssu.android.domain.usecase.user.NicknameValidationResult import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase import com.eatssu.android.domain.usecase.user.SetUserNicknameUseCase import com.eatssu.android.domain.usecase.user.ValidateNicknameLocalUseCase -import com.eatssu.android.presentation.UiEvent -import com.eatssu.android.presentation.UiState +import com.eatssu.android.domain.usecase.user.ValidateNicknameServerUseCase +import com.eatssu.common.UiEvent +import com.eatssu.common.UiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow 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_menu_12.xml b/app/src/main/res/drawable/ic_menu_12.xml new file mode 100644 index 000000000..907baaf85 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_12.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_menu_24.png b/app/src/main/res/drawable/ic_menu_24.png deleted file mode 100644 index 4bae24d7b..000000000 Binary files a/app/src/main/res/drawable/ic_menu_24.png and /dev/null differ 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/drawable/ic_three_dot.png b/app/src/main/res/drawable/ic_three_dot.png deleted file mode 100644 index 7df3eba69..000000000 Binary files a/app/src/main/res/drawable/ic_three_dot.png and /dev/null differ diff --git a/app/src/main/res/layout/activity_info.xml b/app/src/main/res/layout/activity_info.xml deleted file mode 100644 index 74fa97af7..000000000 --- a/app/src/main/res/layout/activity_info.xml +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_my_review_list.xml b/app/src/main/res/layout/activity_my_review_list.xml deleted file mode 100644 index b4f3b1947..000000000 --- a/app/src/main/res/layout/activity_my_review_list.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_others_review_dialog.xml b/app/src/main/res/layout/activity_others_review_dialog.xml deleted file mode 100644 index 53bd19d86..000000000 --- a/app/src/main/res/layout/activity_others_review_dialog.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file 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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -