From 50f0fb3d15ef12640289879700947b246183b4fa Mon Sep 17 00:00:00 2001 From: klaus Date: Wed, 10 Jun 2026 11:29:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/model/ChatRoomTimeTextFormatter.kt | 78 +++++++++ app/src/main/res/values-en/strings.xml | 4 + app/src/main/res/values-ja/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../chat/ChatRoomTimeTextFormatterTest.kt | 153 ++++++++++++++++++ 5 files changed, 243 insertions(+) create mode 100644 app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomTimeTextFormatter.kt create mode 100644 app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomTimeTextFormatterTest.kt diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomTimeTextFormatter.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomTimeTextFormatter.kt new file mode 100644 index 00000000..99592178 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomTimeTextFormatter.kt @@ -0,0 +1,78 @@ +package kr.co.vividnext.sodalive.v2.main.chat.model + +import android.content.Context +import kr.co.vividnext.sodalive.R +import java.text.ParsePosition +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +fun formatChatRoomLastMessageTime( + context: Context, + isoText: String?, + nowMillis: Long = System.currentTimeMillis(), + timeZone: TimeZone = TimeZone.getDefault(), + locale: Locale = Locale.getDefault() +): String { + val messageMillis = parseIsoTimeMillis(isoText) + ?: return context.getString(R.string.screen_chat_time_just_now) + val diffMillis = (nowMillis - messageMillis).coerceAtLeast(0L) + + val minute = 60_000L + val hour = 60 * minute + val day = 24 * hour + + return when { + diffMillis < minute -> context.getString(R.string.screen_chat_time_just_now) + diffMillis < hour -> context.getString(R.string.screen_chat_time_minutes, (diffMillis / minute).toInt()) + diffMillis < day -> context.getString(R.string.screen_chat_time_hours, (diffMillis / hour).toInt()) + diffMillis < 8 * day -> context.getString(R.string.screen_chat_time_days, (diffMillis / day).toInt()) + else -> formatDateText(messageMillis, nowMillis, timeZone, locale) + } +} + +private fun parseIsoTimeMillis(isoText: String?): Long? { + if (isoText.isNullOrBlank()) return null + + val value = isoText.trim() + val patterns = listOf( + "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", + "yyyy-MM-dd'T'HH:mm:ssXXX", + "yyyy-MM-dd'T'HH:mm:ss.SSSXX", + "yyyy-MM-dd'T'HH:mm:ssXX", + "yyyy-MM-dd'T'HH:mm:ss.SSSX", + "yyyy-MM-dd'T'HH:mm:ssX" + ) + + for (pattern in patterns) { + val formatter = SimpleDateFormat(pattern, Locale.US).apply { isLenient = false } + val position = ParsePosition(0) + val parsed: Date? = formatter.parse(value, position) + if (parsed != null && position.index == value.length) return parsed.time + } + + return null +} + +private fun formatDateText( + messageMillis: Long, + nowMillis: Long, + timeZone: TimeZone, + locale: Locale +): String { + val nowCalendar = Calendar.getInstance(timeZone, locale).apply { timeInMillis = nowMillis } + val messageCalendar = Calendar.getInstance(timeZone, locale).apply { timeInMillis = messageMillis } + val pattern = if (nowCalendar.get(Calendar.YEAR) == messageCalendar.get(Calendar.YEAR)) { + when (locale.language) { + Locale.ENGLISH.language -> "MMM d" + Locale.JAPANESE.language -> "M月d日" + else -> "M월 d일" + } + } else { + "yyyy.MM.dd" + } + + return SimpleDateFormat(pattern, locale).apply { this.timeZone = timeZone }.format(Date(messageMillis)) +} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 50fac9e5..df8e8633 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -149,6 +149,10 @@ Chat Live My + Just now + %1$d min ago + %1$d hours ago + %1$d days ago On Air View all There are currently no live broadcasts available, or access may be restricted due to age limits.\nVerify your identity or follow the channel to get notified when a live broadcast starts. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ae524738..adba994e 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -149,6 +149,10 @@ チャット ライブ マイ + たった今 + %1$d分前 + %1$d時間前 + %1$d日前 ただいま配信中 配信中のライブをすべて見る 参加可能なライブがないか、年齢制限で入室できません。\n本人確認を行うか、チャンネルをフォローして\n配信通知を受け取ってみましょう。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 523af957..05145378 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -148,6 +148,10 @@ 대화 라이브 마이 + 방금 전 + %1$d분 전 + %1$d시간 전 + %1$d일 전 지금 라이브 중 지금 라이브 중 전체보기 현재 참여 가능한 라이브 방송이 없거나\n연령제한으로 입장이 불가능합니다.\n본인인증을 해보거나 채널을 팔로잉하고\n라이브 방송 알림을 받아보세요. diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomTimeTextFormatterTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomTimeTextFormatterTest.kt new file mode 100644 index 00000000..5d5c6519 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomTimeTextFormatterTest.kt @@ -0,0 +1,153 @@ +package kr.co.vividnext.sodalive.v2.main.chat + +import android.app.Application +import android.content.Context +import android.content.res.Configuration +import androidx.test.core.app.ApplicationProvider +import kr.co.vividnext.sodalive.R +import kr.co.vividnext.sodalive.v2.main.chat.model.formatChatRoomLastMessageTime +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale +import java.util.TimeZone + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +class ChatRoomTimeTextFormatterTest { + + private val nowMillis = 1_781_006_400_000L + private val seoulTimeZone: TimeZone = TimeZone.getTimeZone("Asia/Seoul") + + @Test + fun `일주일 이내 시간은 상대 시간 문구로 표시한다`() { + val context = localizedContext(Locale.KOREAN) + + assertEquals( + context.getString(R.string.screen_chat_time_just_now), + formatChatRoomLastMessageTime(context, "2026-06-09T11:59:30Z", nowMillis, seoulTimeZone, Locale.KOREAN) + ) + assertEquals( + context.getString(R.string.screen_chat_time_minutes, 3), + formatChatRoomLastMessageTime(context, "2026-06-09T11:57:00Z", nowMillis, seoulTimeZone, Locale.KOREAN) + ) + assertEquals( + context.getString(R.string.screen_chat_time_hours, 2), + formatChatRoomLastMessageTime(context, "2026-06-09T10:00:00Z", nowMillis, seoulTimeZone, Locale.KOREAN) + ) + assertEquals( + context.getString(R.string.screen_chat_time_days, 7), + formatChatRoomLastMessageTime(context, "2026-06-02T12:00:00Z", nowMillis, seoulTimeZone, Locale.KOREAN) + ) + } + + @Test + fun `일주일이 지난 같은 연도 날짜는 locale별 날짜 문구로 표시한다`() { + assertEquals( + "6월 1일", + formatChatRoomLastMessageTime( + localizedContext(Locale.KOREAN), + "2026-06-01T12:00:00Z", + nowMillis, + seoulTimeZone, + Locale.KOREAN + ) + ) + assertEquals( + "Jun 1", + formatChatRoomLastMessageTime( + localizedContext(Locale.ENGLISH), + "2026-06-01T12:00:00Z", + nowMillis, + seoulTimeZone, + Locale.ENGLISH + ) + ) + assertEquals( + "6月1日", + formatChatRoomLastMessageTime( + localizedContext(Locale.JAPANESE), + "2026-06-01T12:00:00Z", + nowMillis, + seoulTimeZone, + Locale.JAPANESE + ) + ) + } + + @Test + fun `전년도 날짜는 yyyy 점 MM 점 dd 형식으로 표시한다`() { + assertEquals( + "2025.12.31", + formatChatRoomLastMessageTime( + localizedContext(Locale.KOREAN), + "2025-12-31T12:00:00Z", + nowMillis, + seoulTimeZone, + Locale.KOREAN + ) + ) + } + + @Test + fun `대상 timezone 변환 결과의 로컬 날짜를 사용한다`() { + assertEquals( + "6월 1일", + formatChatRoomLastMessageTime( + localizedContext(Locale.KOREAN), + "2026-05-31T15:30:00Z", + nowMillis, + seoulTimeZone, + Locale.KOREAN + ) + ) + } + + @Test + fun `분 단위 offset이 포함된 ISO 시간은 offset 전체를 반영한다`() { + val context = localizedContext(Locale.KOREAN) + + assertEquals( + context.getString(R.string.screen_chat_time_minutes, 30), + formatChatRoomLastMessageTime( + context, + "2026-06-09T17:00:00+05:30", + nowMillis, + seoulTimeZone, + Locale.KOREAN + ) + ) + } + + @Test + fun `blank 또는 파싱 실패 시간은 방금 전으로 표시한다`() { + val context = localizedContext(Locale.KOREAN) + + assertEquals( + context.getString(R.string.screen_chat_time_just_now), + formatChatRoomLastMessageTime(context, "", nowMillis, seoulTimeZone, Locale.KOREAN) + ) + assertEquals( + context.getString(R.string.screen_chat_time_just_now), + formatChatRoomLastMessageTime(context, "not-a-date", nowMillis, seoulTimeZone, Locale.KOREAN) + ) + assertEquals( + context.getString(R.string.screen_chat_time_just_now), + formatChatRoomLastMessageTime( + context, + "2026-06-09T12:00:00Zjunk", + nowMillis, + seoulTimeZone, + Locale.KOREAN + ) + ) + } + + private fun localizedContext(locale: Locale): Context { + val context = ApplicationProvider.getApplicationContext() + val configuration = Configuration(context.resources.configuration).apply { setLocale(locale) } + return context.createConfigurationContext(configuration) + } +}