Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
package org.sopt.official.feature.appjamtamp.component

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
Expand All @@ -35,22 +34,24 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.sopt.official.common.util.noRippleClickable
import org.sopt.official.designsystem.SoptTheme

@Composable
internal fun AppjamtampButton(
text: String,
onClicked: () -> Unit,
modifier: Modifier = Modifier,
isEnabled: Boolean = true,
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(
color = SoptTheme.colors.primary,
color = if (isEnabled) SoptTheme.colors.primary else SoptTheme.colors.onSurface300,
shape = RoundedCornerShape(9.dp),
)
.clickable(onClick = onClicked),
.noRippleClickable(onClick = { if (isEnabled) onClicked() }),
Comment on lines 50 to +54
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

isEnabled가 false일 때 버튼은 시각적으로 비활성화되지만, 클릭 한정자(noRippleClickable)는 여전히 연결되어 있어 클릭 이벤트를 가로챕니다(람다 내부에서 아무 동작도 하지 않더라도). 이는 TalkBack과 같은 접근성 서비스나 키보드 탐색 시 비활성화된 버튼이 여전히 클릭 가능한 것처럼 인식되는 문제를 유발할 수 있습니다.

버튼이 비활성화되었을 때는 클릭 리스너 자체가 연결되지 않도록 .let을 사용하여 noRippleClickable 한정자를 조건부로 적용하는 것이 좋습니다.

            .background(
                color = if (isEnabled) SoptTheme.colors.primary else SoptTheme.colors.onSurface300,
                shape = RoundedCornerShape(9.dp),
            )
            .let {
                if (isEnabled) it.noRippleClickable(onClick = onClicked) else it
            }

contentAlignment = Alignment.Center,
) {
Text(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.sopt.official.common.util.throttledNoRippleClickable
import org.sopt.official.designsystem.SoptTheme
import org.sopt.official.feature.appjamtamp.R

Expand All @@ -57,7 +58,7 @@ internal fun BackButtonHeader(
imageVector = ImageVector.vectorResource(R.drawable.ic_back_32),
contentDescription = null,
tint = SoptTheme.colors.onSurface10,
modifier = Modifier.clickable(onClick = onBackButtonClick)
modifier = Modifier.throttledNoRippleClickable(onClick = onBackButtonClick),
)

Text(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,11 @@ internal fun MissionDetailRoute(
}
}

LaunchedEffect(!uiState.isLoading, progress) {
if (progress >= 0.99f && !uiState.isLoading) {
LaunchedEffect(showPostSubmissionBadge, !uiState.isLoading, progress) {
if (showPostSubmissionBadge && progress >= 0.99f && !uiState.isLoading) {
delay(500L)
viewModel.updateShowPostSubmissionBadge()
navigateUp()
}
}
Comment on lines +133 to 139
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

progressLaunchedEffect의 키로 사용하면, progress >= 0.99f 조건이 참이 된 이후에도 Lottie 애니메이션의 매 프레임마다(즉, progress 값이 미세하게 변경될 때마다) 이펙트가 취소되고 재시작됩니다. 이로 인해 불필요한 코루틴 취소 및 재시작이 발생하며, 애니메이션이 완전히 멈출 때까지 delay(500L)가 계속 뒤로 밀리게 됩니다.

대신 LaunchedEffect의 키로는 showPostSubmissionBadge만 사용하고, 내부에서 snapshotFlow를 통해 progress와 loading 조건을 대기하도록 변경하면 불필요한 재시작 없이 부드럽게 동작합니다.

참고: androidx.compose.runtime.snapshotFlowkotlinx.coroutines.flow.first 임포트가 필요합니다.

    LaunchedEffect(showPostSubmissionBadge) {
        if (showPostSubmissionBadge) {
            snapshotFlow { progress >= 0.99f && !uiState.isLoading }
                .first { it }
            delay(500L)
            viewModel.updateShowPostSubmissionBadge()
            navigateUp()
        }
    }


Expand All @@ -148,7 +149,8 @@ internal fun MissionDetailRoute(
},
onDatePickerClick = { isDatePickerVisible = true },
onMemoChange = viewModel::updateContent,
onCompleteButtonClick = viewModel::handleSubmit
onCompleteButtonClick = viewModel::handleSubmit,
isSubmitEnabled = uiState.isSubmitEnabled,
)
} else {
MissionDetailScreen(
Expand Down Expand Up @@ -185,7 +187,8 @@ internal fun MissionDetailRoute(

DetailViewType.EDIT -> viewModel.handleSubmit()
}
}
},
isSubmitEnabled = uiState.isSubmitEnabled,
)
}

