diff --git a/.github/workflows/be-autodeploy.yml b/.github/workflows/be-autodeploy.yml new file mode 100644 index 00000000..80a1c105 --- /dev/null +++ b/.github/workflows/be-autodeploy.yml @@ -0,0 +1,71 @@ +name: auto deploy + +on: + push: + branches: + - develop/be + + +jobs: + push_to_registry: + name: Push to ncp container registry + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to NCP Container Registry + uses: docker/login-action@v2 + with: + registry: ${{ secrets.NCP_CONTAINER_REGISTRY }} + username: ${{ secrets.NCP_ACCESS_KEY }} + password: ${{ secrets.NCP_SECRET_KEY }} + - name: Create config file + run: | + echo "export const awsConfig = ${OBJECT_STORAGE_CONFIG}" > ./be/objectStorage.config.ts + env: + OBJECT_STORAGE_CONFIG: ${{ secrets.OBJECT_STORAGE_CONFIG }} + + - name: Create TypeORM config + run: | + mkdir -p ./be/src/configs + echo "import { TypeOrmModuleOptions } from '@nestjs/typeorm';" > ./be/src/configs/typeorm.config.ts + echo "export const typeORMConfig: TypeOrmModuleOptions = ${TYPEORM_CONFIG}" >> ./be/src/configs/typeorm.config.ts + env: + TYPEORM_CONFIG: ${{ secrets.TYPEORM_CONFIG }} + + - name: build and push + uses: docker/build-push-action@v3 + with: + context: ./be + file: ./be/Dockerfile + push: true + tags: ${{ secrets.NCP_CONTAINER_REGISTRY }}/nibobnebob:latest + cache-from: type=registry,ref=${{ secrets.NCP_CONTAINER_REGISTRY }}/nibobnebob:latest + cache-to: type=inline + + + + + + pull_from_registry: + name: Connect server ssh and pull from container registry + needs: push_to_registry + runs-on: ubuntu-latest + steps: + - name: connect ssh to Nginx server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.NGINX_HOST }} + username: ${{ secrets.NGINX_USERNAME }} + password: ${{ secrets.NGINX_PASSWORD }} + port: ${{ secrets.NGINX_PORT }} + script: | + ssh ${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }} <<- 'EOSSH' + docker pull ${{ secrets.NCP_CONTAINER_REGISTRY }}/nibobnebob + docker stop $(docker ps -a -q) + docker rm $(docker ps -a -q) + docker run -d -p 8000:8000 -e API_KEY=${{ secrets.DEV_APIKEY }} -e NODE_ENV="DEV" ${{ secrets.NCP_CONTAINER_REGISTRY }}/nibobnebob + docker image prune -f + EOSSH diff --git a/Aos/app/build.gradle.kts b/Aos/app/build.gradle.kts index aeee1f9d..5f9c0267 100644 --- a/Aos/app/build.gradle.kts +++ b/Aos/app/build.gradle.kts @@ -24,7 +24,7 @@ android { minSdk = 24 targetSdk = 34 versionCode = 1 - versionName = "0.1.0" + versionName = "1.0.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "NAVER_LOGIN_CLIENT_ID", getProperty("naverLoginClientId")) diff --git a/Aos/app/src/main/AndroidManifest.xml b/Aos/app/src/main/AndroidManifest.xml index 6287731c..f2460717 100644 --- a/Aos/app/src/main/AndroidManifest.xml +++ b/Aos/app/src/main/AndroidManifest.xml @@ -33,7 +33,8 @@ + android:exported="false" + android:windowSoftInputMode="adjustPan"> { - override fun RecommendFollowListResponse.toDomainModel(): RecommendFollowListData = RecommendFollowListData( - nickName = nickName, - region = region, - isFollow = isFollow, - profileImage = profileImage - ) + override fun RecommendFollowListResponse.toDomainModel(): RecommendFollowListData = + RecommendFollowListData( + nickName = nickName, + region = region, + isFollow = isFollow, + profileImage = profileImage + ) } } diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/data/remote/RestaurantApi.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/data/remote/RestaurantApi.kt index 7f7a6b53..d6a72907 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/data/remote/RestaurantApi.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/data/remote/RestaurantApi.kt @@ -71,6 +71,8 @@ interface RestaurantApi { // 내 맛집 리스트 @GET("api/user/restaurant") suspend fun myRestaurantList( + @Query("longitude") longitude: String? = null, + @Query("latitude") latitude: String? = null, @Query("limit") limit: Int? = null, @Query("page") page: Int? = null, @Query("sort") sort: String? = null, @@ -119,6 +121,7 @@ interface RestaurantApi { //위치기반 맛집 리스트 @GET("api/restaurant/all") suspend fun nearRestaurantList( + @Query("limit") limit : Int?, @Query("radius") radius: String, @Query("longitude") longitude: String, @Query("latitude") latitude: String diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/data/repository/RestaurantRepositoryImpl.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/data/repository/RestaurantRepositoryImpl.kt index 9806188f..386ad339 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/data/repository/RestaurantRepositoryImpl.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/data/repository/RestaurantRepositoryImpl.kt @@ -11,6 +11,7 @@ import com.avengers.nibobnebob.data.model.response.WishRestaurantResponse.Compan import com.avengers.nibobnebob.data.model.runRemote import com.avengers.nibobnebob.data.remote.RestaurantApi import com.avengers.nibobnebob.domain.model.MyRestaurantData +import com.avengers.nibobnebob.domain.model.RecommendRestaurantData import com.avengers.nibobnebob.domain.model.RestaurantDetailData import com.avengers.nibobnebob.domain.model.RestaurantIsWishData import com.avengers.nibobnebob.domain.model.RestaurantItemsData @@ -18,7 +19,6 @@ import com.avengers.nibobnebob.domain.model.ReviewSortData import com.avengers.nibobnebob.domain.model.SearchRestaurantData import com.avengers.nibobnebob.domain.model.WishRestaurantData import com.avengers.nibobnebob.domain.model.base.BaseState -import com.avengers.nibobnebob.domain.model.RecommendRestaurantData import com.avengers.nibobnebob.domain.model.base.StatusCode import com.avengers.nibobnebob.domain.repository.RestaurantRepository import kotlinx.coroutines.flow.Flow @@ -140,12 +140,15 @@ class RestaurantRepositoryImpl @Inject constructor( } override fun myRestaurantList( + longitude: String?, + latitude: String?, limit: Int?, page: Int?, sort: String? ): Flow> = flow { - when (val result = runRemote { api.myRestaurantList(limit, page, sort) }) { + when (val result = + runRemote { api.myRestaurantList(longitude, latitude, limit, page, sort) }) { is BaseState.Success -> { result.data.body?.let { body -> emit(BaseState.Success(body.toDomainModel())) @@ -268,10 +271,11 @@ class RestaurantRepositoryImpl @Inject constructor( override fun nearRestaurantList( radius: String, longitude: String, - latitude: String + latitude: String, + limit: Int? ): Flow>> = flow { - - when (val result = runRemote { api.nearRestaurantList(radius, longitude, latitude) }) { + when (val result = + runRemote { api.nearRestaurantList(limit, radius, longitude, latitude) }) { is BaseState.Success -> { result.data.body?.let { body -> emit(BaseState.Success(body.map { it.toDomainModel() })) diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/domain/model/MyRestaurantData.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/domain/model/MyRestaurantData.kt index 928b975e..5916b1d5 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/domain/model/MyRestaurantData.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/domain/model/MyRestaurantData.kt @@ -14,7 +14,7 @@ data class MyRestaurantItemData( val address: String, val category: String, val id: Int, - val createdAt : String, + val createdAt: String?, val location: LocationData, val name: String, val phoneNumber: String, diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/domain/repository/RestaurantRepository.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/domain/repository/RestaurantRepository.kt index 075234c4..5ecf683d 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/domain/repository/RestaurantRepository.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/domain/repository/RestaurantRepository.kt @@ -42,6 +42,8 @@ interface RestaurantRepository { ): Flow> fun myRestaurantList( + longitude: String? = null, + latitude: String? = null, limit: Int? = null, page: Int? = null, sort: String? = null @@ -82,6 +84,7 @@ interface RestaurantRepository { radius: String, longitude: String, latitude: String, + limit: Int? ): Flow>> fun likeReview(reviewId: Int): Flow> diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/domain/usecase/restaurant/GetMyRestaurantListUseCase.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/domain/usecase/restaurant/GetMyRestaurantListUseCase.kt index e0fac5a1..6ba0b1bb 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/domain/usecase/restaurant/GetMyRestaurantListUseCase.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/domain/usecase/restaurant/GetMyRestaurantListUseCase.kt @@ -12,8 +12,11 @@ class GetMyRestaurantListUseCase @Inject constructor( private val repository: RestaurantRepository ) { operator fun invoke( + longitude: String? = null, + latitude: String? = null, limit: Int? = null, page: Int? = null, sort: String? = null, - ): Flow> = repository.myRestaurantList(limit, page, sort) + ): Flow> = + repository.myRestaurantList(longitude, latitude, limit, page, sort) } \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/base/BaseFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/base/BaseFragment.kt index cf1b4518..e39bc178 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/base/BaseFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/base/BaseFragment.kt @@ -84,7 +84,7 @@ abstract class BaseFragment( } fun showToastMessage(message: String) { - Toast.makeText(context, message, Toast.LENGTH_LONG).show() + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } fun showTwoButtonTitleDialog( diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/customview/RecommendRestaurantDialog.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/customview/RecommendRestaurantDialog.kt index ecd8998f..1ce128fa 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/customview/RecommendRestaurantDialog.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/customview/RecommendRestaurantDialog.kt @@ -5,6 +5,7 @@ import android.content.Context import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.os.Bundle +import android.view.View import com.avengers.nibobnebob.databinding.DialogRecommendRestaurantBinding import com.avengers.nibobnebob.presentation.ui.main.home.adapter.HomeRecommendAdapter import com.avengers.nibobnebob.presentation.ui.main.home.model.UiRecommendRestaurantData @@ -13,7 +14,6 @@ class RecommendRestaurantDialog( context: Context, private val uiRecommendRestaurantDataList: List, private val restaurantClickListener: (Int) -> Unit - ) : Dialog(context) { private lateinit var binding: DialogRecommendRestaurantBinding @@ -35,6 +35,11 @@ class RecommendRestaurantDialog( rvRecommendRestaurant.adapter = adapter adapter.submitList(uiRecommendRestaurantDataList) + if(uiRecommendRestaurantDataList.isEmpty()){ + binding.tvIsEmpty.visibility = View.VISIBLE + } + + binding.tvCancel.setOnClickListener { dismiss() } diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/NavigationExtensions.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/NavigationExtensions.kt index 6480816a..dd69d6e6 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/NavigationExtensions.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/NavigationExtensions.kt @@ -1,6 +1,10 @@ package com.avengers.nibobnebob.presentation.ui +import android.app.Activity +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.FragmentActivity import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController import com.avengers.nibobnebob.NavGraphDirections @@ -31,4 +35,12 @@ internal fun NavController.toMyPage() { internal fun NavController.toUserDetail(nickName: String) { val action = NavGraphDirections.globalToUserDetailFragment(nickName) navigate(action) +} + +internal fun customBack(activity : FragmentActivity, nav : NavController){ + activity.onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + nav.navigateUp() + } + }) } \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/bindingadapters/ButtonBindingAdapter.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/bindingadapters/ButtonBindingAdapter.kt index c6c5726f..3fbd6088 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/bindingadapters/ButtonBindingAdapter.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/bindingadapters/ButtonBindingAdapter.kt @@ -12,7 +12,8 @@ fun bindDoneButtonEnable(btn: AppCompatButton, state: EditProfileUiState?) = wit state ?: return val allValid = state.nickName.isValid && state.location.isValid && state.birth.isValid - val isChanged = state.nickName.isChanged || state.location.isChanged || state.birth.isChanged || state.profileImage.isChanged || state.isMale.isChanged + val isChanged = + state.nickName.isChanged || state.location.isChanged || state.birth.isChanged || state.profileImage.isChanged || state.isMale.isChanged isEnabled = allValid && isChanged } diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/bindingadapters/TextViewBindingAdapter.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/bindingadapters/TextViewBindingAdapter.kt index 454a17df..84e35f36 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/bindingadapters/TextViewBindingAdapter.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/bindingadapters/TextViewBindingAdapter.kt @@ -16,7 +16,6 @@ import com.avengers.nibobnebob.presentation.util.Constants.FILTER_NEW import com.avengers.nibobnebob.presentation.util.Constants.FILTER_OLD import com.avengers.nibobnebob.presentation.util.Constants.FILTER_WORST import com.avengers.nibobnebob.presentation.util.LoginType -import com.google.android.material.textfield.TextInputLayout // signup @BindingAdapter("helperMessage") @@ -38,6 +37,24 @@ fun bindHelpMessage(tv: TextView, inputState: InputState) = with(tv) { } } +//signup +@BindingAdapter("emailValidation") +fun bindEmailValidationr(tv: TextView, inputState: InputState) = with(tv) { + when (inputState) { + is InputState.Success -> { + setTextColor(ContextCompat.getColor(context, R.color.nn_dark6)) + } + + is InputState.Error -> { + setTextColor(ContextCompat.getColor(context, R.color.nn_dark6)) + } + + is InputState.Empty -> { + setTextColor(ContextCompat.getColor(context, R.color.nn_dark2)) + } + } +} + @SuppressLint("SetTextI18n") @BindingAdapter("textLength", "textLimit") fun bindTextLength(tv: TextView, text: String, limit: Int) { @@ -87,7 +104,7 @@ fun bindNickHelperText(tv: TextView, state: EditInputState?) = with(tv) { @BindingAdapter("loginType") fun bindLoginType(tv: TextView, type: String?) = with(tv) { type ?: return - text = if (type == LoginType.NAVER_LOGIN) "네이버 소셜로그인" else "" + text = if (type == LoginType.NAVER_LOGIN) "네이버 소셜로그인" else "일반 로그인" } @BindingAdapter("filterType") @@ -100,3 +117,8 @@ fun bindFilterType(tv: TextView, type: String) = with(tv) { else -> "" } } + +@BindingAdapter("adjustText") +fun bindLongText(tv: TextView, value: String) { + tv.text = if (value.length > 27) "${value.substring(0, 27)}.." else value +} diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/IntroActivity.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/IntroActivity.kt index 8e5764f6..540fd553 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/IntroActivity.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/IntroActivity.kt @@ -2,16 +2,20 @@ package com.avengers.nibobnebob.presentation.ui.intro import android.Manifest import android.app.Activity +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.provider.MediaStore +import android.view.MotionEvent +import android.view.inputmethod.InputMethodManager import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import com.avengers.nibobnebob.databinding.ActivityIntroBinding import com.avengers.nibobnebob.presentation.base.BaseActivity +import com.avengers.nibobnebob.presentation.ui.adjustKeyboard import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -100,4 +104,10 @@ class IntroActivity : BaseActivity(ActivityIntroBinding::i } } } + + override fun onTouchEvent(event: MotionEvent?): Boolean { + val imm: InputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(currentFocus?.windowToken, 0) + return true + } } \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/login/LoginFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/login/LoginFragment.kt index 7438d454..10e22eaf 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/login/LoginFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/login/LoginFragment.kt @@ -97,6 +97,7 @@ class LoginFragment : BaseFragment(R.layout.fragment_login private fun NavController.toDetailSignup() { val action = LoginFragmentDirections.actionLoginFragmentToDetailSignupFragment( + provider = "naver", email = viewModel.naverEmail.value ) this.navigate(action) diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/BasicSignupViewModel.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/BasicSignupViewModel.kt index dc96c8c5..8658cb0d 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/BasicSignupViewModel.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/BasicSignupViewModel.kt @@ -27,20 +27,21 @@ data class BasicSignupUiState( val isEmailNotEmpty: Boolean = true ) -sealed class BasicSignupEvents{ +sealed class BasicSignupEvents { data object NavigateToBack : BasicSignupEvents() data class NavigateToDetailSignup( val provider: String, val email: String, val password: String, ) : BasicSignupEvents() - data class ShowSnackMessage(val msg: String): BasicSignupEvents() + + data class ShowSnackMessage(val msg: String) : BasicSignupEvents() } @HiltViewModel class BasicSignupViewModel @Inject constructor( private val getEmailValidationUseCase: GetEmailValidationUseCase -) : ViewModel(){ +) : ViewModel() { private val _uiState = MutableStateFlow(BasicSignupUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -54,22 +55,23 @@ class BasicSignupViewModel @Inject constructor( private val emailValidation = MutableStateFlow(false) private val passwordValidation = MutableStateFlow(false) - val isDataReady = combine(emailValidation, passwordValidation){ emailValidation, passwordValidation -> - emailValidation && passwordValidation - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(), - false - ) + val isDataReady = + combine(emailValidation, passwordValidation) { emailValidation, passwordValidation -> + emailValidation && passwordValidation + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + false + ) - init{ + init { observeEmail() observePasswordCheck() } - private fun observeEmail(){ + private fun observeEmail() { email.onEach { - if(ValidationUtil.checkEmail(it)){ + if (ValidationUtil.checkEmail(it)) { _uiState.update { state -> emailValidation.value = false state.copy( @@ -77,6 +79,14 @@ class BasicSignupViewModel @Inject constructor( emailState = InputState.Empty ) } + } else if (it.isEmpty()) { + _uiState.update { state -> + emailValidation.value = false + state.copy( + isEmailNotEmpty = false, + emailState = InputState.Error("이메일을 입력해주세요.") + ) + } } else { _uiState.update { state -> emailValidation.value = false @@ -89,10 +99,31 @@ class BasicSignupViewModel @Inject constructor( }.launchIn(viewModelScope) } - private fun observePasswordCheck(){ + private fun observePasswordCheck() { + + password.onEach { + if (it.isNotBlank()) { + if (it == passwordCheck.value) { + passwordValidation.value = true + _uiState.update { state -> + state.copy( + passwordCheckState = InputState.Success("비밀번호가 일치합니다.") + ) + } + } else { + passwordValidation.value = false + _uiState.update { state -> + state.copy( + passwordCheckState = InputState.Error("비밀번호가 일치하지 않습니다.") + ) + } + } + } + }.launchIn(viewModelScope) + passwordCheck.onEach { - if(it.isNotBlank()){ - if(it == password.value){ + if (it.isNotBlank()) { + if (it == password.value) { passwordValidation.value = true _uiState.update { state -> state.copy( @@ -111,11 +142,11 @@ class BasicSignupViewModel @Inject constructor( }.launchIn(viewModelScope) } - fun checkEmail(){ + fun checkEmail() { getEmailValidationUseCase(email.value).onEach { - when(it){ + when (it) { is BaseState.Success -> { - if(it.data.isExist){ + if (it.data.isExist) { emailValidation.value = false _uiState.update { state -> state.copy( @@ -131,6 +162,7 @@ class BasicSignupViewModel @Inject constructor( } } } + is BaseState.Error -> { emailValidation.value = false _events.emit(BasicSignupEvents.ShowSnackMessage(it.message)) @@ -139,19 +171,21 @@ class BasicSignupViewModel @Inject constructor( }.launchIn(viewModelScope) } - fun navigateToBack(){ + fun navigateToBack() { viewModelScope.launch { _events.emit(BasicSignupEvents.NavigateToBack) } } - fun navigateToDetailSignup(){ + fun navigateToDetailSignup() { viewModelScope.launch { - _events.emit(BasicSignupEvents.NavigateToDetailSignup( - provider = "site", - email = email.value, - password = password.value, - )) + _events.emit( + BasicSignupEvents.NavigateToDetailSignup( + provider = "site", + email = email.value, + password = password.value, + ) + ) } } } \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/DetailSignupFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/DetailSignupFragment.kt index 98be15ab..80d9c904 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/DetailSignupFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/DetailSignupFragment.kt @@ -4,7 +4,6 @@ import android.content.Intent import androidx.core.content.ContextCompat import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels -import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.avengers.nibobnebob.R @@ -53,7 +52,7 @@ class DetailSignupFragment : is DetailSignupEvents.ShowLoading -> showLoading(requireContext()) is DetailSignupEvents.DismissLoading -> dismissLoading() is DetailSignupEvents.GoToMainActivity -> { - val intent = Intent(requireContext(),MainActivity::class.java) + val intent = Intent(requireContext(), MainActivity::class.java) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) startActivity(intent) } @@ -97,10 +96,5 @@ class DetailSignupFragment : setSimpleItems(resources.getStringArray(R.array.location_list)) } } - - private fun NavController.toLoginFragment() { - val action = DetailSignupFragmentDirections.actionDetailSignupFragmentToLoginFragment() - this.navigate(action) - } } diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/DetailSignupViewModel.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/DetailSignupViewModel.kt index 1035ecff..37dab70c 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/DetailSignupViewModel.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/intro/signup/DetailSignupViewModel.kt @@ -94,6 +94,7 @@ class DetailSignupViewModel @Inject constructor( nick.onEach { _uiState.update { state -> + nickValidation.value = false state.copy( isNickNotEmpty = it.isNotBlank(), nickState = InputState.Empty diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/MainActivity.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/MainActivity.kt index f88bdc73..70de496d 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/MainActivity.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/MainActivity.kt @@ -2,11 +2,15 @@ package com.avengers.nibobnebob.presentation.ui.main import android.Manifest import android.app.Activity +import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.provider.MediaStore +import android.view.MotionEvent import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.app.ActivityCompat @@ -49,6 +53,7 @@ class MainActivity : BaseActivity(ActivityMainBinding::infl viewModel.events.collect{ when(it){ is MainEvents.OpenGallery -> onCheckStoragePermissions() + is MainEvents.FinishApp -> finish() } } } @@ -124,4 +129,9 @@ class MainActivity : BaseActivity(ActivityMainBinding::infl } } } + override fun onTouchEvent(event: MotionEvent?): Boolean { + val imm: InputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(currentFocus?.windowToken, 0) + return true + } } \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/MainViewModel.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/MainViewModel.kt index a6a8620d..dff2ce68 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/MainViewModel.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/MainViewModel.kt @@ -18,6 +18,7 @@ import javax.inject.Inject sealed class MainEvents{ data object OpenGallery: MainEvents() + data object FinishApp : MainEvents() } @HiltViewModel @@ -40,6 +41,12 @@ class MainViewModel @Inject constructor( } } + fun finishApp() { + viewModelScope.launch { + _events.emit(MainEvents.FinishApp) + } + } + fun setUriString(uri : String){ _image.value = uri } diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/follow/FollowFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/follow/FollowFragment.kt index c8c95a7e..b1f2367f 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/follow/FollowFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/follow/FollowFragment.kt @@ -1,5 +1,6 @@ package com.avengers.nibobnebob.presentation.ui.main.follow +import androidx.activity.OnBackPressedCallback import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.navigation.NavController @@ -24,6 +25,7 @@ class FollowFragment : BaseFragment(R.layout.fragment_fol binding.rvFollowList.adapter = FollowAdapter() binding.rvRecommendFriend.adapter = FollowAdapter() setTabSelectedListener() + finishApp() } override fun initNetworkView() { @@ -58,6 +60,20 @@ class FollowFragment : BaseFragment(R.layout.fragment_fol }) } + private fun finishApp(){ + var backPressTime = 0L + requireActivity().onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if(System.currentTimeMillis() - backPressTime <= 2000) { + parentViewModel.finishApp() + } else{ + backPressTime = System.currentTimeMillis() + showToastMessage("뒤로가기 버튼을 한 번 더 누르면 종료됩니다.") + } + } + }) + } + private fun NavController.toFollowSearch() { val action = FollowFragmentDirections.actionFollowFragmentToFollowSearchFragment() navigate(action) diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/follow/search/FollowSearchFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/follow/search/FollowSearchFragment.kt index 47620e8a..88135313 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/follow/search/FollowSearchFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/follow/search/FollowSearchFragment.kt @@ -8,6 +8,7 @@ import com.avengers.nibobnebob.databinding.FragmentFollowSearchBinding import com.avengers.nibobnebob.presentation.base.BaseFragment import com.avengers.nibobnebob.presentation.customview.SelectRegionDialog import com.avengers.nibobnebob.presentation.ui.adjustKeyboard +import com.avengers.nibobnebob.presentation.ui.customBack import com.avengers.nibobnebob.presentation.ui.main.MainViewModel import com.avengers.nibobnebob.presentation.ui.main.follow.adapter.FollowSearchAdapter import com.avengers.nibobnebob.presentation.ui.toUserDetail @@ -25,6 +26,7 @@ class FollowSearchFragment : binding.rvFollowSearch.itemAnimator = null setEditText() viewModel.observeKeyword() + customBack(requireActivity(), findNavController()) } override fun initNetworkView() { diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/mapper/UiUserDetailDataMapper.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/mapper/UiUserDetailDataMapper.kt index d1a15d0b..37d81ead 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/mapper/UiUserDetailDataMapper.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/mapper/UiUserDetailDataMapper.kt @@ -21,6 +21,7 @@ internal fun UserDetailData.toUiUserDetailData() = UiUserDetailData( internal fun UserDetailRestaurantData.toUiUserDetailRestaurantData() = UiUserDetailRestaurantData( + id = id, name = name, address = address, phoneNumber = phoneNumber diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/model/UiUserDetailData.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/model/UiUserDetailData.kt index 18c33e2b..c890f35b 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/model/UiUserDetailData.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/model/UiUserDetailData.kt @@ -11,6 +11,7 @@ data class UiUserDetailData( data class UiUserDetailRestaurantData( + val id : Int, val name: String, val address: String, val phoneNumber: String diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantadd/AddMyRestaurantFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantadd/AddMyRestaurantFragment.kt index 056bcd93..e692017e 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantadd/AddMyRestaurantFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantadd/AddMyRestaurantFragment.kt @@ -7,6 +7,7 @@ import androidx.navigation.fragment.navArgs import com.avengers.nibobnebob.R import com.avengers.nibobnebob.databinding.FragmentAddMyRestaurantBinding import com.avengers.nibobnebob.presentation.base.BaseFragment +import com.avengers.nibobnebob.presentation.ui.customBack import com.avengers.nibobnebob.presentation.ui.main.MainViewModel import com.avengers.nibobnebob.presentation.ui.toHome import com.avengers.nibobnebob.presentation.ui.toMultiPart @@ -29,6 +30,7 @@ class AddMyRestaurantFragment : setSliderListener() setVisitMethodRadioListener() initImageObserver() + customBack(requireActivity(), findNavController()) } override fun initNetworkView() { diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantdetail/RestaurantDetailFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantdetail/RestaurantDetailFragment.kt index 0bea70ec..df361e4e 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantdetail/RestaurantDetailFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantdetail/RestaurantDetailFragment.kt @@ -9,6 +9,7 @@ import com.avengers.nibobnebob.R import com.avengers.nibobnebob.databinding.FragmentRestaurantDetailBinding import com.avengers.nibobnebob.presentation.base.BaseFragment import com.avengers.nibobnebob.presentation.customview.TwoButtonTitleDialog +import com.avengers.nibobnebob.presentation.ui.customBack import com.avengers.nibobnebob.presentation.ui.main.MainViewModel import com.avengers.nibobnebob.presentation.ui.main.global.restaurantdetail.adapter.RestaurantReviewAdapter import dagger.hilt.android.AndroidEntryPoint @@ -27,6 +28,7 @@ class RestaurantDetailFragment : binding.vm = viewModel viewModel.setRestaurantId(restaurantId) binding.rvReview.adapter = RestaurantReviewAdapter() + customBack(requireActivity(), findNavController()) } override fun initNetworkView() { diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantreview/RestaurantReviewsFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantreview/RestaurantReviewsFragment.kt index d2557082..b57d72f3 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantreview/RestaurantReviewsFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantreview/RestaurantReviewsFragment.kt @@ -1,6 +1,5 @@ package com.avengers.nibobnebob.presentation.ui.main.global.restaurantreview -import android.util.Log import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -10,6 +9,7 @@ import androidx.recyclerview.widget.RecyclerView import com.avengers.nibobnebob.R import com.avengers.nibobnebob.databinding.FragmentRestaurantReviewsBinding import com.avengers.nibobnebob.presentation.base.BaseFragment +import com.avengers.nibobnebob.presentation.ui.customBack import com.avengers.nibobnebob.presentation.ui.main.MainViewModel import com.avengers.nibobnebob.presentation.ui.main.global.restaurantdetail.adapter.RestaurantReviewAdapter import com.avengers.nibobnebob.presentation.util.Constants @@ -43,11 +43,11 @@ class RestaurantReviewsFragment : val isNotLoading = !viewModel.uiState.value.isLoading if (scrollBottom && hasNextPage && isNotLoading) { - Log.d("TEST", "end") viewModel.loadNextPage() } } }) + customBack(requireActivity(), findNavController()) } diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantreview/RestaurantReviewsViewModel.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantreview/RestaurantReviewsViewModel.kt index 6cab66bb..6c98f1d5 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantreview/RestaurantReviewsViewModel.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/restaurantreview/RestaurantReviewsViewModel.kt @@ -219,6 +219,6 @@ class RestaurantReviewsViewModel @Inject constructor( companion object { const val FIRST_PAGE = 1 - const val ITEM_LIMIT = 3 + const val ITEM_LIMIT = 5 } } \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/UserDetailFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/UserDetailFragment.kt index 6f38dcaa..68e31bb0 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/UserDetailFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/UserDetailFragment.kt @@ -4,12 +4,14 @@ import android.os.Build import androidx.annotation.RequiresApi import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.avengers.nibobnebob.R import com.avengers.nibobnebob.databinding.FragmentUserDetailBinding import com.avengers.nibobnebob.presentation.base.BaseFragment import com.avengers.nibobnebob.presentation.customview.ImageDialog +import com.avengers.nibobnebob.presentation.ui.customBack import com.avengers.nibobnebob.presentation.ui.main.MainViewModel import com.avengers.nibobnebob.presentation.ui.main.global.userdetail.adapter.UserDetailRestaurantAdapter import dagger.hilt.android.AndroidEntryPoint @@ -21,11 +23,13 @@ class UserDetailFragment : BaseFragment(R.layout.frag private val viewModel: UserDetailViewModel by viewModels() private val args: UserDetailFragmentArgs by navArgs() private val nickName by lazy { args.nickName } + private val adapter = UserDetailRestaurantAdapter { id -> viewModel.restaurantDetail(id) } - override fun initView() { - binding.vm = viewModel + override fun initView() = with(binding) { + vm = viewModel viewModel.setNick(nickName) - binding.rvRestaurant.adapter = UserDetailRestaurantAdapter() + rvRestaurant.adapter = adapter + customBack(requireActivity(), findNavController()) } @RequiresApi(Build.VERSION_CODES.O) @@ -37,6 +41,11 @@ class UserDetailFragment : BaseFragment(R.layout.frag repeatOnStarted { viewModel.events.collect { when (it) { + is UserDetailEvents.NavigateToRestaurantDetail -> + findNavController().toRestaurantDetail( + it.id + ) + is UserDetailEvents.NavigateToBack -> findNavController().navigateUp() is UserDetailEvents.ShowSnackMessage -> showSnackBar(it.msg) is UserDetailEvents.ShowToastMessage -> showToastMessage(it.msg) @@ -48,4 +57,11 @@ class UserDetailFragment : BaseFragment(R.layout.frag } } } + + + private fun NavController.toRestaurantDetail(id: Int) { + val action = + UserDetailFragmentDirections.actionUserDetailFragmentToRestaurantDetailFragment(id) + navigate(action) + } } \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/UserDetailViewModel.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/UserDetailViewModel.kt index 5ea6cb8d..6d8246ce 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/UserDetailViewModel.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/UserDetailViewModel.kt @@ -34,6 +34,7 @@ sealed class UserDetailEvents { data class ShowToastMessage(val msg: String) : UserDetailEvents() data object NavigateToBack : UserDetailEvents() data class ShowBiggerImageDialog(val img: String) : UserDetailEvents() + data class NavigateToRestaurantDetail(val id: Int) : UserDetailEvents() } @HiltViewModel @@ -126,4 +127,10 @@ class UserDetailViewModel @Inject constructor( _events.emit(UserDetailEvents.NavigateToBack) } } + + fun restaurantDetail(id : Int) { + viewModelScope.launch { + _events.emit(UserDetailEvents.NavigateToRestaurantDetail(id)) + } + } } \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/adapter/UserDetailRestaurantAdapter.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/adapter/UserDetailRestaurantAdapter.kt index 86415691..39ed4eda 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/adapter/UserDetailRestaurantAdapter.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/global/userdetail/adapter/UserDetailRestaurantAdapter.kt @@ -8,7 +8,9 @@ import com.avengers.nibobnebob.databinding.ItemUserDetailRestaurantBinding import com.avengers.nibobnebob.presentation.ui.main.global.model.UiUserDetailRestaurantData import com.avengers.nibobnebob.presentation.util.DefaultDiffUtil -class UserDetailRestaurantAdapter() : +class UserDetailRestaurantAdapter( + private val restaurantClick: (Int) -> Unit +) : ListAdapter(DefaultDiffUtil()) { override fun onCreateViewHolder( @@ -24,14 +26,18 @@ class UserDetailRestaurantAdapter() : } override fun onBindViewHolder(holder: UserDetailRestaurantViewHolder, position: Int) { - holder.bind(getItem(position)) + holder.bind(getItem(position), restaurantClick) } } class UserDetailRestaurantViewHolder(private val binding: ItemUserDetailRestaurantBinding) : RecyclerView.ViewHolder(binding.root) { - fun bind(item: UiUserDetailRestaurantData) { - + fun bind( + item: UiUserDetailRestaurantData, + restaurantClick: (Int) -> Unit + ) { + binding.item = item + binding.root.setOnClickListener { restaurantClick(item.id) } } } \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/HomeFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/HomeFragment.kt index e54a80b8..62866fbd 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/HomeFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/HomeFragment.kt @@ -1,6 +1,7 @@ package com.avengers.nibobnebob.presentation.ui.main.home import android.Manifest +import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -34,6 +35,7 @@ import com.naver.maps.map.overlay.OverlayImage import com.naver.maps.map.util.FusedLocationSource import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel @AndroidEntryPoint class HomeFragment : BaseFragment(R.layout.fragment_home), OnMapReadyCallback { @@ -42,6 +44,7 @@ class HomeFragment : BaseFragment(R.layout.fragment_home), override val parentViewModel: MainViewModel by activityViewModels() private val args: HomeFragmentArgs by navArgs() private val restaurantId by lazy { args.addRestaurantId } + private val initCompletedNaverMap = Channel() companion object { const val LOCATION_PERMISSION_REQUEST_CODE = 1000 @@ -61,10 +64,15 @@ class HomeFragment : BaseFragment(R.layout.fragment_home), initMapView() binding.rvHomeFilter.adapter = HomeFilterAdapter() viewModel.setAddRestaurantId(restaurantId) + finishApp() } override fun initNetworkView() { - viewModel.getFilterList() + repeatOnStarted { + viewModel.getFilterList() + initCompletedNaverMap.receive() + viewModel.getMarkerList() + } } private fun initStateObserver() { @@ -114,6 +122,9 @@ class HomeFragment : BaseFragment(R.layout.fragment_home), is HomeEvents.RemoveMarkers -> removeAllMarker() is HomeEvents.ShowSnackMessage -> showSnackBar(event.msg) + + is HomeEvents.ShowLoading -> showLoading(requireContext()) + is HomeEvents.DismissLoading -> dismissLoading() } } } @@ -133,8 +144,7 @@ class HomeFragment : BaseFragment(R.layout.fragment_home), naverMap.moveCamera(cameraUpdate) viewModel.uiState.value.markerList.forEach { data -> - val isNear = viewModel.uiState.value.curFilter == NEAR_RESTAURANT - setMarker(data, isNear) + setMarker(data) } } @@ -148,10 +158,8 @@ class HomeFragment : BaseFragment(R.layout.fragment_home), locationSource = FusedLocationSource(this, LOCATION_PERMISSION_REQUEST_CODE) } - // NaverMap 관련 Setting override fun onMapReady(nM: NaverMap) { this.naverMap = nM - // 맵 ui Settings with(naverMap.uiSettings) { isCompassEnabled = false isZoomControlEnabled = false @@ -160,7 +168,9 @@ class HomeFragment : BaseFragment(R.layout.fragment_home), naverMap.locationSource = locationSource setMapListener() initStateObserver() - viewModel.getMarkerList() + repeatOnStarted { + initCompletedNaverMap.send(Unit) + } } private fun setMapListener() { @@ -205,12 +215,14 @@ class HomeFragment : BaseFragment(R.layout.fragment_home), else viewModel.trackingOff() } - private fun setMarker(data: UiRestaurantData, isNear: Boolean) { + private fun setMarker(data: UiRestaurantData) { val marker = Marker() marker.position = LatLng(data.latitude, data.longitude) - marker.icon = if (isNear) +// Log.d("marker test--", "${viewModel.uiState.value.curFilter}..${NEAR_RESTAURANT}.. ") + + marker.icon = if (viewModel.uiState.value.curFilter == NEAR_RESTAURANT) OverlayImage.fromResource(R.drawable.ic_marker_near) else OverlayImage.fromResource(R.drawable.ic_marker) @@ -258,6 +270,20 @@ class HomeFragment : BaseFragment(R.layout.fragment_home), markerList.clear() } + private fun finishApp() { + var backPressTime = 0L + requireActivity().onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (System.currentTimeMillis() - backPressTime <= 2000) { + parentViewModel.finishApp() + } else { + backPressTime = System.currentTimeMillis() + showToastMessage("뒤로가기 버튼을 한 번 더 누르면 종료됩니다.") + } + } + }) + } + private suspend fun addWishTest(id: Int, curState: Boolean): Boolean { return lifecycleScope.async { diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/HomeViewModel.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/HomeViewModel.kt index 6ff29f75..103b7ab1 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/HomeViewModel.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/HomeViewModel.kt @@ -20,6 +20,7 @@ import com.naver.maps.geometry.LatLng import com.naver.maps.map.overlay.Marker import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -27,7 +28,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.lang.Math.pow @@ -43,14 +46,14 @@ data class HomeUiState( val markerList: List = emptyList(), val recommendList: List = emptyList(), val curFilter: String = MY_LIST, - val cameraLatitude: Double = 0.0, - val cameraLongitude: Double = 0.0, - val cameraZoom: Double = 0.0, + val cameraLatitude: Double = 37.553836, + val cameraLongitude: Double = 126.969652, + val cameraZoom: Double = 12.0, val cameraRadius: Double = 0.0, val curLatitude: Double = 0.0, val curLongitude: Double = 0.0, val curSelectedMarker: Marker? = null, - val addRestaurantId: Int = -1 + val addRestaurantId: Int = 0 ) sealed class TrackingState { @@ -73,6 +76,9 @@ sealed class HomeEvents { data class ShowSnackMessage( val msg: String ) : HomeEvents() + + data object ShowLoading : HomeEvents() + data object DismissLoading : HomeEvents() } @HiltViewModel @@ -87,17 +93,13 @@ class HomeViewModel @Inject constructor( private val _uiState = MutableStateFlow(HomeUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _events = MutableSharedFlow() + private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) val events: SharedFlow = _events.asSharedFlow() - init { - _uiState.update { state -> - state.copy( - filterList = listOf(UiFilterData(MY_LIST, true, ::onFilterItemClicked)) - ) - } - } - fun updateLocation(latitude: Double, longitude: Double) { _uiState.update { state -> state.copy( @@ -173,13 +175,13 @@ class HomeViewModel @Inject constructor( fun getFilterList() { followRepository.getMyFollowing().onEach { it -> val initialFilterList = listOf( - UiFilterData(MY_LIST, true, ::onFilterItemClicked), - UiFilterData(NEAR_RESTAURANT, false, ::onFilterItemClicked) + UiFilterData(MY_LIST, isChecked(MY_LIST), ::onFilterItemClicked), + UiFilterData(NEAR_RESTAURANT, isChecked(NEAR_RESTAURANT), ::onFilterItemClicked) ) when (it) { is BaseState.Success -> { val filterList = initialFilterList + it.data.map { - UiFilterData(it.nickName, false, ::onFilterItemClicked) + UiFilterData(it.nickName, isChecked(it.nickName), ::onFilterItemClicked) } _uiState.update { state -> state.copy( @@ -202,42 +204,19 @@ class HomeViewModel @Inject constructor( }.launchIn(viewModelScope) } - fun updateNearRestaurant() { - val nearRestaurantFilter = uiState.value.filterList.find { it.name == NEAR_RESTAURANT } - if (uiState.value.curFilter == NEAR_RESTAURANT && nearRestaurantFilter?.isSelected == true) { - nearRestaurantList() - } else { - onFilterItemClicked(NEAR_RESTAURANT) - } + private fun isChecked(filterName: String): Boolean { + return filterName == uiState.value.curFilter } - private fun resetMarkerList() { - viewModelScope.launch { - _uiState.update { state -> - state.copy(markerList = emptyList()) - } - _events.emit(HomeEvents.SetNewMarkers) - } + fun updateNearRestaurant() { + onFilterItemClicked(NEAR_RESTAURANT) } fun getMarkerList() { - if (uiState.value.filterList.all { !it.isSelected }) { - resetMarkerList() - return - } - - when (_uiState.value.curFilter) { - NEAR_RESTAURANT -> { - nearRestaurantList() - } - - MY_LIST -> { - myRestaurantList() - } - - else -> { - userRestaurantList() - } + when (uiState.value.curFilter) { + NEAR_RESTAURANT -> nearRestaurantList() + MY_LIST -> myRestaurantList() + else -> userRestaurantList() } } @@ -245,12 +224,18 @@ class HomeViewModel @Inject constructor( restaurantRepository.nearRestaurantList( radius = uiState.value.cameraRadius.toString(), longitude = uiState.value.cameraLongitude.toString(), - latitude = uiState.value.cameraLatitude.toString() - ).onEach { + latitude = uiState.value.cameraLatitude.toString(), + limit = if(uiState.value.cameraRadius < 500) 100 else 40 + //이게 null일때 갯수제한이여야하는데.. 반대로 되어있어 200개 임시로 적음 + ).onStart { + _events.emit(HomeEvents.ShowLoading) + }.onEach { + _events.emit(HomeEvents.RemoveMarkers) when (it) { is BaseState.Success -> { _uiState.update { state -> state.copy( + curFilter = NEAR_RESTAURANT, markerList = it.data.map { restaurants -> restaurants.toUiRestaurantData() } @@ -264,12 +249,19 @@ class HomeViewModel @Inject constructor( } } _events.emit(HomeEvents.SetNewMarkers) + }.onCompletion { + _events.emit(HomeEvents.DismissLoading) }.launchIn(viewModelScope) } private fun myRestaurantList() { - myRestaurantListUseCase().onEach { + myRestaurantListUseCase( + longitude = DEFAULT_LONGITUDE, + latitude = DEFAULT_LATITUDE + ).onStart { + _events.emit(HomeEvents.ShowLoading) + }.onEach { _events.emit(HomeEvents.RemoveMarkers) when (it) { is BaseState.Success -> { @@ -279,6 +271,7 @@ class HomeViewModel @Inject constructor( } _uiState.update { state -> state.copy( + curFilter = MY_LIST, markerList = restaurantsList ) } @@ -289,15 +282,19 @@ class HomeViewModel @Inject constructor( is BaseState.Error -> _events.emit(HomeEvents.ShowSnackMessage(ERROR_MSG)) } _events.emit(HomeEvents.SetNewMarkers) + }.onCompletion { + _events.emit(HomeEvents.DismissLoading) }.launchIn(viewModelScope) } private fun userRestaurantList() { restaurantRepository.filterRestaurantList( - _uiState.value.curFilter, - "${_uiState.value.curLatitude} ${_uiState.value.curLongitude}", - 50000 - ).onEach { + filter = _uiState.value.curFilter, + location = "${_uiState.value.curLatitude} ${_uiState.value.curLongitude}", + radius = 50000 + ).onStart { + _events.emit(HomeEvents.ShowLoading) + }.onEach { _events.emit(HomeEvents.RemoveMarkers) when (it) { is BaseState.Success -> { @@ -314,6 +311,8 @@ class HomeViewModel @Inject constructor( is BaseState.Error -> _events.emit(HomeEvents.ShowSnackMessage(ERROR_MSG)) } _events.emit(HomeEvents.SetNewMarkers) + }.onCompletion { + _events.emit(HomeEvents.DismissLoading) }.launchIn(viewModelScope) } @@ -402,7 +401,7 @@ class HomeViewModel @Inject constructor( private fun moveCamera() { if (_uiState.value.markerList.isEmpty()) return - if (uiState.value.addRestaurantId != 0) { + if (uiState.value.addRestaurantId > 0) { val restaurantItem: UiRestaurantData = uiState.value.markerList.first { it.id == uiState.value.addRestaurantId } _uiState.update { state -> @@ -484,7 +483,7 @@ class HomeViewModel @Inject constructor( } fun setAddRestaurantId(restaurantId: Int) { - if (restaurantId != -1) { + if (restaurantId <= 0) { _uiState.update { state -> state.copy( addRestaurantId = restaurantId @@ -493,4 +492,9 @@ class HomeViewModel @Inject constructor( } } + companion object { + const val DEFAULT_LATITUDE = "37.55" + const val DEFAULT_LONGITUDE = "126.9" + } + } \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/search/RestaurantSearchFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/search/RestaurantSearchFragment.kt index 4b543af6..3aefb1c6 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/search/RestaurantSearchFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/search/RestaurantSearchFragment.kt @@ -19,6 +19,7 @@ import com.avengers.nibobnebob.R import com.avengers.nibobnebob.databinding.FragmentRestaurantSearchBinding import com.avengers.nibobnebob.presentation.base.BaseFragment import com.avengers.nibobnebob.presentation.ui.adjustKeyboard +import com.avengers.nibobnebob.presentation.ui.customBack import com.avengers.nibobnebob.presentation.ui.main.MainActivity import com.avengers.nibobnebob.presentation.ui.main.MainViewModel import com.avengers.nibobnebob.presentation.ui.main.home.adapter.HomeSearchAdapter @@ -43,6 +44,7 @@ class RestaurantSearchFragment : fetchCurrentLocation() view?.let { clearFocus(it) } initStateObserver() + customBack(requireActivity(), findNavController()) } override fun initNetworkView() { @@ -73,39 +75,33 @@ class RestaurantSearchFragment : } } - private fun fetchCurrentLocation() { + private fun checkLocationPermission() : Boolean{ val locationManager = requireContext().getSystemService(Context.LOCATION_SERVICE) as LocationManager + return (ActivityCompat.checkSelfPermission( + requireContext(), + Manifest.permission.ACCESS_FINE_LOCATION + ) + == PackageManager.PERMISSION_GRANTED && + ActivityCompat.checkSelfPermission( + requireContext(), + Manifest.permission.ACCESS_COARSE_LOCATION + ) + == PackageManager.PERMISSION_GRANTED && + locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) + ) + } - if (!locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) { - startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) - } else { - if (ActivityCompat.checkSelfPermission( - requireContext(), - Manifest.permission.ACCESS_FINE_LOCATION - ) - == PackageManager.PERMISSION_GRANTED && - ActivityCompat.checkSelfPermission( - requireContext(), - Manifest.permission.ACCESS_COARSE_LOCATION - ) - == PackageManager.PERMISSION_GRANTED - ) { - LocationServices.getFusedLocationProviderClient(activity as MainActivity).apply { - lastLocation.addOnSuccessListener { location: Location? -> - viewModel.setCurrentLocation(location?.latitude, location?.longitude) - } + private fun fetchCurrentLocation() { + + if(checkLocationPermission()){ + LocationServices.getFusedLocationProviderClient(activity as MainActivity).apply { + lastLocation.addOnSuccessListener { location: Location? -> + viewModel.setCurrentLocation(location?.latitude, location?.longitude) } - } else { - ActivityCompat.requestPermissions( - requireActivity(), - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - ), - 1000 - ) } + } else { + showToastMessage("GPS나 위치 권한을 허용해주세요.") } } diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/search/RestaurantSearchMapFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/search/RestaurantSearchMapFragment.kt index 5d233774..f74ac703 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/search/RestaurantSearchMapFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/home/search/RestaurantSearchMapFragment.kt @@ -16,6 +16,7 @@ import com.avengers.nibobnebob.R import com.avengers.nibobnebob.databinding.FragmentRestaurantSearchMapBinding import com.avengers.nibobnebob.presentation.base.BaseFragment import com.avengers.nibobnebob.presentation.customview.RestaurantBottomSheet +import com.avengers.nibobnebob.presentation.ui.customBack import com.avengers.nibobnebob.presentation.ui.main.MainViewModel import com.avengers.nibobnebob.presentation.ui.main.home.model.UiRestaurantData import com.avengers.nibobnebob.presentation.ui.toAddRestaurant @@ -61,6 +62,7 @@ class RestaurantSearchMapFragment : override fun initView() { initMapView() initClickEvent() + customBack(requireActivity(), findNavController()) } override fun initNetworkView() {} diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/MyPageFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/MyPageFragment.kt index 08539aa8..46897c86 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/MyPageFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/MyPageFragment.kt @@ -3,6 +3,7 @@ package com.avengers.nibobnebob.presentation.ui.main.mypage import android.app.AlertDialog import android.content.Intent import android.os.Build +import androidx.activity.OnBackPressedCallback import androidx.annotation.RequiresApi import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -27,7 +28,7 @@ class MyPageFragment : BaseFragment(R.layout.fragment_my_ override fun initView() { binding.svm = sharedViewModel binding.vm = viewModel - + finishApp() binding.tvWithdraw.setOnClickListener { @@ -91,6 +92,20 @@ class MyPageFragment : BaseFragment(R.layout.fragment_my_ } + private fun finishApp(){ + var backPressTime = 0L + requireActivity().onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if(System.currentTimeMillis() - backPressTime <= 2000) { + parentViewModel.finishApp() + } else{ + backPressTime = System.currentTimeMillis() + showToastMessage("뒤로가기 버튼을 한 번 더 누르면 종료됩니다.") + } + } + }) + } + private fun NavController.toEditProfile() { val action = MyPageFragmentDirections.globalToEditProfileFragment() diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/edit/EditProfileFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/edit/EditProfileFragment.kt index 08a45a90..ebfbf6c0 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/edit/EditProfileFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/edit/EditProfileFragment.kt @@ -1,13 +1,16 @@ package com.avengers.nibobnebob.presentation.ui.main.mypage.edit +import androidx.activity.OnBackPressedCallback import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.navigation.NavController import androidx.navigation.Navigation +import androidx.navigation.fragment.findNavController import com.avengers.nibobnebob.R import com.avengers.nibobnebob.databinding.FragmentEditProfileBinding import com.avengers.nibobnebob.presentation.base.BaseFragment import com.avengers.nibobnebob.presentation.customview.CalendarDatePicker +import com.avengers.nibobnebob.presentation.ui.customBack import com.avengers.nibobnebob.presentation.ui.main.MainViewModel import com.avengers.nibobnebob.presentation.ui.main.mypage.share.MyPageSharedUiEvent import com.avengers.nibobnebob.presentation.ui.main.mypage.share.MyPageSharedViewModel @@ -29,6 +32,7 @@ class EditProfileFragment : setDateBtnListener() initImageObserver() setGenderRadioListener() + customBack(requireActivity(), findNavController()) } override fun initNetworkView() { @@ -81,7 +85,6 @@ class EditProfileFragment : repeatOnStarted { parentViewModel.image.collect { viewModel.setImage(it, it.toMultiPart(requireContext(), "profileImage")) - parentViewModel.uriCollected() } } } diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/edit/EditProfileViewModel.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/edit/EditProfileViewModel.kt index 99b20715..682bd2ca 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/edit/EditProfileViewModel.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/edit/EditProfileViewModel.kt @@ -157,7 +157,7 @@ class EditProfileViewModel @Inject constructor( nickName = EditInputState( helperText = Validation.VALID_NICK, isValid = true, - isChanged = (originalNickName != nickState.value) && locationState.value != 0 + isChanged = (originalNickName != nickState.value) ) ) } @@ -202,7 +202,7 @@ class EditProfileViewModel @Inject constructor( birth = EditInputState( helperText = if (!validData && birth.isNotEmpty()) Validation.INVALID_DATE else Validation.VALID_DATE, isValid = validData, - isChanged = originalBirth != birth && locationState.value != 0 + isChanged = originalBirth != birth ) ) } @@ -214,7 +214,7 @@ class EditProfileViewModel @Inject constructor( _uiState.update { state -> state.copy( isMale = EditInputState( - isChanged = originalIsMale != isMale && locationState.value != 0 + isChanged = originalIsMale != isMale ) ) } @@ -231,7 +231,7 @@ class EditProfileViewModel @Inject constructor( _uiState.update { state -> state.copy( profileImage = EditInputState( - isChanged = (originalProfileImage != profileImageState.value) && locationState.value != 0 + isChanged = (originalProfileImage != profileImageState.value) ) ) } diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/mapper/UiMyListDataMapper.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/mapper/UiMyListDataMapper.kt index 3f8b59aa..211b1edc 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/mapper/UiMyListDataMapper.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/mapper/UiMyListDataMapper.kt @@ -3,7 +3,6 @@ package com.avengers.nibobnebob.presentation.ui.main.mypage.mapper import android.os.Build import androidx.annotation.RequiresApi import com.avengers.nibobnebob.domain.model.MyRestaurantItemData -import com.avengers.nibobnebob.domain.model.RestaurantItemsData import com.avengers.nibobnebob.presentation.ui.main.mypage.model.UiMyListData import com.avengers.nibobnebob.presentation.ui.toCreateDateString @@ -12,5 +11,5 @@ fun MyRestaurantItemData.toUiMyListData(): UiMyListData = UiMyListData( id = id, name = name, address = address, - date = createdAt.toCreateDateString() + date = createdAt?.toCreateDateString() ) \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/model/UiMyListData.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/model/UiMyListData.kt index c5933281..89c50e4e 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/model/UiMyListData.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/model/UiMyListData.kt @@ -4,5 +4,5 @@ data class UiMyListData( val id: Int, val name: String, val address: String, - val date: String + val date: String? ) diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/mylist/MyRestaurantListFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/mylist/MyRestaurantListFragment.kt index 228878a8..f62dcba5 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/mylist/MyRestaurantListFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/mylist/MyRestaurantListFragment.kt @@ -9,6 +9,7 @@ import androidx.recyclerview.widget.RecyclerView import com.avengers.nibobnebob.R import com.avengers.nibobnebob.databinding.FragmentMyRestaurantListBinding import com.avengers.nibobnebob.presentation.base.BaseFragment +import com.avengers.nibobnebob.presentation.ui.customBack import com.avengers.nibobnebob.presentation.ui.main.MainViewModel import com.avengers.nibobnebob.presentation.ui.main.mypage.share.MyPageSharedUiEvent import com.avengers.nibobnebob.presentation.ui.main.mypage.share.MyPageSharedViewModel @@ -45,6 +46,7 @@ class MyRestaurantListFragment : } }) setFilterMenu() + customBack(requireActivity(), findNavController()) } override fun initNetworkView() { diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/wishlist/WishRestaurantListFragment.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/wishlist/WishRestaurantListFragment.kt index d7de114e..9efd5915 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/wishlist/WishRestaurantListFragment.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/ui/main/mypage/wishlist/WishRestaurantListFragment.kt @@ -9,10 +9,10 @@ import androidx.recyclerview.widget.RecyclerView import com.avengers.nibobnebob.R import com.avengers.nibobnebob.databinding.FragmentWishRestaurantListBinding import com.avengers.nibobnebob.presentation.base.BaseFragment +import com.avengers.nibobnebob.presentation.ui.customBack import com.avengers.nibobnebob.presentation.ui.main.MainViewModel import com.avengers.nibobnebob.presentation.ui.main.mypage.share.MyPageSharedUiEvent import com.avengers.nibobnebob.presentation.ui.main.mypage.share.MyPageSharedViewModel -import com.avengers.nibobnebob.presentation.ui.toAddRestaurant import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -47,6 +47,7 @@ class WishRestaurantListFragment : } }) setFilterMenu() + customBack(requireActivity(), findNavController()) } override fun initNetworkView() { @@ -127,4 +128,13 @@ class WishRestaurantListFragment : navigate(action) } + private fun NavController.toAddRestaurant(restaurantName: String, restaurantId: Int) { + val action = + WishRestaurantListFragmentDirections.actionWishRestaurantListFragmentToAddMyRestaurantFragment2( + restaurantName, + restaurantId + ) + navigate(action) + } + } \ No newline at end of file diff --git a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/util/LocationArray.kt b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/util/LocationArray.kt index b6da6611..6222e377 100644 --- a/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/util/LocationArray.kt +++ b/Aos/app/src/main/java/com/avengers/nibobnebob/presentation/util/LocationArray.kt @@ -4,27 +4,30 @@ package com.avengers.nibobnebob.presentation.util object LocationArray { val LOCATION_ARRAY = listOf( "지역을 선택하세요.", - "용산구", - "은평구", - "강서구", "강남구", - "서초구", - "동작구", + "강동구", + "강북구", + "강서구", "관악구", - "금천구", - "영등포구", - "양천구", - "구로구", - "송파구", "광진구", - "강동구", - "성동구", - "중구", - "서대문구", + "구로구", + "금천구", + "노원구", + "도봉구", + "동대문구", + "동작구", "마포구", + "서대문구", + "서초구", + "성동구", + "성북구", + "송파구", + "양천구", + "영등포구", + "용산구", "은평구", - "도봉구", - "노원구", + "종로구", + "중구", "중랑구", ) } \ No newline at end of file diff --git a/Aos/app/src/main/res/drawable/ic_add_photo.xml b/Aos/app/src/main/res/drawable/ic_add_photo.xml index 6517d390..736f48ab 100644 --- a/Aos/app/src/main/res/drawable/ic_add_photo.xml +++ b/Aos/app/src/main/res/drawable/ic_add_photo.xml @@ -5,11 +5,11 @@ android:viewportHeight="100"> diff --git a/Aos/app/src/main/res/drawable/ic_add_review_photo.xml b/Aos/app/src/main/res/drawable/ic_add_review_photo.xml index 1c4efbc4..11a7f631 100644 --- a/Aos/app/src/main/res/drawable/ic_add_review_photo.xml +++ b/Aos/app/src/main/res/drawable/ic_add_review_photo.xml @@ -6,12 +6,12 @@ + android:fillColor="@color/nn_dark7" + android:strokeColor="@color/nn_primary2"/> + android:fillColor="@color/nn_primary6"/> + android:fillColor="@color/nn_primary6"/> diff --git a/Aos/app/src/main/res/drawable/ic_app_logo.xml b/Aos/app/src/main/res/drawable/ic_app_logo.xml index ecd5e2d6..a6b24ef6 100644 --- a/Aos/app/src/main/res/drawable/ic_app_logo.xml +++ b/Aos/app/src/main/res/drawable/ic_app_logo.xml @@ -5,15 +5,15 @@ android:viewportHeight="332"> + android:fillColor="@color/nn_primary6"/> + android:fillColor="@color/nn_primary3"/> diff --git a/Aos/app/src/main/res/drawable/ic_back.xml b/Aos/app/src/main/res/drawable/ic_back.xml index 6167d0fa..adedd99e 100644 --- a/Aos/app/src/main/res/drawable/ic_back.xml +++ b/Aos/app/src/main/res/drawable/ic_back.xml @@ -5,5 +5,5 @@ android:viewportHeight="24"> + android:fillColor="@color/black"/> diff --git a/Aos/app/src/main/res/drawable/ic_calendar.xml b/Aos/app/src/main/res/drawable/ic_calendar.xml index 562079ea..82754a8e 100644 --- a/Aos/app/src/main/res/drawable/ic_calendar.xml +++ b/Aos/app/src/main/res/drawable/ic_calendar.xml @@ -5,6 +5,6 @@ android:viewportHeight="40"> diff --git a/Aos/app/src/main/res/drawable/ic_check.xml b/Aos/app/src/main/res/drawable/ic_check.xml index aaf9a3bb..d2d00e68 100644 --- a/Aos/app/src/main/res/drawable/ic_check.xml +++ b/Aos/app/src/main/res/drawable/ic_check.xml @@ -5,5 +5,5 @@ android:viewportHeight="11"> + android:fillColor="@color/black"/> diff --git a/Aos/app/src/main/res/drawable/ic_close.xml b/Aos/app/src/main/res/drawable/ic_close.xml index 21d2213e..33c5f8d0 100644 --- a/Aos/app/src/main/res/drawable/ic_close.xml +++ b/Aos/app/src/main/res/drawable/ic_close.xml @@ -5,5 +5,5 @@ android:viewportHeight="11"> + android:fillColor="@color/black"/> diff --git a/Aos/app/src/main/res/drawable/ic_drop_down.xml b/Aos/app/src/main/res/drawable/ic_drop_down.xml index 0c0a19be..1eed2eaa 100644 --- a/Aos/app/src/main/res/drawable/ic_drop_down.xml +++ b/Aos/app/src/main/res/drawable/ic_drop_down.xml @@ -5,5 +5,5 @@ android:viewportHeight="10"> + android:fillColor="@color/black"/> diff --git a/Aos/app/src/main/res/drawable/ic_edit.xml b/Aos/app/src/main/res/drawable/ic_edit.xml index 015d1c0a..868ca21f 100644 --- a/Aos/app/src/main/res/drawable/ic_edit.xml +++ b/Aos/app/src/main/res/drawable/ic_edit.xml @@ -5,5 +5,5 @@ android:viewportHeight="18"> + android:fillColor="@color/nn_primary6"/> diff --git a/Aos/app/src/main/res/drawable/ic_enter.xml b/Aos/app/src/main/res/drawable/ic_enter.xml index 6f805c53..c3408973 100644 --- a/Aos/app/src/main/res/drawable/ic_enter.xml +++ b/Aos/app/src/main/res/drawable/ic_enter.xml @@ -7,5 +7,5 @@ android:strokeWidth="1" android:pathData="M1,1L5.671,6L1,11" android:fillColor="#00000000" - android:strokeColor="#262626"/> + android:strokeColor="@color/black"/> diff --git a/Aos/app/src/main/res/drawable/ic_filter.xml b/Aos/app/src/main/res/drawable/ic_filter.xml index 6c63011d..9cc2f226 100644 --- a/Aos/app/src/main/res/drawable/ic_filter.xml +++ b/Aos/app/src/main/res/drawable/ic_filter.xml @@ -5,5 +5,5 @@ android:viewportHeight="10"> + android:fillColor="@color/nn_dark1"/> diff --git a/Aos/app/src/main/res/drawable/ic_following.xml b/Aos/app/src/main/res/drawable/ic_following.xml index 704b1583..5ae9bf7e 100644 --- a/Aos/app/src/main/res/drawable/ic_following.xml +++ b/Aos/app/src/main/res/drawable/ic_following.xml @@ -5,8 +5,8 @@ android:viewportHeight="24"> + android:fillColor="@color/nn_primary1"/> + android:fillColor="@color/nn_primary1"/> diff --git a/Aos/app/src/main/res/drawable/ic_home.xml b/Aos/app/src/main/res/drawable/ic_home.xml index ca578333..14b6e22b 100644 --- a/Aos/app/src/main/res/drawable/ic_home.xml +++ b/Aos/app/src/main/res/drawable/ic_home.xml @@ -8,6 +8,6 @@ android:strokeLineJoin="round" android:strokeWidth="3" android:fillColor="#00000000" - android:strokeColor="#E1D6CC" + android:strokeColor="@color/nn_primary1" android:strokeLineCap="round"/> diff --git a/Aos/app/src/main/res/drawable/ic_location_circle.xml b/Aos/app/src/main/res/drawable/ic_location_circle.xml index 05cfc98a..170a0d82 100644 --- a/Aos/app/src/main/res/drawable/ic_location_circle.xml +++ b/Aos/app/src/main/res/drawable/ic_location_circle.xml @@ -5,5 +5,5 @@ android:viewportHeight="24"> + android:fillColor="@color/nn_primary5"/> diff --git a/Aos/app/src/main/res/drawable/ic_location_off.xml b/Aos/app/src/main/res/drawable/ic_location_off.xml index 3bd22a8e..76259e5c 100644 --- a/Aos/app/src/main/res/drawable/ic_location_off.xml +++ b/Aos/app/src/main/res/drawable/ic_location_off.xml @@ -7,8 +7,8 @@ android:strokeWidth="1" android:pathData="M17,17m-16.5,0a16.5,16.5 0,1 1,33 0a16.5,16.5 0,1 1,-33 0" android:fillColor="#ffffff" - android:strokeColor="#C8CBD0"/> + android:strokeColor="@color/nn_dark6"/> + android:fillColor="@color/nn_dark6"/> diff --git a/Aos/app/src/main/res/drawable/ic_location_on.xml b/Aos/app/src/main/res/drawable/ic_location_on.xml index 0c314356..59d871fc 100644 --- a/Aos/app/src/main/res/drawable/ic_location_on.xml +++ b/Aos/app/src/main/res/drawable/ic_location_on.xml @@ -7,8 +7,8 @@ android:strokeWidth="1" android:pathData="M17,17m-16.5,0a16.5,16.5 0,1 1,33 0a16.5,16.5 0,1 1,-33 0" android:fillColor="#ffffff" - android:strokeColor="#643E71"/> + android:strokeColor="@color/nn_rainbow_purple"/> + android:fillColor="@color/nn_rainbow_purple"/> diff --git a/Aos/app/src/main/res/drawable/ic_logo.xml b/Aos/app/src/main/res/drawable/ic_logo.xml index 328c8491..004cd147 100644 --- a/Aos/app/src/main/res/drawable/ic_logo.xml +++ b/Aos/app/src/main/res/drawable/ic_logo.xml @@ -5,8 +5,8 @@ android:viewportHeight="150"> + android:fillColor="@color/nn_primary6"/> + android:fillColor="@color/nn_dark6"/> diff --git a/Aos/app/src/main/res/drawable/ic_logo_big.xml b/Aos/app/src/main/res/drawable/ic_logo_big.xml index 51acbb67..597fd5b7 100644 --- a/Aos/app/src/main/res/drawable/ic_logo_big.xml +++ b/Aos/app/src/main/res/drawable/ic_logo_big.xml @@ -5,8 +5,8 @@ android:viewportHeight="150"> + android:fillColor="@color/nn_primary6"/> + android:fillColor="@color/nn_dark6"/> \ No newline at end of file diff --git a/Aos/app/src/main/res/drawable/ic_logo_home.xml b/Aos/app/src/main/res/drawable/ic_logo_home.xml index 714be889..ebc5503a 100644 --- a/Aos/app/src/main/res/drawable/ic_logo_home.xml +++ b/Aos/app/src/main/res/drawable/ic_logo_home.xml @@ -5,8 +5,8 @@ android:viewportHeight="32"> + android:fillColor="@color/nn_primary6"/> + android:fillColor="@color/nn_dark6"/> diff --git a/Aos/app/src/main/res/drawable/ic_marker.xml b/Aos/app/src/main/res/drawable/ic_marker.xml index 77185778..bbb57e19 100644 --- a/Aos/app/src/main/res/drawable/ic_marker.xml +++ b/Aos/app/src/main/res/drawable/ic_marker.xml @@ -8,5 +8,5 @@ android:fillColor="#000000"/> + android:fillColor="@color/nn_primary1"/> diff --git a/Aos/app/src/main/res/drawable/ic_marker_near.xml b/Aos/app/src/main/res/drawable/ic_marker_near.xml index 49eef48a..d9f44ac7 100644 --- a/Aos/app/src/main/res/drawable/ic_marker_near.xml +++ b/Aos/app/src/main/res/drawable/ic_marker_near.xml @@ -5,10 +5,10 @@ android:viewportWidth="25" android:viewportHeight="32"> + android:strokeColor="@color/black" /> diff --git a/Aos/app/src/main/res/drawable/ic_more.xml b/Aos/app/src/main/res/drawable/ic_more.xml index 2646aae9..5f923b04 100644 --- a/Aos/app/src/main/res/drawable/ic_more.xml +++ b/Aos/app/src/main/res/drawable/ic_more.xml @@ -5,6 +5,6 @@ android:viewportHeight="24"> diff --git a/Aos/app/src/main/res/drawable/ic_mypage.xml b/Aos/app/src/main/res/drawable/ic_mypage.xml index d0a39b59..54b74804 100644 --- a/Aos/app/src/main/res/drawable/ic_mypage.xml +++ b/Aos/app/src/main/res/drawable/ic_mypage.xml @@ -8,20 +8,20 @@ android:strokeLineJoin="round" android:strokeWidth="3" android:fillColor="#00000000" - android:strokeColor="#E1D6CC" + android:strokeColor="@color/nn_primary1" android:strokeLineCap="round"/> diff --git a/Aos/app/src/main/res/drawable/ic_plus.xml b/Aos/app/src/main/res/drawable/ic_plus.xml index 3d938df1..d0aa877b 100644 --- a/Aos/app/src/main/res/drawable/ic_plus.xml +++ b/Aos/app/src/main/res/drawable/ic_plus.xml @@ -5,5 +5,5 @@ android:viewportHeight="24"> + android:fillColor="@color/black"/> diff --git a/Aos/app/src/main/res/drawable/ic_refresh.xml b/Aos/app/src/main/res/drawable/ic_refresh.xml index b4aac64f..31f7cb33 100644 --- a/Aos/app/src/main/res/drawable/ic_refresh.xml +++ b/Aos/app/src/main/res/drawable/ic_refresh.xml @@ -5,5 +5,5 @@ android:viewportHeight="16"> + android:fillColor="@color/nn_dark1"/> diff --git a/Aos/app/src/main/res/drawable/ic_search.xml b/Aos/app/src/main/res/drawable/ic_search.xml index 3f1b377b..21a8db29 100644 --- a/Aos/app/src/main/res/drawable/ic_search.xml +++ b/Aos/app/src/main/res/drawable/ic_search.xml @@ -5,6 +5,6 @@ android:viewportHeight="24"> diff --git a/Aos/app/src/main/res/drawable/ic_star_border.xml b/Aos/app/src/main/res/drawable/ic_star_border.xml index 264e6254..83933d08 100644 --- a/Aos/app/src/main/res/drawable/ic_star_border.xml +++ b/Aos/app/src/main/res/drawable/ic_star_border.xml @@ -5,5 +5,5 @@ android:viewportHeight="24"> + android:fillColor="@color/nn_primary6"/> diff --git a/Aos/app/src/main/res/drawable/ic_star_full.xml b/Aos/app/src/main/res/drawable/ic_star_full.xml index d690aef7..3c460c06 100644 --- a/Aos/app/src/main/res/drawable/ic_star_full.xml +++ b/Aos/app/src/main/res/drawable/ic_star_full.xml @@ -5,5 +5,5 @@ android:viewportHeight="24"> + android:fillColor="@color/nn_primary6"/> diff --git a/Aos/app/src/main/res/drawable/ic_thumbs_down_blank.xml b/Aos/app/src/main/res/drawable/ic_thumbs_down_blank.xml index 2b3c123a..a3ecdcd0 100644 --- a/Aos/app/src/main/res/drawable/ic_thumbs_down_blank.xml +++ b/Aos/app/src/main/res/drawable/ic_thumbs_down_blank.xml @@ -8,6 +8,6 @@ android:pathData="M3.931,1.688L1.259,9.61C1.08,10.142 1.497,10.689 2.097,10.707L7.29,10.865C7.609,10.875 7.836,11.157 7.755,11.443L7.502,12.348C7.269,13.177 7.176,14.035 7.227,14.892L7.254,15.358C7.284,15.857 7.502,16.331 7.872,16.696C8.209,17.03 8.675,17.226 9.166,17.241L9.274,17.244C9.571,17.253 9.85,17.112 10.001,16.874L11.265,14.897C11.832,14.01 12.658,13.293 13.648,12.828L16.706,11.391C17.001,11.252 17.19,10.974 17.2,10.666L17.453,2.328C17.467,1.863 17.074,1.474 16.574,1.459L4.82,1.101C4.415,1.089 4.052,1.329 3.931,1.688Z" android:strokeLineJoin="round" android:fillColor="#00000000" - android:strokeColor="#717784" + android:strokeColor="@color/nn_dark4" android:strokeLineCap="round"/> diff --git a/Aos/app/src/main/res/drawable/ic_thumbs_up_blank.xml b/Aos/app/src/main/res/drawable/ic_thumbs_up_blank.xml index 907e5b82..401ec0f5 100644 --- a/Aos/app/src/main/res/drawable/ic_thumbs_up_blank.xml +++ b/Aos/app/src/main/res/drawable/ic_thumbs_up_blank.xml @@ -8,6 +8,6 @@ android:pathData="M14.535,16.387L16.965,8.387C17.129,7.849 16.695,7.316 16.094,7.316H10.899C10.58,7.316 10.345,7.04 10.417,6.752L10.642,5.84C10.85,5.005 10.916,4.144 10.84,3.289L10.798,2.824C10.754,2.326 10.521,1.859 10.141,1.505C9.793,1.182 9.322,1 8.83,1H8.722C8.425,1 8.151,1.15 8.006,1.392L6.804,3.406C6.264,4.31 5.46,5.052 4.485,5.547L1.472,7.076C1.181,7.224 1,7.508 1,7.816V16.158C1,16.623 1.405,17 1.905,17H13.665C14.07,17 14.425,16.75 14.535,16.387Z" android:strokeLineJoin="round" android:fillColor="#00000000" - android:strokeColor="#717784" + android:strokeColor="@color/nn_dark4" android:strokeLineCap="round"/> diff --git a/Aos/app/src/main/res/drawable/ic_x.xml b/Aos/app/src/main/res/drawable/ic_x.xml index f0eda536..6d83a5b1 100644 --- a/Aos/app/src/main/res/drawable/ic_x.xml +++ b/Aos/app/src/main/res/drawable/ic_x.xml @@ -5,5 +5,5 @@ android:viewportHeight="11"> + android:fillColor="@color/black"/> diff --git a/Aos/app/src/main/res/layout/bottom_sheet_restaurant.xml b/Aos/app/src/main/res/layout/bottom_sheet_restaurant.xml index 43068218..1fc80baf 100644 --- a/Aos/app/src/main/res/layout/bottom_sheet_restaurant.xml +++ b/Aos/app/src/main/res/layout/bottom_sheet_restaurant.xml @@ -139,12 +139,14 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:layout_marginEnd="20dp" + android:layout_marginBottom="2dp" android:background="@drawable/rect_primary3fill_nostroke_radius10" android:minHeight="0dp" android:paddingHorizontal="8dp" android:paddingVertical="4dp" android:text="@string/bottom_add_btn" android:visibility="@{item.inMyList ? View.INVISIBLE : View.VISIBLE}" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@id/btn_go_review" app:layout_constraintTop_toBottomOf="@id/tv_review_count_label" /> @@ -153,12 +155,13 @@ style="@style/TextSmallBold" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="28dp" + android:layout_marginBottom="2dp" android:background="@drawable/rect_primary3fill_nostroke_radius10" android:minHeight="0dp" android:paddingHorizontal="8dp" android:paddingVertical="4dp" android:text="@string/bottom_review_btn" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/btn_add_my_restaurant" /> diff --git a/Aos/app/src/main/res/layout/dialog_loading.xml b/Aos/app/src/main/res/layout/dialog_loading.xml index a38367df..0f2181f1 100644 --- a/Aos/app/src/main/res/layout/dialog_loading.xml +++ b/Aos/app/src/main/res/layout/dialog_loading.xml @@ -28,7 +28,7 @@ android:layout_height="wrap_content" android:layout_marginTop="10dp" android:text="Loading ..." - android:textColor="@color/white" + android:textColor="@color/nn_primary1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/progressBar" /> diff --git a/Aos/app/src/main/res/layout/dialog_recommend_restaurant.xml b/Aos/app/src/main/res/layout/dialog_recommend_restaurant.xml index 79307862..2d54ce51 100644 --- a/Aos/app/src/main/res/layout/dialog_recommend_restaurant.xml +++ b/Aos/app/src/main/res/layout/dialog_recommend_restaurant.xml @@ -23,6 +23,18 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + @@ -197,7 +199,7 @@ android:text="@string/add_traffic_yes" android:textColor="@color/nn_dark1" android:visibility="@{vm.uiState.visitWithCar? View.INVISIBLE : View.VISIBLE}" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/sd_traffic" /> diff --git a/Aos/app/src/main/res/layout/fragment_basic_signup.xml b/Aos/app/src/main/res/layout/fragment_basic_signup.xml index d77e323d..0dfe3ab0 100644 --- a/Aos/app/src/main/res/layout/fragment_basic_signup.xml +++ b/Aos/app/src/main/res/layout/fragment_basic_signup.xml @@ -43,7 +43,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="20dp" - android:text="기본 정보 입력" + android:text="@string/input_basic_information" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toEndOf="@id/btn_back" app:layout_constraintTop_toTopOf="parent" /> @@ -56,7 +56,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" - android:text="이메일" + android:text="@string/email" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/layout_toolbar" /> @@ -76,7 +76,8 @@ style="@style/TextSmallRegular" android:layout_width="match_parent" android:layout_height="wrap_content" - android:hint="이메일" + android:hint="@string/email" + android:inputType="textEmailAddress" android:text="@={vm.email}" /> @@ -95,16 +96,15 @@ @@ -114,7 +114,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="40dp" - android:text="비밀번호" + android:text="@string/password" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tv_email_helper_text" /> @@ -133,7 +133,7 @@ style="@style/TextSmallRegular" android:layout_width="match_parent" android:layout_height="wrap_content" - android:hint="비밀번호" + android:hint="@string/password" android:inputType="textPassword" android:text="@={vm.password}" /> @@ -155,7 +155,7 @@ style="@style/TextSmallRegular" android:layout_width="match_parent" android:layout_height="wrap_content" - android:hint="비밀번호 확인" + android:hint="@string/check_password" android:inputType="textPassword" android:text="@={vm.passwordCheck}" /> @@ -183,7 +183,7 @@ android:background="@drawable/selector_next_btn" android:enabled="@{vm.isDataReady()}" android:onClick="@{() -> vm.navigateToDetailSignup()}" - android:text="다음" + android:text="@string/next" android:textColor="@color/selector_next_btn_text" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" diff --git a/Aos/app/src/main/res/layout/fragment_follow.xml b/Aos/app/src/main/res/layout/fragment_follow.xml index c81c9746..440a130f 100644 --- a/Aos/app/src/main/res/layout/fragment_follow.xml +++ b/Aos/app/src/main/res/layout/fragment_follow.xml @@ -14,8 +14,7 @@ + android:clipToPadding="false"> + app:layout_constraintGuide_percent="0.9537713" /> diff --git a/Aos/app/src/main/res/layout/fragment_home.xml b/Aos/app/src/main/res/layout/fragment_home.xml index 0c8267b2..1feaab5d 100644 --- a/Aos/app/src/main/res/layout/fragment_home.xml +++ b/Aos/app/src/main/res/layout/fragment_home.xml @@ -56,11 +56,12 @@ + + @@ -143,15 +151,18 @@ android:layout_marginTop="50dp" android:layout_marginEnd="20dp" android:background="@drawable/rect_naverfill_nostroke_12radius" - android:drawableStart="@drawable/ic_naver" - android:paddingStart="110dp" android:text="@string/start_naver_text" - android:textAlignment="textStart" + android:textAlignment="center" android:textColor="@color/white" android:textStyle="bold" + android:drawableStart="@drawable/ic_naver" + android:paddingStart="80dp" + android:paddingEnd="115dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/tv_do_signup" /> + app:layout_constraintTop_toBottomOf="@id/tv_do_signup" + tools:ignore="RtlSymmetry" /> + + \ No newline at end of file diff --git a/Aos/app/src/main/res/layout/fragment_restaurant_search.xml b/Aos/app/src/main/res/layout/fragment_restaurant_search.xml index b43b27ca..2523aff6 100644 --- a/Aos/app/src/main/res/layout/fragment_restaurant_search.xml +++ b/Aos/app/src/main/res/layout/fragment_restaurant_search.xml @@ -58,6 +58,7 @@ + + @@ -130,6 +132,19 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/tv_region" /> + + diff --git a/Aos/app/src/main/res/layout/item_my_list.xml b/Aos/app/src/main/res/layout/item_my_list.xml index cc8e8c13..b2056a3d 100644 --- a/Aos/app/src/main/res/layout/item_my_list.xml +++ b/Aos/app/src/main/res/layout/item_my_list.xml @@ -48,7 +48,7 @@ style="@style/TextSmallBold" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@{item.name}" + app:adjustText="@{item.name}" app:layout_constraintStart_toStartOf="@id/gl_start" app:layout_constraintTop_toTopOf="@id/gl_top" /> @@ -57,8 +57,8 @@ style="@style/CaptionRegular" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@{item.address}" android:textColor="@color/nn_primary5" + app:adjustText="@{item.address}" app:layout_constraintBottom_toBottomOf="@id/gl_bottom" app:layout_constraintStart_toStartOf="@id/gl_start" app:layout_constraintTop_toTopOf="@id/gl_top" /> diff --git a/Aos/app/src/main/res/layout/item_user_detail_restaurant.xml b/Aos/app/src/main/res/layout/item_user_detail_restaurant.xml index 989abbb3..17caf46c 100644 --- a/Aos/app/src/main/res/layout/item_user_detail_restaurant.xml +++ b/Aos/app/src/main/res/layout/item_user_detail_restaurant.xml @@ -4,6 +4,9 @@ + @@ -53,6 +57,7 @@ style="@style/CaptionRegular" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:text="@{item.address}" android:textColor="@color/nn_primary5" app:layout_constraintBottom_toBottomOf="@id/gl_bottom" app:layout_constraintStart_toStartOf="@id/gl_start" @@ -63,6 +68,7 @@ style="@style/CaptionRegular" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:text="@{item.phoneNumber.empty? @string/phone_number_empty : item.phoneNumber}" android:textColor="@color/nn_primary5" app:layout_constraintBottom_toBottomOf="@id/gl_bottom" app:layout_constraintStart_toStartOf="@id/gl_start" diff --git a/Aos/app/src/main/res/layout/item_wish_list.xml b/Aos/app/src/main/res/layout/item_wish_list.xml index 8b4376ba..f117918f 100644 --- a/Aos/app/src/main/res/layout/item_wish_list.xml +++ b/Aos/app/src/main/res/layout/item_wish_list.xml @@ -59,7 +59,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="8dp" - android:text="@{item.name}" + app:adjustText="@{item.name}" app:layout_constraintStart_toEndOf="@id/iv_star" app:layout_constraintTop_toTopOf="@id/gl_top" /> @@ -69,8 +69,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="8dp" - android:text="@{item.address}" android:textColor="@color/nn_primary5" + app:adjustText="@{item.address}" app:layout_constraintBottom_toBottomOf="@id/gl_bottom" app:layout_constraintStart_toEndOf="@id/iv_star" /> diff --git a/Aos/app/src/main/res/navigation/nav_graph.xml b/Aos/app/src/main/res/navigation/nav_graph.xml index 49f08c87..4a45ba35 100644 --- a/Aos/app/src/main/res/navigation/nav_graph.xml +++ b/Aos/app/src/main/res/navigation/nav_graph.xml @@ -46,6 +46,9 @@ android:name="nickName" app:argType="string" android:defaultValue=""/> + + + + + #FFEEEFF3 + + #FFFFFFFF + #FF000000 + + #FF333333 + #69808A + #FF666A6B + #FF909694 + #FFB9A6AE + #FFD2C4C1 + #FFE9E3E3 + + #FF6E7D80 + #FF4A5A62 + #33E1D6CC + #66E1D6CC + #99E1D6CC + #CCE1D6CC + #FFE1D6CC + + #FFE1D6CC + #FFCCE1D6 + #FFE8E2E4 + #FFB3ADA9 + #FF8D8787 + #FF625F5Fㅜ + + + + #FFCC4141 + #FFF59A11 + #FFE0DC88 + #FF4AA754 + #FF0D1986 + #FF222152 + #FF6437E1 + + #FF03C75A + #4D909D8F + + \ No newline at end of file diff --git a/Aos/app/src/main/res/values/strings.xml b/Aos/app/src/main/res/values/strings.xml index 423f34d3..0759d668 100644 --- a/Aos/app/src/main/res/values/strings.xml +++ b/Aos/app/src/main/res/values/strings.xml @@ -118,30 +118,40 @@ 오늘의 추천 음식점 리뷰 전체보기 ]]> + 아직 추가한 맛집이 없습니다. + 전화번호 없음 + \n\n추천드릴 음식점이 존재하지 않습니다.\n\n + (선택) 사진 등록 + 이메일 중복확인 + 다음 + 기본 정보 입력 + 비밀번호 확인 - 용산구 - 은평구 - 강서구 강남구 - 서초구 - 동작구 + 강동구 + 강북구 + 강서구 관악구 - 금천구 - 영등포구 - 양천구 - 구로구 - 송파구 광진구 - 강동구 - 성동구 - 중구 - 종로구 - 서대문구 + 구로구 + 금천구 + 노원구 + 도봉구 + 동대문구 + 동작구 마포구 + 서대문구 + 서초구 + 성동구 + 성북구 + 송파구 + 양천구 + 영등포구 + 용산구 은평구 - 도봉구 - 노원구 + 종로구 + 중구 중랑구 diff --git a/Aos/app/src/main/res/values/themes.xml b/Aos/app/src/main/res/values/themes.xml index b3e06e54..f18f1392 100644 --- a/Aos/app/src/main/res/values/themes.xml +++ b/Aos/app/src/main/res/values/themes.xml @@ -1,13 +1,20 @@ - + + + - \ No newline at end of file + + + diff --git a/README.md b/README.md index 7a6a43aa..69666f15 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,110 @@ # 🍚 니밥내밥 ( NIBOB-NEBOB )
-**맛집지도 공유 플랫폼, 니밥내밥**
-내 맛집을 지도에 등록하고,
-다른 유저와 내 맛집 지도를 공유해보세요.
+![Group 2608558](https://github.com/boostcampwm2023/and06-nibobnebob/assets/82799840/31c93c05-b519-4810-b53e-5d6bfe5cfcba) + +![Group 2608569 (1)](https://github.com/boostcampwm2023/and06-nibobnebob/assets/82799840/8323d852-f60b-4202-b39f-5eb0595b3e65) +
[👉🏻 wiki 바로가기](https://github.com/boostcampwm2023/and06-nibobnebob/wiki) + + + + +

-## 🔑 기능 소개 -*구현 이후 작성 예정* +## 🤔 주요 개발 과정과 고민 + +|주제|설명| +|--|--| +|[**앱 flow**](https://github.com/boostcampwm2023/and06-nibobnebob/wiki/%E3%80%B0%EF%B8%8F-App-%ED%99%94%EB%A9%B4-flow)|각 페이지별 깊은 depth 로 인해 발생하는 소통 문제 해결을 위해 flow 작성.| +|[**권한 허용 여부에 따른 메인화면 flow**](https://github.com/boostcampwm2023/and06-nibobnebob/wiki/%F0%9F%93%8D-%EC%9C%84%EC%B9%98-%EA%B6%8C%ED%95%9C-%ED%97%88%EC%9A%A9%EC%97%90-%EB%94%B0%EB%A5%B8-%EB%A9%94%EC%9D%B8-%ED%99%94%EB%A9%B4-flow)|지도가 핵심 기능인 만큼, 위치 권한과 GPS ON 여부에 따라 메인 화면의 flow 가 달라진다.| +|[**사용자 경험을 고려한 지도 필터간 전환**](https://github.com/boostcampwm2023/and06-nibobnebob/wiki/%5BK011%5D-%EB%85%B8%EA%B7%A0%EC%9A%B1#%EB%84%A4%EC%9D%B4%EB%B2%84%EC%A7%80%EB%8F%84-%EC%82%AC%EC%9A%A9%EC%9E%90-%EA%B2%BD%ED%97%98-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0)|필터에 따라 다르게 보여지는 지도와 맛집 마커를 전환하는 과정에서 사용자 경험을 고려했다.| +|[**왜 공공 API를 선택했나?**](https://github.com/boostcampwm2023/and06-nibobnebob/wiki/%E2%9C%92-%5BBE%5D-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80#%EA%B3%B5%EA%B3%B5-api-%EC%84%A0%ED%83%9D-%EC%9D%B4%EC%9C%A0)|근처의 모든 음식점을 가져올 때, 네이버/카카오 api 가 아닌 공공 api 를 선택한 이유?| +|[**왜 이미지를 리사이징 하나?**](https://github.com/boostcampwm2023/and06-nibobnebob/wiki/%E2%9C%92-%5BBE%5D-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80#%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A6%AC%EC%82%AC%EC%9D%B4%EC%A7%95)|Object Storage에 이미지를 업로드 할 때, 리사이징 하는 이유?| +|[**어플리케이션 통신 과정**](https://github.com/boostcampwm2023/and06-nibobnebob/wiki/%E2%9C%92-%5BBE%5D-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80#%EC%96%B4%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98-%ED%86%B5%EC%8B%A0-%EA%B3%BC%EC%A0%95)|사용자의 요청부터 서버의 응답까지 어플리케이션 통신 과정 설계 이유| + +
+ +📍 [AOS 기술 선택 이유 바로가기](https://github.com/boostcampwm2023/and06-nibobnebob/wiki/%E2%9C%92-%5BAOS%5D-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80#%EA%B8%B0%EC%88%A0%EC%A0%81-%EA%B3%A0%EB%AF%BC) + +📍 [BE 기술 선택 이유 바로가기](https://github.com/boostcampwm2023/and06-nibobnebob/wiki/%E2%9C%92-%5BBE%5D-%EA%B0%9C%EB%B0%9C%EC%9D%BC%EC%A7%80#%EA%B8%B0%EC%88%A0%EC%A0%81-%EB%8F%84%EC%A0%84) + +

+ +## 📲 주요 기능 동작 화면 + +### 로그인 / 회원가입 +일반 회원가입|네이버 회원가입| +|------|---| +| + +
+ +### 홈화면 +홈 지도|위치 트래킹|홈 검색| +|------|---|---| +|||| + +
+ +### 팔로우 + +유저 추천 및 팔로우|지역으로 유저 검색|유저 검색 및 유저상세| +|------|---|---| +|||| + +
+ +### 맛집 등록하기 및 상세보기 + +맛집 등록하기|맛집 상세보기|위시리스트 추가하기| +|------|---|---| +|||| + +
+ +### 마이페이지 +프로필 수정|나의 위시리스트|내 맛집 리스트 +|------|---|---| +||||

## 📚 기술 스택 - Android + +| 분류 | 사용기술 | +| -- | -- | +|아키텍처|MVVM, Clean Architecture| +|네트워크 통신|Retrofit, Okhttp| +|이미지 처리|Glide, MultiPart| +|비동기|Coroutine, Flow| +|DI|Hilt| +|데이터 저장|DataStore| + +
+ - Backend +| 분류 | 사용 기술| +|-- | --| +|서버 프레임워크 | NestJS| +|프로그래밍 언어 | TypeScript| +|테스트 | Jest, Docker, Apache Jmeter| +|로깅 | Winston| +|DB | PostgreSQL, TypeORM| +|웹 서버 | NginX| +|클라우드 컴퓨팅 | Naver Cloud Platform| +|이미지 저장 | Object Storage, multer, sharp| +|CI/CD | GitHub Action, Docker| + +

## 👬 팀 소개 @@ -44,3 +128,4 @@ | [K011_노균욱](https://github.com/BENDENG1) | [K015_박진성](https://github.com/plashdof) | [K024_오세영](https://github.com/yy0ung) | [J123_이태훈](https://github.com/LeeTH916) | [J155_최근혁](https://github.com/GeunH) |
+ diff --git a/be/Dockerfile b/be/Dockerfile new file mode 100644 index 00000000..2a17d513 --- /dev/null +++ b/be/Dockerfile @@ -0,0 +1,23 @@ +# 베이스 이미지 선택 +FROM node:20 + +# 작업 디렉토리 설정 +WORKDIR /usr/src/app + +# 종속성 파일 복사 (package.json 및 package-lock.json) +COPY package*.json ./ + +# 종속성 설치 +RUN npm install + +# 애플리케이션 소스 코드 복사 +COPY . . + +# TypeScript 컴파일 +RUN npm run build + +# 애플리케이션 실행 포트 지정 +EXPOSE 8000 + +# 애플리케이션 실행 명령어 +CMD ["node", "dist/src/main"] diff --git a/be/dockerignore b/be/dockerignore new file mode 100644 index 00000000..24cdedf8 --- /dev/null +++ b/be/dockerignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* \ No newline at end of file diff --git a/be/package-lock.json b/be/package-lock.json index 26abfff5..7f28bf65 100644 --- a/be/package-lock.json +++ b/be/package-lock.json @@ -14,18 +14,21 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.15", "@nestjs/typeorm": "^10.0.1", "aws-sdk": "^2.348.0", "axios": "^1.6.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "multer": "^1.4.5-lts.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "passport-naver": "^1.0.6", "pg": "^8.11.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", + "sharp": "^0.33.0", "swagger-ui-express": "^5.0.0", "typeorm": "^0.3.17", "uuid": "^9.0.1", @@ -37,6 +40,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/supertest": "^2.0.12", "@types/winston": "^2.4.4", @@ -894,6 +898,15 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.44.0.tgz", + "integrity": "sha512-ZX/etZEZw8DR7zAB1eVQT40lNo0jeqpb6dCgOvctB6FIQ5PoXfMuNY8+ayQfu8tNQbAB8gQWSSJupR8NxeiZXw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1005,6 +1018,437 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.0.tgz", + "integrity": "sha512-070tEheekI1LJWTGPC9WlQEa5UoKTXzzlORBHMX4TbfUxMiL336YHR8vBEUNsjse0RJCX8dZ4ZXwT595aEF1ug==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.0.tgz", + "integrity": "sha512-pu/nvn152F3qbPeUkr+4e9zVvEhD3jhwzF473veQfMPkOYo9aoWXSfdZH/E6F+nYC3qvFjbxbvdDbUtEbghLqw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-VzYd6OwnUR81sInf3alj1wiokY50DjsHz5bvfnsFpxs5tqQxESoHtJO6xyksDs3RIkyhMWq2FufXo6GNSU9BMw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=11", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.0.tgz", + "integrity": "sha512-dD9OznTlHD6aovRswaPNEy8dKtSAmNo4++tO7uuR4o5VxbVAOoEQ1uSmN4iFAdQneTHws1lkTZeiXPrcCkh6IA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "macos": ">=10.13", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.0.tgz", + "integrity": "sha512-VwgD2eEikDJUk09Mn9Dzi1OW2OJFRQK+XlBTkUNmAWPrtj8Ly0yq05DFgu1VCMx2/DqCGQVi5A1dM9hTmxf3uw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.0.tgz", + "integrity": "sha512-xTYThiqEZEZc0PRU90yVtM3KE7lw1bKdnDQ9kCTHWbqWyHOe4NpPOtMGy27YnN51q0J5dqRrvicfPbALIOeAZA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.0.tgz", + "integrity": "sha512-o9E46WWBC6JsBlwU4QyU9578G77HBDT1NInd+aERfxeOPbk0qBZHgoDsQmA2v9TbqJRWzoBPx1aLOhprBMgPjw==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.0.tgz", + "integrity": "sha512-naldaJy4hSVhWBgEjfdBY85CAa4UO+W1nx6a1sWStHZ7EUfNiuBTTN2KUYT5dH1+p/xij1t2QSXfCiFJoC5S/Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.0.tgz", + "integrity": "sha512-OdorplCyvmSAPsoJLldtLh3nLxRrkAAAOHsGWGDYfN0kh730gifK+UZb3dWORRa6EusNqCTjfXV4GxvgJ/nPDQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.0.tgz", + "integrity": "sha512-FW8iK6rJrg+X2jKD0Ajhjv6y74lToIBEvkZhl42nZt563FfxkCYacrXZtd+q/sRQDypQLzY5WdLkVTbJoPyqNg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.0.tgz", + "integrity": "sha512-4horD3wMFd5a0ddbDY8/dXU9CaOgHjEHALAddXgafoR5oWq5s8X61PDgsSeh4Qupsdo6ycfPPSSNBrfVQnwwrg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.0.tgz", + "integrity": "sha512-dcomVSrtgF70SyOr8RCOCQ8XGVThXwe71A1d8MGA+mXEVRJ/J6/TrCbBEJh9ddcEIIsrnrkolaEvYSHqVhswQw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.0.tgz", + "integrity": "sha512-TiVJbx38J2rNVfA309ffSOB+3/7wOsZYQEOlKqOUdWD/nqkjNGrX+YQGz7nzcf5oy2lC+d37+w183iNXRZNngQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.28", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.0.tgz", + "integrity": "sha512-PaZM4Zi7/Ek71WgTdvR+KzTZpBqrQOFcPe7/8ZoPRlTYYRe43k6TWsf4GVH6XKRLMYeSp8J89RfAhBrSP4itNA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "glibc": ">=2.26", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.0.tgz", + "integrity": "sha512-1QLbbN0zt+32eVrg7bb1lwtvEaZwlhEsY1OrijroMkwAqlHqFj6R33Y47s2XUv7P6Ie1PwCxK/uFnNqMnkd5kg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.0.tgz", + "integrity": "sha512-CecqgB/CnkvCWFhmfN9ZhPGMLXaEBXl4o7WtA6U3Ztrlh/s7FUKX4vNxpMSYLIrWuuzjiaYdfU3+Tdqh1xaHfw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "musl": ">=1.2.2", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.0.tgz", + "integrity": "sha512-Hn4js32gUX9qkISlemZBUPuMs0k/xNJebUNl/L6djnU07B/HAA2KaxRVb3HvbU5fL242hLOcp0+tR+M8dvJUFw==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^0.44.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.0.tgz", + "integrity": "sha512-5HfcsCZi3l5nPRF2q3bllMVMDXBqEWI3Q8KQONfzl0TferFE5lnsIG0A1YrntMAGqvkzdW6y1Ci1A2uTvxhfzg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.0.tgz", + "integrity": "sha512-i3DtP/2ce1yKFj4OzOnOYltOEL/+dp4dc4dJXJBv6god1AFTcmkaA99H/7SwOmkCOBQkbVvA3lCGm3/5nDtf9Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0", + "yarn": ">=3.2.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2043,6 +2487,37 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/multer": { + "version": "1.4.4-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", + "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@nestjs/schedule": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.0.0.tgz", + "integrity": "sha512-zz4h54m/F/1qyQKvMJCRphmuwGqJltDAkFxUXCVqJBXEs5kbPt93Pza3heCQOcMH22MZNhGlc9DmDMLXVHmgVQ==", + "dependencies": { + "cron": "3.1.3", + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.12" + } + }, "node_modules/@nestjs/schematics": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.0.3.tgz", @@ -2446,12 +2921,26 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.7.tgz", + "integrity": "sha512-gKc9P2d4g5uYwmy4s/MO/yOVPmvHyvzka1YH6i5dM03UrFofHSmgc0D0ymbDRStFWHusk6cwwF6nhLm/ckBbbQ==" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, + "node_modules/@types/multer": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", + "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.0.tgz", @@ -4438,6 +4927,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "devOptional": true }, + "node_modules/cron": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.3.tgz", + "integrity": "sha512-KVxeKTKYj2eNzN4ElnT6nRSbjbfhyxR92O/Jdp6SH3pc05CDJws59jBrZWEMQlxevCiE6QUTrXy+Im3vC3oD3A==", + "dependencies": { + "@types/luxon": "~3.3.0", + "luxon": "~3.4.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4765,7 +5263,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", - "dev": true, "engines": { "node": ">=8" } @@ -8281,6 +8778,14 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/macos-release": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.1.tgz", @@ -8579,9 +9084,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multer": { - "version": "1.4.4-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", - "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.0.0", @@ -10509,6 +11014,57 @@ "sha.js": "bin.js" } }, + "node_modules/sharp": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.0.tgz", + "integrity": "sha512-99DZKudjm/Rmz+M0/26t4DKpXyywAOJaayGS9boEn7FvgtG0RYBi46uPE2c+obcJRtA3AZa0QwJot63gJQ1F0Q==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "semver": "^7.5.4" + }, + "engines": { + "libvips": ">=8.15.0", + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.0", + "@img/sharp-darwin-x64": "0.33.0", + "@img/sharp-libvips-darwin-arm64": "1.0.0", + "@img/sharp-libvips-darwin-x64": "1.0.0", + "@img/sharp-libvips-linux-arm": "1.0.0", + "@img/sharp-libvips-linux-arm64": "1.0.0", + "@img/sharp-libvips-linux-s390x": "1.0.0", + "@img/sharp-libvips-linux-x64": "1.0.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.0", + "@img/sharp-libvips-linuxmusl-x64": "1.0.0", + "@img/sharp-linux-arm": "0.33.0", + "@img/sharp-linux-arm64": "0.33.0", + "@img/sharp-linux-s390x": "0.33.0", + "@img/sharp-linux-x64": "0.33.0", + "@img/sharp-linuxmusl-arm64": "0.33.0", + "@img/sharp-linuxmusl-x64": "0.33.0", + "@img/sharp-wasm32": "0.33.0", + "@img/sharp-win32-ia32": "0.33.0", + "@img/sharp-win32-x64": "0.33.0" + } + }, + "node_modules/sharp/node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/be/package.json b/be/package.json index 02fd800a..02bcb026 100644 --- a/be/package.json +++ b/be/package.json @@ -26,18 +26,21 @@ "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.15", "@nestjs/typeorm": "^10.0.1", "aws-sdk": "^2.348.0", "axios": "^1.6.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "multer": "^1.4.5-lts.1", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "passport-naver": "^1.0.6", "pg": "^8.11.3", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", + "sharp": "^0.33.0", "swagger-ui-express": "^5.0.0", "typeorm": "^0.3.17", "uuid": "^9.0.1", @@ -49,6 +52,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/supertest": "^2.0.12", "@types/winston": "^2.4.4", @@ -92,4 +96,4 @@ ], "coverageDirectory": "../coverage" } -} \ No newline at end of file +} diff --git a/be/src/auth/auth.controller.ts b/be/src/auth/auth.controller.ts index bbf98afc..3a1c195e 100644 --- a/be/src/auth/auth.controller.ts +++ b/be/src/auth/auth.controller.ts @@ -4,6 +4,7 @@ import { Headers, Post, UseGuards, + UsePipes, ValidationPipe, } from "@nestjs/common"; import { AuthService } from "./auth.service"; @@ -15,11 +16,22 @@ import { ApiBody, } from "@nestjs/swagger"; import { RefreshTokenDto } from "./dto/refreshToken.dto"; +import { LoginInfoDto } from "./dto/loginInfo.dto"; @ApiTags("Authentication") @Controller("auth") export class AuthController { - constructor(private authService: AuthService) {} + constructor(private authService: AuthService) { } + + @Post("login") + @ApiOperation({ summary: "일반 로그인" }) + @ApiResponse({ status: 200, description: "성공적으로 로그인됨." }) + @ApiResponse({ status: 401, description: "인증 오류" }) + @UsePipes(new ValidationPipe) + login(@Body() loginInfoDto: LoginInfoDto) { + return this.authService.login(loginInfoDto); + } + @Post("social-login") @ApiOperation({ summary: "네이버 소셜 로그인" }) diff --git a/be/src/auth/auth.module.ts b/be/src/auth/auth.module.ts index 7606b236..03a526bd 100644 --- a/be/src/auth/auth.module.ts +++ b/be/src/auth/auth.module.ts @@ -5,6 +5,7 @@ import { JwtModule } from "@nestjs/jwt"; import { PassportModule } from "@nestjs/passport"; import { UserModule } from "../user/user.module"; import { JwtStrategy } from "./strategy/jwt.strategy"; +import { AuthRepository } from "./auth.repository"; @Module({ imports: [ @@ -18,7 +19,7 @@ import { JwtStrategy } from "./strategy/jwt.strategy"; forwardRef(() => UserModule), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy], - exports: [PassportModule], + providers: [AuthService, JwtStrategy, AuthRepository], + exports: [PassportModule, AuthService], }) -export class AuthModule {} +export class AuthModule { } diff --git a/be/src/auth/auth.repository.ts b/be/src/auth/auth.repository.ts new file mode 100644 index 00000000..f91b8f86 --- /dev/null +++ b/be/src/auth/auth.repository.ts @@ -0,0 +1,14 @@ +import { DataSource, IsNull, Repository, Not, In } from "typeorm"; +import { + ConflictException, + Injectable, + BadRequestException, +} from "@nestjs/common"; +import { AuthRefreshTokenEntity } from "./entity/auth.refreshtoken.entity"; + +@Injectable() +export class AuthRepository extends Repository { + constructor(private dataSource: DataSource) { + super(AuthRefreshTokenEntity, dataSource.createEntityManager()); + } +} \ No newline at end of file diff --git a/be/src/auth/auth.service.ts b/be/src/auth/auth.service.ts index 3b718b3b..a5ad685d 100644 --- a/be/src/auth/auth.service.ts +++ b/be/src/auth/auth.service.ts @@ -3,17 +3,39 @@ import { NotFoundException, HttpException, HttpStatus, + BadRequestException, + UnauthorizedException, + ForbiddenException, } from "@nestjs/common"; import { UserRepository } from "../user/user.repository"; import { JwtService } from "@nestjs/jwt"; import axios from "axios"; +import { LoginInfoDto } from "./dto/loginInfo.dto"; +import { comparePasswords } from "../utils/encryption.utils"; +import { AuthRepository } from "./auth.repository"; @Injectable() export class AuthService { constructor( private userRepository: UserRepository, - private jwtService: JwtService - ) {} + private jwtService: JwtService, + private authRepository: AuthRepository + ) { } + async login(loginInfoDto: LoginInfoDto) { + const data = await this.userRepository.findOne({ select: ["password"], where: { email: loginInfoDto.email, provider: "site" } }) + try { + const result = await comparePasswords(loginInfoDto.password, data["password"]); + if (result) return this.signin(loginInfoDto); + else throw new HttpException("LOGIN FAILED", HttpStatus.FORBIDDEN); + } catch (err) { + throw new HttpException("LOGIN FAILED", HttpStatus.FORBIDDEN); + } + } + + async logout(id: number) { + await this.authRepository.delete({ id: id }); + } + async NaverAuth(authorization: string) { if (!authorization) { throw new HttpException( @@ -36,21 +58,24 @@ export class AuthService { } } + async createTokens(id: number) { + const payload = { id: id }; + const accessToken = this.jwtService.sign(payload); + const refreshToken = this.jwtService.sign(payload, { + secret: "nibobnebob", + expiresIn: "7d", + }); + await this.authRepository.upsert({ id: id, accessToken: accessToken, refreshToken: refreshToken }, ["id"]); + return { accessToken, refreshToken }; + } + async signin(loginRequestUser: any) { const user = await this.userRepository.findOneBy({ email: loginRequestUser.email, }); if (user) { - const payload = { id: user.id }; - const accessToken = this.jwtService.sign(payload); - - const refreshToken = this.jwtService.sign(payload, { - secret: "nibobnebob", - expiresIn: "7d", - }); - - return { accessToken, refreshToken }; + return await this.createTokens(user.id); } else { throw new NotFoundException( "사용자가 등록되어 있지 않습니다. 회원가입을 진행해주세요" @@ -63,9 +88,13 @@ export class AuthService { const decoded = this.jwtService.verify(refreshToken, { secret: "nibobnebob", }); - const payload = { id: decoded.id }; - const accessToken = this.jwtService.sign(payload); - return { accessToken }; + if (await this.authRepository.findOne({ where: { id: decoded.id, refreshToken: refreshToken } })) { + const payload = { id: decoded.id }; + const accessToken = this.jwtService.sign(payload); + await this.authRepository.update(decoded.id, { accessToken: accessToken }); + return { accessToken }; + } + throw new HttpException("Invalid refresh token", HttpStatus.UNAUTHORIZED); } catch (err) { throw new HttpException("Invalid refresh token", HttpStatus.UNAUTHORIZED); } diff --git a/be/src/auth/dto/loginInfo.dto.ts b/be/src/auth/dto/loginInfo.dto.ts new file mode 100644 index 00000000..c4f68c77 --- /dev/null +++ b/be/src/auth/dto/loginInfo.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsString, MaxLength } from "class-validator"; + +export class LoginInfoDto { + @ApiProperty({ + example: "user@example.com", + description: "The email of the user", + }) + @IsEmail() + @IsNotEmpty() + @MaxLength(50) + email: string; + + @ApiProperty({ + description: "The password of the user", + }) + @IsString() + @IsNotEmpty() + @MaxLength(50) + password: string; +} diff --git a/be/src/auth/entity/auth.refreshtoken.entity.ts b/be/src/auth/entity/auth.refreshtoken.entity.ts new file mode 100644 index 00000000..f026e5fc --- /dev/null +++ b/be/src/auth/entity/auth.refreshtoken.entity.ts @@ -0,0 +1,13 @@ +import { Entity, Column, PrimaryColumn } from 'typeorm'; + +@Entity("auth_token") +export class AuthRefreshTokenEntity { + @PrimaryColumn() + id: number; + + @Column({ type: 'varchar', length: 300 }) + accessToken: string + + @Column({ type: 'varchar', length: 300 }) + refreshToken: string +} diff --git a/be/src/aws/aws.service.ts b/be/src/aws/aws.service.ts index a4682836..d2745dc8 100644 --- a/be/src/aws/aws.service.ts +++ b/be/src/aws/aws.service.ts @@ -1,7 +1,7 @@ import { Injectable } from "@nestjs/common"; import * as AWS from "aws-sdk"; -import { awsConfig } from "objectStorage.config"; -import { v4 } from "uuid"; +import { awsConfig } from "../../objectStorage.config"; +import * as sharp from "sharp"; @Injectable() export class AwsService { @@ -18,12 +18,21 @@ export class AwsService { }); } - async uploadToS3(path: string, data: Buffer){ - await this.s3.putObject({ - Bucket: awsConfig.bucket, - Key: path, - Body: data, - }).promise(); + async uploadToS3(path: string, data: Buffer) { + + try { + const resizedBuffer = await sharp(data) + .resize(256, 256) + .toBuffer(); + + await this.s3.putObject({ + Bucket: awsConfig.bucket, + Key: path, + Body: resizedBuffer, + }).promise(); + } catch (error) { + throw error; + } } getImageURL(path: string) { diff --git a/be/src/main.ts b/be/src/main.ts index 9cb01427..f042556e 100644 --- a/be/src/main.ts +++ b/be/src/main.ts @@ -3,9 +3,12 @@ import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; import { AppModule } from "./app.module"; import { TransformInterceptor } from "./response.interceptor"; import { HttpExceptionFilter } from "./error.filter"; +import * as fs from 'fs'; async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.setGlobalPrefix("api"); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor()); @@ -20,4 +23,4 @@ async function bootstrap() { SwaggerModule.setup("api", app, document); await app.listen(8000); } -bootstrap(); +bootstrap(); \ No newline at end of file diff --git a/be/src/restaurant/entities/restaurant.entity.ts b/be/src/restaurant/entities/restaurant.entity.ts index 9bc236d1..94f58ca6 100644 --- a/be/src/restaurant/entities/restaurant.entity.ts +++ b/be/src/restaurant/entities/restaurant.entity.ts @@ -8,9 +8,8 @@ import { OneToMany, } from "typeorm"; import { Point } from "geojson"; -import { ReviewInfoEntity } from "src/review/entities/review.entity"; -import { User } from "src/user/entities/user.entity"; -import { UserRestaurantListEntity } from "src/user/entities/user.restaurantlist.entity"; +import { ReviewInfoEntity } from "../../review/entities/review.entity"; +import { UserRestaurantListEntity } from "../../user/entities/user.restaurantlist.entity"; @Unique("unique_name_location", ["name", "location"]) @Entity("restaurant") diff --git a/be/src/restaurant/restaurant.controller.ts b/be/src/restaurant/restaurant.controller.ts index c3a918ea..21d74e7e 100644 --- a/be/src/restaurant/restaurant.controller.ts +++ b/be/src/restaurant/restaurant.controller.ts @@ -19,13 +19,13 @@ import { import { RestaurantService } from "./restaurant.service"; import { SearchInfoDto } from "./dto/seachInfo.dto"; import { FilterInfoDto } from "./dto/filterInfo.dto"; -import { GetUser, TokenInfo } from "src/user/user.decorator"; +import { GetUser, TokenInfo } from "../user/user.decorator"; import { LocationDto } from "./dto/location.dto"; @ApiTags("Home") @Controller("restaurant") export class RestaurantController { - constructor(private restaurantService: RestaurantService) {} + constructor(private restaurantService: RestaurantService) { } @Get("autocomplete/:partialRestaurantName") @UseGuards(AuthGuard("jwt")) @ApiBearerAuth() @@ -162,6 +162,12 @@ export class RestaurantController { type: String, description: "검색 반경", }) + @ApiQuery({ + name: "limit", + required: false, + type: String, + description: "응답 개수", + }) @ApiResponse({ status: 200, description: "전체 음식점 리스트 요청 성공", @@ -174,8 +180,9 @@ export class RestaurantController { @UsePipes(new ValidationPipe()) entireRestaurantList( @GetUser() tokenInfo: TokenInfo, - @Query() locationDto: LocationDto + @Query() locationDto: LocationDto, + @Query("limit") limit: string ) { - return this.restaurantService.entireRestaurantList(locationDto, tokenInfo); + return this.restaurantService.entireRestaurantList(locationDto, tokenInfo, limit); } } diff --git a/be/src/restaurant/restaurant.module.ts b/be/src/restaurant/restaurant.module.ts index 27c2f1af..a1adc5a5 100644 --- a/be/src/restaurant/restaurant.module.ts +++ b/be/src/restaurant/restaurant.module.ts @@ -1,14 +1,15 @@ import { Module } from "@nestjs/common"; import { RestaurantController } from "./restaurant.controller"; -import { AuthModule } from "src/auth/auth.module"; +import { AuthModule } from "../auth/auth.module"; import { RestaurantService } from "./restaurant.service"; import { RestaurantRepository } from "./restaurant.repository"; -import { UserModule } from "src/user/user.module"; -import { ReviewModule } from "src/review/review.module"; +import { UserModule } from "../user/user.module"; +import { ReviewModule } from "../review/review.module"; +import { ScheduleModule } from '@nestjs/schedule'; @Module({ - imports: [AuthModule, UserModule, ReviewModule], + imports: [AuthModule, UserModule, ReviewModule, ScheduleModule.forRoot(),], controllers: [RestaurantController], providers: [RestaurantService, RestaurantRepository], }) -export class RestaurantModule {} +export class RestaurantModule { } diff --git a/be/src/restaurant/restaurant.repository.ts b/be/src/restaurant/restaurant.repository.ts index 370a144f..44ef917f 100644 --- a/be/src/restaurant/restaurant.repository.ts +++ b/be/src/restaurant/restaurant.repository.ts @@ -2,12 +2,12 @@ import { DataSource, Repository, Like } from "typeorm"; import { Injectable } from "@nestjs/common"; import { RestaurantInfoEntity } from "./entities/restaurant.entity"; import { SearchInfoDto } from "./dto/seachInfo.dto"; -import { TokenInfo } from "src/user/user.decorator"; -import { UserRestaurantListEntity } from "src/user/entities/user.restaurantlist.entity"; +import { TokenInfo } from "../user/user.decorator"; +import { UserRestaurantListEntity } from "../user/entities/user.restaurantlist.entity"; import { FilterInfoDto } from "./dto/filterInfo.dto"; -import { User } from "src/user/entities/user.entity"; +import { User } from "../user/entities/user.entity"; import { LocationDto } from "./dto/location.dto"; -import { UserWishRestaurantListEntity } from "src/user/entities/user.wishrestaurantlist.entity"; +import { UserWishRestaurantListEntity } from "../user/entities/user.wishrestaurantlist.entity"; @Injectable() export class RestaurantRepository extends Repository { @@ -178,7 +178,9 @@ export class RestaurantRepository extends Repository { } } - async entireRestaurantList(locationDto: LocationDto, tokenInfo: TokenInfo) { + async entireRestaurantList(locationDto: LocationDto, tokenInfo: TokenInfo, limit: string = "40") { + const limitNum = parseInt(limit); + return this.createQueryBuilder("restaurant") .leftJoin( UserRestaurantListEntity, @@ -208,6 +210,7 @@ export class RestaurantRepository extends Repository { location, ST_GeomFromText('POINT(${locationDto.longitude} ${locationDto.latitude})', 4326)) < ${locationDto.radius}` ) + .limit(limitNum) .getRawMany(); } diff --git a/be/src/restaurant/restaurant.service.ts b/be/src/restaurant/restaurant.service.ts index cda63ac7..1026ea13 100644 --- a/be/src/restaurant/restaurant.service.ts +++ b/be/src/restaurant/restaurant.service.ts @@ -4,29 +4,28 @@ import { SearchInfoDto } from "./dto/seachInfo.dto"; import * as proj4 from "proj4"; import axios from "axios"; import { FilterInfoDto } from "./dto/filterInfo.dto"; -import { TokenInfo } from "src/user/user.decorator"; -import { UserRepository } from "src/user/user.repository"; -import { ReviewRepository } from "src/review/review.repository"; +import { TokenInfo } from "../user/user.decorator"; +import { UserRepository } from "../user/user.repository"; +import { ReviewRepository } from "../review/review.repository"; import { LocationDto } from "./dto/location.dto"; +import { AwsService } from "../aws/aws.service"; +import { Cron } from "@nestjs/schedule"; -const key = "api키 입력하세요"; +const key = process.env.API_KEY; @Injectable() -export class RestaurantService implements OnModuleInit { - onModuleInit() { - //this.updateRestaurantsFromSeoulData(); - setInterval( - () => { - this.updateRestaurantsFromSeoulData(); - }, - 1000 * 60 * 60 * 24 * 3 - ); +export class RestaurantService { + + @Cron('0 0 2 * * *') + handleCron() { + this.updateRestaurantsFromSeoulData(); } constructor( private restaurantRepository: RestaurantRepository, private userRepository: UserRepository, - private reviewRepository: ReviewRepository + private reviewRepository: ReviewRepository, + private awsService: AwsService ) { } async searchRestaurant(searchInfoDto: SearchInfoDto, tokenInfo: TokenInfo) { @@ -43,6 +42,22 @@ export class RestaurantService implements OnModuleInit { }) .getCount(); + const reviewInfo = await this.reviewRepository + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select(["review.id", "review.reviewImage"],) + .groupBy("review.id") + .where("review.restaurant_id = :restaurantId and review.reviewImage is NOT NULL", { restaurantId: restaurant.restaurant_id }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawOne(); + if (reviewInfo) { + restaurant.restaurant_reviewImage = this.awsService.getImageURL(reviewInfo.review_reviewImage); + } + else { + restaurant.restaurant_reviewImage = this.awsService.getImageURL("review/images/defaultImage.png"); + } + + restaurant.restaurant_reviewCnt = reviewCount; } @@ -58,6 +73,7 @@ export class RestaurantService implements OnModuleInit { const reviews = await this.reviewRepository .createQueryBuilder("review") .leftJoinAndSelect("review.user", "user") + .leftJoin("review.reviewLikes", "reviewLike", "reviewLike.userId = :userId", { userId: tokenInfo.id }) .select([ "review.id", "review.isCarVisit", @@ -68,17 +84,30 @@ export class RestaurantService implements OnModuleInit { "review.restroomCleanliness", "review.overallExperience", "user.nickName as reviewer", - "review.createdAt" + "user.profileImage", + "review.createdAt", + "review.reviewImage", + "reviewLike.isLike as isLike" ]) + .addSelect("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "likeCount") + .addSelect("COUNT(CASE WHEN reviewLike.isLike = false THEN 1 ELSE NULL END)", "dislikeCount") + .groupBy("review.id, user.nickName, user.profileImage, review.isCarVisit, review.transportationAccessibility, review.parkingArea, review.taste, review.service, review.restroomCleanliness, review.overallExperience, review.createdAt, review.reviewImage, reviewLike.isLike") .where("review.restaurant_id = :restaurantId", { restaurantId: restaurant.restaurant_id, }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") .getRawMany(); - restaurant.restaurant_reviewCnt = reviews.length; - restaurant.reviews = reviews.slice(0, 3); - + restaurant.restaurant_reviewCnt = reviews.length; + const reviewList = reviews.slice(0, 3); + reviewList.forEach((element) => { + if (element.review_reviewImage && element.review_reviewImage != "review/images/defaultImage.png") element.review_reviewImage = this.awsService.getImageURL(element.review_reviewImage); + else { element.review_reviewImage = "" } + if (element.user_profileImage) element.user_profileImage = this.awsService.getImageURL(element.user_profileImage); + + }) + restaurant.reviews = reviewList; return restaurant; } @@ -105,16 +134,33 @@ export class RestaurantService implements OnModuleInit { }) .getCount(); + const reviewInfo = await this.reviewRepository + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select(["review.id", "review.reviewImage"],) + .groupBy("review.id") + .where("review.restaurant_id = :restaurantId and review.reviewImage is NOT NULL", { restaurantId: restaurant.restaurant_id }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawOne(); + if (reviewInfo) { + restaurant.restaurant_reviewImage = this.awsService.getImageURL(reviewInfo.review_reviewImage); + } + else { + restaurant.restaurant_reviewImage = this.awsService.getImageURL("review/images/defaultImage.png"); + } + + restaurant.restaurant_reviewCnt = reviewCount; } return restaurants; } - async entireRestaurantList(locationDto: LocationDto, tokenInfo: TokenInfo) { + async entireRestaurantList(locationDto: LocationDto, tokenInfo: TokenInfo, limit: string) { const restaurants = await this.restaurantRepository.entireRestaurantList( locationDto, - tokenInfo + tokenInfo, + limit ); for (const restaurant of restaurants) { @@ -124,7 +170,20 @@ export class RestaurantService implements OnModuleInit { restaurantId: restaurant.restaurant_id, }) .getCount(); - + const reviewInfo = await this.reviewRepository + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select(["review.id", "review.reviewImage"],) + .groupBy("review.id") + .where("review.restaurant_id = :restaurantId and review.reviewImage is NOT NULL", { restaurantId: restaurant.restaurant_id }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawOne(); + if (reviewInfo) { + restaurant.restaurant_reviewImage = this.awsService.getImageURL(reviewInfo.review_reviewImage); + } + else { + restaurant.restaurant_reviewImage = this.awsService.getImageURL("review/images/defaultImage.png"); + } restaurant.restaurant_reviewCnt = reviewCount; } diff --git a/be/src/review/dto/reviewInfo.dto.ts b/be/src/review/dto/reviewInfo.dto.ts index ee47c7bd..3af8774f 100644 --- a/be/src/review/dto/reviewInfo.dto.ts +++ b/be/src/review/dto/reviewInfo.dto.ts @@ -10,12 +10,14 @@ import { Max, Min, } from "class-validator"; +import { Transform } from 'class-transformer'; export class ReviewInfoDto { @ApiProperty({ example: "true", description: "The transportation for visiting", }) + @Transform(({ value }) => value === 'true') @IsBoolean() @IsNotEmpty() isCarVisit: boolean; @@ -24,8 +26,11 @@ export class ReviewInfoDto { example: "0", description: "transportation Accessibility for visiting", }) - @IsInt() @IsOptional() + @Transform(({ value }) => { + return !value ? null : parseInt(value); + }) + @IsInt() @Min(0) @Max(4) transportationAccessibility: number | null; @@ -34,13 +39,17 @@ export class ReviewInfoDto { example: "0", description: "condition of the restaurant's parking area", }) - @IsInt() @IsOptional() + @Transform(({ value }) => { + return !value ? null : parseInt(value); + }) + @IsInt() @Min(0) @Max(4) parkingArea: number | null; @ApiProperty({ example: "0", description: "The taste of the food" }) + @Transform(({ value }) => parseInt(value)) @IsInt() @IsNotEmpty() @Min(0) @@ -48,6 +57,7 @@ export class ReviewInfoDto { taste: number; @ApiProperty({ example: "0", description: "The service of the restaurant" }) + @Transform(({ value }) => parseInt(value)) @IsInt() @IsNotEmpty() @Min(0) @@ -58,6 +68,7 @@ export class ReviewInfoDto { example: "0", description: "The condition of the restaurant's restroom", }) + @Transform(({ value }) => parseInt(value)) @IsInt() @IsNotEmpty() @Min(0) diff --git a/be/src/review/entities/review.entity.ts b/be/src/review/entities/review.entity.ts index c4b579bb..9b4b507b 100644 --- a/be/src/review/entities/review.entity.ts +++ b/be/src/review/entities/review.entity.ts @@ -6,10 +6,12 @@ import { ManyToOne, JoinColumn, OneToOne, + OneToMany, } from "typeorm"; -import { User } from "src/user/entities/user.entity"; -import { RestaurantInfoEntity } from "src/restaurant/entities/restaurant.entity"; -import { UserRestaurantListEntity } from "src/user/entities/user.restaurantlist.entity"; +import { User } from "../../user/entities/user.entity"; +import { RestaurantInfoEntity } from "../../restaurant/entities/restaurant.entity"; +import { UserRestaurantListEntity } from "../../user/entities/user.restaurantlist.entity"; +import { ReviewLikeEntity } from "./review.like.entity"; @Entity("review") export class ReviewInfoEntity { @@ -37,9 +39,15 @@ export class ReviewInfoEntity { @Column({ type: "text" }) overallExperience: string; + @Column({ type: "text", nullable: true, default: null }) + reviewImage: string; + @CreateDateColumn({ name: "created_at" }) createdAt: Date; + @OneToMany(() => ReviewLikeEntity, reviewLike => reviewLike.review) + reviewLikes: ReviewLikeEntity[]; + @ManyToOne(() => User) @JoinColumn({ name: "user_id" }) user: User; diff --git a/be/src/review/entities/review.like.entity.ts b/be/src/review/entities/review.like.entity.ts new file mode 100644 index 00000000..e502460d --- /dev/null +++ b/be/src/review/entities/review.like.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + PrimaryColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + DeleteDateColumn, +} from "typeorm"; +import { User } from "../../user/entities/user.entity"; +import { ReviewInfoEntity } from "./review.entity"; + +@Entity("reviewLike") +export class ReviewLikeEntity { + @PrimaryColumn({ name: "review_id" }) + reviewId: number; + + @PrimaryColumn({ name: "user_id" }) + userId: number; + + @Column({ name: "is_like" }) + isLike: boolean; + + @CreateDateColumn({ name: "created_at" }) + createdAt: Date; + + @DeleteDateColumn({ name: "deleted_at", nullable: true, type: "timestamp" }) + deletedAt: Date | null; + + @ManyToOne(() => ReviewInfoEntity) + @JoinColumn({ name: "review_id", referencedColumnName: "id" }) + review: ReviewInfoEntity; + + @ManyToOne(() => User) + @JoinColumn({ name: "user_id", referencedColumnName: "id" }) + user: User; +} diff --git a/be/src/review/review.controller.spec.ts b/be/src/review/review.controller.spec.ts new file mode 100644 index 00000000..b1b04586 --- /dev/null +++ b/be/src/review/review.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReviewController } from './review.controller'; + +describe('ReviewController', () => { + let controller: ReviewController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ReviewController], + }).compile(); + + controller = module.get(ReviewController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/be/src/review/review.controller.ts b/be/src/review/review.controller.ts new file mode 100644 index 00000000..2e79c27a --- /dev/null +++ b/be/src/review/review.controller.ts @@ -0,0 +1,67 @@ +import { Controller, Get, Param, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { ReviewService } from './review.service'; +import { ApiBearerAuth, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { GetUser, TokenInfo } from '../user/user.decorator'; +import { SortInfoDto } from '../utils/sortInfo.dto'; + +@ApiTags("Review") +@Controller('review') +export class ReviewController { + constructor(private reviewService: ReviewService) { } + + @Get("/:restaurantId") + @UseGuards(AuthGuard("jwt")) + @ApiBearerAuth() + @ApiQuery({ name: 'sort', required: false, description: '정렬 기준' }) + @ApiQuery({ name: 'page', required: false, description: '페이지 번호' }) + @ApiQuery({ name: 'limit', required: false, description: '페이지 당 항목 수' }) + + @ApiOperation({ summary: "리뷰 정렬 요청" }) + @ApiResponse({ status: 200, description: "리뷰 정렬 요청 성공" }) + @ApiResponse({ status: 401, description: "인증 실패" }) + @ApiResponse({ status: 400, description: "부적절한 요청" }) + @UsePipes(new ValidationPipe()) + async getSortedReviews( + @GetUser() tokenInfo: TokenInfo, + @Param('restaurantId') restaurantId: string, + @Query() getSortedReviewsDto: SortInfoDto + ) { + const restaurantNumber = parseInt(restaurantId, 10); + return await this.reviewService.getSortedReviews(tokenInfo, restaurantNumber, getSortedReviewsDto); + } + + @Post("/:reviewId/like") + @UseGuards(AuthGuard("jwt")) + @ApiBearerAuth() + @ApiOperation({ summary: "리뷰 좋아요 요청" }) + @ApiResponse({ status: 200, description: "리뷰 좋아요 요청 성공" }) + @ApiResponse({ status: 401, description: "인증 실패" }) + @ApiResponse({ status: 400, description: "부적절한 요청" }) + async reviewLike( + @GetUser() tokenInfo: TokenInfo, + @Param("reviewId") reviewid: number + ) { + return await this.reviewService.reviewLike( + tokenInfo, + reviewid + ); + } + + @Post("/:reviewId/unlike") + @UseGuards(AuthGuard("jwt")) + @ApiBearerAuth() + @ApiOperation({ summary: "리뷰 싫어요 요청" }) + @ApiResponse({ status: 200, description: "리뷰 싫어요 요청 성공" }) + @ApiResponse({ status: 401, description: "인증 실패" }) + @ApiResponse({ status: 400, description: "부적절한 요청" }) + async reviewUnLike( + @GetUser() tokenInfo: TokenInfo, + @Param("reviewId") reviewid: number + ) { + return await this.reviewService.reviewUnLike( + tokenInfo, + reviewid + ); + } +} diff --git a/be/src/review/review.like.repository.ts b/be/src/review/review.like.repository.ts new file mode 100644 index 00000000..d49ad054 --- /dev/null +++ b/be/src/review/review.like.repository.ts @@ -0,0 +1,10 @@ +import { DataSource, IsNull, Repository, Not } from "typeorm"; +import { ConflictException, Injectable } from "@nestjs/common"; +import { ReviewLikeEntity } from "./entities/review.like.entity"; + +@Injectable() +export class ReviewLikeRepository extends Repository { + constructor(private dataSource: DataSource) { + super(ReviewLikeEntity, dataSource.createEntityManager()); + } +} diff --git a/be/src/review/review.module.ts b/be/src/review/review.module.ts index dc114741..d1b2a33a 100644 --- a/be/src/review/review.module.ts +++ b/be/src/review/review.module.ts @@ -1,8 +1,12 @@ import { Module } from "@nestjs/common"; import { ReviewRepository } from "./review.repository"; +import { ReviewController } from "./review.controller"; +import { ReviewService } from "./review.service"; +import { ReviewLikeRepository } from "./review.like.repository"; @Module({ - providers: [ReviewRepository], + controllers: [ReviewController], + providers: [ReviewRepository, ReviewLikeRepository, ReviewService], exports: [ReviewRepository], }) -export class ReviewModule {} +export class ReviewModule { } diff --git a/be/src/review/review.repository.ts b/be/src/review/review.repository.ts index f8b6464d..f5fd07dd 100644 --- a/be/src/review/review.repository.ts +++ b/be/src/review/review.repository.ts @@ -1,10 +1,139 @@ import { DataSource, IsNull, Repository, Not } from "typeorm"; import { ConflictException, Injectable } from "@nestjs/common"; import { ReviewInfoEntity } from "./entities/review.entity"; +import { TokenInfo } from "../user/user.decorator"; +import { SortInfoDto } from "../utils/sortInfo.dto"; @Injectable() export class ReviewRepository extends Repository { constructor(private dataSource: DataSource) { super(ReviewInfoEntity, dataSource.createEntityManager()); } + + async getReviewIdsWithLikes(sort: string) { + if (sort === "ASC") { + return await this + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select("review.id", "reviewId") + .addSelect("COUNT(reviewLike.isLike)", "likeCount") + .groupBy("review.id") + .orderBy("COUNT(CASE WHEN reviewLike.isLike = false THEN 1 ELSE NULL END)", "DESC") + .getRawMany(); + } + else { + return await this + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select("review.id", "reviewId") + .addSelect("COUNT(reviewLike.isLike)", "likeCount") + .groupBy("review.id") + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawMany(); + } + } + async getSortedReviews(getSortedReviewsDto: SortInfoDto, restaurantId: number, id: TokenInfo["id"], sortedReviewIds: number[]) { + const pageNumber = parseInt(getSortedReviewsDto.page as unknown as string) || 1; + const limitNumber = parseInt(getSortedReviewsDto.limit as unknown as string) || 10; + const skipNumber = (pageNumber - 1) * limitNumber; + if (getSortedReviewsDto && getSortedReviewsDto.sort === "TIME_DESC") { + const items = await this.createQueryBuilder("review") + .leftJoinAndSelect("review.user", "user") + .leftJoin("review.reviewLikes", "reviewLike", "reviewLike.userId = :userId", { userId: id }) + .select([ + "review.id", + "review.isCarVisit", + "review.transportationAccessibility", + "review.parkingArea", + "review.taste", + "review.service", + "review.restroomCleanliness", + "review.overallExperience", + "user.nickName as reviewer", + "user.profileImage", + "review.createdAt", + "review.reviewImage", + "reviewLike.isLike as isLike", + ]) + .where("review.restaurant_id = :restaurantId", { + restaurantId: restaurantId, + }) + .orderBy("review.createdAt", "DESC") + .offset(skipNumber) + .limit(limitNumber + 1) + .getRawMany(); + + const hasNext = items.length > limitNumber; + const resultItems = hasNext ? items.slice(0, -1) : items; + + return { hasNext, items: resultItems }; + } + else if (getSortedReviewsDto && getSortedReviewsDto.sort === "TIME_ASC") { + const items = await this.createQueryBuilder("review") + .leftJoinAndSelect("review.user", "user") + .leftJoin("review.reviewLikes", "reviewLike", "reviewLike.userId = :userId", { userId: id }) + .select([ + "review.id", + "review.isCarVisit", + "review.transportationAccessibility", + "review.parkingArea", + "review.taste", + "review.service", + "review.restroomCleanliness", + "review.overallExperience", + "user.nickName as reviewer", + "user.profileImage", + "review.createdAt", + "review.reviewImage", + "reviewLike.isLike as isLike", + ]) + .where("review.restaurant_id = :restaurantId", { + restaurantId: restaurantId, + }) + .orderBy("review.createdAt", "ASC") + .offset(skipNumber) + .limit(limitNumber + 1) + .getRawMany(); + + const hasNext = items.length > limitNumber; + const resultItems = hasNext ? items.slice(0, -1) : items; + + return { hasNext, items: resultItems }; + } + else { + if (sortedReviewIds.length) { + const items = await this.createQueryBuilder("review") + .leftJoinAndSelect("review.user", "user") + .leftJoin("review.reviewLikes", "reviewLike", "reviewLike.userId = :userId", { userId: id }) + .select([ + "review.id", + "review.isCarVisit", + "review.transportationAccessibility", + "review.parkingArea", + "review.taste", + "review.service", + "review.restroomCleanliness", + "review.overallExperience", + "user.nickName as reviewer", + "user.profileImage", + "review.createdAt", + "review.reviewImage", + "reviewLike.isLike as isLike", + ]) + .where("review.id IN (:...sortedReviewIds)", { sortedReviewIds }) + .andWhere("review.restaurant_id = :restaurantId", { restaurantId: restaurantId }) + .offset(skipNumber) + .limit(limitNumber + 1) + .getRawMany(); + const sortedItems = sortedReviewIds + .map(id => items.find(item => item.review_id === id)) + .filter(item => item !== undefined); + + const hasNext = sortedItems.length > limitNumber; + const resultItems = hasNext ? sortedItems.slice(0, -1) : sortedItems; + + return { hasNext, items: resultItems }; + } + } + } } diff --git a/be/src/review/review.service.spec.ts b/be/src/review/review.service.spec.ts new file mode 100644 index 00000000..b0fc157f --- /dev/null +++ b/be/src/review/review.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReviewService } from './review.service'; + +describe('ReviewService', () => { + let service: ReviewService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ReviewService], + }).compile(); + + service = module.get(ReviewService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/be/src/review/review.service.ts b/be/src/review/review.service.ts new file mode 100644 index 00000000..38682911 --- /dev/null +++ b/be/src/review/review.service.ts @@ -0,0 +1,87 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ReviewRepository } from './review.repository'; +import { TokenInfo } from '../user/user.decorator'; +import { ReviewLikeRepository } from './review.like.repository'; +import { SortInfoDto } from '../utils/sortInfo.dto'; +import { AwsService } from '../aws/aws.service'; + +@Injectable() +export class ReviewService { + constructor( + private reviewRepository: ReviewRepository, + private reviewLikeRepository: ReviewLikeRepository, + private awsService: AwsService + ) { } + + async getSortedReviews(tokenInfo: TokenInfo, restaurantId: number, getSortedReviewsDto: SortInfoDto) { + let sortedReviewIds; + if (getSortedReviewsDto.sort === "REVIEW_ASC") { + const getReviewIdsWithLikes = await this.reviewRepository.getReviewIdsWithLikes("ASC"); + sortedReviewIds = getReviewIdsWithLikes.map(rl => rl.reviewId); + } + else { + const getReviewIdsWithLikes = await this.reviewRepository.getReviewIdsWithLikes("DESC"); + sortedReviewIds = getReviewIdsWithLikes.map(rl => rl.reviewId); + } + + const reviews = await this.reviewRepository.getSortedReviews(getSortedReviewsDto, restaurantId, tokenInfo.id, sortedReviewIds); + for (const review of reviews.items) { + const likeCounts = await this.reviewLikeRepository.createQueryBuilder("reviewLike") + .select("reviewLike.isLike", "status") + .addSelect("COUNT(*)", "count") + .where("reviewLike.reviewId = :reviewId", { reviewId: review.review_id }) + .groupBy("reviewLike.isLike") + .getRawMany(); + if (review.user_profileImage) review.user_profileImage = this.awsService.getImageURL(review.user_profileImage); + if (review.review_reviewImage && review.review_reviewImage != "review/images/defaultImage.png") review.review_reviewImage = this.awsService.getImageURL(review.review_reviewImage); + else { review.review_reviewImage = "" } + review.likeCount = Number(likeCounts.find(lc => lc.status === true)?.count) || 0; + review.dislikeCount = Number(likeCounts.find(lc => lc.status === false)?.count) || 0; + } + + return reviews; + } + + async reviewLike(tokenInfo: TokenInfo, reviewId: number) { + const existingLike = await this.reviewLikeRepository.findOne({ + where: { userId: tokenInfo.id, reviewId: reviewId } + }); + + if (existingLike && existingLike.isLike) { + await this.reviewLikeRepository.remove(existingLike); + } else { + const entity = this.reviewLikeRepository.create({ + userId: tokenInfo.id, + reviewId: reviewId, + isLike: true, + }); + try { + await this.reviewLikeRepository.upsert(entity, ['userId', 'reviewId']); + } catch (err) { + throw new BadRequestException(); + } + } + } + + async reviewUnLike(tokenInfo: TokenInfo, reviewId: number) { + const existingLike = await this.reviewLikeRepository.findOne({ + where: { userId: tokenInfo.id, reviewId: reviewId } + }); + + if (existingLike && !existingLike.isLike) { + await this.reviewLikeRepository.remove(existingLike); + } else { + const entity = this.reviewLikeRepository.create({ + userId: tokenInfo.id, + reviewId: reviewId, + isLike: false, + }); + try { + await this.reviewLikeRepository.upsert(entity, ['userId', 'reviewId']); + } catch (err) { + throw new BadRequestException(); + } + } + } + +} diff --git a/be/src/user/dto/userInfo.dto.ts b/be/src/user/dto/userInfo.dto.ts index f76dff31..2b63c5de 100644 --- a/be/src/user/dto/userInfo.dto.ts +++ b/be/src/user/dto/userInfo.dto.ts @@ -9,7 +9,7 @@ import { IsOptional, IsInstance, } from "class-validator"; -import { isArrayBuffer } from "util/types"; +import { Transform } from 'class-transformer'; export class UserInfoDto { @ApiProperty({ @@ -21,7 +21,7 @@ export class UserInfoDto { @MaxLength(50) email: string; - @ApiProperty({ example: "1234", description: "The password of the user" }) + @ApiProperty({ example: "1234", description: "The password of the user", required: false }) @IsString() @IsOptional() @MaxLength(50) @@ -55,16 +55,8 @@ export class UserInfoDto { example: true, description: "The gender of the user. true is male, false is female", }) + @Transform(({ value }) => value === 'true') @IsBoolean() @IsNotEmpty() isMale: boolean; - - @ApiProperty({ - example: "", - description: "The profile image of the user", - type: 'string', - format: 'binary' - }) - @IsOptional() - profileImage?: Buffer; } diff --git a/be/src/user/entities/user.entity.ts b/be/src/user/entities/user.entity.ts index 90c4b276..d7145d7c 100644 --- a/be/src/user/entities/user.entity.ts +++ b/be/src/user/entities/user.entity.ts @@ -9,7 +9,7 @@ import { } from "typeorm"; import { FollowEntity } from "./user.followList.entity"; import { UserRestaurantListEntity } from "./user.restaurantlist.entity"; -import { ReviewInfoEntity } from "src/review/entities/review.entity"; +import { ReviewInfoEntity } from "../../review/entities/review.entity"; @Entity() export class User { @@ -37,7 +37,7 @@ export class User { @Column({ type: "varchar", length: 20, nullable: true }) provider: string | null; - @Column({ type: "text", default : "profile/images/defaultprofile.png"}) + @Column({ type: "text", default: "profile/images/defaultprofile.png" }) profileImage: string; @CreateDateColumn({ type: "timestamp" }) @@ -55,7 +55,7 @@ export class User { @OneToMany(() => FollowEntity, (follow) => follow.followedUserId) follower: FollowEntity[]; - @OneToMany(() => UserRestaurantListEntity, (list) => list.userId) + @OneToMany(() => UserRestaurantListEntity, (list) => list.user) restaurant: UserRestaurantListEntity[]; @OneToMany(() => ReviewInfoEntity, (review) => review.user) diff --git a/be/src/user/user.controller.ts b/be/src/user/user.controller.ts index f7a37d44..56316010 100644 --- a/be/src/user/user.controller.ts +++ b/be/src/user/user.controller.ts @@ -10,9 +10,14 @@ import { ValidationPipe, UseGuards, Query, + UseInterceptors, + UploadedFile, + BadRequestException, } from "@nestjs/common"; import { ApiBearerAuth, + ApiBody, + ApiConsumes, ApiOperation, ApiParam, ApiQuery, @@ -24,9 +29,19 @@ import { UserService } from "./user.service"; import { GetUser, TokenInfo } from "./user.decorator"; import { AuthGuard } from "@nestjs/passport"; import { SearchInfoDto } from "../restaurant/dto/seachInfo.dto"; -import { LocationDto } from "src/restaurant/dto/location.dto"; -import { ReviewInfoDto } from "src/review/dto/reviewInfo.dto"; +import { LocationDto } from "../restaurant/dto/location.dto"; +import { ReviewInfoDto } from "../review/dto/reviewInfo.dto"; import { ParseArrayPipe } from "../utils/parsearraypipe"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { memoryStorage } from 'multer'; +import { plainToClass } from "class-transformer"; +import { validate } from "class-validator"; +import { SortInfoDto } from "../utils/sortInfo.dto"; + +const multerOptions = { + storage: memoryStorage(), +}; + @Controller("user") export class UserController { @@ -155,16 +170,27 @@ export class UserController { type: String, description: "검색 반경", }) + @ApiQuery({ + name: "sort", + required: false, + type: String, + description: "선택된 필터", + }) + @ApiQuery({ name: 'page', required: false, description: '페이지 번호' }) + @ApiQuery({ name: 'limit', required: false, description: '페이지 당 항목 수' }) @ApiResponse({ status: 200, description: "내 맛집 리스트 정보 요청 성공" }) @ApiResponse({ status: 401, description: "인증 실패" }) @ApiResponse({ status: 400, description: "부적절한 요청" }) + @UsePipes(new ValidationPipe()) async getMyRestaurantListInfo( @Query() locationDto: LocationDto, + @Query() sortInfoDto: SortInfoDto, @GetUser() tokenInfo: TokenInfo ) { const searchInfoDto = new SearchInfoDto("", locationDto); return await this.userService.getMyRestaurantListInfo( searchInfoDto, + sortInfoDto, tokenInfo ); } @@ -174,10 +200,40 @@ export class UserController { @UseGuards(AuthGuard("jwt")) @ApiBearerAuth() @ApiOperation({ summary: "내 위시 맛집 리스트 정보 가져오기" }) + @ApiQuery({ + name: "sort", + required: false, + type: String, + description: "선택된 필터", + }) + @ApiQuery({ name: 'page', required: false, description: '페이지 번호' }) + @ApiQuery({ name: 'limit', required: false, description: '페이지 당 항목 수' }) @ApiResponse({ status: 200, description: "내 맛집 리스트 정보 요청 성공" }) @ApiResponse({ status: 401, description: "인증 실패" }) - async getMyWishRestaurantListInfo(@GetUser() tokenInfo: TokenInfo) { - return await this.userService.getMyWishRestaurantListInfo(tokenInfo); + async getMyWishRestaurantListInfo(@GetUser() tokenInfo: TokenInfo, @Query() sortInfoDto: SortInfoDto) { + return await this.userService.getMyWishRestaurantListInfo(tokenInfo, sortInfoDto); + } + + @ApiTags("Home") + @Get("/state/wish-restaurant") + @UseGuards(AuthGuard("jwt")) + @ApiBearerAuth() + @ApiOperation({ summary: "위시 맛집리스트 포함 여부 정보 가져오기" }) + @ApiResponse({ status: 200, description: "위시 맛집리스트 포함 여부 정보 요청 성공" }) + @ApiResponse({ status: 401, description: "인증 실패" }) + async getStateIsWish(@GetUser() tokenInfo: TokenInfo, @Query("restaurantid") restaurantid: number) { + return await this.userService.getStateIsWish(tokenInfo, restaurantid); + } + + @ApiTags("Home") + @Get("/recommend-food") + @UseGuards(AuthGuard("jwt")) + @ApiBearerAuth() + @ApiOperation({ summary: "추천 음식 정보 가져오기" }) + @ApiResponse({ status: 200, description: "추천 음식 정보 요청 성공" }) + @ApiResponse({ status: 401, description: "인증 실패" }) + async getRecommendFood(@GetUser() tokenInfo: TokenInfo) { + return await this.userService.getRecommendFood(tokenInfo); } @ApiTags("Follow/Following", "Home") @@ -189,7 +245,7 @@ export class UserController { @ApiResponse({ status: 401, description: "인증 실패" }) @ApiResponse({ status: 400, description: "부적절한 요청" }) async getMyFollowListInfo(@GetUser() tokenInfo: TokenInfo) { - return await this.userService.getMyFollowListInfo(tokenInfo); + return this.userService.getMyFollowListInfo(tokenInfo); } @ApiTags("Follow/Following") @@ -212,17 +268,41 @@ export class UserController { @ApiResponse({ status: 200, description: "추천 사용자 정보 요청 성공" }) @ApiResponse({ status: 401, description: "인증 실패" }) async getRecommendUserListInfo(@GetUser() tokenInfo: TokenInfo) { - return await this.userService.getRecommendUserListInfo(tokenInfo); + return this.userService.getRecommendUserListInfo(tokenInfo); } @ApiTags("Signup") @Post() + @UseInterceptors(FileInterceptor('profileImage', multerOptions)) @ApiOperation({ summary: "유저 회원가입" }) @ApiResponse({ status: 200, description: "회원가입 성공" }) @ApiResponse({ status: 400, description: "부적절한 요청" }) - @UsePipes(new ValidationPipe()) - async singup(@Body() userInfoDto: UserInfoDto) { - return await this.userService.signup(userInfoDto); + @ApiBody({ + schema: { + type: 'object', + description: "회원가입", + required: ['email', 'provider', 'nickName', 'region', 'birthdate', 'isMale'], + properties: { + email: { type: 'string', example: 'user@example.com', description: 'The email of the user' }, + password: { type: 'string', example: '1234', description: 'The password of the user' }, + provider: { type: 'string', example: 'naver', description: 'The provider of the user' }, + nickName: { type: 'string', example: 'test', description: 'The nickname of the user' }, + region: { type: 'string', example: '강동구', description: 'The region of the user' }, + birthdate: { type: 'string', example: '1234/56/78', description: 'The birth of the user' }, + isMale: { type: 'boolean', example: true, description: 'The gender of the user. true is male, false is female' }, + profileImage: { type: 'string', format: 'binary', description: 'The profile image of the user' }, + }, + }, + }) + @ApiConsumes('multipart/form-data') + async singup(@Body() body, @UploadedFile() file: Express.Multer.File) { + const userInfoDto = plainToClass(UserInfoDto, body); + const errors = await validate(userInfoDto); + if (errors.length > 0) { + console.log(errors); + throw new BadRequestException(errors); + } + return await this.userService.signup(file, userInfoDto); } @ApiTags("Follow/Following") @@ -249,6 +329,7 @@ export class UserController { @ApiTags("RestaurantList") @Post("/restaurant/:restaurantid") + @UseInterceptors(FileInterceptor('reviewImage', multerOptions)) @UseGuards(AuthGuard("jwt")) @ApiBearerAuth() @ApiOperation({ summary: "내 맛집 리스트에 등록하기" }) @@ -258,19 +339,76 @@ export class UserController { description: "음식점 id", type: Number, }) + @ApiBody({ + schema: { + type: 'object', + description: "리뷰 등록하기", + required: ['isCarVisit', 'taste', 'service', 'restroomCleanliness', 'overallExperience'], + properties: { + isCarVisit: { type: 'boolean', example: true, description: 'The transportation for visiting' }, + transportationAccessibility: { + type: 'integer', + example: 0, + description: 'Transportation accessibility for visiting', + minimum: 0, + maximum: 4 + }, + parkingArea: { + type: 'integer', + example: 0, + description: "Condition of the restaurant's parking area", + minimum: 0, + maximum: 4 + }, + taste: { + type: 'integer', + example: 0, + description: 'The taste of the food', + minimum: 0, + maximum: 4 + }, + service: { + type: 'integer', + example: 0, + description: 'The service of the restaurant', + minimum: 0, + maximum: 4 + }, + restroomCleanliness: { + type: 'integer', + example: 0, + description: "The condition of the restaurant's restroom", + minimum: 0, + maximum: 4 + }, + overallExperience: { + type: 'string', + example: '20자 이상 작성하기', + description: 'The overall experience about the restaurant', + minLength: 20 + }, + reviewImage: { type: 'string', format: 'binary', description: 'The image of food' }, + }, + }, + }) @ApiResponse({ status: 200, description: "맛집리스트 등록 성공" }) @ApiResponse({ status: 401, description: "인증 실패" }) @ApiResponse({ status: 400, description: "부적절한 요청" }) - @UsePipes(new ValidationPipe()) + @ApiConsumes('multipart/form-data') async addRestaurantToNebob( - @Body() reviewInfoDto: ReviewInfoDto, + @Body() body, @GetUser() tokenInfo: TokenInfo, - @Param("restaurantid") restaurantid: number + @Param("restaurantid") restaurantid: number, + @UploadedFile() file: Express.Multer.File ) { + const reviewInfoDto = plainToClass(ReviewInfoDto, body); + const errors = await validate(reviewInfoDto); + if (errors.length > 0) throw new BadRequestException(errors); return await this.userService.addRestaurantToNebob( reviewInfoDto, tokenInfo, - restaurantid + restaurantid, + file ); } @@ -382,17 +520,49 @@ export class UserController { @ApiTags("Mypage") @Put() + @UseInterceptors(FileInterceptor('profileImage', multerOptions)) @UseGuards(AuthGuard("jwt")) @ApiBearerAuth() + @ApiBody({ + schema: { + type: 'object', + description: "회원정보 수정", + required: ['email', 'provider', 'nickName', 'region', 'birthdate', 'isMale'], + properties: { + email: { type: 'string', example: 'user@example.com', description: 'The email of the user' }, + password: { type: 'string', example: '1234', description: 'The password of the user' }, + provider: { type: 'string', example: 'naver', description: 'The provider of the user' }, + nickName: { type: 'string', example: 'test', description: 'The nickname of the user' }, + region: { type: 'string', example: '강동구', description: 'The region of the user' }, + birthdate: { type: 'string', example: '1234/56/78', description: 'The birth of the user' }, + isMale: { type: 'boolean', example: true, description: 'The gender of the user. true is male, false is female' }, + profileImage: { type: 'string', format: 'binary', description: 'The profile image of the user' }, + isImageChanged: { type: 'boolean', example: true, description: 'The Boolean Value of ProfileImageChanged' } + }, + }, + }) @ApiOperation({ summary: "유저 회원정보 수정" }) @ApiResponse({ status: 200, description: "회원정보 수정 성공" }) @ApiResponse({ status: 401, description: "인증 실패" }) @ApiResponse({ status: 400, description: "부적절한 요청" }) - @UsePipes(new ValidationPipe()) + @ApiConsumes('multipart/form-data') async updateMypageUserInfo( + @UploadedFile() file: Express.Multer.File, @GetUser() tokenInfo: TokenInfo, - @Body() userInfoDto: UserInfoDto + @Body() body ) { - return await this.userService.updateMypageUserInfo(tokenInfo, userInfoDto); + const userInfoDto = plainToClass(UserInfoDto, body); + if (body.isImageChanged === "true" || body.isImageChanged === true) { + body.isImageChanged = true; + } else if (body.isImageChanged === "false" || body.isImageChanged === false) { + body.isImageChanged = false; + } else { + throw new BadRequestException(); + } + + + const errors = await validate(userInfoDto); + if (errors.length > 0) throw new BadRequestException(errors); + return await this.userService.updateMypageUserInfo(file, tokenInfo, userInfoDto, body.isImageChanged); } } diff --git a/be/src/user/user.repository.ts b/be/src/user/user.repository.ts index 47138c31..d6cc13de 100644 --- a/be/src/user/user.repository.ts +++ b/be/src/user/user.repository.ts @@ -12,15 +12,8 @@ export class UserRepository extends Repository { constructor(private dataSource: DataSource) { super(User, dataSource.createEntityManager()); } - async createUser(userentity: User): Promise { - try { - await this.save(userentity); - } catch (err) { - if (err.code === "23505") { - throw new ConflictException("Duplicated Value"); - } - } - return; + async createUser(userentity: User) { + return await this.save(userentity); } async getNickNameAvailability(nickName: UserInfoDto["nickName"]) { const user = await this.findOne({ @@ -52,7 +45,7 @@ export class UserRepository extends Repository { } async getUsersInfo(targetInfoIds: number[]) { const userInfo = await this.find({ - select: ["nickName", "region"], + select: ["nickName", "region", "profileImage"], where: { id: In(targetInfoIds) }, }); return userInfo; @@ -72,15 +65,42 @@ export class UserRepository extends Repository { }); return { userInfo: userInfo }; } - async getRecommendUserListInfo(idList: number[]) { + + async getRecommendUserListInfo(idList: number[], id: number) { + const curUser = await this.findOne({ + select: ["id", "region"], + where: { id: id }, + }); + + const myRestaurants = await this.createQueryBuilder("user") + .leftJoinAndSelect("user.restaurant", "userRestaurant") + .where("user.id = :id", { id }) + .select("userRestaurant.restaurantId") + .getRawMany(); + + const myFavRestaurants = myRestaurants.map( + (r) => r.userRestaurant_restaurant_id + ); + const userInfo = await this.createQueryBuilder("user") - .select(["user.nickName", "user.region"]) + .leftJoin("user.restaurant", "userRestaurant") + .select([ + "user.nickName", + "user.region", + "user.profileImage", + 'SUM(CASE WHEN userRestaurant.restaurantId IN (:...myFavRestaurants) THEN 1 ELSE 0 END) AS "commonRestaurant"', + ]) + .setParameter("myFavRestaurants", myFavRestaurants) .where("user.id NOT IN (:...idList)", { idList }) - .orderBy("RANDOM()") - .limit(2) - .getMany(); + .andWhere("user.region = :yourRegion", { yourRegion: curUser.region }) + .groupBy("user.id") + .orderBy("\"commonRestaurant\"", "DESC") + .limit(10) + .getRawMany(); + return userInfo; } + async logout(id: number) { return {}; } @@ -115,11 +135,11 @@ export class UserRepository extends Repository { region: userEntity["region"], provider: userEntity["provider"], password: userEntity["password"], - profileImage : userEntity["profileImage"] + profileImage: userEntity["profileImage"], }; if (!isEmailDuplicate) { - updateObject["email"] =userEntity["email"]; + updateObject["email"] = userEntity["email"]; } if (!isNickNameDuplicate) { updateObject["nickName"] = userEntity["nickName"]; diff --git a/be/src/user/user.restaurantList.repository.ts b/be/src/user/user.restaurantList.repository.ts index 9d0c134c..1c5ade72 100644 --- a/be/src/user/user.restaurantList.repository.ts +++ b/be/src/user/user.restaurantList.repository.ts @@ -2,9 +2,10 @@ import { DataSource, IsNull, Repository, Not } from "typeorm"; import { ConflictException, Injectable } from "@nestjs/common"; import { UserRestaurantListEntity } from "./entities/user.restaurantlist.entity"; import { TokenInfo } from "./user.decorator"; -import { SearchInfoDto } from "src/restaurant/dto/seachInfo.dto"; -import { ReviewInfoEntity } from "src/review/entities/review.entity"; +import { SearchInfoDto } from "../restaurant/dto/seachInfo.dto"; +import { ReviewInfoEntity } from "../review/entities/review.entity"; import { UserWishRestaurantListEntity } from "./entities/user.wishrestaurantlist.entity"; +import { SortInfoDto } from "../utils/sortInfo.dto"; @Injectable() export class UserRestaurantListRepository extends Repository { @@ -74,10 +75,11 @@ export class UserRestaurantListRepository extends Repository sortInfoDto.limit; + const resultItems = hasNext ? items.slice(0, -1) : items; + + return { + hasNext, + items: resultItems, + } + + } + } + async getMyFavoriteFoodCategory(id: TokenInfo['id'], region) { + const categoryCounts = await this.createQueryBuilder("userRestaurantList") + .select("restaurant.category", "category") + .addSelect("COUNT(restaurant.category)", "count") + .innerJoin("userRestaurantList.restaurant", "restaurant") + .where("userRestaurantList.userId = :id", { id }) + .groupBy("restaurant.category") + .getRawMany(); + + + if (categoryCounts.length) { + const favoriteCategory = categoryCounts.reduce((a, b) => a.count > b.count ? a : b).category; + + const subQuery = await this.createQueryBuilder() + .select("DISTINCT(userRestaurantListSub.restaurantId)", "restaurantId") + .from(UserRestaurantListEntity, "userRestaurantListSub") + .where("userRestaurantListSub.userId = :id", { id }) .getRawMany(); + + const restaurantIds = subQuery.map(item => item.restaurantId); + + const result = await this + .createQueryBuilder("userRestaurantList") + .leftJoinAndSelect("userRestaurantList.restaurant", "restaurant") + .select(["restaurant.id", "restaurant.name", "restaurant.category"]) + .where("restaurant.category = :category", { category: favoriteCategory }) + .andWhere("restaurant.address LIKE :region", { region: `%${region.region}%` }) + .andWhere("userRestaurantList.restaurantId NOT IN (:...restaurantIds)", { restaurantIds: restaurantIds }) + .groupBy("restaurant.id") + .getRawMany(); + + if (result.length > 0) { + let recommendedRestaurants = []; + let usedIndexes = new Set(); + + for (let i = 0; i < Math.min(3, result.length); i++) { + let randomIndex; + do { + randomIndex = Math.floor(Math.random() * result.length); + } while (usedIndexes.has(randomIndex)); + + usedIndexes.add(randomIndex); + recommendedRestaurants.push(result[randomIndex]); + } + return recommendedRestaurants; + } + } + else { + const result = await this + .createQueryBuilder("userRestaurantList") + .leftJoinAndSelect("userRestaurantList.restaurant", "restaurant") + .select(["restaurant.id", "restaurant.name", "restaurant.category"]) + .andWhere("restaurant.address LIKE :region", { region: `%${region.region}%` }) + .groupBy("restaurant.id") + .getRawMany(); + + if (result.length > 0) { + let recommendedRestaurants = []; + let usedIndexes = new Set(); + + for (let i = 0; i < Math.min(3, result.length); i++) { + let randomIndex; + do { + randomIndex = Math.floor(Math.random() * result.length); + } while (usedIndexes.has(randomIndex)); + + usedIndexes.add(randomIndex); + recommendedRestaurants.push(result[randomIndex]); + } + return recommendedRestaurants; + } } + return []; } } diff --git a/be/src/user/user.service.ts b/be/src/user/user.service.ts index d823193a..5ab7881c 100644 --- a/be/src/user/user.service.ts +++ b/be/src/user/user.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, UploadedFile } from "@nestjs/common"; import { UserInfoDto } from "./dto/userInfo.dto"; import { InjectRepository } from "@nestjs/typeorm"; import { UserRepository } from "./user.repository"; @@ -8,14 +8,16 @@ import { SearchInfoDto } from "../restaurant/dto/seachInfo.dto"; import { UserRestaurantListRepository } from "./user.restaurantList.repository"; import { UserFollowListRepository } from "./user.followList.repository"; import { Equal, In, Like, Not } from "typeorm"; -import { BadRequestException } from "@nestjs/common/exceptions"; -import { ReviewInfoDto } from "src/review/dto/reviewInfo.dto"; -import { ReviewRepository } from "src/review/review.repository"; +import { BadRequestException, ConflictException } from "@nestjs/common/exceptions"; +import { ReviewInfoDto } from "../review/dto/reviewInfo.dto"; +import { ReviewRepository } from "../review/review.repository"; import { UserWishRestaurantListRepository } from "./user.wishrestaurantList.repository"; -import { AwsService } from "src/aws/aws.service"; +import { AwsService } from "../aws/aws.service"; import { v4 } from "uuid"; import { User } from "./entities/user.entity"; -import { RestaurantInfoEntity } from "src/restaurant/entities/restaurant.entity"; +import { RestaurantInfoEntity } from "../restaurant/entities/restaurant.entity"; +import { AuthService } from "../auth/auth.service"; +import { SortInfoDto } from "../utils/sortInfo.dto"; @Injectable() export class UserService { @@ -26,24 +28,39 @@ export class UserService { private userFollowListRepositoy: UserFollowListRepository, private reviewRepository: ReviewRepository, private userWishRestaurantListRepository: UserWishRestaurantListRepository, - private awsService: AwsService - ) {} - async signup(userInfoDto: UserInfoDto) { - userInfoDto.password = await hashPassword(userInfoDto.password); + private awsService: AwsService, + private authService: AuthService, + ) { } + async signup(@UploadedFile() file: Express.Multer.File, userInfoDto: UserInfoDto) { + if (userInfoDto.password) userInfoDto.password = await hashPassword(userInfoDto.password); + let profileImage; + + if (file) { + const uuid = v4(); + profileImage = `profile/images/${uuid}.png`; + } else { + profileImage = "profile/images/defaultprofile.png"; + } + const user = { ...userInfoDto, - profileImage: "profile/images/defaultprofile.png", + profileImage: profileImage }; - if (userInfoDto.profileImage) { - const uuid = v4(); - user.profileImage = `profile/images/${uuid}.png`; - } + try { + const newUser = this.usersRepository.create(user); + const result = await this.usersRepository.createUser(newUser); + if (file) { + await this.awsService.uploadToS3(profileImage, file.buffer); + } + return this.authService.createTokens(result.id); + } catch (error) { + if (error.code === "23505") { + throw new ConflictException("Duplicated Value"); + } + } + - const newUser = this.usersRepository.create(user); - await this.usersRepository.createUser(newUser); - if (userInfoDto.profileImage)this.awsService.uploadToS3(user.profileImage, userInfoDto.profileImage); - return; } async getNickNameAvailability(nickName: UserInfoDto["nickName"]) { return await this.usersRepository.getNickNameAvailability(nickName); @@ -77,7 +94,8 @@ export class UserService { targetInfo.id, tokenInfo.id ); - if ( restaurantList )result["restaurants"] = restaurantList; + if (restaurantList) result["restaurants"] = restaurantList; + else result["restaurants"] = []; result.profileImage = this.awsService.getImageURL(result.profileImage); return result; } catch (err) { @@ -91,15 +109,21 @@ export class UserService { } async getMyRestaurantListInfo( searchInfoDto: SearchInfoDto, + sortInfoDto: SortInfoDto, tokenInfo: TokenInfo ) { const results = await this.userRestaurantListRepository.getMyRestaurantListInfo( searchInfoDto, + sortInfoDto, tokenInfo.id ); - for (const restaurant of results) { + let list + if ('items' in results) list = results.items; + else list = results; + + for (const restaurant of list) { const reviewCount = await this.reviewRepository .createQueryBuilder("review") .where("review.restaurant_id = :restaurantId", { @@ -107,30 +131,52 @@ export class UserService { }) .getCount(); + const reviewInfo = await this.reviewRepository + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select(["review.id", "review.reviewImage"],) + .groupBy("review.id") + .where("review.restaurant_id = :restaurantId and review.reviewImage is NOT NULL", { restaurantId: restaurant.restaurant_id }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawOne(); + if (reviewInfo) { + restaurant.restaurant_reviewImage = this.awsService.getImageURL(reviewInfo.review_reviewImage); + } + else { + restaurant.restaurant_reviewImage = this.awsService.getImageURL("review/images/defaultImage.png"); + } + restaurant.isMy = true; restaurant.restaurant_reviewCnt = reviewCount; } return results; } - async getMyWishRestaurantListInfo(tokenInfo: TokenInfo) { + async getMyWishRestaurantListInfo(tokenInfo: TokenInfo, sortInfoDto: SortInfoDto) { const result = await this.userWishRestaurantListRepository.getMyWishRestaurantListInfo( - tokenInfo.id + tokenInfo.id, + sortInfoDto ); return result; } + async getStateIsWish(tokenInfo: TokenInfo, restaurantId: number) { + const result = await this.userWishRestaurantListRepository.findOne({ where: { restaurantId: restaurantId, userId: tokenInfo["id"] } }); + if (result) return { isWish: true }; + else return { isWish: false }; + } async getMyFollowListInfo(tokenInfo: TokenInfo) { const userIds = await this.userFollowListRepositoy.getMyFollowListInfo( tokenInfo.id ); const userIdValues = userIds.map((user) => user.followingUserId); const result = await this.usersRepository.find({ - select: ["nickName", "region"], + select: ["nickName", "region", "profileImage"], where: { id: In(userIdValues) }, }); return result.map((user) => ({ ...user, + profileImage: this.awsService.getImageURL(user.profileImage), isFollow: true, })); } @@ -146,7 +192,7 @@ export class UserService { (user) => user.followingUserId ); const result = await this.usersRepository.find({ - select: ["id", "nickName", "region"], + select: ["id", "nickName", "region", "profileImage"], where: { id: In(followerUserIdValues) }, }); @@ -154,23 +200,68 @@ export class UserService { const { id, ...userInfo } = user; return { ...userInfo, + profileImage: this.awsService.getImageURL(userInfo.profileImage), isFollow: followUserIdValues.includes(id) ? true : false, }; }); } + + + async getRecommendUserListInfo(tokenInfo: TokenInfo) { const userIds = await this.userFollowListRepositoy.getMyFollowListInfo( tokenInfo.id ); const userIdValues = userIds.map((user) => user.followingUserId); userIdValues.push(tokenInfo.id); - const result = - await this.usersRepository.getRecommendUserListInfo(userIdValues); - return result.map((user) => ({ + const result = await this.usersRepository.getRecommendUserListInfo(userIdValues, tokenInfo.id); + + function getRandomInts(min: number, max: number, count: number): number[] { + if (max === -1) { + return []; + } else if (max === 0) { + return [0]; + } + + const ints = new Set(); + while (ints.size < count) { + const rand = Math.floor(Math.random() * (max - min + 1)) + min; + ints.add(rand); + } + return [...ints].sort((a, b) => a - b); + } + + const randomIndexes = getRandomInts(0, result.length - 1, 2); + if (randomIndexes.length === 0) return []; + + const selectedUsers = randomIndexes.map(index => result[index]); + return selectedUsers.map((user) => ({ ...user, + user_profileImage: this.awsService.getImageURL(user.user_profileImage), isFollow: false, })); } + async getRecommendFood(tokenInfo: TokenInfo) { + const region = await this.usersRepository.findOne({ select: ["region"], where: { id: tokenInfo.id } }); + const restaurants = await this.userRestaurantListRepository.getMyFavoriteFoodCategory(tokenInfo.id, region); + for (const restaurant of restaurants) { + const reviewInfo = await this.reviewRepository + .createQueryBuilder("review") + .leftJoin("review.reviewLikes", "reviewLike") + .select(["review.id", "review.reviewImage"],) + .groupBy("review.id") + .where("review.restaurant_id = :restaurantId and review.reviewImage is NOT NULL", { restaurantId: restaurant.restaurant_id }) + .orderBy("COUNT(CASE WHEN reviewLike.isLike = true THEN 1 ELSE NULL END)", "DESC") + .getRawOne(); + if (reviewInfo) { + restaurant.restaurant_reviewImage = this.awsService.getImageURL(reviewInfo.review_reviewImage); + } + else { + restaurant.restaurant_reviewImage = this.awsService.getImageURL("review/images/defaultImage.png"); + } + } + return restaurants; + } async searchTargetUser(tokenInfo: TokenInfo, nickName: string, region: string[]) { const whereCondition: any = { nickName: Like(`%${nickName}%`), @@ -195,6 +286,7 @@ export class UserService { )) ? true : false; + result[i]["profileImage"] = this.awsService.getImageURL(result[i]["profileImage"]); } return result; } @@ -235,9 +327,19 @@ export class UserService { async addRestaurantToNebob( reviewInfoDto: ReviewInfoDto, tokenInfo: TokenInfo, - restaurantId: number + restaurantId: number, + file: Express.Multer.File ) { const reviewEntity = this.reviewRepository.create(reviewInfoDto); + let reviewImage; + if (file) { + const uuid = v4(); + reviewImage = `review/images/${uuid}.png`; + reviewEntity.reviewImage = reviewImage; + } + else { + reviewEntity.reviewImage = `review/images/defaultImage.png`; + } const userEntity = new User(); userEntity.id = tokenInfo["id"]; reviewEntity.user = userEntity; @@ -252,6 +354,7 @@ export class UserService { restaurantId, reviewEntity ); + if (file) await this.awsService.uploadToS3(reviewImage, file.buffer); } catch (err) { throw new BadRequestException(); } @@ -290,30 +393,38 @@ export class UserService { } async logout(tokenInfo: TokenInfo) { - return await this.usersRepository.logout(tokenInfo.id); + return await this.authService.logout(tokenInfo.id); } async deleteUserAccount(tokenInfo: TokenInfo) { return await this.usersRepository.deleteUserAccount(tokenInfo.id); } - async updateMypageUserInfo(tokenInfo: TokenInfo, userInfoDto: UserInfoDto) { - userInfoDto.password = await hashPassword(userInfoDto.password); - const user = { + async updateMypageUserInfo(file: Express.Multer.File, tokenInfo: TokenInfo, userInfoDto: UserInfoDto, isChanged: Boolean) { + const existedInfo = await this.usersRepository.findOne({ select: ["profileImage", "password"], where: { id: tokenInfo.id } }) + + if (userInfoDto.password) userInfoDto.password = await hashPassword(userInfoDto.password); + else userInfoDto.password = existedInfo.password; + + let profileImage = existedInfo.profileImage; + if (isChanged) { + if (file) { + const uuid = v4(); + profileImage = `profile/images/${uuid}.png`; + } else { + profileImage = "profile/images/defaultprofile.png"; + } + } + + let user = { ...userInfoDto, - profileImage: "profile/images/defaultprofile.png", + profileImage }; - if (userInfoDto.profileImage) { - const uuid = v4(); - user.profileImage = `profile/images/${uuid}.png`; - } - const newUser = this.usersRepository.create(user); - const result = await this.usersRepository.updateMypageUserInfo( - tokenInfo.id, - newUser - ); - if (userInfoDto.profileImage)this.awsService.uploadToS3(user.profileImage, userInfoDto.profileImage); - return result; + const updatedUser = await this.usersRepository.updateMypageUserInfo(tokenInfo.id, newUser); + if (file && isChanged) { + this.awsService.uploadToS3(profileImage, file.buffer); + } + return updatedUser; } } diff --git a/be/src/user/user.wishrestaurantList.repository.ts b/be/src/user/user.wishrestaurantList.repository.ts index 8736cd58..819f222a 100644 --- a/be/src/user/user.wishrestaurantList.repository.ts +++ b/be/src/user/user.wishrestaurantList.repository.ts @@ -4,6 +4,7 @@ import { UserWishRestaurantListEntity } from "./entities/user.wishrestaurantlist import { TokenInfo } from "./user.decorator"; import { RestaurantInfoEntity } from "src/restaurant/entities/restaurant.entity"; import { UserRestaurantListEntity } from "./entities/user.restaurantlist.entity"; +import { SortInfoDto } from "src/utils/sortInfo.dto"; @Injectable() export class UserWishRestaurantListRepository extends Repository { constructor(private dataSource: DataSource) { @@ -32,8 +33,8 @@ export class UserWishRestaurantListRepository extends Repository sortInfoDto.limit; + const resultItems = hasNext ? items.slice(0, -1) : items; + + return { + hasNext, + items : resultItems, + } } } diff --git a/be/src/utils/sortInfo.dto.ts b/be/src/utils/sortInfo.dto.ts new file mode 100644 index 00000000..709394a2 --- /dev/null +++ b/be/src/utils/sortInfo.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsInt, IsOptional, Min, IsNotEmpty } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class SortInfoDto { + @IsString() + @IsOptional() + sort?: string; + + @IsInt() + @Min(1) + @Type(() => Number) + @IsOptional() + page?: number; + + @IsInt() + @Min(1) + @Type(() => Number) + @IsOptional() + limit?: number; +} \ No newline at end of file diff --git a/be/test/app.e2e-spec.ts b/be/test/app.e2e-spec.ts index 05db0a79..1163677a 100644 --- a/be/test/app.e2e-spec.ts +++ b/be/test/app.e2e-spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { INestApplication } from "@nestjs/common"; import * as request from "supertest"; -import { AppModule } from "./../src/app.module"; +import { AppModule } from "../src/app.module"; describe("AppController (e2e)", () => { let app: INestApplication; @@ -15,10 +15,26 @@ describe("AppController (e2e)", () => { await app.init(); }); - it("/ (GET)", () => { - return request(app.getHttpServer()) - .get("/") - .expect(200) - .expect("Hello World!"); + it('/api/user', async () => { + const userData = { + email: "test@example.com", + password: "1234", + provider: "site", + nickName: "test", + region: "강남구", + birthdate: "1234/56/78", + isMale: true + }; + + const response = await request(app.getHttpServer()) + .post('/user') + .send(userData) + .expect(201); + + expect(response.statusCode).toBe(201); + }); + + afterAll(async () => { + await app.close(); }); });