Skip to content

Commit 1d211aa

Browse files
authored
[FIX/#1591] 앱잼탬프 QA 사항 반영 (#1598)
* [FIX/#1591] 앱잼 뒤로가기 버튼 클릭 처리 개선 * [FIX/#1591] 미션 제출 후 상세 화면 자동 복귀 * [FEAT/#1591] 미션 상세 완료 버튼 활성화 조건 개선 * [FIX/#1591] 앱잼 현황 시간 표기 QA 반영 * [MOD/#1591] toRelativeTime 확장 함수 유틸화 및 개선 - `RelativeTimeExt.kt`를 생성하여 `toRelativeTime` 확장 함수를 공통 유틸리티로 분리하였습니다. - 기존 `java.util.Date` 기반 로직을 `java.time` (LocalDateTime, ZonedDateTime) API를 사용하도록 개선하였습니다. - `Top3RecentRankingMission`에서 로컬에 정의되어 있던 `toRelativeTime`을 제거하고 공통 유틸을 사용하도록 수정하였습니다. * [MOD/#1591] AppjamtampButton 클릭 시 리플 효과 추가 - `noRippleClickable`을 기본 `clickable`로 변경하여 버튼 클릭 시 시각적 피드백(리플 효과)이 발생하도록 수정하였습니다. * [REFACTOR/#1591] MissionDetailRoute의 미션 완료 후 처리 로직 개선 - `LaunchedEffect`의 관찰 키를 `showPostSubmissionBadge`로 최적화. - `snapshotFlow`를 사용하여 진행률(progress) 및 로딩 상태 조건이 충족될 때까지 대기한 후 네비게이션이 동작하도록 수정. * [MOD/#1591] 상대 시간 표시 포맷 및 로직 수정 - `monthDayFormatter` 패턴에 공백 추가 ("M월 d일") - "주 전" 표시 기준을 35일에서 28일로 변경하여 한 달 이상의 날짜는 날짜 포맷으로 노출되도록 수정 * [FEAT/#1591] RelativeTimeExt 단위 테스트 추가 및 로직 개선 - `toRelativeTime` 함수에 `currentDateTime` 파라미터를 추가하여 테스트 가능성을 개선합니다. - 시간 차이 계산 시 변수 대신 밀리초 단위 비교(TimeUnit)를 사용하도록 로직을 정교화합니다. - `RelativeTimeExtTest`를 통해 경계값 및 예외 상황에 대한 유닛 테스트를 추가합니다.
1 parent 057b3e1 commit 1d211aa

8 files changed

Lines changed: 185 additions & 45 deletions

File tree

feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/component/AppjamtampButton.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,16 @@ internal fun AppjamtampButton(
4242
text: String,
4343
onClicked: () -> Unit,
4444
modifier: Modifier = Modifier,
45+
isEnabled: Boolean = true,
4546
) {
4647
Box(
4748
modifier = modifier
4849
.fillMaxWidth()
4950
.background(
50-
color = SoptTheme.colors.primary,
51+
color = if (isEnabled) SoptTheme.colors.primary else SoptTheme.colors.onSurface300,
5152
shape = RoundedCornerShape(9.dp),
5253
)
53-
.clickable(onClick = onClicked),
54+
.clickable(onClick = { if (isEnabled) onClicked() }),
5455
contentAlignment = Alignment.Center,
5556
) {
5657
Text(

feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/component/BackButtonHeader.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
3838
import androidx.compose.ui.res.vectorResource
3939
import androidx.compose.ui.tooling.preview.Preview
4040
import androidx.compose.ui.unit.dp
41+
import org.sopt.official.common.util.throttledNoRippleClickable
4142
import org.sopt.official.designsystem.SoptTheme
4243
import org.sopt.official.feature.appjamtamp.R
4344

@@ -57,7 +58,7 @@ internal fun BackButtonHeader(
5758
imageVector = ImageVector.vectorResource(R.drawable.ic_back_32),
5859
contentDescription = null,
5960
tint = SoptTheme.colors.onSurface10,
60-
modifier = Modifier.clickable(onClick = onBackButtonClick)
61+
modifier = Modifier.throttledNoRippleClickable(onClick = onBackButtonClick),
6162
)
6263

6364
Text(

feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailRoute.kt

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import androidx.compose.runtime.getValue
4343
import androidx.compose.runtime.mutableStateOf
4444
import androidx.compose.runtime.remember
4545
import androidx.compose.runtime.setValue
46+
import androidx.compose.runtime.snapshotFlow
4647
import androidx.compose.ui.Alignment
4748
import androidx.compose.ui.Modifier
4849
import androidx.compose.ui.graphics.vector.ImageVector
@@ -59,6 +60,7 @@ import com.airbnb.lottie.compose.LottieCompositionSpec
5960
import com.airbnb.lottie.compose.animateLottieCompositionAsState
6061
import com.airbnb.lottie.compose.rememberLottieComposition
6162
import kotlinx.coroutines.delay
63+
import kotlinx.coroutines.flow.first
6264
import org.sopt.official.designsystem.SoptTheme
6365
import org.sopt.official.designsystem.component.dialog.TwoButtonDialog
6466
import org.sopt.official.feature.appjamtamp.R
@@ -130,10 +132,12 @@ internal fun MissionDetailRoute(
130132
}
131133
}
132134

133-
LaunchedEffect(!uiState.isLoading, progress) {
134-
if (progress >= 0.99f && !uiState.isLoading) {
135+
LaunchedEffect(showPostSubmissionBadge) {
136+
if (showPostSubmissionBadge) {
137+
snapshotFlow { progress >= 0.99f && !uiState.isLoading }.first { it }
135138
delay(500L)
136139
viewModel.updateShowPostSubmissionBadge()
140+
navigateUp()
137141
}
138142
}
139143

@@ -148,7 +152,8 @@ internal fun MissionDetailRoute(
148152
},
149153
onDatePickerClick = { isDatePickerVisible = true },
150154
onMemoChange = viewModel::updateContent,
151-
onCompleteButtonClick = viewModel::handleSubmit
155+
onCompleteButtonClick = viewModel::handleSubmit,
156+
isSubmitEnabled = uiState.isSubmitEnabled,
152157
)
153158
} else {
154159
MissionDetailScreen(
@@ -185,7 +190,8 @@ internal fun MissionDetailRoute(
185190

186191
DetailViewType.EDIT -> viewModel.handleSubmit()
187192
}
188-
}
193+
},
194+
isSubmitEnabled = uiState.isSubmitEnabled,
189195
)
190196
}
191197

@@ -249,7 +255,8 @@ private fun MyEmptyMissionDetailScreen(
249255
onClickZoomIn: (String) -> Unit,
250256
onDatePickerClick: () -> Unit,
251257
onMemoChange: (String) -> Unit,
252-
onCompleteButtonClick: () -> Unit
258+
onCompleteButtonClick: () -> Unit,
259+
isSubmitEnabled: Boolean,
253260
) {
254261
val scrollState = rememberScrollState()
255262

@@ -306,6 +313,7 @@ private fun MyEmptyMissionDetailScreen(
306313
AppjamtampButton(
307314
text = "미션 완료",
308315
onClicked = onCompleteButtonClick,
316+
isEnabled = isSubmitEnabled,
309317
modifier = Modifier
310318
.fillMaxWidth()
311319
.padding(bottom = 20.dp)
@@ -322,7 +330,8 @@ private fun MissionDetailScreen(
322330
onClickZoomIn: (String) -> Unit,
323331
onDatePickerClick: () -> Unit,
324332
onMemoChange: (String) -> Unit,
325-
onActionButtonClick: () -> Unit
333+
onActionButtonClick: () -> Unit,
334+
isSubmitEnabled: Boolean,
326335
) {
327336
val scrollState = rememberScrollState()
328337
var isEditable by remember(uiState.viewType) { mutableStateOf(uiState.viewType == DetailViewType.EDIT) }
@@ -436,6 +445,7 @@ private fun MissionDetailScreen(
436445
AppjamtampButton(
437446
text = "미션 완료",
438447
onClicked = onActionButtonClick,
448+
isEnabled = isSubmitEnabled,
439449
modifier = Modifier
440450
.fillMaxWidth()
441451
.padding(bottom = 20.dp)
@@ -456,7 +466,8 @@ private fun MyEmptyMissionDetailScreenPreview() {
456466
onClickZoomIn = {},
457467
onDatePickerClick = {},
458468
onMemoChange = {},
459-
onCompleteButtonClick = {}
469+
onCompleteButtonClick = {},
470+
isSubmitEnabled = true,
460471
)
461472
}
462473
}
@@ -473,7 +484,8 @@ private fun MyMissionDetailScreenPreview() {
473484
onDatePickerClick = {},
474485
onMemoChange = {},
475486
onActionButtonClick = {},
476-
onToolbarIconClick = {}
487+
onToolbarIconClick = {},
488+
isSubmitEnabled = true,
477489
)
478490
}
479491
}

feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailState.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,22 @@ internal data class MissionDetailState(
5151
val viewCount: Int = 0,
5252

5353
val clappers: ImmutableList<StampClapUserModel> = persistentListOf(),
54-
val showPostSubmissionBadge: Boolean = false
55-
)
54+
val showPostSubmissionBadge: Boolean = false,
55+
56+
val initSnapshotImageModel: ImageModel = ImageModel.Empty,
57+
val initSnapshotDate: String = "",
58+
val initSnapshotContent: String = "",
59+
) {
60+
val isSubmitEnabled: Boolean
61+
get() {
62+
val commonGuard = !isLoading && content.isNotBlank() && date.isNotBlank() && !imageModel.isEmpty()
63+
64+
if (!commonGuard) return false
65+
66+
return when (viewType) {
67+
DetailViewType.WRITE -> true
68+
DetailViewType.EDIT -> content != initSnapshotContent || date != initSnapshotDate || imageModel != initSnapshotImageModel
69+
DetailViewType.READ_ONLY, DetailViewType.COMPLETE -> false
70+
}
71+
}
72+
}

feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missiondetail/MissionDetailViewModel.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ internal class MissionDetailViewModel @Inject constructor(
129129
imageModel = ImageModel.Remote(stamp.images),
130130
date = stamp.activityDate,
131131
content = stamp.contents,
132+
initSnapshotImageModel = ImageModel.Remote(stamp.images),
133+
initSnapshotDate = stamp.activityDate,
134+
initSnapshotContent = stamp.contents,
132135
teamName = stamp.teamName,
133136
stampId = stamp.stampId,
134137
writer = User(
@@ -260,7 +263,10 @@ internal class MissionDetailViewModel @Inject constructor(
260263
_missionDetailState.update {
261264
it.copy(
262265
isLoading = false,
263-
viewType = DetailViewType.COMPLETE
266+
viewType = DetailViewType.COMPLETE,
267+
initSnapshotImageModel = ImageModel.Remote(imageModel.url),
268+
initSnapshotDate = date,
269+
initSnapshotContent = content,
264270
)
265271
}
266272
}.onFailure { e ->

feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/component/Top3RecentRankingMission.kt

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -51,17 +51,13 @@ import androidx.compose.ui.text.style.TextOverflow
5151
import androidx.compose.ui.tooling.preview.Preview
5252
import androidx.compose.ui.unit.dp
5353
import coil.compose.AsyncImage
54-
import java.text.SimpleDateFormat
55-
import java.util.Date
56-
import java.util.Locale
57-
import java.util.TimeZone
58-
import java.util.concurrent.TimeUnit
5954
import org.sopt.official.designsystem.GrayAlpha100
6055
import org.sopt.official.designsystem.SoptTheme
6156
import org.sopt.official.designsystem.White
6257
import org.sopt.official.designsystem.component.UrlImage
6358
import org.sopt.official.feature.appjamtamp.R
6459
import org.sopt.official.feature.appjamtamp.ranking.model.Top3RecentRankingUiModel
60+
import org.sopt.official.feature.appjamtamp.util.toRelativeTime
6561

6662
@Composable
6763
internal fun Top3RecentRankingMission(
@@ -152,32 +148,6 @@ internal fun Top3RecentRankingMission(
152148
}
153149
}
154150

155-
private fun String?.toRelativeTime(): String {
156-
if (this.isNullOrBlank()) return ""
157-
158-
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.KOREA)
159-
dateFormat.timeZone = TimeZone.getTimeZone("Asia/Seoul")
160-
161-
val date = dateFormat.parse(this) ?: return ""
162-
val currentDate = Date()
163-
164-
val diffMillis = currentDate.time - date.time
165-
if (diffMillis < 0) return "1분 전"
166-
167-
val minutes = TimeUnit.MILLISECONDS.toMinutes(diffMillis)
168-
val hours = TimeUnit.MILLISECONDS.toHours(diffMillis)
169-
170-
return when {
171-
minutes == 0L -> "1분 전"
172-
minutes in 1..59 -> "${minutes}분 전"
173-
hours in 1..24 -> "${hours}시간 전"
174-
else -> {
175-
val days = TimeUnit.MILLISECONDS.toDays(diffMillis)
176-
"${days}일 전"
177-
}
178-
}
179-
}
180-
181151
@Preview
182152
@Composable
183153
private fun Top3RecentRankingMissionPreview() {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* MIT License
3+
* Copyright 2025-2026 SOPT - Shout Our Passion Together
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy
6+
* of this software and associated documentation files (the "Software"), to deal
7+
* in the Software without restriction, including without limitation the rights
8+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
* copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be included in all
13+
* copies or substantial portions of the Software.
14+
*
15+
* https://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
* SOFTWARE.
24+
*/
25+
package org.sopt.official.feature.appjamtamp.util
26+
27+
import java.time.LocalDateTime
28+
import java.time.ZoneId
29+
import java.time.ZonedDateTime
30+
import java.time.format.DateTimeFormatter
31+
import java.util.Locale
32+
import java.util.concurrent.TimeUnit
33+
34+
private val seoulZoneId: ZoneId = ZoneId.of("Asia/Seoul")
35+
private val monthDayFormatter: DateTimeFormatter =
36+
DateTimeFormatter.ofPattern("M월 d일", Locale.KOREA)
37+
38+
fun String?.toRelativeTime(
39+
currentDateTime: ZonedDateTime = ZonedDateTime.now(seoulZoneId)
40+
): String {
41+
if (this.isNullOrBlank()) return ""
42+
43+
val dateTime = runCatching {
44+
LocalDateTime.parse(this).atZone(seoulZoneId)
45+
}.getOrNull() ?: return ""
46+
47+
val diffMillis = currentDateTime.toInstant().toEpochMilli() - dateTime.toInstant().toEpochMilli()
48+
49+
if (diffMillis < 0) return "방금 전"
50+
51+
val minutes = TimeUnit.MILLISECONDS.toMinutes(diffMillis)
52+
val hours = TimeUnit.MILLISECONDS.toHours(diffMillis)
53+
val days = TimeUnit.MILLISECONDS.toDays(diffMillis)
54+
55+
return when {
56+
diffMillis < TimeUnit.MINUTES.toMillis(10) -> "방금 전"
57+
diffMillis < TimeUnit.HOURS.toMillis(1) -> "${minutes}분 전"
58+
diffMillis < TimeUnit.DAYS.toMillis(1) -> "${hours}시간 전"
59+
diffMillis < TimeUnit.DAYS.toMillis(7) -> "${days}일 전"
60+
diffMillis < TimeUnit.DAYS.toMillis(28) -> "${days / 7}주 전"
61+
else -> dateTime.format(monthDayFormatter)
62+
}
63+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* MIT License
3+
* Copyright 2026 SOPT - Shout Our Passion Together
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy
6+
* of this software and associated documentation files (the "Software"), to deal
7+
* in the Software without restriction, including without limitation the rights
8+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
* copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be included in all
13+
* copies or substantial portions of the Software.
14+
*
15+
* https://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
* SOFTWARE.
24+
*/
25+
package org.sopt.official.feature.appjamtamp.util
26+
27+
import com.google.common.truth.Truth.assertThat
28+
import java.time.ZoneId
29+
import java.time.ZonedDateTime
30+
import org.junit.jupiter.api.DisplayName
31+
import org.junit.jupiter.params.ParameterizedTest
32+
import org.junit.jupiter.params.provider.Arguments
33+
import org.junit.jupiter.params.provider.MethodSource
34+
35+
class RelativeTimeExtTest {
36+
37+
@ParameterizedTest
38+
@MethodSource("relativeTimeCases")
39+
@DisplayName("createdAt 문자열을 상대 시간 문구로 변환한다")
40+
fun toRelativeTime(description: String, createdAt: String?, expected: String) {
41+
// when
42+
val actual = createdAt.toRelativeTime(currentDateTime = fixedNow)
43+
44+
// then
45+
assertThat(actual).isEqualTo(expected)
46+
}
47+
48+
companion object {
49+
private val fixedNow: ZonedDateTime =
50+
ZonedDateTime.of(2026, 6, 3, 12, 0, 0, 0, ZoneId.of("Asia/Seoul"))
51+
52+
@JvmStatic
53+
fun relativeTimeCases() = listOf(
54+
Arguments.of("null 입력은 빈 문자열을 반환한다", null, ""),
55+
Arguments.of("blank 입력은 빈 문자열을 반환한다", "", ""),
56+
Arguments.of("파싱 불가능한 입력은 빈 문자열을 반환한다", "invalid", ""),
57+
Arguments.of("미래 시각은 방금 전으로 표시한다", "2026-06-03T12:01:00", "방금 전"),
58+
Arguments.of("10분 미만은 방금 전으로 표시한다", "2026-06-03T11:50:01", "방금 전"),
59+
Arguments.of("10분 경계는 분 전으로 표시한다", "2026-06-03T11:50:00", "10분 전"),
60+
Arguments.of("1시간 미만은 분 전으로 표시한다", "2026-06-03T11:01:00", "59분 전"),
61+
Arguments.of("1시간 경계는 시간 전으로 표시한다", "2026-06-03T11:00:00", "1시간 전"),
62+
Arguments.of("24시간 미만은 시간 전으로 표시한다", "2026-06-02T12:01:00", "23시간 전"),
63+
Arguments.of("24시간 경계는 일 전으로 표시한다", "2026-06-02T12:00:00", "1일 전"),
64+
Arguments.of("7일 미만은 일 전으로 표시한다", "2026-05-27T12:00:01", "6일 전"),
65+
Arguments.of("7일 경계는 주 전으로 표시한다", "2026-05-27T12:00:00", "1주 전"),
66+
Arguments.of("28일 미만은 주 전으로 표시한다", "2026-05-06T12:00:01", "3주 전"),
67+
Arguments.of("28일 경계는 월일 포맷으로 표시한다", "2026-05-06T12:00:00", "5월 6일"),
68+
)
69+
}
70+
}

0 commit comments

Comments
 (0)