Expand Down Expand Up @@ -249,7 +252,8 @@ private fun MyEmptyMissionDetailScreen(
onClickZoomIn: (String) -> Unit,
onDatePickerClick: () -> Unit,
onMemoChange: (String) -> Unit,
onCompleteButtonClick: () -> Unit
onCompleteButtonClick: () -> Unit,
isSubmitEnabled: Boolean,
) {
val scrollState = rememberScrollState()

Expand Down Expand Up @@ -306,6 +310,7 @@ private fun MyEmptyMissionDetailScreen(
AppjamtampButton(
text = "미션 완료",
onClicked = onCompleteButtonClick,
isEnabled = isSubmitEnabled,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp)
Expand All @@ -322,7 +327,8 @@ private fun MissionDetailScreen(
onClickZoomIn: (String) -> Unit,
onDatePickerClick: () -> Unit,
onMemoChange: (String) -> Unit,
onActionButtonClick: () -> Unit
onActionButtonClick: () -> Unit,
isSubmitEnabled: Boolean,
) {
val scrollState = rememberScrollState()
var isEditable by remember(uiState.viewType) { mutableStateOf(uiState.viewType == DetailViewType.EDIT) }
Expand Down Expand Up @@ -436,6 +442,7 @@ private fun MissionDetailScreen(
AppjamtampButton(
text = "미션 완료",
onClicked = onActionButtonClick,
isEnabled = isSubmitEnabled,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 20.dp)
Expand All @@ -456,7 +463,8 @@ private fun MyEmptyMissionDetailScreenPreview() {
onClickZoomIn = {},
onDatePickerClick = {},
onMemoChange = {},
onCompleteButtonClick = {}
onCompleteButtonClick = {},
isSubmitEnabled = true,
)
}
}
Expand All @@ -473,7 +481,8 @@ private fun MyMissionDetailScreenPreview() {
onDatePickerClick = {},
onMemoChange = {},
onActionButtonClick = {},
onToolbarIconClick = {}
onToolbarIconClick = {},
isSubmitEnabled = true,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,22 @@ internal data class MissionDetailState(
val viewCount: Int = 0,

val clappers: ImmutableList<StampClapUserModel> = persistentListOf(),
val showPostSubmissionBadge: Boolean = false
)
val showPostSubmissionBadge: Boolean = false,

val initSnapshotImageModel: ImageModel = ImageModel.Empty,
val initSnapshotDate: String = "",
val initSnapshotContent: String = "",
) {
val isSubmitEnabled: Boolean
get() {
val commonGuard = !isLoading && content.isNotBlank() && date.isNotBlank() && !imageModel.isEmpty()

if (!commonGuard) return false

return when (viewType) {
DetailViewType.WRITE -> true
DetailViewType.EDIT -> content != initSnapshotContent || date != initSnapshotDate || imageModel != initSnapshotImageModel
DetailViewType.READ_ONLY, DetailViewType.COMPLETE -> false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ internal class MissionDetailViewModel @Inject constructor(
imageModel = ImageModel.Remote(stamp.images),
date = stamp.activityDate,
content = stamp.contents,
initSnapshotImageModel = ImageModel.Remote(stamp.images),
initSnapshotDate = stamp.activityDate,
initSnapshotContent = stamp.contents,
teamName = stamp.teamName,
stampId = stamp.stampId,
writer = User(
Expand Down Expand Up @@ -260,7 +263,10 @@ internal class MissionDetailViewModel @Inject constructor(
_missionDetailState.update {
it.copy(
isLoading = false,
viewType = DetailViewType.COMPLETE
viewType = DetailViewType.COMPLETE,
initSnapshotImageModel = ImageModel.Remote(imageModel.url),
initSnapshotDate = date,
initSnapshotContent = content,
Comment on lines +267 to +269
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

modifyMission()에서는 초기 스냅샷 값(initSnapshotImageModel, initSnapshotDate, initSnapshotContent)을 올바르게 업데이트하고 있지만, 새로운 미션을 작성하는 submitMission()에서는 성공적으로 제출된 후 이 스냅샷 값들을 업데이트하지 않고 있습니다.

이로 인해 사용자가 새 미션을 제출한 직후 상세 화면에서 바로 수정을 시도할 경우, 초기 스냅샷 값이 여전히 비어 있는 상태(기본값)로 유지되어 isSubmitEnabled 조건이 오동작하게 됩니다(예: 제출된 미션과 비교해 아무런 변경 사항이 없음에도 완료 버튼이 활성화됨).

submitMission()onSuccess 블록에서도 이 스냅샷 값들을 최신 상태로 업데이트하도록 보완해 주세요.

)
}
}.onFailure { e ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,24 +157,27 @@ private fun String?.toRelativeTime(): String {

val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.KOREA)
dateFormat.timeZone = TimeZone.getTimeZone("Asia/Seoul")
val monthDayFormat = SimpleDateFormat("M월d일", Locale.KOREA).apply {
timeZone = TimeZone.getTimeZone("Asia/Seoul")
}

val date = dateFormat.parse(this) ?: return ""
val date = runCatching { dateFormat.parse(this) }.getOrNull() ?: return ""
val currentDate = Date()

val diffMillis = currentDate.time - date.time
if (diffMillis < 0) return "1분 전"
if (diffMillis < 0) return "방금 전"

val minutes = TimeUnit.MILLISECONDS.toMinutes(diffMillis)
val hours = TimeUnit.MILLISECONDS.toHours(diffMillis)
val days = TimeUnit.MILLISECONDS.toDays(diffMillis)

return when {
minutes == 0L -> "1분 전"
minutes in 1..59 -> "${minutes}분 전"
hours in 1..24 -> "${hours}시간 전"
else -> {
val days = TimeUnit.MILLISECONDS.toDays(diffMillis)
"${days}일 전"
}
minutes < 10L -> "방금 전"
minutes < 60L -> "${minutes}분 전"
hours < 25L -> "${hours}시간 전"
days < 7L -> "${days}일 전"
days < 35L -> "${days / 7}주 전"
else -> monthDayFormat.format(date)
Comment on lines +179 to +180
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

4주 이후 포맷 요구사항과 분기 조건이 어긋납니다.

Line 179의 days < 35L 조건 때문에 28~34일이 "4주 전"으로 표시됩니다. PR 명세(4주 이후는 "n월 n일")에 맞추려면 주 단위 표기는 27일까지로 제한되어야 합니다.

수정 제안 diff
-        days < 35L -> "${days / 7}주 전"
+        days < 28L -> "${days / 7}주 전"
         else -> monthDayFormat.format(date)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/component/Top3RecentRankingMission.kt`
around lines 179 - 180, The week-based formatting branch in
Top3RecentRankingMission (the days-based when/else that currently uses `days <
35L`) incorrectly includes 28–34 days as "n주 전"; change the condition to limit
week display to 27 days (e.g., `days < 28L` or `days <= 27L`) so that any date
>= 28 days falls through to the else branch and uses monthDayFormat.format(date)
per the spec.

}
}
Comment on lines 158 to 182
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

toRelativeTime() 함수가 호출될 때마다 SimpleDateFormat 인스턴스를 매번 생성하는 것은 비효율적입니다. 특히 이 함수는 리스트 아이템의 Jetpack Compose 리컴포지션 중에 호출되므로, SimpleDateFormat 인스턴스 생성 비용이 누적되어 성능 저하를 유발할 수 있습니다.

성능을 최적화하고 불필요한 객체 할당을 피하기 위해 포맷터를 캐싱하는 것이 좋습니다. SimpleDateFormat은 스레드 안전하지 않으므로, 파일 수준의 private Holder 객체 내부에 ThreadLocal을 사용하여 안전하게 캐싱할 수 있습니다.

    val date = runCatching { Holder.dateFormat.get().parse(this) }.getOrNull() ?: return ""
    val currentDate = Date()

    val diffMillis = currentDate.time - date.time
    if (diffMillis < 0) return "방금 전"

    val minutes = TimeUnit.MILLISECONDS.toMinutes(diffMillis)
    val hours = TimeUnit.MILLISECONDS.toHours(diffMillis)
    val days = TimeUnit.MILLISECONDS.toDays(diffMillis)

    return when {
        minutes < 10L -> "방금 전"
        minutes < 60L -> "${minutes}분 전"
        hours < 25L -> "${hours}시간 전"
        days < 7L -> "${days}일 전"
        days < 35L -> "${days / 7}주 전"
        else -> Holder.monthDayFormat.get().format(date)
    }
}

private object Holder {
    val dateFormat = object : ThreadLocal<SimpleDateFormat>() {
        override fun initialValue() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.KOREA).apply {
            timeZone = TimeZone.getTimeZone("Asia/Seoul")
        }
    }
    val monthDayFormat = object : ThreadLocal<SimpleDateFormat>() {
        override fun initialValue() = SimpleDateFormat("M월d일", Locale.KOREA).apply {
            timeZone = TimeZone.getTimeZone("Asia/Seoul")
        }
    }
}


Expand Down
Loading