diff --git a/.github/ISSUE_TEMPLATE/bug-issue-template.md b/.github/ISSUE_TEMPLATE/bug-issue-template.md new file mode 100644 index 000000000..e9ebc3e49 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-issue-template.md @@ -0,0 +1,19 @@ +--- +name: bug issue template +about: 이슈를 생성해주세요. +title: 'fix: 작업 제목' +labels: 'fix' +assignees: '' + +--- + +## 🤮 As Is (오마이갓 비상사태) +> 어떤 상황에서 발생한 버그인지 설명해주세요. (육하원칙이면 더 좋아요!) + +## 🤬 To Be +> 버그가 없었다면 어떻게 동작해야 하는지 설명해주세요. + +## 😇 이때까지 끝낼게요! +> 버그 해결 예상 날짜를 작성해주세요. 신중하게 생각해요! + +## 😵 참고 자료(선택) diff --git a/.github/ISSUE_TEMPLATE/feat-issue-template.md b/.github/ISSUE_TEMPLATE/feat-issue-template.md new file mode 100644 index 000000000..ff69b085f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feat-issue-template.md @@ -0,0 +1,26 @@ +--- +name: feat issue template +about: 이슈를 생성해주세요. +title: 'feat: 작업 제목' +labels: 'feat' +assignees: '' + +--- + +## 🥸 어떤 기능인가요? +> 추가하려는 기능을 설명해주세요. + +## ✅ 작업 내용 +- [ ] TODO +- [ ] TODO +- [ ] TODO + +## 😇 이때까지 끝낼게요! +> 기능 개발 완료 예상 날짜를 작성해주세요. 신중하게 생각해요! + +## 😵 참고할만한 자료(선택) + +## 🙇‍♀️ 이슈 확인했어요:) +> 팀원에게 이슈 확인을 부탁해요! 이슈를 확인한 팀원은 체크 표시를 해주세요! +- [ ] 팀원명 + diff --git a/.github/ISSUE_TEMPLATE/refactor-issue-template.md b/.github/ISSUE_TEMPLATE/refactor-issue-template.md new file mode 100644 index 000000000..dc000f9ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor-issue-template.md @@ -0,0 +1,23 @@ +--- +name: refactor issue template +about: 이슈를 생성해주세요. +title: 'refactor: 작업 제목' +labels: 'refactor' +assignees: '' + +--- + +## 🤮 As Is +> 리팩터링하고자 하는 파트와 이유를 구체적으로 설명해주세요. + +## 🤩 To Be +> 리팩터링 방향을 구체적으로 공유해주세요. + +## 😇 이때까지 끝낼게요! +> 리팩터링 완료 예상 날짜를 작성해주세요. 신중하게 생각해요! + +## 😵 참고 자료(선택) + +## 🙇‍♀️이슈 확인했어요:) +> 팀원에게 이슈 확인을 부탁해요! +- [ ] 팀원명 diff --git a/.github/ISSUE_TEMPLATE/staccato-issue-template.md b/.github/ISSUE_TEMPLATE/staccato-issue-template.md deleted file mode 100644 index 13b0491b8..000000000 --- a/.github/ISSUE_TEMPLATE/staccato-issue-template.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: staccato issue template -about: 이슈를 생성해주세요. -title: 'prefix: 작업 제목' -labels: '' -assignees: '' - ---- - -### ✅ CheckList -- [ ] -- [ ] -- [ ] -- [ ] diff --git a/.github/workflows/android-cd.yml b/.github/workflows/android-cd.yml index 638d085fb..e90fc373f 100644 --- a/.github/workflows/android-cd.yml +++ b/.github/workflows/android-cd.yml @@ -3,7 +3,7 @@ name: Android CI/CD for release on: push: paths: 'android/**' - branches: [ "main", "release-an" ] + branches: [ "release-an" ] env: BASE_URL: ${{ secrets.BASE_URL }} diff --git a/.github/workflows/backend-ci-cd-dev.yml b/.github/workflows/backend-ci-cd-dev.yml index fdf967544..58b86db55 100644 --- a/.github/workflows/backend-ci-cd-dev.yml +++ b/.github/workflows/backend-ci-cd-dev.yml @@ -25,6 +25,16 @@ jobs: java-version: '17' distribution: 'temurin' + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -58,16 +68,9 @@ jobs: sudo docker login --username ${{ secrets.DOCKERHUB_DEPLOY_USERNAME }} --password ${{ secrets.DOCKERHUB_DEPLOY_TOKEN }} sudo docker pull staccato/staccato:dev - - name: Stop and remove existing container - run: | - sudo docker stop staccato-backend-app || true - sudo docker rm staccato-backend-app || true + - name: Docker Compose up + run: sudo docker-compose -f /home/ubuntu/staccato/docker-compose.yml up -d - - name: Docker run - run: | - sudo docker run --env-file /home/ubuntu/staccato/.env \ - -v /home/ubuntu/staccato/logs:/logs \ - -p 8080:8080 \ - -d --name staccato-backend-app staccato/staccato:dev - sudo docker image prune -af + - name: Docker image Prune + run: sudo docker image prune -af diff --git a/.github/workflows/backend-ci-cd-prod.yml b/.github/workflows/backend-ci-cd-prod.yml index b283cfbff..f5b76e656 100644 --- a/.github/workflows/backend-ci-cd-prod.yml +++ b/.github/workflows/backend-ci-cd-prod.yml @@ -25,6 +25,16 @@ jobs: java-version: '17' distribution: 'temurin' + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 44e07d7b6..83919e39f 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -25,6 +25,16 @@ jobs: java-version: '17' distribution: 'temurin' + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/android/Staccato_AN/app/build.gradle.kts b/android/Staccato_AN/app/build.gradle.kts index b5abe17fc..491621904 100644 --- a/android/Staccato_AN/app/build.gradle.kts +++ b/android/Staccato_AN/app/build.gradle.kts @@ -30,8 +30,8 @@ android { applicationId = "com.on.staccato" minSdk = 26 targetSdk = 34 - versionCode = 5 - versionName = "1.2.0" + versionCode = 7 + versionName = "1.2.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/comment/CommentApiService.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/comment/CommentApiService.kt index d1db36896..dbadce42e 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/comment/CommentApiService.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/comment/CommentApiService.kt @@ -9,33 +9,35 @@ import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.PUT +import retrofit2.http.Path import retrofit2.http.Query interface CommentApiService { - @GET(COMMENTS_PATH) + @GET(COMMENTS_URI) suspend fun getComments( - @Query("momentId") momentId: Long, + @Query(STACCATO_ID) staccatoId: Long, ): Response - @POST(COMMENTS_PATH) + @POST(COMMENTS_URI) suspend fun postComment( @Body commentRequest: CommentRequest, ): Response - @PUT(COMMENTS_PATH) + @PUT(COMMENTS_URI_WITH_COMMENT_ID) suspend fun putComment( - @Query("commentId") commentId: Long, + @Path(COMMENT_ID) commentId: Long, @Body commentUpdateRequest: CommentUpdateRequest, ): Response - @DELETE(COMMENTS_PATH) + @DELETE(COMMENTS_URI_WITH_COMMENT_ID) suspend fun deleteComment( - @Query("commentId") commentId: Long, + @Path(COMMENT_ID) commentId: Long, ): Response companion object { - private const val COMMENTS_PATH = "/comments" - private const val COMMENT_ID = "/{commentId}" - private const val COMMENTS_PATH_WITH_ID = "$COMMENTS_PATH$COMMENT_ID" + private const val COMMENTS_URI = "/comments" + private const val STACCATO_ID = "momentId" + private const val COMMENT_ID = "commentId" + private const val COMMENTS_URI_WITH_COMMENT_ID = "$COMMENTS_URI/v2/{$COMMENT_ID}" } } diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/comment/CommentRequest.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/comment/CommentRequest.kt index 239fd3f49..5caef6cd8 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/comment/CommentRequest.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/comment/CommentRequest.kt @@ -5,6 +5,6 @@ import kotlinx.serialization.Serializable @Serializable data class CommentRequest( - @SerialName("momentId") val momentId: Long, + @SerialName("momentId") val staccatoId: Long, @SerialName("content") val content: String, ) diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/mapper/CommentMapper.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/mapper/CommentMapper.kt index 73365fd52..aea3cdd76 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/mapper/CommentMapper.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/data/dto/mapper/CommentMapper.kt @@ -19,6 +19,6 @@ fun CommentDto.toDomain(): Comment = fun NewComment.toDto(): CommentRequest = CommentRequest( - momentId = staccatoId, + staccatoId = staccatoId, content = content, ) diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/bindingadapter/BindingAdapters.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/bindingadapter/BindingAdapters.kt index 971e95b47..2d288c706 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/bindingadapter/BindingAdapters.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/bindingadapter/BindingAdapters.kt @@ -1,10 +1,10 @@ package com.on.staccato.presentation.bindingadapter -import android.net.Uri import android.view.View import android.view.ViewGroup import android.widget.ScrollView import androidx.databinding.BindingAdapter +import com.on.staccato.presentation.memorycreation.ThumbnailUiModel import com.on.staccato.presentation.timeline.model.TimelineUiModel @BindingAdapter("visibleOrGone") @@ -24,26 +24,20 @@ fun ScrollView.setScrollToBottom(isScrollable: Boolean) { } } -@BindingAdapter(value = ["visibilityByEmptyThumbnailUri", "visibilityByEmptyThumbnailUrl"]) -fun View.setThumbnailVisibility( - thumbnailUri: Uri?, - thumbnailUrl: String?, -) { +@BindingAdapter(value = ["visibilityByEmptyThumbnail"]) +fun View.setThumbnailVisibility(thumbnail: ThumbnailUiModel) { visibility = - if (thumbnailUri == null && thumbnailUrl == null) { + if (thumbnail.uri == null && thumbnail.url == null) { View.VISIBLE } else { View.GONE } } -@BindingAdapter(value = ["loadingVisibilityByThumbnailUri", "visibilityByEmptyThumbnailUrl"]) -fun View.setThumbnailLoadingVisibility( - thumbnailUri: Uri?, - thumbnailUrl: String?, -) { +@BindingAdapter(value = ["loadingVisibilityByThumbnail"]) +fun View.setThumbnailLoadingVisibility(thumbnail: ThumbnailUiModel) { visibility = - if (thumbnailUri != null && thumbnailUrl == null) { + if (thumbnail.uri != null && thumbnail.url == null) { View.VISIBLE } else { View.GONE diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/common/PhotoAttachFragment.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/common/PhotoAttachFragment.kt index 39483ae29..09ce1c5b9 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/common/PhotoAttachFragment.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/common/PhotoAttachFragment.kt @@ -11,7 +11,9 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build +import android.os.Build.VERSION import android.os.Bundle +import android.os.ext.SdkExtensions import android.provider.MediaStore import android.provider.Settings import android.view.LayoutInflater @@ -19,6 +21,7 @@ import android.view.View import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresExtension import androidx.annotation.StringRes import androidx.core.content.ContextCompat import com.google.android.material.bottomsheet.BottomSheetDialogFragment @@ -43,6 +46,7 @@ class PhotoAttachFragment : BottomSheetDialogFragment(), PhotoAttachHandler { private lateinit var cameraLauncher: ActivityResultLauncher private var multipleAbleOption: Boolean = false private var currentImageUri: Uri? = null + private var attachableImageCount: Int = INVALID_ATTACHABLE_IMAGE_COUNT override fun onAttach(context: Context) { super.onAttach(context) @@ -94,6 +98,10 @@ class PhotoAttachFragment : BottomSheetDialogFragment(), PhotoAttachHandler { multipleAbleOption = option } + fun setCurrentImageCount(count: Int) { + attachableImageCount = count + } + private fun initUrisSelectedListener(context: Context) { if (context is OnUrisSelectedListener) { uriSelectedListener = context @@ -103,8 +111,10 @@ class PhotoAttachFragment : BottomSheetDialogFragment(), PhotoAttachHandler { } private fun initRequestPermissionLauncher() { - requestCameraPermissionLauncher = buildRequestPermissionLauncher { startCamera() } - requestGalleryPermissionLauncher = buildRequestPermissionLauncher { launchGallery() } + requestCameraPermissionLauncher = + buildRequestPermissionLauncher(::startCamera, ::handleCameraPermissionNotGranted) + requestGalleryPermissionLauncher = + buildRequestPermissionLauncher(::launchGallery, ::handleGalleryPermissionNotGranted) } private fun initCameraLauncher() { @@ -133,12 +143,15 @@ class PhotoAttachFragment : BottomSheetDialogFragment(), PhotoAttachHandler { } } - private fun buildRequestPermissionLauncher(actionOnPermissionGranted: () -> Unit): ActivityResultLauncher> = + private fun buildRequestPermissionLauncher( + actionOnPermissionGranted: () -> Unit, + actionOnPermissionNotGranted: () -> Unit, + ): ActivityResultLauncher> = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> if (permissions.all { (_, isGranted) -> isGranted }) { actionOnPermissionGranted() } else { - showPermissionSnackBar() + actionOnPermissionNotGranted() } } @@ -165,7 +178,7 @@ class PhotoAttachFragment : BottomSheetDialogFragment(), PhotoAttachHandler { private fun launchGallery() { val intent = Intent(Intent.ACTION_PICK) - .setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*") + .setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_TYPE) .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multipleAbleOption) galleryLauncher.launch(intent) } @@ -188,8 +201,33 @@ class PhotoAttachFragment : BottomSheetDialogFragment(), PhotoAttachHandler { } } - private fun showPermissionSnackBar() { - showSettingSnackBar(R.string.snack_bar_require_photo_album_permission) + private fun handleCameraPermissionNotGranted() { + showSettingSnackBar(R.string.photo_require_camera_permission) + } + + private fun handleGalleryPermissionNotGranted() { + if (isPhotoPickerAvailable()) { + val intent = + Intent(MediaStore.ACTION_PICK_IMAGES) + .setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_TYPE) + .setImageCountLimit() + galleryLauncher.launch(intent) + } else { + showSettingSnackBar(R.string.photo_require_photo_album_permission) + } + } + + private fun isPhotoPickerAvailable() = + (Build.VERSION_CODES.TIRAMISU <= VERSION.SDK_INT) || ( + (Build.VERSION_CODES.R <= VERSION.SDK_INT) && + (MIN_EXTENSION_VERSION <= SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R)) + ) + + @RequiresExtension(extension = Build.VERSION_CODES.R, version = 2) + private fun Intent.setImageCountLimit(): Intent { + if (!multipleAbleOption || attachableImageCount < MIN_COUNT_FOR_PICK_IMAGES_MAX) return this + putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, attachableImageCount) + return this } private fun showCameraErrorSnackBar() { @@ -263,23 +301,28 @@ class PhotoAttachFragment : BottomSheetDialogFragment(), PhotoAttachHandler { private fun createImageContent(fileName: String): ContentValues = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, "img_$fileName.jpg") - put(MediaStore.Images.Media.MIME_TYPE, "image/jpg") + put(MediaStore.Images.Media.MIME_TYPE, IMAGE_JPG_TYPE) } companion object { const val TAG = "PhotoAttachModalBottomSheet" const val PACKAGE_SCHEME = "package" + private const val IMAGE_JPG_TYPE = "image/jpg" + private const val IMAGE_TYPE = "image/*" private const val FILENAME_DATE_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" + private const val MIN_COUNT_FOR_PICK_IMAGES_MAX = 2 + private const val MIN_EXTENSION_VERSION = 2 + private const val INVALID_ATTACHABLE_IMAGE_COUNT = -1 private val CAMERA_REQUIRED_PERMISSIONS = mutableListOf( Manifest.permission.CAMERA, ).apply { - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + if (VERSION.SDK_INT <= Build.VERSION_CODES.P) { add(Manifest.permission.WRITE_EXTERNAL_STORAGE) } }.toTypedArray() private val GALLERY_REQUIRED_PERMISSION = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { READ_MEDIA_IMAGES } else { READ_EXTERNAL_STORAGE diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/main/MainActivity.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/main/MainActivity.kt index ef0ec74aa..401245f75 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/main/MainActivity.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/main/MainActivity.kt @@ -80,6 +80,7 @@ class MainActivity : observeCurrentLocation() observeStaccatoLocations() observeStaccatoId() + observeDeletedStaccato() setupBottomSheetController() setupBackPressedHandler() setUpBottomSheetBehaviorAction() @@ -274,6 +275,14 @@ class MainActivity : } } + private fun observeDeletedStaccato() { + sharedViewModel.isStaccatosUpdated.observe(this) { isDeleted -> + if (isDeleted) { + mapsViewModel.loadStaccatos() + } + } + } + private fun navigateToStaccato(staccatoId: Long?) { val navOptions = NavOptions.Builder() diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/main/viewmodel/SharedViewModel.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/main/viewmodel/SharedViewModel.kt index aba050492..0391e14f4 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/main/viewmodel/SharedViewModel.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/main/viewmodel/SharedViewModel.kt @@ -11,7 +11,9 @@ class SharedViewModel : ViewModel() { val isTimelineUpdated: SingleLiveData get() = _isTimelineUpdated - private val isStaccatosUpdated = MutableSingleLiveData(false) + private val _isStaccatosUpdated = MutableSingleLiveData(false) + val isStaccatosUpdated: SingleLiveData + get() = _isStaccatosUpdated private val _isPermissionCancelClicked = MutableLiveData(false) val isPermissionCancelClicked: LiveData get() = _isPermissionCancelClicked @@ -24,7 +26,7 @@ class SharedViewModel : ViewModel() { } fun setStaccatosHasUpdated() { - isStaccatosUpdated.setValue(true) + _isStaccatosUpdated.setValue(true) } fun updateIsPermissionCancelClicked() { diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/MemoryCreationActivity.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/MemoryCreationActivity.kt index b8370d7e0..c45343526 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/MemoryCreationActivity.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/MemoryCreationActivity.kt @@ -63,14 +63,12 @@ class MemoryCreationActivity : override fun onImageDeletionClicked() { currentSnackBar?.dismiss() - viewModel.setThumbnailUri(null) - viewModel.setThumbnailUrl(null) + viewModel.clearThumbnail() } override fun onUrisSelected(vararg uris: Uri) { currentSnackBar?.dismiss() viewModel.createThumbnailUrl(this, uris.first()) - viewModel.setThumbnailUri(uris.first()) } private fun buildDateRangePicker() = diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/ThumbnailUiModel.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/ThumbnailUiModel.kt new file mode 100644 index 000000000..69e166368 --- /dev/null +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/ThumbnailUiModel.kt @@ -0,0 +1,14 @@ +package com.on.staccato.presentation.memorycreation + +import android.net.Uri + +data class ThumbnailUiModel( + val uri: Uri? = null, + val url: String? = null, +) { + fun updateUrl(newUrl: String?): ThumbnailUiModel = this.copy(url = newUrl) + + fun isEqualUri(newUri: Uri?): Boolean = uri == newUri + + fun clear() = this.copy(uri = null, url = null) +} diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/viewmodel/MemoryCreationViewModel.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/viewmodel/MemoryCreationViewModel.kt index b6d1c7f46..519080cee 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/viewmodel/MemoryCreationViewModel.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memorycreation/viewmodel/MemoryCreationViewModel.kt @@ -21,12 +21,16 @@ import com.on.staccato.presentation.common.MutableSingleLiveData import com.on.staccato.presentation.common.SingleLiveData import com.on.staccato.presentation.memorycreation.DateConverter.convertLongToLocalDate import com.on.staccato.presentation.memorycreation.MemoryCreationError +import com.on.staccato.presentation.memorycreation.ThumbnailUiModel import com.on.staccato.presentation.util.convertMemoryUriToFile import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import java.time.LocalDate import javax.inject.Inject +private typealias ThumbnailUri = Uri + @HiltViewModel class MemoryCreationViewModel @Inject @@ -36,6 +40,7 @@ class MemoryCreationViewModel ) : ViewModel() { val title = ObservableField() val description = ObservableField() + val isPeriodActive = MutableLiveData(false) private val _startDate = MutableLiveData(null) val startDate: LiveData get() = _startDate @@ -46,11 +51,8 @@ class MemoryCreationViewModel private val _createdMemoryId = MutableLiveData() val createdMemoryId: LiveData get() = _createdMemoryId - private val _thumbnailUri = MutableLiveData(null) - val thumbnailUri: LiveData get() = _thumbnailUri - - private val _thumbnailUrl = MutableLiveData(null) - val thumbnailUrl: LiveData get() = _thumbnailUrl + private val _thumbnail = MutableLiveData(ThumbnailUiModel()) + val thumbnail: LiveData get() = _thumbnail private val _isPosting = MutableLiveData(false) val isPosting: LiveData get() = _isPosting @@ -58,39 +60,25 @@ class MemoryCreationViewModel private val _isPhotoPosting = MutableLiveData(false) val isPhotoPosting: LiveData get() = _isPhotoPosting - val isPeriodActive = MutableLiveData(false) - private val _errorMessage = MutableLiveData() val errorMessage: LiveData get() = _errorMessage private val _error = MutableSingleLiveData() val error: SingleLiveData get() = _error + private val thumbnailJobs = mutableMapOf() + fun createThumbnailUrl( context: Context, - thumbnailUri: Uri, + uri: Uri, ) { - _thumbnailUri.value = thumbnailUri _isPhotoPosting.value = true - val thumbnailFile = convertMemoryUriToFile(context, thumbnailUri, name = MEMORY_FILE_NAME) - viewModelScope.launch { - val result: ResponseResult = - imageRepository.convertImageFileToUrl(thumbnailFile) - result.onSuccess(::setThumbnailUrl) - .onServerError(::handlePhotoError) - .onException { e, message -> - handlePhotoException(e, message, thumbnailUri) - } - } - } - - fun setThumbnailUri(thumbnailUri: Uri?) { - _thumbnailUri.value = thumbnailUri + setThumbnailUri(uri) + registerThumbnailJob(context, uri) } - fun setThumbnailUrl(imageResponse: ImageResponse?) { - _thumbnailUrl.value = imageResponse?.imageUrl - _isPhotoPosting.value = false + fun clearThumbnail() { + _thumbnail.value = thumbnail.value?.clear() } fun setMemoryPeriod( @@ -114,13 +102,57 @@ class MemoryCreationViewModel } } + private fun setThumbnailUri(uri: Uri?) { + val currentJob = thumbnailJobs[_thumbnail.value?.uri] + if (isNewUri(uri) && currentJob?.isActive == true) { + currentJob.cancel() + } + _thumbnail.value = ThumbnailUiModel(uri = uri, url = null) + } + + private fun isNewUri(uri: Uri?): Boolean = _thumbnail.value?.isEqualUri(uri) == false + + private fun registerThumbnailJob( + context: Context, + uri: Uri, + ) { + val job = createFetchingThumbnailJob(context, uri) + job.invokeOnCompletion { + thumbnailJobs.remove(uri) + } + thumbnailJobs[uri] = job + } + + private fun createFetchingThumbnailJob( + context: Context, + uri: Uri, + ): Job { + val thumbnailFile = convertMemoryUriToFile(context, uri, name = MEMORY_FILE_NAME) + return viewModelScope.launch { + val result: ResponseResult = + imageRepository.convertImageFileToUrl(thumbnailFile) + result + .onSuccess(::setThumbnailUrl) + .onServerError(::handlePhotoError) + .onException { e, message -> + handlePhotoException(e, message, uri) + } + } + } + + private fun setThumbnailUrl(imageResponse: ImageResponse) { + val newUrl = imageResponse.imageUrl + _thumbnail.value = _thumbnail.value?.updateUrl(newUrl) + _isPhotoPosting.value = false + } + private fun setCreatedMemoryId(memoryCreationResponse: MemoryCreationResponse) { _createdMemoryId.value = memoryCreationResponse.memoryId } private fun makeNewMemory() = NewMemory( - memoryThumbnailUrl = thumbnailUrl.value, + memoryThumbnailUrl = _thumbnail.value?.url, memoryTitle = title.get() ?: throw IllegalArgumentException(), startAt = getDateByPeriodSetting(startDate), endAt = getDateByPeriodSetting(endDate), @@ -145,9 +177,11 @@ class MemoryCreationViewModel private fun handlePhotoException( e: Throwable, message: String, - thumbnailUri: Uri, + uri: Uri, ) { - _error.setValue(MemoryCreationError.Thumbnail(message, thumbnailUri)) + if (thumbnailJobs[uri]?.isActive == true) { + _error.setValue(MemoryCreationError.Thumbnail(message, uri)) + } } private fun handleCreateServerError( diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memoryupdate/MemoryUpdateActivity.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memoryupdate/MemoryUpdateActivity.kt index 62e8db679..310c155a2 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memoryupdate/MemoryUpdateActivity.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memoryupdate/MemoryUpdateActivity.kt @@ -65,13 +65,11 @@ class MemoryUpdateActivity : override fun onPhotoDeletionClicked() { currentSnackBar?.dismiss() - viewModel.setThumbnailUri(null) - viewModel.setThumbnailUrl(null) + viewModel.clearThumbnail() } override fun onUrisSelected(vararg uris: Uri) { currentSnackBar?.dismiss() - viewModel.setThumbnailUri(uris.first()) viewModel.createThumbnailUrl(this, uris.first()) } diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memoryupdate/viewmodel/MemoryUpdateViewModel.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memoryupdate/viewmodel/MemoryUpdateViewModel.kt index 28e70ad59..069384766 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memoryupdate/viewmodel/MemoryUpdateViewModel.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/memoryupdate/viewmodel/MemoryUpdateViewModel.kt @@ -20,13 +20,17 @@ import com.on.staccato.domain.repository.MemoryRepository import com.on.staccato.presentation.common.MutableSingleLiveData import com.on.staccato.presentation.common.SingleLiveData import com.on.staccato.presentation.memorycreation.DateConverter.convertLongToLocalDate +import com.on.staccato.presentation.memorycreation.ThumbnailUiModel import com.on.staccato.presentation.memoryupdate.MemoryUpdateError import com.on.staccato.presentation.util.convertMemoryUriToFile import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import java.time.LocalDate import javax.inject.Inject +private typealias ThumbnailUri = Uri + @HiltViewModel class MemoryUpdateViewModel @Inject @@ -37,11 +41,8 @@ class MemoryUpdateViewModel private val _memory = MutableLiveData() val memory: LiveData get() = _memory - private val _thumbnailUri = MutableLiveData(null) - val thumbnailUri: LiveData get() = _thumbnailUri - - private val _thumbnailUrl = MutableLiveData(null) - val thumbnailUrl: LiveData get() = _thumbnailUrl + private val _thumbnail = MutableLiveData(ThumbnailUiModel()) + val thumbnail: LiveData get() = _thumbnail val title = ObservableField() val description = ObservableField() @@ -71,8 +72,10 @@ class MemoryUpdateViewModel private val _error = MutableSingleLiveData() val error: SingleLiveData get() = _error - fun fetchMemory(memoryId: Long) { - fetchMemoryId(memoryId) + private val thumbnailJobs = mutableMapOf() + + fun fetchMemory(id: Long) { + memoryId = id viewModelScope.launch { val result = memoryRepository.getMemory(memoryId) result @@ -82,39 +85,6 @@ class MemoryUpdateViewModel } } - private fun fetchMemoryId(id: Long) { - memoryId = id - } - - fun createThumbnailUrl( - context: Context, - thumbnailUri: Uri, - ) { - _thumbnailUrl.value = null - _thumbnailUri.value = thumbnailUri - _isPhotoPosting.value = true - val thumbnailFile = convertMemoryUriToFile(context, thumbnailUri, name = MEMORY_FILE_NAME) - viewModelScope.launch { - val result: ResponseResult = - imageRepository.convertImageFileToUrl(thumbnailFile) - result.onSuccess(::setThumbnailUrl) - .onServerError(::handlePhotoError) - .onException { e, message -> - handlePhotoException(e, message, thumbnailUri) - } - } - } - - fun setThumbnailUri(thumbnailUri: Uri?) { - _thumbnailUri.value = thumbnailUri - _thumbnailUrl.value = null - } - - fun setThumbnailUrl(imageResponse: ImageResponse?) { - _thumbnailUrl.value = imageResponse?.imageUrl - _isPhotoPosting.value = false - } - fun updateMemory() { viewModelScope.launch { val newMemory: NewMemory = makeNewMemory() @@ -134,8 +104,21 @@ class MemoryUpdateViewModel _endDate.value = convertLongToLocalDate(endAt) } + fun createThumbnailUrl( + context: Context, + uri: Uri, + ) { + _isPhotoPosting.value = true + setThumbnailUri(uri) + registerThumbnailJob(context, uri) + } + + fun clearThumbnail() { + _thumbnail.value = thumbnail.value?.clear() + } + private fun initializeMemory(memory: Memory) { - _thumbnailUrl.value = memory.memoryThumbnailUrl + _thumbnail.value = _thumbnail.value?.updateUrl(memory.memoryThumbnailUrl) title.set(memory.memoryTitle) description.set(memory.description) _startDate.value = memory.startAt @@ -149,7 +132,7 @@ class MemoryUpdateViewModel private fun makeNewMemory() = NewMemory( - memoryThumbnailUrl = thumbnailUrl.value, + memoryThumbnailUrl = _thumbnail.value?.url, memoryTitle = title.get() ?: throw IllegalArgumentException(), startAt = getDateByPeriodSetting(startDate), endAt = getDateByPeriodSetting(endDate), @@ -169,6 +152,49 @@ class MemoryUpdateViewModel _isUpdateSuccess.setValue(true) } + private fun setThumbnailUri(uri: Uri?) { + val currentJob = thumbnailJobs[_thumbnail.value?.uri] + if (isNewUri(uri) && currentJob?.isActive == true) { + currentJob.cancel() + } + _thumbnail.value = ThumbnailUiModel(uri = uri, url = null) + } + + private fun isNewUri(uri: Uri?): Boolean = _thumbnail.value?.isEqualUri(uri) == false + + private fun registerThumbnailJob( + context: Context, + uri: Uri, + ) { + val thumbnailJob = createFetchingThumbnailJob(context, uri) + thumbnailJob.invokeOnCompletion { + thumbnailJobs.remove(uri) + } + thumbnailJobs[uri] = thumbnailJob + } + + private fun createFetchingThumbnailJob( + context: Context, + uri: Uri, + ): Job { + val thumbnailFile = convertMemoryUriToFile(context, uri, name = MEMORY_FILE_NAME) + return viewModelScope.launch { + val result: ResponseResult = + imageRepository.convertImageFileToUrl(thumbnailFile) + result.onSuccess(::setThumbnailUrl) + .onServerError(::handlePhotoError) + .onException { e, message -> + handlePhotoException(e, message, uri) + } + } + } + + private fun setThumbnailUrl(imageResponse: ImageResponse) { + val newUrl = imageResponse.imageUrl + _thumbnail.value = _thumbnail.value?.updateUrl(newUrl) + _isPhotoPosting.value = false + } + private fun handlePhotoError( status: Status, message: String, @@ -181,7 +207,9 @@ class MemoryUpdateViewModel message: String, uri: Uri, ) { - _error.setValue(MemoryUpdateError.Thumbnail(message, uri)) + if (thumbnailJobs[uri]?.isActive == true) { + _error.setValue(MemoryUpdateError.Thumbnail(message, uri)) + } } private fun handleInitializeMemoryError( diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatocreation/StaccatoCreationActivity.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatocreation/StaccatoCreationActivity.kt index bd884f62f..244726521 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatocreation/StaccatoCreationActivity.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatocreation/StaccatoCreationActivity.kt @@ -296,8 +296,9 @@ class StaccatoCreationActivity : viewModel.fetchPhotosUrlsByUris(this) } viewModel.currentPhotos.observe(this) { photos -> + photoAttachFragment.setCurrentImageCount(StaccatoCreationViewModel.MAX_PHOTO_NUMBER - photos.size) photoAttachAdapter.submitList( - listOf(AttachedPhotoUiModel.addPhotoButton).plus(photos.attachedPhotos), + listOf(AttachedPhotoUiModel.addPhotoButton, *photos.attachedPhotos.toTypedArray()), ) } viewModel.memoryCandidates.observe(this) { diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatocreation/viewmodel/StaccatoCreationViewModel.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatocreation/viewmodel/StaccatoCreationViewModel.kt index 0fed7c8d6..6a6de9630 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatocreation/viewmodel/StaccatoCreationViewModel.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatocreation/viewmodel/StaccatoCreationViewModel.kt @@ -27,6 +27,7 @@ import com.on.staccato.presentation.util.convertExcretaFile import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.time.LocalDate import java.time.LocalDateTime @@ -246,7 +247,9 @@ class StaccatoCreationViewModel imageRepository.convertImageFileToUrl(multiPartBody) .onSuccess { updatePhotoWithUrl(photo, it.imageUrl) - }.onException(::handleException) + }.onException { e, message -> + if (this.isActive) handleException(e, message) + } .onServerError(::handleServerError) } diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatoupdate/StaccatoUpdateActivity.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatoupdate/StaccatoUpdateActivity.kt index 413a949c2..3f7059bd3 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatoupdate/StaccatoUpdateActivity.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatoupdate/StaccatoUpdateActivity.kt @@ -42,6 +42,7 @@ import com.on.staccato.presentation.staccatocreation.adapter.PhotoAttachAdapter import com.on.staccato.presentation.staccatocreation.dialog.MemorySelectionFragment import com.on.staccato.presentation.staccatocreation.dialog.VisitedAtSelectionFragment import com.on.staccato.presentation.staccatocreation.model.AttachedPhotoUiModel +import com.on.staccato.presentation.staccatocreation.viewmodel.StaccatoCreationViewModel import com.on.staccato.presentation.staccatoupdate.viewmodel.StaccatoUpdateViewModel import com.on.staccato.presentation.util.getSnackBarWithAction import com.on.staccato.presentation.util.showToast @@ -293,8 +294,9 @@ class StaccatoUpdateActivity : viewModel.fetchPhotosUrlsByUris(this) } viewModel.currentPhotos.observe(this) { photos -> + photoAttachFragment.setCurrentImageCount(StaccatoCreationViewModel.MAX_PHOTO_NUMBER - photos.size) photoAttachAdapter.submitList( - listOf(AttachedPhotoUiModel.addPhotoButton).plus(photos.attachedPhotos), + listOf(AttachedPhotoUiModel.addPhotoButton, *photos.attachedPhotos.toTypedArray()), ) } viewModel.memoryCandidates.observe(this) { diff --git a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatoupdate/viewmodel/StaccatoUpdateViewModel.kt b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatoupdate/viewmodel/StaccatoUpdateViewModel.kt index 8f5e8b4af..956cfd104 100644 --- a/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatoupdate/viewmodel/StaccatoUpdateViewModel.kt +++ b/android/Staccato_AN/app/src/main/java/com/on/staccato/presentation/staccatoupdate/viewmodel/StaccatoUpdateViewModel.kt @@ -31,6 +31,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.time.LocalDateTime import javax.inject.Inject @@ -259,7 +260,9 @@ class StaccatoUpdateViewModel imageRepository.convertImageFileToUrl(multiPartBody) .onSuccess { updatePhotoWithUrl(photo, it.imageUrl) - }.onException(::handleUpdatePhotoException) + }.onException { e, message -> + if (this.isActive) handleUpdatePhotoException(e, message) + } .onServerError(::handleServerError) } diff --git a/android/Staccato_AN/app/src/main/res/layout/activity_memory_creation.xml b/android/Staccato_AN/app/src/main/res/layout/activity_memory_creation.xml index 21a7b40e7..fffef2455 100644 --- a/android/Staccato_AN/app/src/main/res/layout/activity_memory_creation.xml +++ b/android/Staccato_AN/app/src/main/res/layout/activity_memory_creation.xml @@ -58,8 +58,8 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintWidth_max="500dp" - bind:coilRoundedCornerImageUri="@{viewModel.thumbnailUri}" - bind:coilRoundedCornerImageUrl="@{viewModel.thumbnailUrl}" + bind:coilRoundedCornerImageUri="@{viewModel.thumbnail.uri}" + bind:coilRoundedCornerImageUrl="@{viewModel.thumbnail.url}" bind:coilRoundedCornerPlaceHolder="@{@drawable/shape_all_gray1_8dp}" bind:coilRoundingRadius="@{12f}" /> @@ -71,7 +71,7 @@ android:onClick="@{() -> handler.onImageDeletionClicked()}" android:padding="12dp" android:src="@drawable/ic_delete" - android:visibility="@{viewModel.thumbnailUrl == null ? View.GONE : View.VISIBLE }" + android:visibility="@{viewModel.thumbnail.url == null ? View.GONE : View.VISIBLE }" app:layout_constraintEnd_toEndOf="@id/iv_memory_creation_photo_attach" app:layout_constraintTop_toTopOf="@id/iv_memory_creation_photo_attach" tools:visibility="visible" /> @@ -85,8 +85,7 @@ app:layout_constraintEnd_toEndOf="@id/iv_memory_creation_photo_attach" app:layout_constraintStart_toStartOf="@id/iv_memory_creation_photo_attach" app:layout_constraintTop_toTopOf="@id/iv_memory_creation_photo_attach" - bind:visibilityByEmptyThumbnailUri="@{viewModel.thumbnailUri}" - bind:visibilityByEmptyThumbnailUrl="@{viewModel.thumbnailUrl}" /> + bind:visibilityByEmptyThumbnail="@{viewModel.thumbnail}" /> @@ -71,7 +71,7 @@ android:onClick="@{() -> handler.onPhotoDeletionClicked()}" android:padding="12dp" android:src="@drawable/ic_delete" - android:visibility="@{viewModel.thumbnailUrl == null ? View.GONE : View.VISIBLE }" + android:visibility="@{viewModel.thumbnail.url == null ? View.GONE : View.VISIBLE }" app:layout_constraintEnd_toEndOf="@id/iv_memory_update_photo_attach" app:layout_constraintTop_toTopOf="@id/iv_memory_update_photo_attach" tools:visibility="visible" /> @@ -85,8 +85,7 @@ app:layout_constraintEnd_toEndOf="@id/iv_memory_update_photo_attach" app:layout_constraintStart_toStartOf="@id/iv_memory_update_photo_attach" app:layout_constraintTop_toTopOf="@id/iv_memory_update_photo_attach" - bind:visibilityByEmptyThumbnailUri="@{viewModel.thumbnailUri}" - bind:visibilityByEmptyThumbnailUrl="@{viewModel.thumbnailUrl}" + bind:visibilityByEmptyThumbnail="@{viewModel.thumbnail}" tools:visibility="visible" /> 개인정보처리방침 피드백으로 혼내주기 스타카토 인스타그램 - 앱 버전 1.2.0 + 앱 버전 1.2.1 프로필 정보를 가져올 수 없습니다. 프로필 이미지 변경에 실패했습니다. "피드백 페이지를 열 수 있는 브라우저가 없어요" @@ -167,11 +167,12 @@ 사진을 등록해 주세요 카메라 열기 앨범에서 가져오기 - 카메라와 사진 및 동영상 접근 권한이 필요합니다.\n설정에서 권한을 허용해 주세요. - 카메라 실행 중 에러가 발생했습니다.\n잠시 후에 다시 시도해주세요. - 사진을 불러올 수 없습니다.\n잠시 후에 다시 시도해주세요. + 카메라 권한을 허용해 주세요. + 사진 접근 권한을 허용해 주세요. + 카메라 실행 중 에러가 발생했습니다.\n잠시 후에 다시 시도해 주세요. + 사진을 불러올 수 없습니다.\n잠시 후에 다시 시도해 주세요. 실행할 수 있는 카메라 앱이 없습니다. - 설정으로 이동하기 + 설정하기 // dialog_delete_impossibility 추억을 삭제할 수 없어요. diff --git a/android/Staccato_AN/deploy/whatsnew/whatsnew-ko-KR b/android/Staccato_AN/deploy/whatsnew/whatsnew-ko-KR index f126f68aa..f243b8f80 100644 --- a/android/Staccato_AN/deploy/whatsnew/whatsnew-ko-KR +++ b/android/Staccato_AN/deploy/whatsnew/whatsnew-ko-KR @@ -3,17 +3,22 @@ 빠르고 간단하게 일상의 모습을 기록하고, 지도 위에서 한눈에 확인할 수 있어요. 어디서 무엇을 했는지, 기분은 어땠는지 알려주세요! +1.2.1 버전 업데이트 내용 (2024.11.27) +[버그 수정] + - 로딩 중인 이미지를 삭제해도 네트워크 오류 메시지가 나타나지 않아요. + - 추억의 썸네일 이미지 업로드 오류를 수정했어요. + 1.2.0 버전 업데이트 내용 (2024.10.24) [새로운 기능] -- 카메라로 사진을 촬영해 바로 올릴 수 있어요. -- 정렬 버튼으로 추억 순서를 바꿀 수 있어요. + - 카메라로 사진을 촬영해 바로 올릴 수 있어요. + - 정렬 버튼으로 추억 순서를 바꿀 수 있어요. [UI 개선] -- 마커 디자인, 기분 캐릭터, 댓글 디자인을 개선했어요. -- 글자 크기 조절 대응 및 접근성을 향상했어요. -- 다크모드를 지원해요. + - 마커 디자인, 기분 캐릭터, 댓글 디자인을 개선했어요. + - 글자 크기 조절 대응 및 접근성을 향상했어요. + - 다크모드를 지원해요. [버그 수정] -- 네트워크가 불안정할 때 메시지로 알려드려요. + - 네트워크가 불안정할 때 메시지로 알려드려요. [기타] -- 마이페이지에서 개발자에게 피드백을 줄 수 있어요. -- 마이페이지에서 스타카토 인스타 계정으로 이동할 수 있어요. + - 마이페이지에서 개발자에게 피드백을 줄 수 있어요. + - 마이페이지에서 스타카토 인스타 계정으로 이동할 수 있어요. diff --git a/backend/src/main/java/com/staccato/auth/service/AuthService.java b/backend/src/main/java/com/staccato/auth/service/AuthService.java index 07e03e485..567f84304 100644 --- a/backend/src/main/java/com/staccato/auth/service/AuthService.java +++ b/backend/src/main/java/com/staccato/auth/service/AuthService.java @@ -48,7 +48,7 @@ private void validateNickname(Nickname nickname) { } private void createBasicMemory(Member member) { - Memory memory = Memory.basic(); + Memory memory = Memory.basic(member.getNickname()); memory.addMemoryMember(member); memoryRepository.save(memory); } diff --git a/backend/src/main/java/com/staccato/comment/controller/CommentController.java b/backend/src/main/java/com/staccato/comment/controller/CommentController.java index 86ee389e7..a1ba6ffed 100644 --- a/backend/src/main/java/com/staccato/comment/controller/CommentController.java +++ b/backend/src/main/java/com/staccato/comment/controller/CommentController.java @@ -1,21 +1,19 @@ package com.staccato.comment.controller; import java.net.URI; - import jakarta.validation.Valid; import jakarta.validation.constraints.Min; - import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; - import com.staccato.comment.controller.docs.CommentControllerDocs; import com.staccato.comment.service.CommentService; import com.staccato.comment.service.dto.request.CommentRequest; @@ -24,7 +22,6 @@ import com.staccato.config.auth.LoginMember; import com.staccato.config.log.annotation.Trace; import com.staccato.member.domain.Member; - import lombok.RequiredArgsConstructor; @Trace @@ -64,6 +61,16 @@ public ResponseEntity updateComment( return ResponseEntity.ok().build(); } + @PutMapping("/v2/{commentId}") + public ResponseEntity updateCommentV2( + @LoginMember Member member, + @PathVariable @Min(value = 1L, message = "댓글 식별자는 양수로 이루어져야 합니다.") long commentId, + @Valid @RequestBody CommentUpdateRequest commentUpdateRequest + ) { + commentService.updateComment(member, commentId, commentUpdateRequest); + return ResponseEntity.ok().build(); + } + @DeleteMapping public ResponseEntity deleteComment( @RequestParam @Min(value = 1L, message = "댓글 식별자는 양수로 이루어져야 합니다.") long commentId, @@ -72,4 +79,13 @@ public ResponseEntity deleteComment( commentService.deleteComment(commentId, member); return ResponseEntity.ok().build(); } + + @DeleteMapping("/v2/{commentId}") + public ResponseEntity deleteCommentV2( + @PathVariable @Min(value = 1L, message = "댓글 식별자는 양수로 이루어져야 합니다.") long commentId, + @LoginMember Member member + ) { + commentService.deleteComment(commentId, member); + return ResponseEntity.ok().build(); + } } diff --git a/backend/src/main/java/com/staccato/comment/controller/docs/CommentControllerDocs.java b/backend/src/main/java/com/staccato/comment/controller/docs/CommentControllerDocs.java index 3aa7bf733..76ace9027 100644 --- a/backend/src/main/java/com/staccato/comment/controller/docs/CommentControllerDocs.java +++ b/backend/src/main/java/com/staccato/comment/controller/docs/CommentControllerDocs.java @@ -2,14 +2,11 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Min; - import org.springframework.http.ResponseEntity; - import com.staccato.comment.service.dto.request.CommentRequest; import com.staccato.comment.service.dto.request.CommentUpdateRequest; import com.staccato.comment.service.dto.response.CommentResponses; import com.staccato.member.domain.Member; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -70,6 +67,28 @@ ResponseEntity updateComment( @Parameter(description = "댓글 식별자", example = "1") @Min(value = 1L, message = "댓글 식별자는 양수로 이루어져야 합니다.") long commentId, @Parameter(description = "댓글 수정 시 요구 형식") @Valid CommentUpdateRequest commentUpdateRequest); + @Operation(summary = "댓글 수정", description = "댓글을 수정합니다.") + @ApiResponses(value = { + @ApiResponse(description = "댓글 수정 성공", responseCode = "200"), + @ApiResponse(description = """ + <발생 가능한 케이스> + + (1) 댓글 식별자가 양수가 아닐 때 + + (2) 요청한 댓글을 찾을 수 없을 때 + + (3) 댓글 내용이 공백 뿐이거나 없을 때 + + (4) 댓글이 공백 포함 500자 초과일 때 + """, + responseCode = "400") + }) + public ResponseEntity updateCommentV2( + @Parameter(hidden = true) Member member, + @Parameter(description = "댓글 식별자", example = "1") @Min(value = 1L, message = "댓글 식별자는 양수로 이루어져야 합니다.") long commentId, + @Parameter(description = "댓글 수정 시 요구 형식") @Valid CommentUpdateRequest commentUpdateRequest + ); + @Operation(summary = "댓글 삭제", description = "댓글을 삭제합니다.") @ApiResponses(value = { @ApiResponse(description = "댓글 삭제 성공", responseCode = "200"), @@ -78,4 +97,14 @@ ResponseEntity updateComment( ResponseEntity deleteComment( @Parameter(description = "댓글 식별자", example = "1") @Min(value = 1L, message = "댓글 식별자는 양수로 이루어져야 합니다.") long commentId, @Parameter(hidden = true) Member member); + + @Operation(summary = "댓글 삭제", description = "댓글을 삭제합니다.") + @ApiResponses(value = { + @ApiResponse(description = "댓글 삭제 성공", responseCode = "200"), + @ApiResponse(description = "댓글 식별자가 양수가 아닐 시 댓글 삭제 실패", responseCode = "400") + }) + public ResponseEntity deleteCommentV2( + @Parameter(description = "댓글 식별자", example = "1") @Min(value = 1L, message = "댓글 식별자는 양수로 이루어져야 합니다.") long commentId, + @Parameter(hidden = true) Member member + ); } diff --git a/backend/src/main/java/com/staccato/image/infrastructure/S3ObjectClient.java b/backend/src/main/java/com/staccato/image/infrastructure/S3ObjectClient.java index ded00e41b..80016fd36 100644 --- a/backend/src/main/java/com/staccato/image/infrastructure/S3ObjectClient.java +++ b/backend/src/main/java/com/staccato/image/infrastructure/S3ObjectClient.java @@ -2,8 +2,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; - -import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; @@ -20,11 +20,14 @@ public class S3ObjectClient { public S3ObjectClient( @Value("${cloud.aws.s3.bucket}") String bucketName, @Value("${cloud.aws.s3.endpoint}") String endPoint, - @Value("${cloud.aws.cloudfront.endpoint}") String cloudFrontEndPoint + @Value("${cloud.aws.cloudfront.endpoint}") String cloudFrontEndPoint, + @Value("${cloud.aws.access-key}") String accessKey, + @Value("${cloud.aws.secret-access-key}") String secretAccessKey ) { - this.s3Client = software.amazon.awssdk.services.s3.S3Client.builder() + AwsBasicCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretAccessKey); + this.s3Client = S3Client.builder() + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) .region(Region.AP_NORTHEAST_2) - .credentialsProvider(InstanceProfileCredentialsProvider.create()) .build(); this.bucketName = bucketName; this.endPoint = endPoint; diff --git a/backend/src/main/java/com/staccato/image/service/ImageService.java b/backend/src/main/java/com/staccato/image/service/ImageService.java index 2a7209a75..a64d5286d 100644 --- a/backend/src/main/java/com/staccato/image/service/ImageService.java +++ b/backend/src/main/java/com/staccato/image/service/ImageService.java @@ -16,14 +16,13 @@ @Service @RequiredArgsConstructor public class ImageService { - private static final String TEAM_FOLDER_NAME = "staccato/"; private final S3ObjectClient s3ObjectClient; @Value("${image.folder.name}") private String imageFolderName; public ImageUrlResponse uploadImage(MultipartFile image) { String imageExtension = getImageExtension(image); - String key = TEAM_FOLDER_NAME + imageFolderName + UUID.randomUUID() + imageExtension; + String key = imageFolderName + UUID.randomUUID() + imageExtension; String contentType = ImageExtension.getContentType(imageExtension); byte[] imageBytes = getImageBytes(image); diff --git a/backend/src/main/java/com/staccato/memory/domain/Memory.java b/backend/src/main/java/com/staccato/memory/domain/Memory.java index 1161ad131..908183e24 100644 --- a/backend/src/main/java/com/staccato/memory/domain/Memory.java +++ b/backend/src/main/java/com/staccato/memory/domain/Memory.java @@ -15,6 +15,7 @@ import com.staccato.config.domain.BaseEntity; import com.staccato.exception.StaccatoException; import com.staccato.member.domain.Member; +import com.staccato.member.domain.Nickname; import com.staccato.moment.domain.Moment; import lombok.AccessLevel; import lombok.Builder; @@ -26,7 +27,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Memory extends BaseEntity { - private static final String DEFAULT_TITLE = "기본 추억"; + private static final String DEFAULT_SUBTITLE = "의 추억"; private static final String DEFAULT_DESCRIPTION = "스타카토를 추억에 담아보세요."; @Id @@ -52,9 +53,9 @@ public Memory(String thumbnailUrl, @NonNull String title, String description, Lo this.term = new Term(startAt, endAt); } - public static Memory basic() { + public static Memory basic(Nickname memberNickname) { return Memory.builder() - .title(DEFAULT_TITLE) + .title(memberNickname.getNickname() + DEFAULT_SUBTITLE) .description(DEFAULT_DESCRIPTION) .build(); } diff --git a/backend/src/main/java/com/staccato/memory/service/MemoryService.java b/backend/src/main/java/com/staccato/memory/service/MemoryService.java index 4ab197a71..4b42423dd 100644 --- a/backend/src/main/java/com/staccato/memory/service/MemoryService.java +++ b/backend/src/main/java/com/staccato/memory/service/MemoryService.java @@ -74,7 +74,7 @@ private void sortByCreatedAtDescending(List memoryMembers) { public MemoryDetailResponse readMemoryById(long memoryId, Member member) { Memory memory = getMemoryById(memoryId); validateOwner(memory, member); - List momentResponses = getMomentResponses(momentRepository.findAllByMemoryIdOrderByVisitedAt(memoryId)); + List momentResponses = getMomentResponses(momentRepository.findAllByMemoryIdOrdered(memoryId)); return new MemoryDetailResponse(memory, momentResponses); } @@ -99,7 +99,7 @@ public void updateMemory(MemoryRequest memoryRequest, Long memoryId, Member memb if (originMemory.isNotSameTitle(memoryRequest.memoryTitle())) { validateMemoryTitle(updatedMemory, member); } - List moments = momentRepository.findAllByMemoryIdOrderByVisitedAt(memoryId); + List moments = momentRepository.findAllByMemoryId(memoryId); originMemory.update(updatedMemory, moments); } diff --git a/backend/src/main/java/com/staccato/moment/repository/MomentRepository.java b/backend/src/main/java/com/staccato/moment/repository/MomentRepository.java index bb4ec0064..6b4551225 100644 --- a/backend/src/main/java/com/staccato/moment/repository/MomentRepository.java +++ b/backend/src/main/java/com/staccato/moment/repository/MomentRepository.java @@ -9,7 +9,8 @@ import com.staccato.moment.domain.Moment; public interface MomentRepository extends JpaRepository { - List findAllByMemoryIdOrderByVisitedAt(long memoryId); + @Query("SELECT m FROM Moment m WHERE m.memory.id = :memoryId order by m.visitedAt desc, m.createdAt desc") + List findAllByMemoryIdOrdered(long memoryId); List findAllByMemory_MemoryMembers_Member(Member member); diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 02d1a0aff..2e1b3563b 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -43,18 +43,20 @@ security: token: ${ADMIN_TOKEN} cloud: aws: + access-key: ${AWS_ACCESS_KEY} + secret-access-key: ${AWS_SECRET_ACCESS_KEY} s3: - bucket: techcourse-project-2024 - endpoint: https://techcourse-project-2024.s3.ap-northeast-2.amazonaws.com + bucket: ${AWS_S3_BUCKET} + endpoint: ${AWS_S3_ENDPOINT} cloudfront: - endpoint: https://d25aribbn0gp8k.cloudfront.net + endpoint: ${AWS_CLOUDFRONT_ENDPOINT} region: - static: ap-northeast-2 + static: ${AWS_REGION_STATIC} stack: auto: false image: folder: - name: image/ + name: dev/ management: server: diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml index 7659cd292..1017d734e 100644 --- a/backend/src/main/resources/application-local.yml +++ b/backend/src/main/resources/application-local.yml @@ -39,18 +39,20 @@ security: token: ${ADMIN_TOKEN} cloud: aws: + access-key: accessKey + secret-access-key: secretAccessKey s3: - bucket: techcourse-project-2024 - endpoint: https://techcourse-project-2024.s3.ap-northeast-2.amazonaws.com + bucket: bucket + endpoint: endpoint cloudfront: - endpoint: https://d25aribbn0gp8k.cloudfront.net + endpoint: endpoint region: - static: ap-northeast-2 + static: static stack: auto: false image: folder: - name: image/ + name: local/ management: server: diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index c9d30ce97..bf05843d5 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -65,6 +65,8 @@ security: token: ${ADMIN_TOKEN} cloud: aws: + access-key: ${AWS_ACCESS_KEY} + secret-access-key: ${AWS_SECRET_ACCESS_KEY} s3: bucket: ${AWS_S3_BUCKET} endpoint: ${AWS_S3_ENDPOINT} @@ -76,7 +78,7 @@ cloud: auto: false image: folder: - name: image-prod/ + name: prod/ management: server: diff --git a/backend/src/main/resources/error-appender.xml b/backend/src/main/resources/error-appender.xml index ff8f75fac..01044b0e8 100644 --- a/backend/src/main/resources/error-appender.xml +++ b/backend/src/main/resources/error-appender.xml @@ -11,7 +11,7 @@ [%d{yyyy-MM-dd HH:mm:ss}:%-3relative] [%thread] [request_id=%X{request_id:-startup}] %-5level [%C.%M.-%L] - %msg%n - ./backup/error/error-%d{yyyy-MM-dd}.%i.log + ./logs/error/error-%d{yyyy-MM-dd}.%i.log 10MB 15 3GB diff --git a/backend/src/main/resources/info-appender.xml b/backend/src/main/resources/info-appender.xml index da27ad381..7fe9243bf 100644 --- a/backend/src/main/resources/info-appender.xml +++ b/backend/src/main/resources/info-appender.xml @@ -11,7 +11,7 @@ [%d{yyyy-MM-dd HH:mm:ss}:%-3relative] [%thread] [request_id=%X{request_id:-startup}] %-5level - %msg%n - ./backup/info/info-%d{yyyy-MM-dd}.%i.log + ./logs/info/info-%d{yyyy-MM-dd}.%i.log 10MB 15 3GB diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml index 15bebbc77..c67155e94 100644 --- a/backend/src/main/resources/logback-spring.xml +++ b/backend/src/main/resources/logback-spring.xml @@ -18,18 +18,6 @@ - - - - - - - - - - - - diff --git a/backend/src/main/resources/warn-appender.xml b/backend/src/main/resources/warn-appender.xml index 7abeb8b76..b8556bf32 100644 --- a/backend/src/main/resources/warn-appender.xml +++ b/backend/src/main/resources/warn-appender.xml @@ -11,7 +11,7 @@ [%d{yyyy-MM-dd HH:mm:ss}:%-3relative] [%thread] [request_id=%X{request_id:-startup}] %-5level [%C.%M.-%L] - %msg%n - ./backup/warn/warn-%d{yyyy-MM-dd}.%i.log + ./logs/warn/warn-%d{yyyy-MM-dd}.%i.log 10MB 15 3GB diff --git a/backend/src/test/java/com/staccato/comment/controller/CommentControllerTest.java b/backend/src/test/java/com/staccato/comment/controller/CommentControllerTest.java index fa6a089cc..95927da8b 100644 --- a/backend/src/test/java/com/staccato/comment/controller/CommentControllerTest.java +++ b/backend/src/test/java/com/staccato/comment/controller/CommentControllerTest.java @@ -184,6 +184,12 @@ void updateComment() throws Exception { .contentType(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "token")) .andExpect(status().isOk()); + + mockMvc.perform(put("/comments/v2/{commentId}", 1) + .content(commentUpdateRequest) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); } @DisplayName("댓글 식별자가 양수가 아닐 경우 댓글 수정에 실패한다.") @@ -236,6 +242,11 @@ void deleteComment() throws Exception { .contentType(MediaType.APPLICATION_JSON) .header(HttpHeaders.AUTHORIZATION, "token")) .andExpect(status().isOk()); + + mockMvc.perform(delete("/comments/v2/{commentId}", 1) + .contentType(MediaType.APPLICATION_JSON) + .header(HttpHeaders.AUTHORIZATION, "token")) + .andExpect(status().isOk()); } @DisplayName("댓글 식별자가 양수가 아닐 경우 댓글 삭제에 실패한다.") diff --git a/backend/src/test/java/com/staccato/image/infrastructure/FakeS3ObjectClient.java b/backend/src/test/java/com/staccato/image/infrastructure/FakeS3ObjectClient.java index eaf25dd35..f570069a6 100644 --- a/backend/src/test/java/com/staccato/image/infrastructure/FakeS3ObjectClient.java +++ b/backend/src/test/java/com/staccato/image/infrastructure/FakeS3ObjectClient.java @@ -2,7 +2,7 @@ public class FakeS3ObjectClient extends S3ObjectClient { public FakeS3ObjectClient() { - super("fakeBuket", "fakeEndPoint", "fakeCloudFrontEndPoint"); + super("fakeBuket", "fakeEndPoint", "fakeCloudFrontEndPoint", "fakeAccessKey", "fakeSecretAccessKey"); } @Override diff --git a/backend/src/test/java/com/staccato/memory/domain/MemoryTest.java b/backend/src/test/java/com/staccato/memory/domain/MemoryTest.java index 3b99b6fc8..84f2be3cb 100644 --- a/backend/src/test/java/com/staccato/memory/domain/MemoryTest.java +++ b/backend/src/test/java/com/staccato/memory/domain/MemoryTest.java @@ -6,8 +6,11 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import com.staccato.exception.StaccatoException; +import com.staccato.fixture.Member.MemberFixture; import com.staccato.fixture.memory.MemoryFixture; import com.staccato.fixture.moment.MomentFixture; +import com.staccato.member.domain.Member; +import com.staccato.member.domain.Nickname; import com.staccato.moment.domain.Moment; import static org.assertj.core.api.Assertions.assertThat; @@ -27,6 +30,19 @@ void trimMemoryTitle() { assertThat(memory.getTitle()).isEqualTo(expectedTitle); } + @DisplayName("기본 추억은 멤버 이름으로 만들어진다.") + @Test + void createBasicMemoryWithMemberNickname() { + // given + String nickname = "staccato"; + + // when + Memory memory = Memory.basic(new Nickname(nickname)); + + // then + assertThat(memory.getTitle()).isEqualTo(nickname + "의 추억"); + } + @DisplayName("추억을 수정 시 기존 스타카토 기록 날짜를 포함하지 않는 경우 수정에 실패한다.") @Test void validateDuration() { diff --git a/backend/src/test/java/com/staccato/memory/service/MemoryServiceTest.java b/backend/src/test/java/com/staccato/memory/service/MemoryServiceTest.java index 6693f9886..1810c8de1 100644 --- a/backend/src/test/java/com/staccato/memory/service/MemoryServiceTest.java +++ b/backend/src/test/java/com/staccato/memory/service/MemoryServiceTest.java @@ -239,7 +239,7 @@ void cannotReadMemoryByIdIfNotOwner() { .hasMessage("요청하신 작업을 처리할 권한이 없습니다."); } - @DisplayName("특정 추억을 조회하면 스타카토는 오래된 순으로 반환한다.") + @DisplayName("특정 추억을 조회하면 스타카토는 최신순으로 반환한다.") @Test void readMemoryByIdOrderByVisitedAt() { // given @@ -258,7 +258,7 @@ void readMemoryByIdOrderByVisitedAt() { () -> assertThat(memoryDetailResponse.memoryId()).isEqualTo(memoryIdResponse.memoryId()), () -> assertThat(memoryDetailResponse.moments()).hasSize(3), () -> assertThat(memoryDetailResponse.moments().stream().map(MomentResponse::momentId).toList()) - .containsExactly(firstMoment.getId(), secondMoment.getId(), lastMoment.getId()) + .containsExactly(lastMoment.getId(), secondMoment.getId(), firstMoment.getId()) ); } diff --git a/backend/src/test/java/com/staccato/moment/repository/MomentRepositoryTest.java b/backend/src/test/java/com/staccato/moment/repository/MomentRepositoryTest.java index f24ab73bb..9fe9f9c65 100644 --- a/backend/src/test/java/com/staccato/moment/repository/MomentRepositoryTest.java +++ b/backend/src/test/java/com/staccato/moment/repository/MomentRepositoryTest.java @@ -94,7 +94,7 @@ void deleteAllByMemoryIdInBatch() { ); } - @DisplayName("사용자의 특정 추억에 해당하는 모든 스타카토를 조회한다.") + @DisplayName("사용자의 특정 추억에 해당하는 모든 스타카토를 최신순으로 조회한다.") @Test void findAllByMemoryIdOrderByVisitedAt() { // given @@ -107,12 +107,33 @@ void findAllByMemoryIdOrderByVisitedAt() { Moment moment3 = momentRepository.save(MomentFixture.create(memory, LocalDateTime.of(2024, 1, 10, 23, 21))); // when - List moments = momentRepository.findAllByMemoryIdOrderByVisitedAt(memory.getId()); + List moments = momentRepository.findAllByMemoryIdOrdered(memory.getId()); // then assertAll( () -> assertThat(moments.size()).isEqualTo(3), - () -> assertThat(moments).containsExactlyInAnyOrder(moment1, moment2, moment3) + () -> assertThat(moments).containsExactly(moment3, moment2, moment1) + ); + } + + @DisplayName("사용자의 스타카토 방문 날짜가 동일하다면, 생성날짜 기준 최신순으로 조회한다.") + @Test + void findAllByMemoryIdOrderByCreatedAt() { + // given + Member member = memberRepository.save(MemberFixture.create()); + Memory memory = memoryRepository.save(MemoryFixture.create(LocalDate.of(2023, 12, 31), LocalDate.of(2024, 1, 10))); + memoryMemberRepository.save(new MemoryMember(member, memory)); + + Moment moment1 = momentRepository.save(MomentFixture.create(memory, LocalDateTime.of(2024, 1, 10, 23, 21))); + Moment moment2 = momentRepository.save(MomentFixture.create(memory, LocalDateTime.of(2024, 1, 10, 23, 21))); + + // when + List moments = momentRepository.findAllByMemoryIdOrdered(memory.getId()); + + // then + assertAll( + () -> assertThat(moments.size()).isEqualTo(2), + () -> assertThat(moments).containsExactly(moment2, moment1) ); } }