diff --git a/data/src/main/java/org/gdsc/data/datasource/RestaurantDataSource.kt b/data/src/main/java/org/gdsc/data/datasource/RestaurantDataSource.kt index fa6022d7..962cddb9 100644 --- a/data/src/main/java/org/gdsc/data/datasource/RestaurantDataSource.kt +++ b/data/src/main/java/org/gdsc/data/datasource/RestaurantDataSource.kt @@ -2,6 +2,7 @@ package org.gdsc.data.datasource import androidx.paging.PagingData import kotlinx.coroutines.flow.Flow +import okhttp3.MultipartBody import org.gdsc.data.database.RegisteredRestaurant import org.gdsc.data.database.ReviewPaging import org.gdsc.data.model.RegisteredRestaurantResponse @@ -44,4 +45,6 @@ interface RestaurantDataSource { suspend fun getRestaurantReviews(restaurantId: Int): ReviewPaging + suspend fun postRestaurantReview(restaurantId: Int, reviewContent: String, reviewImages: List): Boolean + } \ No newline at end of file diff --git a/data/src/main/java/org/gdsc/data/datasource/RestaurantDataSourceImpl.kt b/data/src/main/java/org/gdsc/data/datasource/RestaurantDataSourceImpl.kt index c76c5670..56bd1719 100644 --- a/data/src/main/java/org/gdsc/data/datasource/RestaurantDataSourceImpl.kt +++ b/data/src/main/java/org/gdsc/data/datasource/RestaurantDataSourceImpl.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import okhttp3.MultipartBody +import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.gdsc.data.database.RegisteredRestaurant import org.gdsc.data.database.RestaurantByMapPagingSource @@ -40,7 +42,7 @@ class RestaurantDataSourceImpl @Inject constructor( private val db: RestaurantDatabase, ) : RestaurantDataSource { - private val coroutineScope : CoroutineScope = CoroutineScope(Dispatchers.IO) + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO) override suspend fun getRestaurantLocationInfo( query: String, latitude: String, @@ -51,7 +53,10 @@ class RestaurantDataSourceImpl @Inject constructor( return restaurantAPI.getRestaurantLocationInfo(query, latitude, longitude, page).data } - override suspend fun getRecommendRestaurantInfo(recommendRestaurantId: Int, userLocation: UserLocation): RestaurantInfoResponse { + override suspend fun getRecommendRestaurantInfo( + recommendRestaurantId: Int, + userLocation: UserLocation + ): RestaurantInfoResponse { return restaurantAPI.getRecommendRestaurantInfo(recommendRestaurantId, userLocation).data } @@ -86,7 +91,8 @@ class RestaurantDataSourceImpl @Inject constructor( mapOf( "name" to restaurantRegistrationRequest.name.toRequestBody(), "introduce" to restaurantRegistrationRequest.introduce.toRequestBody(), - "categoryId" to restaurantRegistrationRequest.categoryId.toString().toRequestBody(), + "categoryId" to restaurantRegistrationRequest.categoryId.toString() + .toRequestBody(), "canDrinkLiquor" to restaurantRegistrationRequest.canDrinkLiquor.toString() .toRequestBody(), "goWellWithLiquor" to restaurantRegistrationRequest.goWellWithLiquor.toRequestBody(), @@ -117,7 +123,11 @@ class RestaurantDataSourceImpl @Inject constructor( @OptIn(ExperimentalPagingApi::class) override suspend fun getRestaurants( - userId: Int, locationData: Location, sortType: SortType, foodCategory: FoodCategory, drinkPossibility: DrinkPossibility + userId: Int, + locationData: Location, + sortType: SortType, + foodCategory: FoodCategory, + drinkPossibility: DrinkPossibility ): Flow> { val categoryFilter = when (foodCategory) { FoodCategory.INIT, FoodCategory.ETC -> null @@ -135,7 +145,7 @@ class RestaurantDataSourceImpl @Inject constructor( FoodCategory.INIT, FoodCategory.ETC -> String.Empty else -> foodCategory.key }, - isCanDrinkLiquor = isCanDrinkLiquor, + isCanDrinkLiquor = isCanDrinkLiquor, ) val restaurantSearchMapRequest = RestaurantSearchMapRequest(filter, locationData) @@ -155,9 +165,23 @@ class RestaurantDataSourceImpl @Inject constructor( ) { with(db.restaurantDao()) { when (sortType) { - SortType.DISTANCE -> getRegisteredRestaurantsSortedDistance(userId, categoryFilter, isCanDrinkLiquor) - SortType.RECENCY -> getRegisteredRestaurantsSortedRecent(userId, categoryFilter, isCanDrinkLiquor) - SortType.LIKED -> getRegisteredRestaurants(userId, categoryFilter, isCanDrinkLiquor) + SortType.DISTANCE -> getRegisteredRestaurantsSortedDistance( + userId, + categoryFilter, + isCanDrinkLiquor + ) + + SortType.RECENCY -> getRegisteredRestaurantsSortedRecent( + userId, + categoryFilter, + isCanDrinkLiquor + ) + + SortType.LIKED -> getRegisteredRestaurants( + userId, + categoryFilter, + isCanDrinkLiquor + ) } } @@ -173,7 +197,12 @@ class RestaurantDataSourceImpl @Inject constructor( } override suspend fun getRestaurantsByMap( - userLocation: Location?, startLocation: Location?, endLocation: Location?, sortType: SortType, foodCategory: FoodCategory?, drinkPossibility: DrinkPossibility? + userLocation: Location?, + startLocation: Location?, + endLocation: Location?, + sortType: SortType, + foodCategory: FoodCategory?, + drinkPossibility: DrinkPossibility? ): Flow> { val restaurantSearchMapRequest = RestaurantSearchMapRequest( userLocation = userLocation, @@ -196,7 +225,8 @@ class RestaurantDataSourceImpl @Inject constructor( config = PagingConfig( pageSize = 20, enablePlaceholders = true - )) { + ) + ) { RestaurantByMapPagingSource( restaurantAPI, restaurantSearchMapRequest @@ -208,4 +238,16 @@ class RestaurantDataSourceImpl @Inject constructor( return restaurantAPI.getRestaurantReviews(restaurantId).data } + override suspend fun postRestaurantReview( + restaurantId: Int, + reviewContent: String, + reviewImages: List + ): Boolean { + + return restaurantAPI.postRestaurantReview( + restaurantId, + MultipartBody.Part.createFormData("reviewContent", reviewContent), reviewImages + ).code == "RESTAURANT_REVIEW_CREATED" + } + } diff --git a/data/src/main/java/org/gdsc/data/network/RestaurantAPI.kt b/data/src/main/java/org/gdsc/data/network/RestaurantAPI.kt index 8b750a94..2d5ca9ea 100644 --- a/data/src/main/java/org/gdsc/data/network/RestaurantAPI.kt +++ b/data/src/main/java/org/gdsc/data/network/RestaurantAPI.kt @@ -81,4 +81,12 @@ interface RestaurantAPI { @Path("recommendRestaurantId") recommendRestaurantId: Int, ): Response + @Multipart + @POST("/api/v1/restaurant/{recommendRestaurantId}/review") + suspend fun postRestaurantReview( + @Path("recommendRestaurantId") recommendRestaurantId: Int, + @Part reviewContent: MultipartBody.Part, + @Part reviewImages: List, + ): Response + } \ No newline at end of file diff --git a/data/src/main/java/org/gdsc/data/repository/RestaurantRepositoryImpl.kt b/data/src/main/java/org/gdsc/data/repository/RestaurantRepositoryImpl.kt index 3cf41be6..a8cfa244 100644 --- a/data/src/main/java/org/gdsc/data/repository/RestaurantRepositoryImpl.kt +++ b/data/src/main/java/org/gdsc/data/repository/RestaurantRepositoryImpl.kt @@ -4,6 +4,7 @@ import androidx.paging.PagingData import androidx.paging.map import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import okhttp3.MultipartBody import org.gdsc.data.datasource.RestaurantDataSource import org.gdsc.domain.DrinkPossibility import org.gdsc.domain.FoodCategory @@ -125,4 +126,12 @@ class RestaurantRepositoryImpl @Inject constructor( override suspend fun getRestaurantReviews(restaurantId: Int): List { return restaurantDataSource.getRestaurantReviews(restaurantId).reviewList } + + override suspend fun postRestaurantReview( + restaurantId: Int, + reviewContent: String, + reviewImages: List + ): Boolean { + return restaurantDataSource.postRestaurantReview(restaurantId, reviewContent, reviewImages) + } } \ No newline at end of file diff --git a/domain/src/main/java/org/gdsc/domain/repository/RestaurantRepository.kt b/domain/src/main/java/org/gdsc/domain/repository/RestaurantRepository.kt index 3744acaa..e6bb650a 100644 --- a/domain/src/main/java/org/gdsc/domain/repository/RestaurantRepository.kt +++ b/domain/src/main/java/org/gdsc/domain/repository/RestaurantRepository.kt @@ -2,6 +2,7 @@ package org.gdsc.domain.repository import androidx.paging.PagingData import kotlinx.coroutines.flow.Flow +import okhttp3.MultipartBody import org.gdsc.domain.DrinkPossibility import org.gdsc.domain.FoodCategory import org.gdsc.domain.SortType @@ -13,7 +14,6 @@ import org.gdsc.domain.model.Review import org.gdsc.domain.model.UserLocation import org.gdsc.domain.model.request.ModifyRestaurantInfoRequest import org.gdsc.domain.model.request.RestaurantRegistrationRequest -import org.gdsc.domain.model.request.RestaurantSearchMapRequest import org.gdsc.domain.model.response.RestaurantInfoResponse interface RestaurantRepository { @@ -43,4 +43,6 @@ interface RestaurantRepository { suspend fun getRestaurantReviews(restaurantId: Int): List + suspend fun postRestaurantReview(restaurantId: Int, reviewContent: String, reviewImages: List): Boolean + } \ No newline at end of file diff --git a/domain/src/main/java/org/gdsc/domain/usecase/PostReviewUseCase.kt b/domain/src/main/java/org/gdsc/domain/usecase/PostReviewUseCase.kt new file mode 100644 index 00000000..2579c1ba --- /dev/null +++ b/domain/src/main/java/org/gdsc/domain/usecase/PostReviewUseCase.kt @@ -0,0 +1,14 @@ +package org.gdsc.domain.usecase + +import okhttp3.MultipartBody +import org.gdsc.domain.repository.RestaurantRepository +import javax.inject.Inject + +class PostReviewUseCase @Inject constructor( + private val restaurantRepository: RestaurantRepository +) { + + suspend operator fun invoke(restaurantId: Int, reviewContent: String, reviewImages: List): Boolean { + return restaurantRepository.postRestaurantReview(restaurantId, reviewContent, reviewImages) + } +} \ No newline at end of file diff --git a/presentation/src/main/java/org/gdsc/presentation/view/mypage/adapter/PhotoWillBeUploadedAdapter.kt b/presentation/src/main/java/org/gdsc/presentation/view/mypage/adapter/PhotoWillBeUploadedAdapter.kt new file mode 100644 index 00000000..62b1d820 --- /dev/null +++ b/presentation/src/main/java/org/gdsc/presentation/view/mypage/adapter/PhotoWillBeUploadedAdapter.kt @@ -0,0 +1,64 @@ +package org.gdsc.presentation.view.mypage.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.net.toUri +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import org.gdsc.presentation.databinding.ItemPhotoWillBeUploadedBinding + +class PhotoWillBeUploadedAdapter( + private val onDeleteButtonClicked: (String) -> Unit +) : + ListAdapter( + diffUtil + ) { + inner class PhotoWillBeUploadedViewHolder(private val binding: ItemPhotoWillBeUploadedBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(url: String) { + + Glide.with(binding.root) + .load(url.toUri()) + .into(binding.photoWillBeUploaded) + + binding.deleteButton.setOnClickListener { + onDeleteButtonClicked(url) + } + } + } + + companion object { + val diffUtil = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean { + return oldItem == newItem + } + } + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): PhotoWillBeUploadedViewHolder { + val inflater = LayoutInflater.from(parent.context) + return PhotoWillBeUploadedViewHolder( + ItemPhotoWillBeUploadedBinding.inflate( + inflater, + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: PhotoWillBeUploadedViewHolder, position: Int) { + holder.apply { + bind(getItem(position)) + } + } +} \ No newline at end of file diff --git a/presentation/src/main/java/org/gdsc/presentation/view/mypage/restaurantdetail/RestaurantDetailFragment.kt b/presentation/src/main/java/org/gdsc/presentation/view/mypage/restaurantdetail/RestaurantDetailFragment.kt index 724f0164..a9750e84 100644 --- a/presentation/src/main/java/org/gdsc/presentation/view/mypage/restaurantdetail/RestaurantDetailFragment.kt +++ b/presentation/src/main/java/org/gdsc/presentation/view/mypage/restaurantdetail/RestaurantDetailFragment.kt @@ -8,15 +8,26 @@ import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast +import androidx.core.net.toUri import androidx.fragment.app.activityViewModels +import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController import com.bumptech.glide.Glide import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import okhttp3.MediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody import org.gdsc.presentation.R import org.gdsc.presentation.databinding.FragmentRestaurantDetailBinding +import org.gdsc.presentation.utils.BitmapUtils.getCompressedBitmapFromUri +import org.gdsc.presentation.utils.BitmapUtils.saveBitmapToFile import org.gdsc.presentation.utils.CalculatorUtils +import org.gdsc.presentation.utils.repeatWhenUiStarted +import org.gdsc.presentation.view.mypage.adapter.PhotoWillBeUploadedAdapter import org.gdsc.presentation.view.mypage.adapter.RestaurantDetailPagerAdapter import org.gdsc.presentation.view.mypage.viewmodel.RestaurantDetailViewModel @@ -28,6 +39,10 @@ class RestaurantDetailFragment : Fragment() { private val viewModel: RestaurantDetailViewModel by activityViewModels() + private val adapter = PhotoWillBeUploadedAdapter { + viewModel.deletePhotoForReviewState(it) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? @@ -41,6 +56,66 @@ class RestaurantDetailFragment : Fragment() { setButtons() setTabLayout() observeData() + + repeatWhenUiStarted { + viewModel.photosForReviewState.collect { + adapter.submitList(it) + } + } + + binding.rvImageListWillBeUploaded.adapter = adapter + + binding.addImageIcon.setOnClickListener { + val directions = + RestaurantDetailFragmentDirections.actionRestaurantDetailFragmentToMultiImagePickerFragment() + + findNavController().navigate(directions) + } + + binding.btnRegister.setOnClickListener { + + val pictures = mutableListOf() + + viewModel.photosForReviewState.value.forEachIndexed { index, sUri -> + + sUri.toUri() + .getCompressedBitmapFromUri(requireContext()) + ?.saveBitmapToFile(requireContext(), "$index.jpg")?.let { imageFile -> + + val requestFile = + RequestBody.create( + MediaType.parse("image/png"), + imageFile + ) + + val body = + MultipartBody.Part.createFormData( + "reviewImages", + imageFile.name, + requestFile + ) + + pictures.add(body) + + } + + } + + viewModel.postReview( + binding.etReview.text.toString(), + pictures + ) { + binding.etReview.text.clear() + Toast.makeText(requireContext(), "후기가 등록되었습니다!", Toast.LENGTH_SHORT).show() + } + } + + setFragmentResultListener("pickImages") { _, bundle -> + val images = bundle.getStringArray("imagesUri") + viewModel.setPhotosForReviewState(images?.toList() ?: emptyList()) + + if (images.isNullOrEmpty()) return@setFragmentResultListener + } } private fun setButtons() { diff --git a/presentation/src/main/java/org/gdsc/presentation/view/mypage/viewmodel/RestaurantDetailViewModel.kt b/presentation/src/main/java/org/gdsc/presentation/view/mypage/viewmodel/RestaurantDetailViewModel.kt index 6cee9ab6..8e9639b8 100644 --- a/presentation/src/main/java/org/gdsc/presentation/view/mypage/viewmodel/RestaurantDetailViewModel.kt +++ b/presentation/src/main/java/org/gdsc/presentation/view/mypage/viewmodel/RestaurantDetailViewModel.kt @@ -5,12 +5,15 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import okhttp3.MultipartBody import org.gdsc.domain.model.Review import org.gdsc.domain.model.UserInfo import org.gdsc.domain.model.response.RestaurantInfoResponse import org.gdsc.domain.usecase.GetRestaurantInfoUseCase import org.gdsc.domain.usecase.GetRestaurantReviewsUseCase +import org.gdsc.domain.usecase.PostReviewUseCase import org.gdsc.domain.usecase.user.GetOtherUserInfoUseCase import org.gdsc.presentation.JmtLocationManager import javax.inject.Inject @@ -21,7 +24,8 @@ class RestaurantDetailViewModel private val jmtLocationManager: JmtLocationManager, private val getRestaurantInfoUseCase: GetRestaurantInfoUseCase, private val getOtherUserInfoUseCase: GetOtherUserInfoUseCase, - private val getRestaurantReviewsUseCase: GetRestaurantReviewsUseCase + private val getRestaurantReviewsUseCase: GetRestaurantReviewsUseCase, + private val postReviewUseCase: PostReviewUseCase ): ViewModel() { private var _restaurantInfo: MutableStateFlow = MutableStateFlow(null) @@ -36,6 +40,17 @@ class RestaurantDetailViewModel val reviews: StateFlow> get() = _reviews + private var _photosForReviewState: MutableStateFlow> = + MutableStateFlow(emptyList()) + val photosForReviewState = _photosForReviewState.asStateFlow() + + fun setPhotosForReviewState(images: List) { + _photosForReviewState.value = images + } + + fun deletePhotoForReviewState(image: String) { + _photosForReviewState.value = _photosForReviewState.value - image + } init { viewModelScope.launch { @@ -54,4 +69,15 @@ class RestaurantDetailViewModel } + fun postReview(content: String, pictures: List, onSuccess: () -> Unit) { + viewModelScope.launch { + val isSuccess = postReviewUseCase(1, content, pictures) + + if (isSuccess) { + _photosForReviewState.value = emptyList() + onSuccess() + } + } + } + } \ No newline at end of file diff --git a/presentation/src/main/res/drawable/add_image_icon.png b/presentation/src/main/res/drawable/add_image_icon.png new file mode 100644 index 00000000..0fd53b18 Binary files /dev/null and b/presentation/src/main/res/drawable/add_image_icon.png differ diff --git a/presentation/src/main/res/drawable/bg_rounded_6_main500.xml b/presentation/src/main/res/drawable/bg_rounded_6_main500.xml new file mode 100644 index 00000000..8b55e5d7 --- /dev/null +++ b/presentation/src/main/res/drawable/bg_rounded_6_main500.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/drawable/delete_icon.png b/presentation/src/main/res/drawable/delete_icon.png new file mode 100644 index 00000000..179c23a2 Binary files /dev/null and b/presentation/src/main/res/drawable/delete_icon.png differ diff --git a/presentation/src/main/res/layout/fragment_restaurant_detail.xml b/presentation/src/main/res/layout/fragment_restaurant_detail.xml index 6982189e..edac825c 100644 --- a/presentation/src/main/res/layout/fragment_restaurant_detail.xml +++ b/presentation/src/main/res/layout/fragment_restaurant_detail.xml @@ -5,187 +5,230 @@ android:layout_height="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto"> - + app:layout_constraintBottom_toTopOf="@id/divider" + app:layout_behavior="@string/appbar_scrolling_view_behavior"> - - - + app:layout_constraintBottom_toBottomOf="parent" + > - - - - - - - - - + + + + + + + + + + + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@id/ct_distance_and_category" + android:layout_marginTop="4dp"> + + + + + + + + + + + + + + - + - + - - - - - - - - - + - + app:layout_constraintTop_toTopOf="parent" + style="@style/text_medium_medium" + android:textColor="@color/grey900"/> - + app:layout_constraintTop_toBottomOf="@id/et_review" + android:layout_marginTop="27dp"/> - + app:layout_constraintBottom_toBottomOf="@id/add_image_icon" + style="@style/text_medium_medium" + android:textColor="@color/white"/> + + diff --git a/presentation/src/main/res/layout/item_photo_will_be_uploaded.xml b/presentation/src/main/res/layout/item_photo_will_be_uploaded.xml new file mode 100644 index 00000000..3bd94ed5 --- /dev/null +++ b/presentation/src/main/res/layout/item_photo_will_be_uploaded.xml @@ -0,0 +1,25 @@ + + + + + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/navigation/main_nav_graph.xml b/presentation/src/main/res/navigation/main_nav_graph.xml index 83c8e114..37c1baa9 100644 --- a/presentation/src/main/res/navigation/main_nav_graph.xml +++ b/presentation/src/main/res/navigation/main_nav_graph.xml @@ -173,6 +173,9 @@ +