diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 00000000..4ef10f8b --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,24 @@ +coverage: + status: + project: + default: + target: 60% + threshold: 2% + +comment: + layout: "reach, diff, flags, files" + behavior: default + require_changes: true + +parsers: + gcov: + branch_detection: + conditional: true + loop: true + method: true + macro: true + +ignore: + - "**/di/**" + - "**/BuildConfig.*" + - "**/generated/**" diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index d8ca94cd..0330be6d 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -85,21 +85,11 @@ jobs: - name: Run unit tests and generate coverage run: ./gradlew generateTestCoverageReport - # Upload Coverage Report - - name: Upload coverage report - uses: actions/upload-artifact@v4 + # Upload Coverage to Codecov + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 with: - name: coverage-report - path: data/build/reports/coverage/test/debug/ - - # Comment PR with coverage result - - name: Comment coverage report in PR - if: github.event_name == 'pull_request' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - COMMENT="๐Ÿš€ ํ…Œ์ŠคํŠธ ์™„๋ฃŒ ๋ฐ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฆฌํฌํŠธ๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\nโžก๏ธ [ํด๋ฆญํ•˜์—ฌ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ฆฌํฌํŠธ ๋‹ค์šด๋กœ๋“œ](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" - curl -s -H "Authorization: token $GITHUB_TOKEN" \ - -X POST \ - -d "{\"body\": \"$COMMENT\"}" \ - "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" + token: ${{ secrets.CODECOV_TOKEN }} + files: data/build/reports/jacoco/testDebugUnitTestCoverage/testDebugUnitTestCoverage.xml + name: codecov-report + fail_ci_if_error: true diff --git a/build-logic/src/main/java/com/yapp/convention/TestAndroid.kt b/build-logic/src/main/java/com/yapp/convention/TestAndroid.kt index 4e6afa62..e4fcee6b 100644 --- a/build-logic/src/main/java/com/yapp/convention/TestAndroid.kt +++ b/build-logic/src/main/java/com/yapp/convention/TestAndroid.kt @@ -22,7 +22,6 @@ internal fun Project.configureComposeUiTest() { @Suppress("UnstableApiUsage") internal fun Project.configureJUnitAndroid() { androidExtension.apply { - testOptions { unitTests.all { it.useJUnitPlatform() } } defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } val libs = extensions.libs diff --git a/build-logic/src/main/java/com/yapp/convention/TestCoverage.kt b/build-logic/src/main/java/com/yapp/convention/TestCoverage.kt index fd54ed18..70510798 100644 --- a/build-logic/src/main/java/com/yapp/convention/TestCoverage.kt +++ b/build-logic/src/main/java/com/yapp/convention/TestCoverage.kt @@ -8,6 +8,7 @@ import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.withType import org.gradle.testing.jacoco.plugins.JacocoPluginExtension import org.gradle.testing.jacoco.plugins.JacocoTaskExtension +import org.gradle.testing.jacoco.tasks.JacocoReport internal fun Project.configureTestCoverage() { pluginManager.apply("jacoco") @@ -51,5 +52,7 @@ internal fun Project.configureTestCoverage() { .get().asFile execFile.exists() } + + (this as? JacocoReport)?.reports?.xml?.required?.set(true) } } diff --git a/core/alarm/build.gradle.kts b/core/alarm/build.gradle.kts index 2ea1ec36..c5ab38e0 100644 --- a/core/alarm/build.gradle.kts +++ b/core/alarm/build.gradle.kts @@ -11,6 +11,7 @@ android { dependencies { implementation(projects.core.analytics) + implementation(projects.core.common) implementation(projects.core.designsystem) implementation(projects.core.media) implementation(projects.domain) diff --git a/core/alarm/src/main/java/com/yapp/alarm/AlarmConstants.kt b/core/alarm/src/main/java/com/yapp/alarm/AlarmConstants.kt index 67d359f1..e007649e 100644 --- a/core/alarm/src/main/java/com/yapp/alarm/AlarmConstants.kt +++ b/core/alarm/src/main/java/com/yapp/alarm/AlarmConstants.kt @@ -16,8 +16,6 @@ object AlarmConstants { const val SNOOZE_ID_OFFSET = 10000 - const val WEEK_INTERVAL_MILLIS: Long = 7 * 24 * 60 * 60 * 1000 - val HOLIDAYS_2025 = setOf( "2025-01-01", "2025-01-27", "2025-01-28", "2025-01-29", "2025-01-30", "2025-03-01", "2025-03-03", "2025-05-05", "2025-05-06", "2025-06-06", diff --git a/core/alarm/src/main/java/com/yapp/alarm/AlarmTimeCalculator.kt b/core/alarm/src/main/java/com/yapp/alarm/AlarmTimeCalculator.kt new file mode 100644 index 00000000..f7d6e590 --- /dev/null +++ b/core/alarm/src/main/java/com/yapp/alarm/AlarmTimeCalculator.kt @@ -0,0 +1,98 @@ +package com.yapp.alarm + +import com.yapp.domain.model.Alarm +import com.yapp.domain.model.AlarmDay +import com.yapp.domain.model.toDayOfWeek +import java.time.Clock +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +class AlarmTimeCalculator @Inject constructor( + private val clock: Clock, +) { + private val holidayDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + private fun isHoliday(dateToCheck: LocalDateTime): Boolean { + if (dateToCheck.year == 2025) { + val dateString = dateToCheck.format(holidayDateFormatter) + return AlarmConstants.HOLIDAYS_2025.contains(dateString) + } + return false + } + + private fun skipHolidaysIfEnabled(initialDateTime: LocalDateTime, alarm: Alarm): LocalDateTime { + if (!alarm.isHolidayAlarmOff) return initialDateTime + + var adjustedDateTime = initialDateTime + while (isHoliday(adjustedDateTime)) { + adjustedDateTime = adjustedDateTime.plusWeeks(1) + } + + return adjustedDateTime + } + + private fun getAlarmDateTimeOnDate(alarm: Alarm, now: LocalDateTime): LocalDateTime { + return now + .withHour(alarm.hour) + .withMinute(alarm.minute) + .withSecond(alarm.second) + .withNano(0) + } + + fun calculateNextRepeatingTimeMillis( + alarm: Alarm, + alarmDay: AlarmDay, + zoneId: ZoneId = clock.zone, + ): Long { + val now = LocalDateTime.now(clock) + val targetDayOfWeek = alarmDay.toDayOfWeek() + + val alarmDateTimeToday = getAlarmDateTimeOnDate(alarm, now) + + var nextAlarmDateTimeCandidate = alarmDateTimeToday + + while (nextAlarmDateTimeCandidate.dayOfWeek != targetDayOfWeek || nextAlarmDateTimeCandidate.isBefore(now)) { + nextAlarmDateTimeCandidate = nextAlarmDateTimeCandidate.plusDays(1) + } + + nextAlarmDateTimeCandidate = skipHolidaysIfEnabled(nextAlarmDateTimeCandidate, alarm) + + return nextAlarmDateTimeCandidate.atZone(zoneId).toInstant().toEpochMilli() + } + + fun calculateNonRepeatingTimeMillis( + alarm: Alarm, + zoneId: ZoneId = clock.zone, + ): Long { + val now = LocalDateTime.now(clock) + var alarmDateTime = getAlarmDateTimeOnDate(alarm, now) + + if (alarmDateTime.isBefore(now)) { + alarmDateTime = alarmDateTime.plusDays(1) + } + + return alarmDateTime.atZone(zoneId).toInstant().toEpochMilli() + } + + fun calculateNextWeeklyRescheduledTimeMillis( + alarm: Alarm, + alarmTargetDay: AlarmDay, + zoneId: ZoneId = clock.zone, + ): Long { + val now = LocalDateTime.now(clock) + val targetDayOfWeek = alarmTargetDay.toDayOfWeek() + + var initialAlarmDateTimeCandidate = getAlarmDateTimeOnDate(alarm, now) + + while (initialAlarmDateTimeCandidate.dayOfWeek != targetDayOfWeek || initialAlarmDateTimeCandidate.isBefore(now)) { + initialAlarmDateTimeCandidate = initialAlarmDateTimeCandidate.plusDays(1) + } + + val nextWeeklyAlarmDateTimeCandidate = initialAlarmDateTimeCandidate.plusWeeks(1) + val nextWeeklyAlarmDateTime = skipHolidaysIfEnabled(nextWeeklyAlarmDateTimeCandidate, alarm) + + return nextWeeklyAlarmDateTime.atZone(zoneId).toInstant().toEpochMilli() + } +} diff --git a/core/alarm/src/main/java/com/yapp/alarm/AndroidAlarmScheduler.kt b/core/alarm/src/main/java/com/yapp/alarm/AndroidAlarmScheduler.kt index 88320196..2691e78b 100644 --- a/core/alarm/src/main/java/com/yapp/alarm/AndroidAlarmScheduler.kt +++ b/core/alarm/src/main/java/com/yapp/alarm/AndroidAlarmScheduler.kt @@ -2,24 +2,20 @@ package com.yapp.alarm import android.app.AlarmManager import android.app.Application -import android.util.Log import com.yapp.alarm.pendingIntent.schedule.createAlarmReceiverPendingIntentForSchedule import com.yapp.alarm.pendingIntent.schedule.createAlarmReceiverPendingIntentForUnSchedule import com.yapp.domain.model.Alarm import com.yapp.domain.model.AlarmDay import com.yapp.domain.model.toAlarmDays -import com.yapp.domain.model.toDayOfWeek import com.yapp.domain.scheduler.AlarmScheduler -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.format.DateTimeFormatter import javax.inject.Inject class AndroidAlarmScheduler @Inject constructor( private val app: Application, private val alarmManager: AlarmManager, + private val alarmTimeCalculator: AlarmTimeCalculator, ) : AlarmScheduler { + override fun scheduleAlarm(alarm: Alarm) { val selectedDays = alarm.repeatDays.toAlarmDays() @@ -32,10 +28,8 @@ class AndroidAlarmScheduler @Inject constructor( } } - fun scheduleWeeklyAlarm(alarm: Alarm, day: AlarmDay) { - val initialTriggerMillis = getNextAlarmTimeMillis(alarm, day) + AlarmConstants.WEEK_INTERVAL_MILLIS - val triggerMillis = findNextNonHolidayDate(initialTriggerMillis) - + private fun setRepeatingAlarm(day: AlarmDay, alarm: Alarm) { + val triggerMillis = alarmTimeCalculator.calculateNextRepeatingTimeMillis(alarm, day) val pendingIntent = createAlarmReceiverPendingIntentForSchedule(app, alarm, day) alarmManager.setExactAndAllowWhileIdle( @@ -43,8 +37,28 @@ class AndroidAlarmScheduler @Inject constructor( triggerMillis, pendingIntent, ) + } + + private fun setNonRepeatingAlarm(alarm: Alarm) { + val triggerMillis = alarmTimeCalculator.calculateNonRepeatingTimeMillis(alarm) + val pendingIntent = createAlarmReceiverPendingIntentForSchedule(app, alarm) + + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerMillis, + pendingIntent, + ) + } + + fun rescheduleUpcomingWeeklyAlarm(alarm: Alarm, day: AlarmDay) { + val triggerMillis = alarmTimeCalculator.calculateNextWeeklyRescheduledTimeMillis(alarm, day) + val pendingIntent = createAlarmReceiverPendingIntentForSchedule(app, alarm, day) - Log.d("AlarmHelper", "Scheduled weekly alarm for $day at: $triggerMillis") + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerMillis, + pendingIntent, + ) } override fun unScheduleAlarm(alarm: Alarm) { @@ -73,85 +87,5 @@ class AndroidAlarmScheduler @Inject constructor( val snoozedAlarmId = alarmId + AlarmConstants.SNOOZE_ID_OFFSET val pendingIntent = createAlarmReceiverPendingIntentForUnSchedule(app, Alarm(id = snoozedAlarmId)) alarmManager.cancel(pendingIntent) - Log.d("AlarmHelper", "Canceled snoozed alarm with id: $snoozedAlarmId") - } - - private fun setRepeatingAlarm(day: AlarmDay, alarm: Alarm) { - val alarmReceiverPendingIntent = - createAlarmReceiverPendingIntentForSchedule(app, alarm, day) - val firstAlarmTriggerMillis = getNextAlarmTimeMillis(alarm, day) - - Log.d("AlarmHelper", "Setting repeating alarm id: ${alarm.id} at: $firstAlarmTriggerMillis") - - alarmManager.setExactAndAllowWhileIdle( - AlarmManager.RTC_WAKEUP, - firstAlarmTriggerMillis, - alarmReceiverPendingIntent, - ) - } - - private fun setNonRepeatingAlarm(alarm: Alarm) { - val alarmReceiverPendingIntent = - createAlarmReceiverPendingIntentForSchedule(app, alarm) - - val triggerMillis = getNextAlarmTimeMillis(alarm, null) - - Log.d("AlarmHelper", "Setting one-time alarm at: $triggerMillis") - - alarmManager.setExactAndAllowWhileIdle( - AlarmManager.RTC_WAKEUP, - triggerMillis, - alarmReceiverPendingIntent, - ) - } - - private fun getNextAlarmTimeMillis(alarm: Alarm, day: AlarmDay?): Long { - val now = LocalDateTime.now().withNano(0) // ๋ฐ€๋ฆฌ์ดˆ ์ œ๊ฑฐํ•˜์—ฌ ์ •ํ™•ํ•œ ์ดˆ ๊ธฐ์ค€ ์„ค์ • - - val alarmHour = when { - alarm.isAm && alarm.hour == 12 -> 0 - !alarm.isAm && alarm.hour != 12 -> alarm.hour + 12 - else -> alarm.hour - } - - var alarmDateTime = now.withHour(alarmHour).withMinute(alarm.minute).withSecond(alarm.second) - - if (day != null) { - val targetDayOfWeek = day.toDayOfWeek() - while (alarmDateTime.dayOfWeek != targetDayOfWeek || alarmDateTime.isBefore(now)) { - alarmDateTime = alarmDateTime.plusDays(1) - } - } else { - if (alarmDateTime.isBefore(now)) { - alarmDateTime = alarmDateTime.plusDays(1) - } - } - - val epochMillis = alarmDateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - - Log.d("AlarmHelper", "Alarm scheduled at: $alarmDateTime (epochMillis=$epochMillis)") - - return epochMillis - } - - private fun findNextNonHolidayDate(initialMillis: Long): Long { - val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") - - var adjustedMillis = initialMillis - - while (true) { - val localDate = Instant.ofEpochMilli(adjustedMillis) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - - val dateString = localDate.format(dateFormatter) - - if (!AlarmConstants.HOLIDAYS_2025.contains(dateString)) { - return adjustedMillis // ๊ณตํœด์ผ์ด ์•„๋‹ˆ๋ผ๋ฉด ํ•ด๋‹น ๋‚ ์งœ ๋ฐ˜ํ™˜ - } - - // ๊ณตํœด์ผ์ด๋ผ๋ฉด ๋‹ค์Œ 1์ฃผ ๋’ค๋กœ ์ด๋™ - adjustedMillis += AlarmConstants.WEEK_INTERVAL_MILLIS - } } } diff --git a/core/alarm/src/main/java/com/yapp/alarm/di/AlarmModule.kt b/core/alarm/src/main/java/com/yapp/alarm/di/AlarmModule.kt index 40f44869..d5b5d624 100644 --- a/core/alarm/src/main/java/com/yapp/alarm/di/AlarmModule.kt +++ b/core/alarm/src/main/java/com/yapp/alarm/di/AlarmModule.kt @@ -2,6 +2,7 @@ package com.yapp.alarm.di import android.app.AlarmManager import android.content.Context +import com.yapp.alarm.AlarmTimeCalculator import com.yapp.alarm.AndroidAlarmScheduler import com.yapp.domain.scheduler.AlarmScheduler import dagger.Binds @@ -10,6 +11,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import java.time.Clock import javax.inject.Singleton @Module @@ -22,6 +24,12 @@ abstract class AlarmModule { ): AlarmScheduler companion object { + @Provides + @Singleton + fun provideAlarmTimeCalculator(clock: Clock): AlarmTimeCalculator { + return AlarmTimeCalculator(clock) + } + @Provides @Singleton fun provideAlarmManager(@ApplicationContext context: Context): AlarmManager { diff --git a/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmReceiver.kt b/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmReceiver.kt index c2eee35c..878cf68e 100644 --- a/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmReceiver.kt +++ b/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmReceiver.kt @@ -152,8 +152,7 @@ class AlarmReceiver : BroadcastReceiver() { .plusMinutes(alarm.snoozeInterval.toLong()) val updatedAlarm = alarm.copy( - isAm = snoozeDateTime.hour < 12, - hour = if (snoozeDateTime.hour == 0) 12 else if (snoozeDateTime.hour > 12) snoozeDateTime.hour - 12 else snoozeDateTime.hour, + hour = snoozeDateTime.hour, minute = snoozeDateTime.minute, second = snoozeDateTime.second, repeatDays = 0, diff --git a/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt b/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt index e0db3f5c..96ebde07 100644 --- a/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt +++ b/core/alarm/src/main/java/com/yapp/alarm/services/AlarmService.kt @@ -103,7 +103,7 @@ class AlarmService : Service() { // ๋ฐ˜๋ณต ์š”์ผ ์•Œ๋žŒ ์‹œ, ๋‹ค์Œ ์ฃผ ๋™์ผ ์š”์ผ ์•Œ๋žŒ ์˜ˆ์•ฝ if (!isOneTimeAlarm) { intent.getStringExtra(AlarmConstants.EXTRA_ALARM_DAY)?.let { - androidAlarmScheduler.scheduleWeeklyAlarm(alarm, AlarmDay.valueOf(it)) + androidAlarmScheduler.rescheduleUpcomingWeeklyAlarm(alarm, AlarmDay.valueOf(it)) } } diff --git a/core/alarm/src/test/kotlin/com/yapp/alarm/AlarmTimeCalculatorTest.kt b/core/alarm/src/test/kotlin/com/yapp/alarm/AlarmTimeCalculatorTest.kt new file mode 100644 index 00000000..a4055be2 --- /dev/null +++ b/core/alarm/src/test/kotlin/com/yapp/alarm/AlarmTimeCalculatorTest.kt @@ -0,0 +1,468 @@ +import com.yapp.alarm.AlarmTimeCalculator +import com.yapp.domain.model.Alarm +import com.yapp.domain.model.AlarmDay +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Clock +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId + +class AlarmTimeCalculatorTest { + + private val testZoneId: ZoneId = ZoneId.of("Asia/Seoul") + + // --- ๊ธฐ์ค€ ์‹œ๊ฐ (Fixed Clocks) --- + private val MONDAY_2024_07_22_10AM: LocalDateTime = LocalDateTime.of(2024, 7, 22, 10, 0, 0) + private val clockMonday2024_10am: Clock = Clock.fixed( + MONDAY_2024_07_22_10AM.toInstant( + testZoneId.rules.getOffset(MONDAY_2024_07_22_10AM) + ), testZoneId + ) + + private val FRIDAY_2024_07_26_3PM: LocalDateTime = LocalDateTime.of(2024, 7, 26, 15, 0, 0) + private val clockFriday2024_3pm: Clock = Clock.fixed( + FRIDAY_2024_07_26_3PM.toInstant(testZoneId.rules.getOffset(FRIDAY_2024_07_26_3PM)), + testZoneId + ) + + private val MONDAY_2025_01_20_10AM: LocalDateTime = LocalDateTime.of(2025, 1, 20, 10, 0, 0) + private val clockMonday2025_01_20_10am: Clock = Clock.fixed( + MONDAY_2025_01_20_10AM.toInstant( + testZoneId.rules.getOffset(MONDAY_2025_01_20_10AM) + ), testZoneId + ) + + private val MONDAY_2025_01_20_2_01PM: LocalDateTime = LocalDateTime.of(2025, 1, 20, 14, 1, 0) + private val clockMonday2025_PrevHoliday_2_01pm: Clock = Clock.fixed( + MONDAY_2025_01_20_2_01PM.toInstant( + testZoneId.rules.getOffset(MONDAY_2025_01_20_2_01PM) + ), testZoneId + ) + + private val MONDAY_HOLIDAY_2025_01_27_10AM: LocalDateTime = + LocalDateTime.of(2025, 1, 27, 10, 0, 0) + private val clockMondayHoliday2025_10am: Clock = Clock.fixed( + MONDAY_HOLIDAY_2025_01_27_10AM.toInstant( + testZoneId.rules.getOffset(MONDAY_HOLIDAY_2025_01_27_10AM) + ), testZoneId + ) + + private val MONDAY_2025_02_17_10AM: LocalDateTime = LocalDateTime.of(2025, 2, 17, 10, 0, 0) + private val clockMonday2025_02_17_10am: Clock = Clock.fixed( + MONDAY_2025_02_17_10AM.toInstant( + testZoneId.rules.getOffset(MONDAY_2025_02_17_10AM) + ), testZoneId + ) + + private fun createTestAlarm( + hour: Int, + minute: Int, + second: Int = 0, + isHolidayAlarmOff: Boolean = false, + repeatDays: Int = 0, // ๊ธฐ๋ณธ๊ฐ’์€ ๋น„๋ฐ˜๋ณต + ): Alarm { + return Alarm( + hour = hour, + minute = minute, + second = second, + repeatDays = repeatDays, + isHolidayAlarmOff = isHolidayAlarmOff, + ) + } + + private fun getExpectedMillis(dateTime: LocalDateTime, zone: ZoneId = testZoneId): Long { + return dateTime.atZone(zone).toInstant().toEpochMilli() + } + + // --- ๋น„๋ฐ˜๋ณต ์•Œ๋žŒ ์‹œ๊ฐ„ ๊ณ„์‚ฐ (calculateNonRepeatingTimeMillis) ํ…Œ์ŠคํŠธ --- + @Test + fun `๋น„๋ฐ˜๋ณต_์•Œ๋žŒ์‹œ๊ฐ„์ด_์˜ค๋Š˜_๋ฏธ๋ž˜์ด๋ฉด_์˜ค๋Š˜_์•Œ๋žŒ์‹œ๊ฐ„์œผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2024-07-22 (์›”) 10:00:00 + // ์•Œ๋žŒ: ์˜ค๋Š˜ 14:00:00, ๋น„๋ฐ˜๋ณต + // ๊ธฐ๋Œ€: 2024-07-22 (์›”) 14:00:00 + + // given + val calculator = AlarmTimeCalculator(clockMonday2024_10am) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm(alarmTime.hour, alarmTime.minute) // repeatDays = 0 (๋น„๋ฐ˜๋ณต) + + // when + val actualMillis = calculator.calculateNonRepeatingTimeMillis(alarm, testZoneId) + + // then + val expectedDateTime = MONDAY_2024_07_22_10AM.with(alarmTime) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `๋น„๋ฐ˜๋ณต_์•Œ๋žŒ์‹œ๊ฐ„์ด_์˜ค๋Š˜_๊ณผ๊ฑฐ์ด๋ฉด_๋‚ด์ผ_์•Œ๋žŒ์‹œ๊ฐ„์œผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2024-07-22 (์›”) 10:00:00 + // ์•Œ๋žŒ: ์˜ค๋Š˜ 08:00:00 (์ด๋ฏธ ์ง€๋‚จ), ๋น„๋ฐ˜๋ณต + // ๊ธฐ๋Œ€: 2024-07-23 (ํ™”) 08:00:00 + + // given + val calculator = AlarmTimeCalculator(clockMonday2024_10am) + val alarmTime = LocalTime.of(8, 0) + val alarm = createTestAlarm(alarmTime.hour, alarmTime.minute) // repeatDays = 0 (๋น„๋ฐ˜๋ณต) + + // when + val actualMillis = calculator.calculateNonRepeatingTimeMillis(alarm, testZoneId) + + // then + val expectedDateTime = MONDAY_2024_07_22_10AM.plusDays(1).with(alarmTime) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + // --- ๋‹ค์Œ ๋ฐ˜๋ณต ์š”์ผ ์•Œ๋žŒ ์‹œ๊ฐ„ ๊ณ„์‚ฐ (calculateNextRepeatingTimeMillis) ํ…Œ์ŠคํŠธ --- + @Test + fun `๋ฐ˜๋ณต์š”์ผ์•Œ๋žŒ_์˜ค๋Š˜์ด_๋Œ€์ƒ์š”์ผ์ด๊ณ _์•Œ๋žŒ์‹œ๊ฐ„์ด_๋ฏธ๋ž˜์ด๋ฉฐ_๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_๋น„ํ™œ์„ฑ์‹œ_์˜ค๋Š˜๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2024-07-22 (์›”) 10:00:00 + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 14:00:00, ๊ณตํœด์ผ ๊ฑด๋„ˆ๋›ฐ๊ธฐ ๋น„ํ™œ์„ฑ + // ๊ธฐ๋Œ€: 2024-07-22 (์›”) 14:00:00 + + // given + val calculator = AlarmTimeCalculator(clockMonday2024_10am) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = false, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextRepeatingTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = MONDAY_2024_07_22_10AM.with(alarmTime) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `๋ฐ˜๋ณต์š”์ผ์•Œ๋žŒ_์˜ค๋Š˜์ด_๊ณตํœด์ผ์ธ_์šธ๋ฆด์š”์ผ์ด๊ณ _์•Œ๋žŒ์‹œ๊ฐ„์ด_๋ฏธ๋ž˜์ด๋ฉฐ_๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_๋น„ํ™œ์„ฑ์‹œ_์˜ค๋Š˜_๊ณตํœด์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2025-01-27 (์›”, ๊ณตํœด์ผ) 10:00:00 + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 14:00:00, ๊ณตํœด์ผ ๊ฑด๋„ˆ๋›ฐ๊ธฐ ๋น„ํ™œ์„ฑ + // ๊ธฐ๋Œ€: 2025-01-27 (์›”, ๊ณตํœด์ผ) 14:00:00 (๊ฑด๋„ˆ๋›ฐ๊ธฐ ๋น„ํ™œ์„ฑ์ด๋ฏ€๋กœ ์˜ค๋Š˜ ๊ณตํœด์ผ์ด์–ด๋„ ์šธ๋ฆผ) + + // given + val calculator = AlarmTimeCalculator(clockMondayHoliday2025_10am) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = false, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextRepeatingTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = MONDAY_HOLIDAY_2025_01_27_10AM.with(alarmTime) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `๋ฐ˜๋ณต์š”์ผ์•Œ๋žŒ_๋‹ค์Œ์ฃผ_์šธ๋ฆด์š”์ผ์ด_๊ณตํœด์ผ์ด๊ณ _๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_๋น„ํ™œ์„ฑ์‹œ_๋‹ค์Œ์ฃผ_๊ณตํœด์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2025-01-20 (์›”) 10:00:00 (๊ณตํœด์ผ ์•„๋‹Œ ์›”์š”์ผ) + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 09:00:00, ๊ณตํœด์ผ ๊ฑด๋„ˆ๋›ฐ๊ธฐ ๋น„ํ™œ์„ฑ + // ๋‹ค์Œ ์ฃผ ์›”์š”์ผ: 2025-01-27 (๊ณตํœด์ผ) + // ๊ธฐ๋Œ€: 2025-01-27 (์›”, ๊ณตํœด์ผ) 09:00:00 (๊ฑด๋„ˆ๋›ฐ๊ธฐ ๋น„ํ™œ์„ฑ์ด๋ฏ€๋กœ ๋‹ค์Œ ์ฃผ ๊ณตํœด์ผ์ด์–ด๋„ ์šธ๋ฆผ) + + // given + val calculator = AlarmTimeCalculator(clockMonday2025_01_20_10am) + val alarmTime = LocalTime.of(9, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = false, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextRepeatingTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = LocalDateTime.of(2025, 1, 27, 9, 0, 0) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `๋ฐ˜๋ณต์š”์ผ์•Œ๋žŒ_๋Œ€์ƒ์š”์ผ์ด_์ด๋ฒˆ์ฃผ_๋ฏธ๋ž˜์š”์ผ์ด๊ณ _๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_๋น„ํ™œ์„ฑ์‹œ_ํ•ด๋‹น์š”์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2024-07-22 (์›”) 10:00:00 + // ์•Œ๋žŒ: ๋งค์ฃผ ์ˆ˜์š”์ผ 11:00:00, ๊ณตํœด์ผ ๊ฑด๋„ˆ๋›ฐ๊ธฐ ๋น„ํ™œ์„ฑ + // ๊ธฐ๋Œ€: 2024-07-24 (์ˆ˜) 11:00:00 + + // given + val calculator = AlarmTimeCalculator(clockMonday2024_10am) + val alarmTime = LocalTime.of(11, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = false, + repeatDays = AlarmDay.WED.bitValue // ์ˆ˜์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextRepeatingTimeMillis(alarm, AlarmDay.WED, testZoneId) + + // then + val expectedDateTime = + MONDAY_2024_07_22_10AM.plusDays(2).with(alarmTime) // 2024-07-24 (์ˆ˜) 11:00 + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `๋ฐ˜๋ณต์š”์ผ์•Œ๋žŒ_์˜ค๋Š˜์ด_๋Œ€์ƒ์š”์ผ์ด๋‚˜_์‹œ๊ฐ„์ด_์ง€๋‚ฌ๊ณ _๋‹ค์Œ์ฃผ_ํ•ด๋‹น์š”์ผ์ด_๊ณตํœด์ผ์ด๋ฉฐ_๊ณตํœด์ผ_๋น„ํ™œ์„ฑ์‹œ_๋‹ค์Œ์ฃผ_๊ณตํœด์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2025-01-20 (์›”) 14:01 (์›”์š”์ผ 14:00 ์•Œ๋žŒ ํ›„) + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 14:00, isHolidayAlarmOff = false + // ๋‹ค์Œ์ฃผ ์›”์š”์ผ: 2025-01-27 (๊ณตํœด์ผ) + // ๊ธฐ๋Œ€: 2025-01-27 (์›”) 14:00 (์˜ต์…˜ Off์ด๋ฏ€๋กœ ๊ณตํœด์ผ์ด์–ด๋„ ์šธ๋ฆผ) + + // given + val calculator = AlarmTimeCalculator(clockMonday2025_PrevHoliday_2_01pm) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = false, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextRepeatingTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = LocalDateTime.of(2025, 1, 27, 14, 0, 0) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `๋ฐ˜๋ณต์š”์ผ์•Œ๋žŒ_์˜ค๋Š˜์ด_๋Œ€์ƒ์š”์ผ์ด๋‚˜_์‹œ๊ฐ„์ด_์ง€๋‚ฌ๊ณ _๋‹ค์Œ์ฃผ_ํ•ด๋‹น์š”์ผ์ด_๊ณตํœด์ผ์ด๋ฉฐ_๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_ํ™œ์„ฑ์‹œ_๋‹ค๋‹ค์Œ์ฃผ_ํ•ด๋‹น์š”์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2025-01-20 (์›”) 14:01 (์›”์š”์ผ 14:00 ์•Œ๋žŒ ํ›„) + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 14:00, isHolidayAlarmOff = true + // ๋‹ค์Œ์ฃผ ์›”์š”์ผ: 2025-01-27 (๊ณตํœด์ผ) + // ๊ธฐ๋Œ€: ๋‹ค๋‹ค์Œ์ฃผ ์›”์š”์ผ 2025-02-03 (์›”) 14:00 + + // given + val calculator = AlarmTimeCalculator(clockMonday2025_PrevHoliday_2_01pm) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = true, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextRepeatingTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = LocalDateTime.of(2025, 2, 3, 14, 0, 0) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `๋ฐ˜๋ณต์š”์ผ์•Œ๋žŒ_์˜ค๋Š˜์ด_๊ณตํœด์ผ์ธ_๋Œ€์ƒ์š”์ผ์ด๊ณ _์•Œ๋žŒ์‹œ๊ฐ„์ด_๋ฏธ๋ž˜์ด๋ฉฐ_๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_ํ™œ์„ฑ์‹œ_๋‹ค์Œ์ฃผ_ํ•ด๋‹น์š”์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2025-01-27 (์›”, ๊ณตํœด์ผ) 10:00 + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 14:00, isHolidayAlarmOff = true + // ๊ธฐ๋Œ€: ๋‹ค์Œ์ฃผ ์›”์š”์ผ 2025-02-03 (์›”) 14:00 + + // given + val calculator = AlarmTimeCalculator(clockMondayHoliday2025_10am) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = true, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextRepeatingTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = LocalDateTime.of(2025, 2, 3, 14, 0, 0) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `๋ฐ˜๋ณต์š”์ผ์•Œ๋žŒ_์˜ค๋Š˜์ด_๊ณตํœด์ผ์ธ_๋Œ€์ƒ์š”์ผ์ด๊ณ _์•Œ๋žŒ์‹œ๊ฐ„์ด_๋ฏธ๋ž˜์ด๋ฉฐ_๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_๋น„ํ™œ์„ฑ์‹œ_์˜ค๋Š˜_๊ณตํœด์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2025-01-27 (์›”, ๊ณตํœด์ผ) 10:00 + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 14:00, isHolidayAlarmOff = false + // ๊ธฐ๋Œ€: ์˜ค๋Š˜ 2025-01-27 (์›”) 14:00 + + // given + val calculator = AlarmTimeCalculator(clockMondayHoliday2025_10am) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = false, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextRepeatingTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = MONDAY_HOLIDAY_2025_01_27_10AM.with(alarmTime) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + + // --- ๋‹ค์Œ ์ฃผ๊ฐ„ ์žฌ์˜ˆ์•ฝ ์•Œ๋žŒ ์‹œ๊ฐ„ ๊ณ„์‚ฐ (calculateNextWeeklyRescheduledTimeMillis) ํ…Œ์ŠคํŠธ --- + @Test + fun `์ฃผ๊ฐ„์žฌ์˜ˆ์•ฝ_ํ˜„์žฌ_์›”์š”์ผ์˜ค์ „_๋Œ€์ƒ๋„_์›”์š”์ผ_๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_๋น„ํ™œ์„ฑ_๋‹ค์Œ์ฃผ_์›”์š”์ผ์ด_๊ณตํœด์ผ์•„๋‹๋•Œ_๋‹ค์Œ์ฃผ_์›”์š”์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2024-07-22 (์›”) 10:00 + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 14:00, isHolidayAlarmOff = false + // ๋‹ค์Œ์ฃผ ์›”์š”์ผ: 2024-07-29 (๊ณตํœด์ผ ์•„๋‹˜) + // ๊ธฐ๋Œ€: 2024-07-29 (์›”) 14:00 + + // given + val calculator = AlarmTimeCalculator(clockMonday2024_10am) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = false, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextWeeklyRescheduledTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = LocalDateTime.of(2024, 7, 29, 14, 0, 0) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `์ฃผ๊ฐ„์žฌ์˜ˆ์•ฝ_๋‹ค์Œ์ฃผ_์šธ๋ฆด์š”์ผ์ด_๊ณตํœด์ผ์ด๊ณ _๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_๋น„ํ™œ์„ฑ์‹œ_๋‹ค์Œ์ฃผ_๊ณตํœด์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2025-01-20 (์›”) 10:00:00 (์„ค ์—ฐํœด ์ „ ์ฃผ ์›”์š”์ผ ์˜ค์ „) + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 14:00:00, ๊ณตํœด์ผ ๊ฑด๋„ˆ๋›ฐ๊ธฐ ๋น„ํ™œ์„ฑ + // ๋‹ค์Œ์ฃผ ์›”์š”์ผ: 2025-01-27 (๊ณตํœด์ผ) + // ๊ธฐ๋Œ€: 2025-01-27 (์›”, ๊ณตํœด์ผ) 14:00:00 (๊ฑด๋„ˆ๋›ฐ๊ธฐ ๋น„ํ™œ์„ฑ์ด๋ฏ€๋กœ ๋‹ค์Œ์ฃผ ๊ณตํœด์ผ์ด์–ด๋„ ์šธ๋ฆผ) + + // given + val calculator = AlarmTimeCalculator(clockMonday2025_01_20_10am) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = false, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextWeeklyRescheduledTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = LocalDateTime.of(2025, 1, 27, 14, 0, 0) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `์ฃผ๊ฐ„์žฌ์˜ˆ์•ฝ_ํ˜„์žฌ_๊ธˆ์š”์ผ์˜คํ›„_๋Œ€์ƒ์€_์›”์š”์ผ_๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_๋น„ํ™œ์„ฑ์‹œ_๋‹ค๋‹ค์Œ์ฃผ_์›”์š”์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2024-07-26 (๊ธˆ) 15:00 + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 14:00, isHolidayAlarmOff = false + // ๋กœ์ง: ๊ฐ€์žฅ ๊ฐ€๊นŒ์šด ๋‹ค์Œ ์›”์š”์ผ(29์ผ)์˜ ๊ทธ ๋‹ค์Œ ์ฃผ ์›”์š”์ผ(5์ผ) + // ๊ธฐ๋Œ€: 2024-08-05 (์›”) 14:00 + + // given + val calculator = AlarmTimeCalculator(clockFriday2024_3pm) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = false, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextWeeklyRescheduledTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = LocalDateTime.of(2024, 8, 5, 14, 0, 0) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `์ฃผ๊ฐ„์žฌ์˜ˆ์•ฝ_ํ˜„์žฌ_์›”์š”์ผ_๋Œ€์ƒ๋„_์›”์š”์ผ_๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_ํ™œ์„ฑ_๋‹ค์Œ์ฃผ_์›”์š”์ผ์ด_๊ณตํœด์ผ์ผ๋•Œ_๋‹ค๋‹ค์Œ์ฃผ_์›”์š”์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2025-01-20 (์›”) 10:00 (์„ค ์—ฐํœด ์ „ ์ฃผ ์›”์š”์ผ ์˜ค์ „) + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 14:00, isHolidayAlarmOff = true + // ๋‹ค์Œ์ฃผ ์›”์š”์ผ: 2025-01-27 (๊ณตํœด์ผ) + // ๊ธฐ๋Œ€: ๋‹ค๋‹ค์Œ์ฃผ ์›”์š”์ผ 2025-02-03 (์›”) 14:00 + + // given + val calculator = AlarmTimeCalculator(clockMonday2025_01_20_10am) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = true, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextWeeklyRescheduledTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = LocalDateTime.of(2025, 2, 3, 14, 0, 0) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } + + @Test + fun `์ฃผ๊ฐ„์žฌ์˜ˆ์•ฝ_ํ˜„์žฌ_์›”์š”์ผ_๋Œ€์ƒ๋„_์›”์š”์ผ_๊ณตํœด์ผ๊ฑด๋„ˆ๋›ฐ๊ธฐ_ํ™œ์„ฑ_๋‹ค์Œ์ฃผ_์›”์š”์ผ์ด_๊ณตํœด์ผ์•„๋‹๋•Œ_๋‹ค์Œ์ฃผ_์›”์š”์ผ๋กœ_๊ณ„์‚ฐ๋œ๋‹ค`() { + // ํ˜„์žฌ: 2025-02-17 (์›”) 10:00 (์‚ผ์ผ์ ˆ ์—ฐํœด ์ „์ „ ์ฃผ ์›”์š”์ผ) + // ์•Œ๋žŒ: ๋งค์ฃผ ์›”์š”์ผ 14:00, isHolidayAlarmOff = true + // ๋‹ค์Œ์ฃผ ์›”์š”์ผ: 2025-02-24 (๊ณตํœด์ผ ์•„๋‹˜) + // ๊ธฐ๋Œ€: 2025-02-24 (์›”) 14:00 + + // given + val calculator = AlarmTimeCalculator(clockMonday2025_02_17_10am) + val alarmTime = LocalTime.of(14, 0) + val alarm = createTestAlarm( + hour = alarmTime.hour, + minute = alarmTime.minute, + isHolidayAlarmOff = true, + repeatDays = AlarmDay.MON.bitValue // ์›”์š”์ผ ๋ฐ˜๋ณต + ) + + // when + val actualMillis = + calculator.calculateNextWeeklyRescheduledTimeMillis(alarm, AlarmDay.MON, testZoneId) + + // then + val expectedDateTime = LocalDateTime.of(2025, 2, 24, 14, 0, 0) + val expectedMillis = getExpectedMillis(expectedDateTime) + assertEquals(expectedMillis, actualMillis) + } +} diff --git a/core/common/src/main/java/com/yapp/common/di/ClockModule.kt b/core/common/src/main/java/com/yapp/common/di/ClockModule.kt new file mode 100644 index 00000000..50158f3a --- /dev/null +++ b/core/common/src/main/java/com/yapp/common/di/ClockModule.kt @@ -0,0 +1,17 @@ +package com.yapp.common.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.time.Clock +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ClockModule { + + @Provides + @Singleton + fun provideClock(): Clock = Clock.systemDefaultZone() +} diff --git a/core/common/src/main/java/com/yapp/common/di/LocaleModule.kt b/core/common/src/main/java/com/yapp/common/di/LocaleModule.kt new file mode 100644 index 00000000..839a5a8f --- /dev/null +++ b/core/common/src/main/java/com/yapp/common/di/LocaleModule.kt @@ -0,0 +1,17 @@ +package com.yapp.common.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.util.Locale +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object LocaleModule { + + @Provides + @Singleton + fun provideLocale(): Locale = Locale.getDefault() +} diff --git a/core/database/schemas/com.yapp.database.AlarmDatabase/2.json b/core/database/schemas/com.yapp.database.AlarmDatabase/2.json index 6e6e50a1..9d84a14a 100644 --- a/core/database/schemas/com.yapp.database.AlarmDatabase/2.json +++ b/core/database/schemas/com.yapp.database.AlarmDatabase/2.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 2, - "identityHash": "557f9b1e0c2913a691c2aed7587e243c", + "identityHash": "3d2a568f32fed54188f8a57463eddcf1", "entities": [ { "tableName": "alarm_database", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `isAm` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `second` INTEGER NOT NULL, `repeatDays` INTEGER NOT NULL, `isHolidayAlarmOff` INTEGER NOT NULL, `isSnoozeEnabled` INTEGER NOT NULL, `snoozeInterval` INTEGER NOT NULL, `snoozeCount` INTEGER NOT NULL, `isVibrationEnabled` INTEGER NOT NULL, `isSoundEnabled` INTEGER NOT NULL, `soundUri` TEXT NOT NULL, `soundVolume` INTEGER NOT NULL, `isAlarmActive` INTEGER NOT NULL, `missionType` INTEGER NOT NULL, `missionCount` INTEGER NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `second` INTEGER NOT NULL, `repeatDays` INTEGER NOT NULL, `isHolidayAlarmOff` INTEGER NOT NULL, `isSnoozeEnabled` INTEGER NOT NULL, `snoozeInterval` INTEGER NOT NULL, `snoozeCount` INTEGER NOT NULL, `isVibrationEnabled` INTEGER NOT NULL, `isSoundEnabled` INTEGER NOT NULL, `soundUri` TEXT NOT NULL, `soundVolume` INTEGER NOT NULL, `isAlarmActive` INTEGER NOT NULL, `missionType` INTEGER NOT NULL DEFAULT 1, `missionCount` INTEGER NOT NULL DEFAULT 10)", "fields": [ { "fieldPath": "id", @@ -14,12 +14,6 @@ "affinity": "INTEGER", "notNull": true }, - { - "fieldPath": "isAm", - "columnName": "isAm", - "affinity": "INTEGER", - "notNull": true - }, { "fieldPath": "hour", "columnName": "hour", @@ -102,13 +96,15 @@ "fieldPath": "missionType", "columnName": "missionType", "affinity": "INTEGER", - "notNull": true + "notNull": true, + "defaultValue": "1" }, { "fieldPath": "missionCount", "columnName": "missionCount", "affinity": "INTEGER", - "notNull": true + "notNull": true, + "defaultValue": "10" } ], "primaryKey": { @@ -121,7 +117,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '557f9b1e0c2913a691c2aed7587e243c')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3d2a568f32fed54188f8a57463eddcf1')" ] } } \ No newline at end of file diff --git a/core/database/src/androidTest/java/com/yapp/database/MigrationTest.kt b/core/database/src/androidTest/java/com/yapp/database/MigrationTest.kt index 8f9a76a1..3798f783 100644 --- a/core/database/src/androidTest/java/com/yapp/database/MigrationTest.kt +++ b/core/database/src/androidTest/java/com/yapp/database/MigrationTest.kt @@ -4,6 +4,7 @@ import androidx.room.testing.MigrationTestHelper import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -23,6 +24,12 @@ class MigrationTest { FrameworkSQLiteOpenHelperFactory(), ) + @After + fun tearDown() { + InstrumentationRegistry.getInstrumentation() + .targetContext.deleteDatabase(testDbName) + } + @Test @Throws(IOException::class) fun `๋ฒ„์ „1์—์„œ_๋ฒ„์ „2๋กœ_๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์‹œ_์ƒˆ_์ปฌ๋Ÿผ์ด_๊ธฐ๋ณธ๊ฐ’์œผ๋กœ_์ฑ„์›Œ์ง`() { @@ -47,8 +54,8 @@ class MigrationTest { isAlarmActive ) VALUES ( null, -- id (autoGenerate) - 1, -- isAm = true - 7, -- hour + 0, -- isAm = false + 11, -- hour 30, -- minute 0, -- second 0, -- repeatDays @@ -69,12 +76,58 @@ class MigrationTest { val db = helper.runMigrationsAndValidate(testDbName, 2, true, DatabaseMigrations.MIGRATION_1_2) - val cursor = db.query("SELECT missionType, missionCount FROM ${AlarmDatabase.DATABASE_NAME}") + val cursor = db.query("SELECT hour, missionType, missionCount FROM ${AlarmDatabase.DATABASE_NAME}") cursor.use { assertEquals(1, it.count) it.moveToFirst() - assertEquals("TAP", it.getString(it.getColumnIndexOrThrow("missionType"))) + assertEquals(1, it.getInt(it.getColumnIndexOrThrow("missionType"))) assertEquals(10, it.getInt(it.getColumnIndexOrThrow("missionCount"))) } + + db.close() + } + + @Test + @Throws(IOException::class) + fun `๋ฒ„์ „1์—์„œ_๋ฒ„์ „2๋กœ_๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์‹œ_12์‹œ๊ฐ„_ํฌ๋งท์ด_24์‹œ๊ฐ„_ํฌ๋งท์œผ๋กœ_์ •ํ™•ํžˆ_๋ณ€ํ™˜๋˜๋Š”์ง€_ํ™•์ธ`() { + helper.createDatabase(testDbName, 1).apply { + // 4๊ฐ€์ง€ ์ผ€์ด์Šค ์‚ฝ์ž… + listOf( + Triple(1, 12, 0), // ์˜ค์ „ 12์‹œ โ†’ 0์‹œ + Triple(0, 12, 12), // ์˜คํ›„ 12์‹œ โ†’ 12์‹œ + Triple(1, 7, 7), // ์˜ค์ „ 7์‹œ โ†’ 7์‹œ + Triple(0, 7, 19), // ์˜คํ›„ 7์‹œ โ†’ 19์‹œ + ).forEach { (isAm, hour12, _) -> + execSQL( + """ + INSERT INTO alarm_database ( + id, isAm, hour, minute, second, repeatDays, isHolidayAlarmOff, + isSnoozeEnabled, snoozeInterval, snoozeCount, isVibrationEnabled, + isSoundEnabled, soundUri, soundVolume, isAlarmActive + ) VALUES ( + null, $isAm, $hour12, 0, 0, 0, 0, 1, 5, 3, 1, 1, 'alarm.mp3', 70, 1 + ) + """.trimIndent(), + ) + } + close() + } + + val db = + helper.runMigrationsAndValidate(testDbName, 2, true, DatabaseMigrations.MIGRATION_1_2) + + val expected = listOf(0, 12, 7, 19) // ๊ธฐ๋Œ€ ๊ฒฐ๊ณผ: ๋ณ€ํ™˜๋œ hour ์ˆœ์„œ + val cursor = db.query("SELECT hour FROM ${AlarmDatabase.DATABASE_NAME}") + cursor.use { + assertEquals(4, it.count) + var idx = 0 + while (it.moveToNext()) { + val actual = it.getInt(it.getColumnIndexOrThrow("hour")) + assertEquals(expected[idx], actual) + idx++ + } + } + + db.close() } } diff --git a/core/database/src/main/java/com/yapp/database/AlarmDao.kt b/core/database/src/main/java/com/yapp/database/AlarmDao.kt index 6bb80174..9d2b4e9f 100644 --- a/core/database/src/main/java/com/yapp/database/AlarmDao.kt +++ b/core/database/src/main/java/com/yapp/database/AlarmDao.kt @@ -22,17 +22,11 @@ interface AlarmDao { @Query("SELECT * FROM ${AlarmDatabase.DATABASE_NAME} WHERE id = :id") suspend fun getAlarm(id: Long): AlarmEntity? - @Query("SELECT * FROM ${AlarmDatabase.DATABASE_NAME} ORDER BY isAm DESC, hour ASC, minute ASC LIMIT :limit OFFSET :offset") - fun getPagedAlarms(limit: Int, offset: Int): Flow> - - @Query("SELECT * FROM ${AlarmDatabase.DATABASE_NAME} ORDER BY isAm DESC, hour ASC, minute ASC") + @Query("SELECT * FROM ${AlarmDatabase.DATABASE_NAME} ORDER BY hour ASC, minute ASC") fun getAllAlarms(): Flow> - @Query("SELECT * FROM ${AlarmDatabase.DATABASE_NAME} WHERE hour = :hour AND minute = :minute AND isAm = :isAm") - fun getAlarmsByTime(hour: Int, minute: Int, isAm: Boolean): Flow> - - @Query("SELECT COUNT(*) FROM ${AlarmDatabase.DATABASE_NAME}") - fun getAlarmCount(): Flow + @Query("SELECT * FROM ${AlarmDatabase.DATABASE_NAME} WHERE hour = :hour AND minute = :minute") + fun getAlarmsByTime(hour: Int, minute: Int): Flow> @Query("DELETE FROM ${AlarmDatabase.DATABASE_NAME} WHERE id = :id") suspend fun deleteAlarm(id: Long): Int diff --git a/core/database/src/main/java/com/yapp/database/AlarmEntity.kt b/core/database/src/main/java/com/yapp/database/AlarmEntity.kt index f8bc636e..72320fbd 100644 --- a/core/database/src/main/java/com/yapp/database/AlarmEntity.kt +++ b/core/database/src/main/java/com/yapp/database/AlarmEntity.kt @@ -1,5 +1,6 @@ package com.yapp.database +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import com.yapp.domain.model.Alarm @@ -10,7 +11,6 @@ data class AlarmEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, - val isAm: Boolean = true, val hour: Int = 6, val minute: Int = 0, val second: Int = 0, @@ -32,13 +32,14 @@ data class AlarmEntity( val isAlarmActive: Boolean = true, + @ColumnInfo(defaultValue = "1") val missionType: MissionType = MissionType.TAP, + @ColumnInfo(defaultValue = "10") val missionCount: Int = 10, ) fun AlarmEntity.toDomain() = Alarm( id = id, - isAm = isAm, hour = hour, minute = minute, second = second, @@ -52,11 +53,12 @@ fun AlarmEntity.toDomain() = Alarm( soundUri = soundUri, soundVolume = soundVolume, isAlarmActive = isAlarmActive, + missionType = missionType, + missionCount = missionCount, ) fun Alarm.toEntity() = AlarmEntity( id = id, - isAm = isAm, hour = hour, minute = minute, second = second, @@ -70,4 +72,6 @@ fun Alarm.toEntity() = AlarmEntity( soundUri = soundUri, soundVolume = soundVolume, isAlarmActive = isAlarmActive, + missionType = missionType, + missionCount = missionCount, ) diff --git a/core/database/src/main/java/com/yapp/database/DatabaseMigrations.kt b/core/database/src/main/java/com/yapp/database/DatabaseMigrations.kt index 60209b5d..8d163fb2 100644 --- a/core/database/src/main/java/com/yapp/database/DatabaseMigrations.kt +++ b/core/database/src/main/java/com/yapp/database/DatabaseMigrations.kt @@ -2,13 +2,85 @@ package com.yapp.database import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase +import com.yapp.database.AlarmDatabase.Companion.DATABASE_NAME internal object DatabaseMigrations { val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("ALTER TABLE ${AlarmDatabase.DATABASE_NAME} ADD COLUMN missionType TEXT NOT NULL DEFAULT 'TAP'") - database.execSQL("ALTER TABLE ${AlarmDatabase.DATABASE_NAME} ADD COLUMN missionCount INTEGER NOT NULL DEFAULT 10") + database.beginTransaction() + try { + // 1. ์ƒˆ ์Šคํ‚ค๋งˆ๋กœ ์ž„์‹œ ํ…Œ์ด๋ธ” ์ƒ์„ฑ (isAm ์ปฌ๋Ÿผ ์ œ์™ธ, missionType, missionCount ์ถ”๊ฐ€ ๋ฐ ๊ธฐ๋ณธ๊ฐ’ ๋ณ€๊ฒฝ) + database.execSQL( + """ + CREATE TABLE ${DATABASE_NAME}_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + hour INTEGER NOT NULL, + minute INTEGER NOT NULL, + second INTEGER NOT NULL, + repeatDays INTEGER NOT NULL, + isHolidayAlarmOff INTEGER NOT NULL, + isSnoozeEnabled INTEGER NOT NULL, + snoozeInterval INTEGER NOT NULL, + snoozeCount INTEGER NOT NULL, + isVibrationEnabled INTEGER NOT NULL, + isSoundEnabled INTEGER NOT NULL, + soundUri TEXT NOT NULL, + soundVolume INTEGER NOT NULL, + isAlarmActive INTEGER NOT NULL, + missionType INTEGER NOT NULL DEFAULT 1, -- ํƒ€์ž… INTEGER, ๊ธฐ๋ณธ๊ฐ’ 1 + missionCount INTEGER NOT NULL DEFAULT 10 -- ํƒ€์ž… INTEGER, ๊ธฐ๋ณธ๊ฐ’ 10 + ) + """.trimIndent(), + ) + + // 2. ๊ธฐ์กด ํ…Œ์ด๋ธ”์—์„œ ์ƒˆ ์ž„์‹œ ํ…Œ์ด๋ธ”๋กœ ๋ฐ์ดํ„ฐ ๋ณต์‚ฌ (isAm ์ปฌ๋Ÿผ์€ ๋ณต์‚ฌํ•˜์ง€ ์•Š์Œ) + database.execSQL( + """ + INSERT INTO ${DATABASE_NAME}_new ( + id, hour, minute, second, repeatDays, isHolidayAlarmOff, + isSnoozeEnabled, snoozeInterval, snoozeCount, isVibrationEnabled, + isSoundEnabled, soundUri, soundVolume, isAlarmActive + -- missionType, missionCount๋Š” CREATE TABLE์—์„œ ์ •์˜๋œ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์ž๋™ ์ฑ„์›Œ์ง + ) + SELECT + id, + -- hour๋ฅผ 24์‹œ๊ฐ„ ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + -- ์˜ˆ์‹œ: isAm ์ปฌ๋Ÿผ์ด 0 (PM)์ด๊ณ  hour๊ฐ€ 12๊ฐ€ ์•„๋‹ˆ๋ฉด hour + 12 + -- ์˜ˆ์‹œ: isAm ์ปฌ๋Ÿผ์ด 1 (AM)์ด๊ณ  hour๊ฐ€ 12 (์ž์ •)์ด๋ฉด 0์œผ๋กœ ๋ณ€ํ™˜ + -- ์‹ค์ œ isAm ์ปฌ๋Ÿผ์˜ ์˜๋ฏธ์™€ ๊ฐ’์— ๋”ฐ๋ผ ์•„๋ž˜ ๋กœ์ง์„ ์กฐ์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. + CASE + WHEN isAm = 0 AND hour != 12 THEN hour + 12 -- ์˜คํ›„ 1์‹œ ~ 11์‹œ -> 13 ~ 23์‹œ + WHEN isAm = 1 AND hour = 12 THEN 0 -- ์˜ค์ „ 12์‹œ (์ž์ •) -> 0์‹œ + ELSE hour -- ๊ทธ ์™ธ (์˜ค์ „ 1์‹œ ~ 11์‹œ, ์˜คํ›„ 12์‹œ(์ •์˜ค)) + END AS hour_24, + minute, + second, + repeatDays, + isHolidayAlarmOff, + isSnoozeEnabled, + snoozeInterval, + snoozeCount, + isVibrationEnabled, + isSoundEnabled, + soundUri, + soundVolume, + isAlarmActive + FROM $DATABASE_NAME + """.trimIndent(), + ) + + // 3. ๊ธฐ์กด ํ…Œ์ด๋ธ” ์‚ญ์ œ + database.execSQL("DROP TABLE $DATABASE_NAME") + + // 4. ์ž„์‹œ ํ…Œ์ด๋ธ”์˜ ์ด๋ฆ„์„ ๊ธฐ์กด ํ…Œ์ด๋ธ” ์ด๋ฆ„์œผ๋กœ ๋ณ€๊ฒฝ + database.execSQL("ALTER TABLE ${DATABASE_NAME}_new RENAME TO $DATABASE_NAME") + + // 5. ์ปค๋ฐ‹ + database.setTransactionSuccessful() + } finally { + database.endTransaction() + } } } } diff --git a/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitPicker.kt b/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitPicker.kt index ee48f71d..6b379cb3 100644 --- a/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitPicker.kt +++ b/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitPicker.kt @@ -14,8 +14,11 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -23,16 +26,22 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.yapp.designsystem.theme.OrbitTheme import kotlinx.coroutines.launch -import java.util.Locale +import java.time.LocalTime + +enum class TimePeriod(val displayName: String) { + AM("์˜ค์ „"), + PM("์˜คํ›„"), + ; + + override fun toString(): String = displayName +} @Composable fun OrbitPicker( modifier: Modifier = Modifier, itemSpacing: Dp = 2.dp, - initialAmPm: String = "์˜ค์ „", - initialHour: String = "1", - initialMinute: String = "00", - onValueChange: (String, Int, Int) -> Unit, + initialTime: LocalTime = LocalTime.now(), + onValueChange: (LocalTime) -> Unit, ) { Surface( modifier = modifier @@ -46,23 +55,24 @@ fun OrbitPicker( .wrapContentSize() .background(OrbitTheme.colors.gray_900), ) { - val amPmItems = remember { listOf("์˜คํ›„", "์˜ค์ „") } - val hourItems = remember { (1..12).map { it.toString() } } - val minuteItems = remember { (0..59).map { String.format(Locale.ROOT, "%02d", it) } } + val amPmItems = remember { TimePeriod.entries.toList().map { it.displayName } } + val hourItems = remember { (1..12).toList() } + val minuteItems = remember { (0..59).toList() } val amPmPickerState = rememberPickerState( - selectedItem = amPmItems.indexOf(initialAmPm).toString(), - startIndex = amPmItems.indexOf(initialAmPm), + initialIndex = if (initialTime.hour < 12) 0 else 1, + items = amPmItems, ) val hourPickerState = rememberPickerState( - selectedItem = hourItems.indexOf(initialHour).toString(), - startIndex = hourItems.indexOf(initialHour), + initialIndex = hourItems.indexOf(if (initialTime.hour % 12 == 0) 12 else initialTime.hour % 12), + items = hourItems, ) val minutePickerState = rememberPickerState( - selectedItem = minuteItems.indexOf(initialMinute).toString(), - startIndex = minuteItems.indexOf(initialMinute), + initialIndex = minuteItems.indexOf(initialTime.minute), + items = minuteItems, ) + var previousHour by remember { mutableIntStateOf(initialTime.hour) } val scope = rememberCoroutineScope() Box(modifier = Modifier.fillMaxWidth()) { @@ -71,7 +81,7 @@ fun OrbitPicker( .fillMaxWidth() .align(Alignment.Center) .padding(horizontal = 20.dp) - .height(50.dp) + .height(45.dp) .background(OrbitTheme.colors.gray_700, shape = RoundedCornerShape(12.dp)), ) @@ -86,7 +96,7 @@ fun OrbitPicker( items = amPmItems, visibleItemsCount = 3, itemSpacing = itemSpacing, - textStyle = OrbitTheme.typography.title2Medium, + textStyle = OrbitTheme.typography.heading1SemiBold, modifier = Modifier.weight(1f), textModifier = Modifier.padding(8.dp), infiniteScroll = false, @@ -105,7 +115,7 @@ fun OrbitPicker( items = hourItems, visibleItemsCount = 5, itemSpacing = itemSpacing, - textStyle = OrbitTheme.typography.title2Medium, + textStyle = OrbitTheme.typography.heading1SemiBold, modifier = Modifier.weight(1f), textModifier = Modifier.padding(8.dp), infiniteScroll = true, @@ -116,12 +126,17 @@ fun OrbitPicker( minutePickerState, onValueChange, ) - }, - onScrollCompleted = { scope.launch { + val currentHour = hourPickerState.selectedItem val currentIndex = amPmPickerState.lazyListState.firstVisibleItemIndex % amPmItems.size val nextIndex = (currentIndex + 1) % amPmItems.size - amPmPickerState.lazyListState.animateScrollToItem(nextIndex) + + if ((currentHour == 12 && previousHour == 11) || + (currentHour == 11 && previousHour == 12) + ) { + amPmPickerState.lazyListState.animateScrollToItem(nextIndex) + } + previousHour = currentHour } }, ) @@ -131,10 +146,11 @@ fun OrbitPicker( items = minuteItems, visibleItemsCount = 5, itemSpacing = itemSpacing, - textStyle = OrbitTheme.typography.title2Medium, + textStyle = OrbitTheme.typography.heading1SemiBold, modifier = Modifier.weight(1f), textModifier = Modifier.padding(8.dp), infiniteScroll = true, + itemFormatter = { it.toString().padStart(2, '0') }, onValueChange = { onPickerValueChange( amPmPickerState, @@ -151,21 +167,32 @@ fun OrbitPicker( } private fun onPickerValueChange( - amPmState: PickerState, - hourState: PickerState, - minuteState: PickerState, - onValueChange: (String, Int, Int) -> Unit, + amPmState: PickerState, + hourState: PickerState, + minuteState: PickerState, + onValueChange: (LocalTime) -> Unit, ) { val amPm = amPmState.selectedItem - val hour = hourState.selectedItem.toIntOrNull() ?: 0 - val minute = minuteState.selectedItem.toIntOrNull() ?: 0 - onValueChange(amPm, hour, minute) + val hour = hourState.selectedItem + val minute = minuteState.selectedItem + + val adjustedHour = if (amPm == TimePeriod.AM.displayName && hour == 12) { + 0 + } else if (amPm == TimePeriod.PM.displayName && hour != 12) { + hour + 12 + } else { + hour + } + + val newTime = LocalTime.of(adjustedHour, minute) + + onValueChange(newTime) } @Preview(showBackground = true) @Composable fun OrbitPickerPreview() { - OrbitPicker { amPm, hour, minute -> - Log.d("OrbitPicker", "selectedAmPm: $amPm, selectedHour: $hour, selectedMinute: $minute") + OrbitPicker() { newTime -> + Log.d("OrbitPicker", "selectedTime: $newTime") } } diff --git a/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitPickerItem.kt b/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitPickerItem.kt index 76ba98d8..729421a2 100644 --- a/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitPickerItem.kt +++ b/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitPickerItem.kt @@ -10,8 +10,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -30,17 +33,17 @@ import kotlinx.coroutines.flow.map import kotlin.math.abs @Composable -fun OrbitPickerItem( +fun OrbitPickerItem( modifier: Modifier = Modifier, - items: List, - state: PickerState = rememberPickerState(), + items: List, + state: PickerState = rememberPickerState(items = items), visibleItemsCount: Int, textModifier: Modifier = Modifier, + itemFormatter: (T) -> String = { it.toString() }, infiniteScroll: Boolean = true, textStyle: TextStyle, itemSpacing: Dp, - onValueChange: (String) -> Unit, - onScrollCompleted: () -> Unit = {}, + onValueChange: (T) -> Unit, ) { val visibleItemsMiddle = visibleItemsCount / 2 val listScrollCount = if (infiniteScroll) Int.MAX_VALUE else items.size + visibleItemsMiddle * 2 @@ -48,31 +51,28 @@ fun OrbitPickerItem( val listState = state.lazyListState val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState) - val itemHeightPixels = remember { mutableIntStateOf(0) } - val itemHeightDp = with(LocalDensity.current) { itemHeightPixels.intValue.toDp() } + var itemHeightPixels by remember { mutableIntStateOf(0) } + val itemHeightDp = with(LocalDensity.current) { itemHeightPixels.toDp() } - LaunchedEffect(key1 = state.startIndex) { - val safeStartIndex = state.startIndex.takeIf { it >= 0 } ?: 0 + LaunchedEffect(state.initialIndex) { + val safeStartIndex = state.initialIndex val listStartIndex = if (infiniteScroll) { - calculateStartIndex(infiniteScroll, items.size, listScrollMiddle, visibleItemsMiddle, safeStartIndex) + getStartIndexForInfiniteScroll(itemHeightPixels, listScrollMiddle, visibleItemsMiddle, safeStartIndex) } else { safeStartIndex } - listState.scrollToItem(listStartIndex, 0) if (!infiniteScroll) { - val selectedItem = items.getOrNull(safeStartIndex) ?: "" - if (selectedItem != state.selectedItem) { - state.selectedItem = selectedItem - onValueChange(selectedItem) + val selectedItem = items.getOrNull(listStartIndex) ?: items.first() + if (listStartIndex != state.selectedIndex.value) { + state.updateSelectedIndex(listStartIndex) } + onValueChange(selectedItem) } } LaunchedEffect(listState) { - var previousAdjustedIndex = -1 - snapshotFlow { listState.layoutInfo } .map { layoutInfo -> val centerOffset = layoutInfo.viewportStartOffset + @@ -82,30 +82,20 @@ fun OrbitPickerItem( abs(itemCenter - centerOffset) }?.index } - .distinctUntilChanged() - .collect { centerIndex -> - if (centerIndex != null) { - val adjustedIndex = if (infiniteScroll) { - centerIndex % items.size - } else { - centerIndex - visibleItemsMiddle - }.coerceIn(0, items.size - 1) - - val newValue = items[adjustedIndex] - + .map { centerIndex -> + centerIndex?.let { index -> if (infiniteScroll) { - val lastIndex = items.size - 1 - if ((previousAdjustedIndex == 0 && adjustedIndex == lastIndex) || - (previousAdjustedIndex == lastIndex && adjustedIndex == 0) - ) { - onScrollCompleted() - } - } - if (newValue != state.selectedItem) { - state.selectedItem = newValue - onValueChange(newValue) + index % items.size + } else { + (index - visibleItemsMiddle).coerceIn(0, items.size - 1) } - previousAdjustedIndex = adjustedIndex + } + } + .distinctUntilChanged() + .collect { adjustedIndex -> + if (adjustedIndex != null && adjustedIndex != state.selectedIndex.value) { + state.updateSelectedIndex(adjustedIndex) + onValueChange(items[adjustedIndex]) } } } @@ -122,8 +112,9 @@ fun OrbitPickerItem( .height(totalItemHeight * visibleItemsCount) .pointerInput(Unit) { detectVerticalDragGestures { change, _ -> change.consume() } }, ) { - items(listScrollCount) { index -> - val layoutInfo = listState.layoutInfo + items(listScrollCount, key = { index -> index }) { index -> + val layoutInfo by remember { derivedStateOf { listState.layoutInfo } } + val viewportCenterOffset = layoutInfo.viewportStartOffset + (layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset) / 2 @@ -141,15 +132,22 @@ fun OrbitPickerItem( val scaleY = 1f - (0.2f * (distanceFromCenter / maxDistance)).coerceIn(0f, 0.4f) + val item = getItemForIndex( + index = index, + items = items, + infiniteScroll = infiniteScroll, + visibleItemsMiddle = visibleItemsMiddle, + ) + Text( - text = getItemForIndex(index, items, infiniteScroll, visibleItemsMiddle), + text = item?.let { itemFormatter(it) } ?: "", maxLines = 1, style = textStyle, color = OrbitTheme.colors.white.copy(alpha = alpha), modifier = Modifier .padding(vertical = itemSpacing / 2) .graphicsLayer(scaleY = scaleY) - .onSizeChanged { size -> itemHeightPixels.intValue = size.height } + .onSizeChanged { size -> itemHeightPixels = size.height } .then(textModifier), ) } @@ -157,37 +155,31 @@ fun OrbitPickerItem( } } -/** - * ๋ฌดํ•œ ์Šคํฌ๋กค๊ณผ ์ดˆ๊ธฐ ์‹œ์ž‘ ์ธ๋ฑ์Šค๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ฆฌ์ŠคํŠธ์˜ ์‹œ์ž‘ ์ธ๋ฑ์Šค๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. - */ -private fun calculateStartIndex( - infiniteScroll: Boolean, +private fun getStartIndexForInfiniteScroll( itemSize: Int, listScrollMiddle: Int, visibleItemsMiddle: Int, startIndex: Int, ): Int { - return if (infiniteScroll) { - listScrollMiddle - listScrollMiddle % itemSize - visibleItemsMiddle + startIndex - } else { - startIndex + visibleItemsMiddle + if (itemSize == 0) { + return listScrollMiddle - visibleItemsMiddle + startIndex } + + return listScrollMiddle - listScrollMiddle % itemSize - visibleItemsMiddle + startIndex } -/** - * ์ฃผ์–ด์ง„ ์ธ๋ฑ์Šค์— ํ•ด๋‹นํ•˜๋Š” ํ•ญ๋ชฉ์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. - * ๋ฌดํ•œ ์Šคํฌ๋กค๊ณผ ๋ณด์ด๋Š” ํ•ญ๋ชฉ์˜ ๊ฐœ์ˆ˜๋ฅผ ๊ณ ๋ คํ•ฉ๋‹ˆ๋‹ค. - */ -private fun getItemForIndex( +private fun getItemForIndex( index: Int, - items: List, + items: List, infiniteScroll: Boolean, visibleItemsMiddle: Int, -): String { +): T? { + require(items.isNotEmpty()) { "Items list cannot be empty." } + return if (!infiniteScroll) { - items.getOrNull(index - visibleItemsMiddle) ?: "" + items.getOrNull(index - visibleItemsMiddle) } else { - items.getOrNull(index % items.size) ?: "" + items.getOrNull(index % items.size) } } @@ -197,7 +189,10 @@ fun OrbitPickerItemPreview() { OrbitTheme { OrbitPickerItem( items = (0..100).map { it.toString() }, - state = rememberPickerState(), + state = rememberPickerState( + initialIndex = 50, + items = (0..100).map { it.toString() }, + ), visibleItemsCount = 5, textStyle = TextStyle.Default, itemSpacing = 8.dp, diff --git a/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitYearMonthPicker.kt b/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitYearMonthPicker.kt index 7d90d39b..012623e5 100644 --- a/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitYearMonthPicker.kt +++ b/core/ui/src/main/java/com/yapp/ui/component/timepicker/OrbitYearMonthPicker.kt @@ -37,23 +37,29 @@ fun OrbitYearMonthPicker( ) { val screenWidth = LocalConfiguration.current.screenWidthDp.dp + val lunarItems = remember { listOf("์–‘๋ ฅ", "์Œ๋ ฅ") } + val yearItems = remember { (1900..2024).map { it.toString() } } + val monthItems = remember { (1..12).map { it.toString() } } + + val startIndexYear = yearItems.indexOf(initialYear).coerceAtLeast(0) + val startIndexMonth = monthItems.indexOf(initialMonth).coerceAtLeast(0) + val lunarState = remember { mutableStateOf(initialLunar) } val yearState = remember { mutableIntStateOf(initialYear.toInt()) } val monthState = remember { mutableIntStateOf(initialMonth.toInt()) } - - val maxDay = getMaxDaysInMonth(yearState.intValue, monthState.intValue) - val dayItems = (1..maxDay).map { it.toString() } - - val startIndexYear = (1900..2024).map { it.toString() }.indexOf(initialYear).takeIf { it >= 0 } ?: 0 - val startIndexMonth = (1..12).map { it.toString() }.indexOf(initialMonth).takeIf { it >= 0 } ?: 0 - val startIndexDay = dayItems.indexOf(initialDay).takeIf { it >= 0 } ?: 0 - val dayState = remember { mutableIntStateOf(initialDay.toInt()) } - val yearPickerState = rememberPickerState(startIndex = startIndexYear) - val monthPickerState = rememberPickerState(startIndex = startIndexMonth) - val dayPickerState = rememberPickerState(startIndex = startIndexDay) + val yearPickerState = rememberPickerState(initialIndex = startIndexYear, items = yearItems) + val monthPickerState = rememberPickerState(initialIndex = startIndexMonth, items = monthItems) + // dayItems๋Š” year/month ๋ณ€๊ฒฝ ์‹œ๋งˆ๋‹ค ๋™๊ธฐํ™” + val dayItems = remember(yearState.intValue, monthState.intValue) { + (1..getMaxDaysInMonth(yearState.intValue, monthState.intValue)).map { it.toString() } + } + val startIndexDay = dayItems.indexOf(initialDay).coerceAtLeast(0) + val dayPickerState = rememberPickerState(initialIndex = startIndexDay, items = dayItems) + + // ์ผ ์ˆ˜ ๋„˜์–ด๊ฐ€๋Š” ๊ฒฝ์šฐ ์กฐ์ • LaunchedEffect(yearState.intValue, monthState.intValue) { val newMaxDay = getMaxDaysInMonth(yearState.intValue, monthState.intValue) if (dayState.intValue > newMaxDay) { @@ -61,25 +67,18 @@ fun OrbitYearMonthPicker( } } + // ๋ณ€๊ฒฝ ์ฝœ๋ฐฑ LaunchedEffect(lunarState.value, yearState.intValue, monthState.intValue, dayState.intValue) { onValueChange(lunarState.value, yearState.intValue, monthState.intValue, dayState.intValue) } - Surface( - modifier = modifier.fillMaxWidth(), - ) { + Surface(modifier = modifier.fillMaxWidth()) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Bottom, modifier = Modifier.background(OrbitTheme.colors.gray_900), ) { - val lunarItems = listOf("์–‘๋ ฅ", "์Œ๋ ฅ") - val yearItems = (1900..2024).map { it.toString() } - val monthItems = (1..12).map { it.toString() } - - Box( - modifier = Modifier.fillMaxWidth(), - ) { + Box(modifier = Modifier.fillMaxWidth()) { Box( modifier = Modifier .fillMaxWidth() @@ -90,7 +89,9 @@ fun OrbitYearMonthPicker( ) Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = screenWidth * 0.1f), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = screenWidth * 0.1f), verticalAlignment = Alignment.CenterVertically, ) { OrbitPickerItem( @@ -142,9 +143,6 @@ fun OrbitYearMonthPicker( } } -/** - * ํŠน์ • ์—ฐ๋„์™€ ์›”์— ๋”ฐ๋ฅธ ์ตœ๋Œ€ ์ผ ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜. - */ private fun getMaxDaysInMonth(year: Int, month: Int): Int { return when (month) { 1, 3, 5, 7, 8, 10, 12 -> 31 @@ -154,9 +152,6 @@ private fun getMaxDaysInMonth(year: Int, month: Int): Int { } } -/** - * ์œค๋…„ ๊ณ„์‚ฐ - */ private fun isLeapYear(year: Int): Boolean { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) } diff --git a/core/ui/src/main/java/com/yapp/ui/component/timepicker/PickerState.kt b/core/ui/src/main/java/com/yapp/ui/component/timepicker/PickerState.kt index 120e3398..2e8b9793 100644 --- a/core/ui/src/main/java/com/yapp/ui/component/timepicker/PickerState.kt +++ b/core/ui/src/main/java/com/yapp/ui/component/timepicker/PickerState.kt @@ -4,16 +4,29 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow -class PickerState( +class PickerState( val lazyListState: LazyListState, - var selectedItem: String, - var startIndex: Int, -) + val initialIndex: Int, + private val items: List, +) { + private val _selectedIndex = MutableStateFlow(initialIndex) + val selectedIndex: StateFlow + get() = _selectedIndex + + val selectedItem: T + get() = items.getOrElse(_selectedIndex.value) { items.first() } + + fun updateSelectedIndex(newIndex: Int) { + _selectedIndex.value = newIndex.coerceIn(0, items.size - 1) + } +} @Composable -fun rememberPickerState( +fun rememberPickerState( lazyListState: LazyListState = rememberLazyListState(), - selectedItem: String = "", - startIndex: Int = 0, -): PickerState = remember { PickerState(lazyListState, selectedItem, startIndex) } + initialIndex: Int = 0, + items: List, +): PickerState = remember { PickerState(lazyListState, initialIndex, items) } diff --git a/data/src/main/java/com/yapp/data/local/datasource/AlarmLocalDataSource.kt b/data/src/main/java/com/yapp/data/local/datasource/AlarmLocalDataSource.kt index 2ff1a748..eb9fb350 100644 --- a/data/src/main/java/com/yapp/data/local/datasource/AlarmLocalDataSource.kt +++ b/data/src/main/java/com/yapp/data/local/datasource/AlarmLocalDataSource.kt @@ -8,9 +8,7 @@ interface AlarmLocalDataSource { val firstDismissedAlarmIdFlow: Flow fun getAllAlarms(): Flow> - fun getPagedAlarms(limit: Int, offset: Int): Flow> - fun getAlarmsByTime(hour: Int, minute: Int, isAm: Boolean): Flow> - fun getAlarmCount(): Flow + fun getAlarmsByTime(hour: Int, minute: Int): Flow> suspend fun insertAlarm(alarm: AlarmEntity): Long suspend fun updateAlarm(alarm: AlarmEntity): Int suspend fun updateAlarmActive(id: Long, active: Boolean): Int diff --git a/data/src/main/java/com/yapp/data/local/datasource/AlarmLocalDataSourceImpl.kt b/data/src/main/java/com/yapp/data/local/datasource/AlarmLocalDataSourceImpl.kt index 6c109877..7c7425b2 100644 --- a/data/src/main/java/com/yapp/data/local/datasource/AlarmLocalDataSourceImpl.kt +++ b/data/src/main/java/com/yapp/data/local/datasource/AlarmLocalDataSourceImpl.kt @@ -20,24 +20,12 @@ class AlarmLocalDataSourceImpl @Inject constructor( .map { alarmEntities -> alarmEntities.map { it.toDomain() } } } - override fun getPagedAlarms( - limit: Int, - offset: Int, - ): Flow> { - return alarmDao.getPagedAlarms(limit, offset) - .map { alarmEntities -> alarmEntities.map { it.toDomain() } } - } - - override fun getAlarmsByTime(hour: Int, minute: Int, isAm: Boolean): Flow> { - return alarmDao.getAlarmsByTime(hour, minute, isAm).map { alarmEntities -> + override fun getAlarmsByTime(hour: Int, minute: Int): Flow> { + return alarmDao.getAlarmsByTime(hour, minute).map { alarmEntities -> alarmEntities.map { it.toDomain() } } } - override fun getAlarmCount(): Flow { - return alarmDao.getAlarmCount() - } - override suspend fun insertAlarm(alarm: AlarmEntity): Long { return alarmDao.insertAlarm(alarm) } diff --git a/data/src/main/java/com/yapp/data/repositoryimpl/AlarmRepositoryImpl.kt b/data/src/main/java/com/yapp/data/repositoryimpl/AlarmRepositoryImpl.kt index 112938be..a1c72135 100644 --- a/data/src/main/java/com/yapp/data/repositoryimpl/AlarmRepositoryImpl.kt +++ b/data/src/main/java/com/yapp/data/repositoryimpl/AlarmRepositoryImpl.kt @@ -47,14 +47,8 @@ class AlarmRepositoryImpl @Inject constructor( override fun getAllAlarms(): Flow> = alarmLocalDataSource.getAllAlarms() - override fun getPagedAlarms(limit: Int, offset: Int): Flow> = - alarmLocalDataSource.getPagedAlarms(limit, offset) - - override fun getAlarmsByTime(hour: Int, minute: Int, isAm: Boolean): Flow> = - alarmLocalDataSource.getAlarmsByTime(hour, minute, isAm) - - override fun getAlarmCount(): Flow = - alarmLocalDataSource.getAlarmCount() + override fun getAlarmsByTime(hour: Int, minute: Int): Flow> = + alarmLocalDataSource.getAlarmsByTime(hour, minute) override suspend fun insertAlarm(alarm: Alarm): Result = runCatching { val alarmId = alarmLocalDataSource.insertAlarm(alarm.toEntity()) diff --git a/domain/src/main/java/com/yapp/domain/model/Alarm.kt b/domain/src/main/java/com/yapp/domain/model/Alarm.kt index f04d4148..14079633 100644 --- a/domain/src/main/java/com/yapp/domain/model/Alarm.kt +++ b/domain/src/main/java/com/yapp/domain/model/Alarm.kt @@ -12,8 +12,6 @@ import kotlinx.serialization.json.Json data class Alarm( val id: Long = 0, - val isAm: Boolean = true, - val hour: Int = 6, val minute: Int = 0, val second: Int = 0, @@ -35,6 +33,9 @@ data class Alarm( val soundVolume: Int = 70, val isAlarmActive: Boolean = true, + + val missionType: MissionType = MissionType.TAP, + val missionCount: Int = 10, ) : Parcelable { companion object { @@ -62,14 +63,7 @@ fun Alarm.copyFrom(source: Alarm): Alarm { } fun Alarm.toTimeString(): String { - val displayHour = if (isAm && hour == 12) { - 0 // ์˜ค์ „ 12์‹œ๋Š” 0์œผ๋กœ ํ‘œ์‹œ - } else if (!isAm && hour != 12) { - hour + 12 // ์˜คํ›„ 1์‹œ~11์‹œ์—๋Š” 12๋ฅผ ๋”ํ•จ - } else { - hour // ์˜ค์ „ 1์‹œ~11์‹œ ๋ฐ ์˜คํ›„ 12์‹œ๋Š” ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ - } - val formattedHour = displayHour.toString().padStart(2, '0') + val formattedHour = hour.toString().padStart(2, '0') val formattedMinute = minute.toString().padStart(2, '0') return "$formattedHour:$formattedMinute" diff --git a/domain/src/main/java/com/yapp/domain/model/MissionType.kt b/domain/src/main/java/com/yapp/domain/model/MissionType.kt index 3146d233..bcfdff3c 100644 --- a/domain/src/main/java/com/yapp/domain/model/MissionType.kt +++ b/domain/src/main/java/com/yapp/domain/model/MissionType.kt @@ -1,22 +1,21 @@ package com.yapp.domain.model enum class MissionType(val value: Int) { - TAP(0), - SHAKE(1), + NONE(0), + TAP(1), + SHAKE(2), ; companion object { fun fromInt(value: Int): MissionType { - return MissionType.entries.find { it.value == value } ?: TAP + return MissionType.entries.find { it.value == value } ?: NONE } fun fromRemoteValue(value: String): MissionType { return when (value) { "tap_mission" -> TAP "shake_mission" -> SHAKE - else -> { - TAP - } + else -> NONE } } } diff --git a/domain/src/main/java/com/yapp/domain/repository/AlarmRepository.kt b/domain/src/main/java/com/yapp/domain/repository/AlarmRepository.kt index 142c1b83..f7dac361 100644 --- a/domain/src/main/java/com/yapp/domain/repository/AlarmRepository.kt +++ b/domain/src/main/java/com/yapp/domain/repository/AlarmRepository.kt @@ -15,9 +15,7 @@ interface AlarmRepository { fun updateAlarmVolume(volume: Int) fun releaseSoundPlayer() fun getAllAlarms(): Flow> - fun getPagedAlarms(limit: Int, offset: Int): Flow> - fun getAlarmsByTime(hour: Int, minute: Int, isAm: Boolean): Flow> - fun getAlarmCount(): Flow + fun getAlarmsByTime(hour: Int, minute: Int): Flow> suspend fun insertAlarm(alarm: Alarm): Result suspend fun updateAlarm(alarm: Alarm): Result suspend fun updateAlarmActive(id: Long, active: Boolean): Result diff --git a/domain/src/main/java/com/yapp/domain/usecase/AlarmUseCase.kt b/domain/src/main/java/com/yapp/domain/usecase/AlarmUseCase.kt index 2411beeb..86720460 100644 --- a/domain/src/main/java/com/yapp/domain/usecase/AlarmUseCase.kt +++ b/domain/src/main/java/com/yapp/domain/usecase/AlarmUseCase.kt @@ -4,11 +4,13 @@ import android.net.Uri import com.yapp.domain.model.Alarm import com.yapp.domain.model.AlarmSound import com.yapp.domain.repository.AlarmRepository +import com.yapp.domain.scheduler.AlarmScheduler import kotlinx.coroutines.flow.Flow import javax.inject.Inject class AlarmUseCase @Inject constructor( private val alarmRepository: AlarmRepository, + private val alarmScheduler: AlarmScheduler, ) { suspend fun getAlarmSounds(): Result> = alarmRepository.getAlarmSounds() fun initializeSoundPlayer(uri: Uri) = alarmRepository.initializeSoundPlayer(uri) @@ -17,12 +19,13 @@ class AlarmUseCase @Inject constructor( fun updateAlarmVolume(volume: Int) = alarmRepository.updateAlarmVolume(volume) fun releaseSoundPlayer() = alarmRepository.releaseSoundPlayer() fun getAllAlarms(): Flow> = alarmRepository.getAllAlarms() - fun getPagedAlarms(limit: Int, offset: Int): Flow> = alarmRepository.getPagedAlarms(limit, offset) - fun getAlarmsByTime(hour: Int, minute: Int, isAm: Boolean): Flow> = alarmRepository.getAlarmsByTime(hour, minute, isAm) - fun getAlarmCount(): Flow = alarmRepository.getAlarmCount() + fun getAlarmsByTime(hour: Int, minute: Int): Flow> = alarmRepository.getAlarmsByTime(hour, minute) suspend fun insertAlarm(alarm: Alarm): Result = alarmRepository.insertAlarm(alarm) suspend fun updateAlarm(alarm: Alarm): Result = alarmRepository.updateAlarm(alarm) suspend fun updateAlarmActive(id: Long, active: Boolean): Result = alarmRepository.updateAlarmActive(id, active) suspend fun getAlarm(id: Long): Result = alarmRepository.getAlarm(id) suspend fun deleteAlarm(id: Long): Result = alarmRepository.deleteAlarm(id) + + fun scheduleAlarm(alarm: Alarm) = alarmScheduler.scheduleAlarm(alarm) + fun unScheduleAlarm(alarm: Alarm) = alarmScheduler.unScheduleAlarm(alarm) } diff --git a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionContract.kt b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionContract.kt index 470f89c3..ea4d0b68 100644 --- a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionContract.kt +++ b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionContract.kt @@ -1,7 +1,6 @@ package com.yapp.alarm.interaction.action import com.yapp.domain.model.Alarm -import com.yapp.ui.base.SideEffect import com.yapp.ui.base.UiState class AlarmActionContract { diff --git a/feature/home/src/main/java/com/yapp/home/HomeContract.kt b/feature/home/src/main/java/com/yapp/home/HomeContract.kt index ee3f5dd1..dee0ecef 100644 --- a/feature/home/src/main/java/com/yapp/home/HomeContract.kt +++ b/feature/home/src/main/java/com/yapp/home/HomeContract.kt @@ -3,7 +3,6 @@ package com.yapp.home import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.yapp.domain.model.Alarm -import com.yapp.ui.base.SideEffect import com.yapp.ui.base.UiState sealed class HomeContract { diff --git a/feature/home/src/main/java/com/yapp/home/HomeNavGraph.kt b/feature/home/src/main/java/com/yapp/home/HomeNavGraph.kt index b044e9de..5e5fae4a 100644 --- a/feature/home/src/main/java/com/yapp/home/HomeNavGraph.kt +++ b/feature/home/src/main/java/com/yapp/home/HomeNavGraph.kt @@ -4,10 +4,10 @@ import androidx.compose.material3.SnackbarHostState import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.compose.navigation -import com.yapp.alarm.addedit.AlarmAddEditRoute import com.yapp.common.navigation.OrbitNavigator import com.yapp.common.navigation.route.HomeBaseRoute import com.yapp.common.navigation.route.HomeDestination +import com.yapp.home.alarm.addedit.AlarmAddEditRoute const val ADD_ALARM_RESULT_KEY = "addAlarmResult" const val UPDATE_ALARM_RESULT_KEY = "updateAlarmResult" diff --git a/feature/home/src/main/java/com/yapp/home/HomeScreen.kt b/feature/home/src/main/java/com/yapp/home/HomeScreen.kt index f65e1061..19d45307 100644 --- a/feature/home/src/main/java/com/yapp/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/yapp/home/HomeScreen.kt @@ -64,11 +64,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.yapp.alarm.component.AlarmListItem -import com.yapp.alarm.component.AlarmListItemMenu import com.yapp.common.navigation.OrbitNavigator import com.yapp.designsystem.theme.OrbitTheme import com.yapp.domain.model.Alarm +import com.yapp.home.alarm.component.AlarmListItem +import com.yapp.home.alarm.component.AlarmListItemMenu import com.yapp.home.component.bottomsheet.AlarmListBottomSheet import com.yapp.ui.component.dialog.OrbitDialog import com.yapp.ui.component.lottie.LottieAnimation @@ -905,7 +905,6 @@ private fun AlarmWithMenu( swipeable = false, selectable = false, selected = selectedAlarmIds.contains(activeItemMenu.id), - isAm = activeItemMenu.isAm, hour = activeItemMenu.hour, minute = activeItemMenu.minute, isActive = activeItemMenu.isAlarmActive, diff --git a/feature/home/src/main/java/com/yapp/home/HomeViewModel.kt b/feature/home/src/main/java/com/yapp/home/HomeViewModel.kt index 04944efc..ae347cea 100644 --- a/feature/home/src/main/java/com/yapp/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/yapp/home/HomeViewModel.kt @@ -4,12 +4,10 @@ import android.util.Log import androidx.lifecycle.ViewModel import com.yapp.common.util.ResourceProvider import com.yapp.domain.model.Alarm -import com.yapp.domain.model.toAlarmDays -import com.yapp.domain.model.toDayOfWeek import com.yapp.domain.repository.FortuneRepository import com.yapp.domain.repository.UserInfoRepository -import com.yapp.domain.scheduler.AlarmScheduler import com.yapp.domain.usecase.AlarmUseCase +import com.yapp.home.util.AlarmDateTimeFormatter import dagger.hilt.android.lifecycle.HiltViewModel import feature.home.R import kotlinx.coroutines.flow.combine @@ -23,8 +21,6 @@ import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.syntax.simple.repeatOnSubscription import org.orbitmvi.orbit.viewmodel.container import java.time.LocalDate -import java.time.LocalDateTime -import java.time.LocalTime import java.time.format.DateTimeFormatter import javax.inject.Inject @@ -32,7 +28,7 @@ import javax.inject.Inject class HomeViewModel @Inject constructor( private val alarmUseCase: AlarmUseCase, private val resourceProvider: ResourceProvider, - private val alarmScheduler: AlarmScheduler, + private val alarmDateTimeFormatter: AlarmDateTimeFormatter, private val fortuneRepository: FortuneRepository, private val userInfoRepository: UserInfoRepository, ) : ViewModel(), ContainerHost { @@ -187,9 +183,9 @@ class HomeViewModel @Inject constructor( } if (updatedAlarm.isAlarmActive) { - alarmScheduler.scheduleAlarm(updatedAlarm) + alarmUseCase.scheduleAlarm(updatedAlarm) } else { - alarmScheduler.unScheduleAlarm(updatedAlarm) + alarmUseCase.unScheduleAlarm(updatedAlarm) } }.onFailure { error -> Log.e("HomeViewModel", "Failed to update alarm state", error) @@ -249,9 +245,9 @@ class HomeViewModel @Inject constructor( } if (updatedAlarm.isAlarmActive) { - alarmScheduler.scheduleAlarm(updatedAlarm) + alarmUseCase.scheduleAlarm(updatedAlarm) } else { - alarmScheduler.unScheduleAlarm(updatedAlarm) + alarmUseCase.unScheduleAlarm(updatedAlarm) } }.onFailure { error -> Log.e("HomeViewModel", "Failed to rollback alarm state", error) @@ -270,7 +266,7 @@ class HomeViewModel @Inject constructor( alarmsToDelete.forEach { alarm -> alarmUseCase.deleteAlarm(alarm.id) - alarmScheduler.unScheduleAlarm(alarm) + alarmUseCase.unScheduleAlarm(alarm) } if (state.activeItemMenu != null) { @@ -293,7 +289,7 @@ class HomeViewModel @Inject constructor( private fun restoreDeletedAlarms(alarmsWithIndex: List) = intent { alarmsWithIndex.forEach { alarm -> alarmUseCase.insertAlarm(alarm) - alarmScheduler.scheduleAlarm(alarm) + alarmUseCase.scheduleAlarm(alarm) } } @@ -304,14 +300,14 @@ class HomeViewModel @Inject constructor( private fun loadAllAlarms() = intent { reduce { state.copy(initialLoading = true) } - alarmUseCase.getAllAlarms().collect { + alarmUseCase.getAllAlarms().collect { alarms -> reduce { state.copy( - alarms = it, + alarms = alarms, initialLoading = false, ) } - updateDeliveryTime(it) + updateDeliveryTime(alarms) } } @@ -320,85 +316,25 @@ class HomeViewModel @Inject constructor( } private fun updateDeliveryTime(alarms: List) = intent { - val earliestAlarm = alarms - .filter { it.isAlarmActive } - .minByOrNull { alarm -> - getNextAlarmDateWithTime(alarm.isAm, alarm.hour, alarm.minute, alarm.repeatDays) - } - - val deliveryTime = earliestAlarm?.let { alarm -> - val alarmDateTime = getNextAlarmDateWithTime(alarm.isAm, alarm.hour, alarm.minute, alarm.repeatDays) - alarmDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")) - } ?: "NONE" - - reduce { state.copy(deliveryTime = formatDeliveryTime(deliveryTime)) } - } - - private fun getNextAlarmDateWithTime(isAm: Boolean, hour: Int, minute: Int, repeatDays: Int): LocalDateTime { - val now = LocalDateTime.now() - - val alarmHour = when { - isAm && hour == 12 -> 0 - !isAm && hour != 12 -> hour + 12 - else -> hour - } - val alarmTime = LocalTime.of(alarmHour, minute) - val todayAlarm = LocalDateTime.of(now.toLocalDate(), alarmTime) - - // ๋ฐ˜๋ณต ์š”์ผ์ด ์„ค์ •๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ โ†’ ๋‹จ์ผ ์•Œ๋žŒ - if (repeatDays == 0) { - return if (todayAlarm.isAfter(now)) todayAlarm else todayAlarm.plusDays(1) - } + val deliveryTimeFormats = AlarmDateTimeFormatter.DeliveryTimeFormats( + noAlarm = resourceProvider.getString(R.string.home_fortune_no_alarm), + today = resourceProvider.getString(R.string.home_fortune_delivery_today, "%s"), + tomorrow = resourceProvider.getString(R.string.home_fortune_delivery_tomorrow, "%s"), + thisYear = resourceProvider.getString(R.string.home_fortune_delivery_this_year, "%s"), + otherYear = resourceProvider.getString(R.string.home_fortune_delivery_other_year, "%s"), + ) - // ๋น„ํŠธ๋งˆ์Šคํฌ ๊ธฐ๋ฐ˜ ๋ฐ˜๋ณต ์š”์ผ ์ถ”์ถœ - val selectedDays = repeatDays.toAlarmDays().map { it.toDayOfWeek() }.sortedBy { it.value } - val currentDayOfWeek = now.dayOfWeek - - // ๊ฐ€์žฅ ๋น ๋ฅธ ๋‹ค์Œ ์•Œ๋žŒ ๋‚ ์งœ ๊ณ„์‚ฐ - val nextDayOffset = selectedDays - .map { (it.value + 7 - currentDayOfWeek.value) % 7 } - .filter { it > 0 || todayAlarm.isAfter(now) } - .minOrNull() ?: (selectedDays.first().value + 7 - currentDayOfWeek.value) - - return todayAlarm.plusDays(nextDayOffset.toLong()) - } - - private fun formatDeliveryTime(deliveryTime: String): String { - return try { - if (deliveryTime == "NONE") return resourceProvider.getString(R.string.home_fortune_no_alarm) - - val inputDateTime = LocalDateTime.parse(deliveryTime, DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")) - val now = LocalDateTime.now() - val today = now.toLocalDate() - val tomorrow = today.plusDays(1) - - return when { - inputDateTime.toLocalDate() == today -> - resourceProvider.getString(R.string.home_fortune_delivery_today, inputDateTime.format(DateTimeFormatter.ofPattern("a h:mm"))) - inputDateTime.toLocalDate() == tomorrow -> - resourceProvider.getString(R.string.home_fortune_delivery_tomorrow, inputDateTime.format(DateTimeFormatter.ofPattern("a h:mm"))) - inputDateTime.year == now.year -> - resourceProvider.getString( - R.string.home_fortune_delivery_this_year, - inputDateTime.format(DateTimeFormatter.ofPattern("M์›” d์ผ a h:mm")), - ) - else -> - resourceProvider.getString( - R.string.home_fortune_delivery_other_year, - inputDateTime.format(DateTimeFormatter.ofPattern("yy๋…„ M์›” d์ผ a h:mm")), - ) - } - } catch (e: Exception) { - resourceProvider.getString(R.string.home_fortune_no_alarm) - } + val formattedTime = alarmDateTimeFormatter.getFormattedEarliestUpcomingAlarmDeliveryTime( + alarms = alarms, + formats = deliveryTimeFormats, + ) + reduce { state.copy(deliveryTime = formattedTime) } } private fun loadDailyFortune() = intent { val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - Log.d("HomeViewModel", "fortuneDate: $fortuneDate, todayDate: $todayDate") - if (fortuneDate != todayDate) { processAction(HomeContract.Action.ShowNoDailyFortuneDialog) } else { diff --git a/feature/home/src/main/java/com/yapp/alarm/AlarmDayLabel.kt b/feature/home/src/main/java/com/yapp/home/alarm/AlarmDayLabel.kt similarity index 94% rename from feature/home/src/main/java/com/yapp/alarm/AlarmDayLabel.kt rename to feature/home/src/main/java/com/yapp/home/alarm/AlarmDayLabel.kt index ee2e1b86..69acb835 100644 --- a/feature/home/src/main/java/com/yapp/alarm/AlarmDayLabel.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/AlarmDayLabel.kt @@ -1,4 +1,4 @@ -package com.yapp.alarm +package com.yapp.home.alarm import com.yapp.domain.model.AlarmDay import feature.home.R diff --git a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditContract.kt b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditContract.kt similarity index 90% rename from feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditContract.kt rename to feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditContract.kt index de6da472..ac629e17 100644 --- a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditContract.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditContract.kt @@ -1,12 +1,14 @@ -package com.yapp.alarm.addedit +package com.yapp.home.alarm.addedit import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.yapp.domain.model.Alarm import com.yapp.domain.model.AlarmDay import com.yapp.domain.model.AlarmSound +import com.yapp.domain.model.MissionType import com.yapp.domain.model.toRepeatDays import com.yapp.ui.base.UiState +import java.time.LocalTime sealed class AlarmAddEditContract { @@ -16,6 +18,7 @@ sealed class AlarmAddEditContract { val timeState: AlarmTimeState = AlarmTimeState(), val daySelectionState: AlarmDaySelectionState = AlarmDaySelectionState(), val holidayState: AlarmHolidayState = AlarmHolidayState(), + val missionState: AlarmMissionState = AlarmMissionState(), val snoozeState: AlarmSnoozeState = AlarmSnoozeState(), val soundState: AlarmSoundState = AlarmSoundState(), val bottomSheetState: BottomSheetType? = null, @@ -24,12 +27,8 @@ sealed class AlarmAddEditContract { ) : UiState data class AlarmTimeState( - val initialAmPm: String = "์˜ค์ „", - val initialHour: String = "1", - val initialMinute: String = "00", - val currentAmPm: String = "์˜ค์ „", - val currentHour: Int = 1, - val currentMinute: Int = 0, + val initialTime: LocalTime = LocalTime.of(1, 0), + val currentTime: LocalTime = LocalTime.of(1, 0), val alarmMessage: String = "", ) @@ -45,6 +44,10 @@ sealed class AlarmAddEditContract { val isDisableHolidayChecked: Boolean = false, ) + data class AlarmMissionState( + val missionType: MissionType = MissionType.TAP, + ) + data class AlarmSnoozeState( val isSnoozeEnabled: Boolean = true, val snoozeIntervalIndex: Int = 2, @@ -74,7 +77,7 @@ sealed class AlarmAddEditContract { data object ShowUnsavedChangesDialog : Action() data object HideUnsavedChangesDialog : Action() data object DeleteAlarm : Action() - data class SetAlarmTime(val amPm: String, val hour: Int, val minute: Int) : Action() + data class SetAlarmTime(val newTime: LocalTime) : Action() data object ToggleWeekdaysSelection : Action() data object ToggleWeekendsSelection : Action() data class ToggleSpecificDaySelection(val day: AlarmDay) : Action() @@ -118,9 +121,8 @@ sealed class AlarmAddEditContract { internal fun AlarmAddEditContract.State.toAlarm(id: Long = 0): Alarm { return Alarm( id = id, - isAm = timeState.currentAmPm == "์˜ค์ „", - hour = timeState.currentHour, - minute = timeState.currentMinute, + hour = timeState.currentTime.hour, + minute = timeState.currentTime.minute, repeatDays = daySelectionState.selectedDays.toRepeatDays(), isHolidayAlarmOff = holidayState.isDisableHolidayChecked, isSnoozeEnabled = snoozeState.isSnoozeEnabled, diff --git a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditScreen.kt b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditScreen.kt similarity index 81% rename from feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditScreen.kt rename to feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditScreen.kt index ebcaec50..895d556f 100644 --- a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditScreen.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditScreen.kt @@ -1,4 +1,4 @@ -package com.yapp.alarm.addedit +package com.yapp.home.alarm.addedit import android.net.Uri import androidx.activity.compose.BackHandler @@ -48,18 +48,19 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.yapp.alarm.component.AlarmCheckItem -import com.yapp.alarm.component.AlarmDayButton -import com.yapp.alarm.component.bottomsheet.AlarmSnoozeBottomSheet -import com.yapp.alarm.component.bottomsheet.AlarmSoundBottomSheet -import com.yapp.alarm.getLabelStringRes import com.yapp.common.navigation.OrbitNavigator import com.yapp.designsystem.theme.OrbitTheme import com.yapp.domain.model.AlarmDay import com.yapp.domain.model.AlarmSound +import com.yapp.domain.model.MissionType import com.yapp.home.ADD_ALARM_RESULT_KEY import com.yapp.home.DELETE_ALARM_RESULT_KEY import com.yapp.home.UPDATE_ALARM_RESULT_KEY +import com.yapp.home.alarm.component.AlarmCheckItem +import com.yapp.home.alarm.component.AlarmDayButton +import com.yapp.home.alarm.component.bottomsheet.AlarmSnoozeBottomSheet +import com.yapp.home.alarm.component.bottomsheet.AlarmSoundBottomSheet +import com.yapp.home.alarm.getLabelStringRes import com.yapp.ui.component.button.OrbitButton import com.yapp.ui.component.dialog.OrbitDialog import com.yapp.ui.component.lottie.LottieAnimation @@ -70,6 +71,7 @@ import feature.home.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.orbitmvi.orbit.compose.collectSideEffect +import java.time.LocalTime @Composable fun AlarmAddEditRoute( @@ -185,13 +187,18 @@ fun AlarmAddEditContent( contentAlignment = Alignment.Center, ) { OrbitPicker( - initialAmPm = state.timeState.initialAmPm, - initialHour = state.timeState.initialHour, - initialMinute = state.timeState.initialMinute, - ) { amPm, hour, minute -> - eventDispatcher(AlarmAddEditContract.Action.SetAlarmTime(amPm, hour, minute)) + initialTime = state.timeState.initialTime, + ) { newTime -> + eventDispatcher(AlarmAddEditContract.Action.SetAlarmTime(newTime)) } } + AlarmAddEditSelectDaysSection( + modifier = Modifier.padding(horizontal = 20.dp), + daysSelectionState = state.daySelectionState, + holidayState = state.holidayState, + processAction = eventDispatcher, + ) + Spacer(modifier = Modifier.height(12.dp)) AlarmAddEditSettingsSection( modifier = Modifier.padding(horizontal = 20.dp), state = state, @@ -409,13 +416,14 @@ private fun AlarmAddEditSettingsSection( shape = RoundedCornerShape(12.dp), ), ) { - AlarmAddEditSelectDaysSection( - state = state.daySelectionState, - processAction = processAction, - ) - AlarmAddEditDisableHolidaySwitch( - state = state.holidayState, - processAction = processAction, + AlarmAddEditSettingItem( + label = stringResource(id = R.string.alarm_add_edit_mission), + description = when (state.missionState.missionType) { + MissionType.TAP -> stringResource(id = R.string.alarm_add_edit_selected_mission_tap) + MissionType.SHAKE -> stringResource(id = R.string.alarm_add_edit_selected_mission_shake) + else -> stringResource(id = R.string.alarm_add_edit_selected_mission_none) + }, + onClick = { }, ) Spacer( modifier = Modifier @@ -424,7 +432,6 @@ private fun AlarmAddEditSettingsSection( .padding(horizontal = 20.dp) .background(OrbitTheme.colors.gray_700), ) - AlarmAddEditSettingItem( label = stringResource(id = R.string.alarm_add_edit_alarm_snooze), description = if (state.snoozeState.isSnoozeEnabled) { @@ -545,79 +552,96 @@ private fun AlarmAddEditSettingItem( @Composable private fun AlarmAddEditSelectDaysSection( - state: AlarmAddEditContract.AlarmDaySelectionState, + modifier: Modifier = Modifier, + daysSelectionState: AlarmAddEditContract.AlarmDaySelectionState, + holidayState: AlarmAddEditContract.AlarmHolidayState, processAction: (AlarmAddEditContract.Action) -> Unit, ) { val configuration = LocalConfiguration.current val screenWidthDp = configuration.screenWidthDp.dp Column( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), + modifier = modifier + .fillMaxWidth() + .background( + color = OrbitTheme.colors.gray_800, + shape = RoundedCornerShape(12.dp), + ) + .clip( + shape = RoundedCornerShape(12.dp), + ), + verticalArrangement = Arrangement.spacedBy(20.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), ) { - Text( - text = stringResource(id = R.string.alarm_add_edit_repeat), - style = OrbitTheme.typography.body1SemiBold, - color = OrbitTheme.colors.white, - ) - - Spacer(modifier = Modifier.weight(1f)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(id = R.string.alarm_add_edit_repeat), + style = OrbitTheme.typography.body1SemiBold, + color = OrbitTheme.colors.white, + ) - AlarmCheckItem( - label = stringResource(id = R.string.alarm_add_edit_weekdays), - isPressed = state.isWeekdaysChecked, - onClick = { - processAction(AlarmAddEditContract.Action.ToggleWeekdaysSelection) - }, - ) - Spacer(modifier = Modifier.width(2.dp)) - AlarmCheckItem( - label = stringResource(id = R.string.alarm_add_edit_weekends), - isPressed = state.isWeekendsChecked, - onClick = { - processAction(AlarmAddEditContract.Action.ToggleWeekendsSelection) - }, - ) - } + Spacer(modifier = Modifier.weight(1f)) - Spacer(modifier = Modifier.height(12.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - state.days.forEach { day -> - AlarmDayButton( - modifier = Modifier.size( - if (screenWidthDp > 360.dp) 36.dp else 34.dp, - ), - label = stringResource(id = day.getLabelStringRes()), - isPressed = state.selectedDays.contains(day), + AlarmCheckItem( + label = stringResource(id = R.string.alarm_add_edit_weekdays), + isPressed = daysSelectionState.isWeekdaysChecked, onClick = { - processAction(AlarmAddEditContract.Action.ToggleSpecificDaySelection(day)) + processAction(AlarmAddEditContract.Action.ToggleWeekdaysSelection) + }, + ) + Spacer(modifier = Modifier.width(2.dp)) + AlarmCheckItem( + label = stringResource(id = R.string.alarm_add_edit_weekends), + isPressed = daysSelectionState.isWeekendsChecked, + onClick = { + processAction(AlarmAddEditContract.Action.ToggleWeekendsSelection) }, ) } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + daysSelectionState.days.forEach { day -> + AlarmDayButton( + modifier = Modifier.size( + if (screenWidthDp > 360.dp) 36.dp else 34.dp, + ), + label = stringResource(id = day.getLabelStringRes()), + isPressed = daysSelectionState.selectedDays.contains(day), + onClick = { + processAction(AlarmAddEditContract.Action.ToggleSpecificDaySelection(day)) + }, + ) + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + AlarmAddEditDisableHolidaySwitch( + state = holidayState, + processAction = processAction, + ) } } } @Composable private fun AlarmAddEditDisableHolidaySwitch( + modifier: Modifier = Modifier, state: AlarmAddEditContract.AlarmHolidayState, processAction: (AlarmAddEditContract.Action) -> Unit, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding( - start = 20.dp, - end = 20.dp, - bottom = 16.dp, - ), + modifier = modifier, verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -650,9 +674,7 @@ fun AlarmAddEditSettingsSectionPreview() { AlarmAddEditSettingsSection( state = AlarmAddEditContract.State( timeState = AlarmAddEditContract.AlarmTimeState( - currentAmPm = "AM", - currentHour = 9, - currentMinute = 30, + currentTime = LocalTime.of(19, 30), ), daySelectionState = AlarmAddEditContract.AlarmDaySelectionState( isWeekdaysChecked = true, @@ -681,25 +703,32 @@ fun AlarmAddEditSettingItemPreview() { @Preview @Composable fun AlarmAddEditScreenPreview() { - AlarmAddEditScreen( - stateProvider = { - AlarmAddEditContract.State( - timeState = AlarmAddEditContract.AlarmTimeState( - currentAmPm = "AM", - currentHour = 9, - currentMinute = 30, - ), - daySelectionState = AlarmAddEditContract.AlarmDaySelectionState( - isWeekdaysChecked = true, - isWeekendsChecked = false, - selectedDays = setOf(AlarmDay.MON, AlarmDay.TUE), - days = AlarmDay.entries.toSet(), - ), - holidayState = AlarmAddEditContract.AlarmHolidayState( - isDisableHolidayChecked = false, - ), + OrbitTheme { + Box( + modifier = Modifier.background( + color = OrbitTheme.colors.gray_900, + ), + ) { + AlarmAddEditScreen( + stateProvider = { + AlarmAddEditContract.State( + initialLoading = false, + timeState = AlarmAddEditContract.AlarmTimeState( + currentTime = LocalTime.of(19, 30), + ), + daySelectionState = AlarmAddEditContract.AlarmDaySelectionState( + isWeekdaysChecked = true, + isWeekendsChecked = false, + selectedDays = setOf(AlarmDay.MON, AlarmDay.TUE), + days = AlarmDay.entries.toSet(), + ), + holidayState = AlarmAddEditContract.AlarmHolidayState( + isDisableHolidayChecked = false, + ), + ) + }, + eventDispatcher = { }, ) - }, - eventDispatcher = { }, - ) + } + } } diff --git a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditViewModel.kt b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditViewModel.kt similarity index 81% rename from feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditViewModel.kt rename to feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditViewModel.kt index 4690da7a..8fa864c5 100644 --- a/feature/home/src/main/java/com/yapp/alarm/addedit/AlarmAddEditViewModel.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditViewModel.kt @@ -1,4 +1,4 @@ -package com.yapp.alarm.addedit +package com.yapp.home.alarm.addedit import android.util.Log import androidx.compose.ui.unit.dp @@ -13,9 +13,9 @@ import com.yapp.domain.model.AlarmSound import com.yapp.domain.model.copyFrom import com.yapp.domain.model.toAlarmDayNames import com.yapp.domain.model.toAlarmDays -import com.yapp.domain.model.toDayOfWeek -import com.yapp.domain.scheduler.AlarmScheduler +import com.yapp.domain.model.toRepeatDays import com.yapp.domain.usecase.AlarmUseCase +import com.yapp.home.util.AlarmDateTimeFormatter import com.yapp.media.haptic.HapticFeedbackManager import com.yapp.media.haptic.HapticType import dagger.hilt.android.lifecycle.HiltViewModel @@ -27,6 +27,7 @@ import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container +import java.time.LocalDateTime import java.time.LocalTime import javax.inject.Inject @@ -35,8 +36,8 @@ class AlarmAddEditViewModel @Inject constructor( private val analyticsHelper: AnalyticsHelper, private val alarmUseCase: AlarmUseCase, private val resourceProvider: ResourceProvider, + private val alarmDateTimeFormatter: AlarmDateTimeFormatter, private val hapticFeedbackManager: HapticFeedbackManager, - private val alarmScheduler: AlarmScheduler, savedStateHandle: SavedStateHandle, ) : ViewModel(), ContainerHost { @@ -68,21 +69,14 @@ class AlarmAddEditViewModel @Inject constructor( alarmUseCase.initializeSoundPlayer(defaultSound.uri) val now = LocalTime.now() - val initialAmPm = if (now.hour < 12) "์˜ค์ „" else "์˜คํ›„" - val initialHour = if (now.hour == 0 || now.hour == 12) 12 else now.hour % 12 - val initialMinute = now.minute reduce { state.copy( initialLoading = false, timeState = state.timeState.copy( - initialAmPm = initialAmPm, - initialHour = "$initialHour", - initialMinute = initialMinute.toString().padStart(2, '0'), - currentAmPm = initialAmPm, - currentHour = initialHour, - currentMinute = initialMinute, - alarmMessage = getAlarmMessage(initialAmPm, initialHour, initialMinute, emptySet()), + initialTime = now, + currentTime = now, + alarmMessage = getAlarmMessage(now, emptySet()), ), soundState = state.soundState.copy(sounds = sounds, soundIndex = defaultSoundIndex), ) @@ -92,8 +86,6 @@ class AlarmAddEditViewModel @Inject constructor( private fun loadExistingAlarm(sounds: List) = intent { alarmUseCase.getAlarm(alarmId).onSuccess { alarm -> val repeatDays = alarm.repeatDays.toAlarmDays() - val isAM = alarm.isAm - val hour = alarm.hour val selectedSoundIndex = sounds.indexOfFirst { it.uri.toString() == alarm.soundUri } val selectedSound = sounds.getOrNull(selectedSoundIndex) ?: sounds.first() @@ -103,13 +95,12 @@ class AlarmAddEditViewModel @Inject constructor( state.copy( initialLoading = false, timeState = state.timeState.copy( - initialAmPm = if (isAM) "์˜ค์ „" else "์˜คํ›„", - initialHour = "$hour", - initialMinute = alarm.minute.toString().padStart(2, '0'), - currentAmPm = if (isAM) "์˜ค์ „" else "์˜คํ›„", - currentHour = hour, - currentMinute = alarm.minute, - alarmMessage = getAlarmMessage(if (isAM) "์˜ค์ „" else "์˜คํ›„", hour, alarm.minute, repeatDays), + initialTime = LocalTime.of(alarm.hour, alarm.minute), + currentTime = LocalTime.of(alarm.hour, alarm.minute), + alarmMessage = getAlarmMessage( + LocalTime.of(alarm.hour, alarm.minute), + repeatDays, + ), ), daySelectionState = setupDaySelectionState(repeatDays, state), holidayState = state.holidayState.copy( @@ -172,7 +163,7 @@ class AlarmAddEditViewModel @Inject constructor( is AlarmAddEditContract.Action.ShowUnsavedChangesDialog -> showUnsavedChangesDialog() is AlarmAddEditContract.Action.HideUnsavedChangesDialog -> hideUnsavedChangesDialog() is AlarmAddEditContract.Action.DeleteAlarm -> deleteAlarm() - is AlarmAddEditContract.Action.SetAlarmTime -> setAlarmTime(action.amPm, action.hour, action.minute) + is AlarmAddEditContract.Action.SetAlarmTime -> setAlarmTime(action.newTime) is AlarmAddEditContract.Action.ToggleWeekdaysSelection -> toggleWeekdaysSelection() is AlarmAddEditContract.Action.ToggleWeekendsSelection -> toggleWeekendsSelection() is AlarmAddEditContract.Action.ToggleSpecificDaySelection -> toggleSpecificDaySelection(action.day) @@ -220,12 +211,12 @@ class AlarmAddEditViewModel @Inject constructor( val updatedAlarm = alarm.copy(id = alarmId) alarmUseCase.getAlarm(alarmId).onSuccess { oldAlarm -> - alarmScheduler.unScheduleAlarm(oldAlarm) + alarmUseCase.unScheduleAlarm(oldAlarm) } alarmUseCase.updateAlarm(updatedAlarm) .onSuccess { - alarmScheduler.scheduleAlarm(updatedAlarm) + alarmUseCase.scheduleAlarm(updatedAlarm) postSideEffect(AlarmAddEditContract.SideEffect.UpdateAlarm(it.id)) } .onFailure { @@ -234,7 +225,7 @@ class AlarmAddEditViewModel @Inject constructor( } private suspend fun checkAndCreateAlarm(newAlarm: Alarm) { - val timeMatchedAlarms = alarmUseCase.getAlarmsByTime(newAlarm.hour, newAlarm.minute, newAlarm.isAm) + val timeMatchedAlarms = alarmUseCase.getAlarmsByTime(newAlarm.hour, newAlarm.minute) .first() when { @@ -279,7 +270,7 @@ class AlarmAddEditViewModel @Inject constructor( ), ), ) - alarmScheduler.scheduleAlarm(it) + alarmUseCase.scheduleAlarm(it) postSideEffect(AlarmAddEditContract.SideEffect.SaveAlarm(it.id)) } .onFailure { @@ -287,12 +278,10 @@ class AlarmAddEditViewModel @Inject constructor( } } - private fun setAlarmTime(amPm: String, hour: Int, minute: Int) = intent { + private fun setAlarmTime(newTime: LocalTime) = intent { val newTimeState = state.timeState.copy( - currentAmPm = amPm, - currentHour = hour, - currentMinute = minute, - alarmMessage = getAlarmMessage(amPm, hour, minute, state.daySelectionState.selectedDays), + currentTime = newTime, + alarmMessage = getAlarmMessage(newTime, state.daySelectionState.selectedDays), ) hapticFeedbackManager.performHapticFeedback(HapticType.LIGHT_TICK) @@ -337,7 +326,7 @@ class AlarmAddEditViewModel @Inject constructor( reduce { state.copy( timeState = state.timeState.copy( - alarmMessage = getAlarmMessage(state.timeState.currentAmPm, state.timeState.currentHour, state.timeState.currentMinute, newDayState.selectedDays), + alarmMessage = getAlarmMessage(state.timeState.currentTime, newDayState.selectedDays), ), daySelectionState = newDayState, holidayState = state.holidayState.copy( @@ -363,7 +352,7 @@ class AlarmAddEditViewModel @Inject constructor( reduce { state.copy( timeState = state.timeState.copy( - alarmMessage = getAlarmMessage(state.timeState.currentAmPm, state.timeState.currentHour, state.timeState.currentMinute, newDayState.selectedDays), + alarmMessage = getAlarmMessage(state.timeState.currentTime, newDayState.selectedDays), ), daySelectionState = newDayState, holidayState = state.holidayState.copy( @@ -391,7 +380,7 @@ class AlarmAddEditViewModel @Inject constructor( reduce { state.copy( timeState = state.timeState.copy( - alarmMessage = getAlarmMessage(state.timeState.currentAmPm, state.timeState.currentHour, state.timeState.currentMinute, newDayState.selectedDays), + alarmMessage = getAlarmMessage(state.timeState.currentTime, newDayState.selectedDays), ), daySelectionState = newDayState, holidayState = state.holidayState.copy( @@ -512,60 +501,24 @@ class AlarmAddEditViewModel @Inject constructor( } } - private fun getAlarmMessage(amPm: String, hour: Int, minute: Int, selectedDays: Set): String { - val now = java.time.LocalDateTime.now() - val alarmHour = convertTo24HourFormat(amPm, hour) - val alarmTimeToday = now.toLocalDate().atTime(alarmHour, minute) - val nextAlarmDateTime = calculateNextAlarmDateTime(now, alarmTimeToday, selectedDays) - val duration = java.time.Duration.between(now, nextAlarmDateTime) - val totalMinutes = duration.toMinutes() - val days = totalMinutes / (24 * 60) - val hours = (totalMinutes % (24 * 60)) / 60 - val minutes = totalMinutes % 60 - - return when { - days > 0 -> "${days}์ผ ${hours}์‹œ๊ฐ„ ํ›„์— ์šธ๋ ค์š”" - hours > 0 -> "${hours}์‹œ๊ฐ„ ${minutes}๋ถ„ ํ›„์— ์šธ๋ ค์š”" - minutes == 0L -> "๊ณง ์šธ๋ ค์š”" - else -> "${minutes}๋ถ„ ํ›„์— ์šธ๋ ค์š”" - } - } - - private fun convertTo24HourFormat(amPm: String, hour: Int): Int = when { - amPm == "์˜คํ›„" && hour != 12 -> hour + 12 - amPm == "์˜ค์ „" && hour == 12 -> 0 - else -> hour - } - - private fun calculateNextAlarmDateTime( - now: java.time.LocalDateTime, - alarmTimeToday: java.time.LocalDateTime, - selectedDays: Set, - ): java.time.LocalDateTime { - if (selectedDays.isEmpty()) { - return if (alarmTimeToday.isBefore(now)) { - alarmTimeToday.plusDays(1) - } else { - alarmTimeToday - } - } - - val currentDayOfWeek = now.dayOfWeek.value - val selectedDaysOfWeek = selectedDays.map { it.toDayOfWeek().value }.sorted() - - if (selectedDaysOfWeek.contains(currentDayOfWeek) && now.toLocalTime().isBefore(alarmTimeToday.toLocalTime())) { - return alarmTimeToday - } - - val nextDay = selectedDaysOfWeek.firstOrNull { it > currentDayOfWeek } - ?: selectedDaysOfWeek.first() - val daysToAdd = if (nextDay > currentDayOfWeek) { - nextDay - currentDayOfWeek - } else { - 7 - (currentDayOfWeek - nextDay) - } + private fun getAlarmMessage(currentTime: LocalTime, selectedDays: Set): String { + val repeatDays = selectedDays.toRepeatDays() + val nextOccurrence = alarmDateTimeFormatter.calculateNextOccurrence( + hour = currentTime.hour, + minute = currentTime.minute, + repeatDays = repeatDays, + now = LocalDateTime.now(), + ) - val nextAlarmDate = now.toLocalDate().plusDays(daysToAdd.toLong()) - return nextAlarmDate.atTime(alarmTimeToday.toLocalTime()) + return alarmDateTimeFormatter.formatTimeDifference( + baseTime = LocalDateTime.now(), + futureTime = nextOccurrence, + formats = AlarmDateTimeFormatter.TimeDifferenceFormats( + daysHoursMinutesFormat = resourceProvider.getString(R.string.alarm_remaining_time_days_hours), + hoursMinutesFormat = resourceProvider.getString(R.string.alarm_remaining_time_hours_minutes), + minutesFormat = resourceProvider.getString(R.string.alarm_remaining_time_minutes_only), + soonFormat = resourceProvider.getString(R.string.alarm_remaining_time_soon), + ), + ) } } diff --git a/feature/home/src/main/java/com/yapp/alarm/component/AlarmCheckItem.kt b/feature/home/src/main/java/com/yapp/home/alarm/component/AlarmCheckItem.kt similarity index 91% rename from feature/home/src/main/java/com/yapp/alarm/component/AlarmCheckItem.kt rename to feature/home/src/main/java/com/yapp/home/alarm/component/AlarmCheckItem.kt index 900d7e96..905f9605 100644 --- a/feature/home/src/main/java/com/yapp/alarm/component/AlarmCheckItem.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/component/AlarmCheckItem.kt @@ -1,4 +1,4 @@ -package com.yapp.alarm.component +package com.yapp.home.alarm.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -13,6 +13,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.yapp.designsystem.theme.OrbitTheme +import core.designsystem.R @Composable internal fun AlarmCheckItem( @@ -30,7 +31,7 @@ internal fun AlarmCheckItem( verticalAlignment = Alignment.CenterVertically, ) { Icon( - painter = painterResource(id = core.designsystem.R.drawable.ic_check), + painter = painterResource(id = R.drawable.ic_check), contentDescription = "Check", tint = if (isPressed) OrbitTheme.colors.main else OrbitTheme.colors.gray_400, ) diff --git a/feature/home/src/main/java/com/yapp/alarm/component/AlarmDayButton.kt b/feature/home/src/main/java/com/yapp/home/alarm/component/AlarmDayButton.kt similarity index 98% rename from feature/home/src/main/java/com/yapp/alarm/component/AlarmDayButton.kt rename to feature/home/src/main/java/com/yapp/home/alarm/component/AlarmDayButton.kt index 4172b075..9e76a68c 100644 --- a/feature/home/src/main/java/com/yapp/alarm/component/AlarmDayButton.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/component/AlarmDayButton.kt @@ -1,4 +1,4 @@ -package com.yapp.alarm.component +package com.yapp.home.alarm.component import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/feature/home/src/main/java/com/yapp/alarm/component/AlarmListItem.kt b/feature/home/src/main/java/com/yapp/home/alarm/component/AlarmListItem.kt similarity index 98% rename from feature/home/src/main/java/com/yapp/alarm/component/AlarmListItem.kt rename to feature/home/src/main/java/com/yapp/home/alarm/component/AlarmListItem.kt index a590bd8e..38a13ff4 100644 --- a/feature/home/src/main/java/com/yapp/alarm/component/AlarmListItem.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/component/AlarmListItem.kt @@ -1,4 +1,4 @@ -package com.yapp.alarm.component +package com.yapp.home.alarm.component import android.os.Handler import android.os.Looper @@ -76,7 +76,6 @@ internal fun AlarmListItem( onLongPress: (Long, Float, Float) -> Unit, onToggleSelect: (Long) -> Unit, onSwipe: (Long) -> Unit, - isAm: Boolean, hour: Int, minute: Int, isActive: Boolean, @@ -197,7 +196,6 @@ internal fun AlarmListItem( repeatDays = repeatDays, isActive = isActive, isHolidayAlarmOff = isHolidayAlarmOff, - isAm = isAm, hour = hour, minute = minute, ) @@ -220,7 +218,6 @@ private fun AlarmListItemContent( repeatDays: Int, isActive: Boolean, isHolidayAlarmOff: Boolean, - isAm: Boolean, hour: Int, minute: Int, ) { @@ -230,6 +227,13 @@ private fun AlarmListItemContent( OrbitTheme.colors.gray_500 to OrbitTheme.colors.gray_500 } + val isAm = hour < 12 + val displayHour = when { + hour == 0 -> 12 + hour > 12 -> hour - 12 + else -> hour + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -260,7 +264,7 @@ private fun AlarmListItemContent( Spacer(modifier = Modifier.width(6.dp)) Text( - text = "$hour", + text = "$displayHour", style = OrbitTheme.typography.title2Medium, color = if (isActive) OrbitTheme.colors.white else OrbitTheme.colors.gray_500, ) @@ -408,7 +412,6 @@ private fun AlarmListItemPreview() { selectable = true, swipeable = false, selected = selected, - isAm = true, hour = 6, minute = 0, isActive = isActive, @@ -436,7 +439,6 @@ private fun AlarmListItemPreview() { selectable = false, selected = false, swipeable = true, - isAm = true, hour = 6, minute = 0, isActive = isActive, @@ -467,7 +469,6 @@ private fun AlarmListItemMenuPreview() { selectable = false, swipeable = false, selected = false, - isAm = true, hour = 6, minute = 0, isActive = true, diff --git a/feature/home/src/main/java/com/yapp/alarm/component/bottomsheet/AlarmSnoozeBottomSheet.kt b/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmSnoozeBottomSheet.kt similarity index 99% rename from feature/home/src/main/java/com/yapp/alarm/component/bottomsheet/AlarmSnoozeBottomSheet.kt rename to feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmSnoozeBottomSheet.kt index 8e2b9109..16c1185d 100644 --- a/feature/home/src/main/java/com/yapp/alarm/component/bottomsheet/AlarmSnoozeBottomSheet.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmSnoozeBottomSheet.kt @@ -1,4 +1,4 @@ -package com.yapp.alarm.component.bottomsheet +package com.yapp.home.alarm.component.bottomsheet import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement diff --git a/feature/home/src/main/java/com/yapp/alarm/component/bottomsheet/AlarmSoundBottomSheet.kt b/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmSoundBottomSheet.kt similarity index 99% rename from feature/home/src/main/java/com/yapp/alarm/component/bottomsheet/AlarmSoundBottomSheet.kt rename to feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmSoundBottomSheet.kt index 04049c3f..e98fe962 100644 --- a/feature/home/src/main/java/com/yapp/alarm/component/bottomsheet/AlarmSoundBottomSheet.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmSoundBottomSheet.kt @@ -1,4 +1,4 @@ -package com.yapp.alarm.component.bottomsheet +package com.yapp.home.alarm.component.bottomsheet import android.net.Uri import androidx.compose.foundation.background diff --git a/feature/home/src/main/java/com/yapp/home/component/bottomsheet/AlarmListBottomSheet.kt b/feature/home/src/main/java/com/yapp/home/component/bottomsheet/AlarmListBottomSheet.kt index 20b00907..9aaec0b3 100644 --- a/feature/home/src/main/java/com/yapp/home/component/bottomsheet/AlarmListBottomSheet.kt +++ b/feature/home/src/main/java/com/yapp/home/component/bottomsheet/AlarmListBottomSheet.kt @@ -48,10 +48,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.yapp.alarm.component.AlarmListItem import com.yapp.designsystem.theme.OrbitTheme import com.yapp.domain.model.Alarm import com.yapp.home.HomeContract +import com.yapp.home.alarm.component.AlarmListItem import com.yapp.home.component.AlarmListDropDownMenu import com.yapp.home.component.AlarmSortDropDownMenu import com.yapp.ui.component.checkbox.OrbitCheckBox @@ -253,7 +253,6 @@ internal fun AlarmBottomSheetContent( onClick = onClickAlarm, onLongPress = onLongPressAlarm, onToggleSelect = onToggleSelect, - isAm = alarm.isAm, hour = alarm.hour, minute = alarm.minute, isActive = alarm.isAlarmActive, diff --git a/feature/home/src/main/java/com/yapp/home/util/AlarmDateTimeFormatter.kt b/feature/home/src/main/java/com/yapp/home/util/AlarmDateTimeFormatter.kt new file mode 100644 index 00000000..448cc811 --- /dev/null +++ b/feature/home/src/main/java/com/yapp/home/util/AlarmDateTimeFormatter.kt @@ -0,0 +1,220 @@ +package com.yapp.home.util + +import android.util.Log +import com.yapp.domain.model.Alarm +import com.yapp.domain.model.toAlarmDays +import com.yapp.domain.model.toDayOfWeek +import java.time.Clock +import java.time.Duration +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException +import java.util.Locale +import javax.inject.Inject + +class AlarmDateTimeFormatter @Inject constructor( + private val clock: Clock, + private val displayLocale: Locale, +) { + + companion object { + private const val NO_ALARM_STRING = "NONE" + private const val DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm" + } + + data class DeliveryTimeFormats( + val noAlarm: String, + val today: String, // ์˜ˆ: "์˜ค๋Š˜ %s" + val tomorrow: String, // ์˜ˆ: "๋‚ด์ผ %s" + val thisYear: String, // ์˜ˆ: "%s" (๋‚ ์งœ์™€ ์‹œ๊ฐ„๋งŒ) + val otherYear: String, // ์˜ˆ: "%s" (๋…„๋„, ๋‚ ์งœ, ์‹œ๊ฐ„) + val todayTimePattern: String = "a h:mm", + val thisYearDatePattern: String = "M์›” d์ผ a h:mm", + val otherYearDatePattern: String = "yy๋…„ M์›” d์ผ a h:mm", + ) + + data class TimeDifferenceFormats( + val daysHoursMinutesFormat: String, // ์˜ˆ: "%1$d์ผ %2$d์‹œ๊ฐ„ %3$d๋ถ„ ํ›„์— ์šธ๋ ค์š”" + val hoursMinutesFormat: String, // ์˜ˆ: "%1$d์‹œ๊ฐ„ %2$d๋ถ„ ํ›„์— ์šธ๋ ค์š”" + val minutesFormat: String, // ์˜ˆ: "%1$d๋ถ„ ํ›„์— ์šธ๋ ค์š”" + val soonFormat: String, // ์˜ˆ: "๊ณง ์šธ๋ ค์š”" + ) + + fun calculateNextOccurrence( + hour: Int, + minute: Int, + repeatDays: Int, + now: LocalDateTime = LocalDateTime.now(clock), + ): LocalDateTime { + val alarmTime = LocalTime.of(hour, minute) + val todayAlarmDateTime = LocalDateTime.of(now.toLocalDate(), alarmTime) + + if (repeatDays == 0) { // ๋‹จ์ผ ์•Œ๋žŒ + return if (todayAlarmDateTime.isAfter(now)) { + todayAlarmDateTime + } else { + todayAlarmDateTime.plusDays(1) + } + } + + val selectedDaysOfWeek = repeatDays.toAlarmDays() + .map { it.toDayOfWeek() } + .sortedBy { it.value } + + require(selectedDaysOfWeek.isNotEmpty()) { + "๋ฐ˜๋ณต ์•Œ๋žŒ์€ ์ตœ์†Œ ํ•˜๋‚˜ ์ด์ƒ์˜ ์š”์ผ์„ ์„ ํƒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. repeatDays: $repeatDays" + } + + val currentDayOfWeek = now.dayOfWeek + + // ์˜ค๋Š˜ ์•Œ๋žŒ์ด ๊ฐ€๋Šฅํ•œ์ง€ ํ™•์ธ + if (selectedDaysOfWeek.contains(currentDayOfWeek) && todayAlarmDateTime.isAfter(now)) { + return todayAlarmDateTime + } + + for (dayOffset in 1..7) { + val nextPotentialDate = now.toLocalDate().plusDays(dayOffset.toLong()) + val dayOfWeekPotentialDate = nextPotentialDate.dayOfWeek + val potentialAlarmDateTime = nextPotentialDate.atTime(alarmTime) + + if (selectedDaysOfWeek.contains(dayOfWeekPotentialDate)) { + return potentialAlarmDateTime + } + } + + error("๋ฐ˜๋ณต ์•Œ๋žŒ์˜ ๋‹ค์Œ ๋ฐœ์ƒ ์‹œ๊ฐ„์„ ๊ณ„์‚ฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. selectedDaysOfWeek: $selectedDaysOfWeek") + } + + private fun formatDeliveryDateTimeString( + deliveryDateTimeString: String, // "yyyy-MM-dd'T'HH:mm" ํฌ๋งท ๋˜๋Š” "NONE" + formats: DeliveryTimeFormats, + now: LocalDateTime = LocalDateTime.now(clock), + ): String { + return try { + if (deliveryDateTimeString.equals(NO_ALARM_STRING, ignoreCase = true)) { + return formats.noAlarm + } + + val inputFormatter = + DateTimeFormatter.ofPattern(DATE_TIME_FORMAT).withLocale(displayLocale) + val alarmOccurrenceDateTime = LocalDateTime.parse( + deliveryDateTimeString, + inputFormatter, + ) + val today = now.toLocalDate() + val tomorrow = today.plusDays(1) + + when { + // 1. ๋…„๋„๊ฐ€ ํ˜„์žฌ ๋…„๋„์™€ ๋‹ค๋ฅด๋ฉด 'otherYear' ํฌ๋งท ์ ์šฉ + alarmOccurrenceDateTime.year != now.year -> { + val formattedDateTime = alarmOccurrenceDateTime.format( + DateTimeFormatter.ofPattern(formats.otherYearDatePattern) + .withLocale(displayLocale), + ) + String.format(formats.otherYear, formattedDateTime) + } + // 2. (๋…„๋„๊ฐ€ ๊ฐ™๊ณ ) ๋‚ ์งœ๊ฐ€ ์˜ค๋Š˜์ด๋ฉด 'today' ํฌ๋งท ์ ์šฉ + alarmOccurrenceDateTime.toLocalDate() == today -> { + val formattedTime = alarmOccurrenceDateTime.format( + DateTimeFormatter.ofPattern(formats.todayTimePattern) + .withLocale(displayLocale), + ) + String.format(formats.today, formattedTime) + } + // 3. (๋…„๋„๊ฐ€ ๊ฐ™๊ณ ) ๋‚ ์งœ๊ฐ€ ๋‚ด์ผ์ด๋ฉด 'tomorrow' ํฌ๋งท ์ ์šฉ + alarmOccurrenceDateTime.toLocalDate() == tomorrow -> { + val formattedTime = alarmOccurrenceDateTime.format( // ๋‚ด์ผ๋„ ์‹œ๊ฐ„ ํฌ๋งท ์‚ฌ์šฉ + DateTimeFormatter.ofPattern(formats.todayTimePattern) + .withLocale(displayLocale), + ) + String.format(formats.tomorrow, formattedTime) + } + // 4. ๊ทธ ์™ธ์˜ ๊ฒฝ์šฐ (๋…„๋„๊ฐ€ ๊ฐ™๊ณ , ์˜ค๋Š˜์ด๋‚˜ ๋‚ด์ผ์ด ์•„๋‹Œ ๋‹ค๋ฅธ ๋‚ ) 'thisYear' ํฌ๋งท ์ ์šฉ + else -> { + val formattedDateTime = alarmOccurrenceDateTime.format( + DateTimeFormatter.ofPattern(formats.thisYearDatePattern) + .withLocale(displayLocale), + ) + String.format(formats.thisYear, formattedDateTime) + } + } + } catch (e: DateTimeParseException) { + Log.e("AlarmDateTimeFormatter", "Invalid date format: $deliveryDateTimeString", e) + formats.noAlarm + } catch (e: Exception) { + Log.e( + "AlarmDateTimeFormatter", + "Error formatting delivery date time: $deliveryDateTimeString", + e, + ) + formats.noAlarm + } + } + + fun getFormattedEarliestUpcomingAlarmDeliveryTime( + alarms: List, + formats: DeliveryTimeFormats, + now: LocalDateTime = LocalDateTime.now(clock), + ): String { + val earliestAlarmDateTime = alarms + .filter { it.isAlarmActive } + .mapNotNull { alarm -> + try { + calculateNextOccurrence(alarm.hour, alarm.minute, alarm.repeatDays, now) + } catch (e: Exception) { + Log.e( + "AlarmDateTimeFormatter", + "Error calculating next occurrence for alarm: $alarm", + e, + ) + null // ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ null๋กœ ์ฒ˜๋ฆฌ + } + } + .minOrNull() + + val deliveryDateTimeString = earliestAlarmDateTime?.format( + DateTimeFormatter.ofPattern(DATE_TIME_FORMAT).withLocale(displayLocale), + ) ?: NO_ALARM_STRING + + return formatDeliveryDateTimeString(deliveryDateTimeString, formats, now) + } + + fun formatTimeDifference( + baseTime: LocalDateTime, + futureTime: LocalDateTime, + formats: TimeDifferenceFormats, + ): String { + if (!futureTime.isAfter(baseTime)) { + return formats.soonFormat + } + + val duration = Duration.between(baseTime, futureTime) + val totalMinutes = duration.toMinutes() + + if (totalMinutes < 1) { + return formats.soonFormat + } + + val days = duration.toDays() + val remainingHours = duration.toHours() % 24 + val remainingMinutes = duration.toMinutes() % 60 + + return when { + days > 0 -> String.format( + formats.daysHoursMinutesFormat, + days, + remainingHours, + remainingMinutes, + ) + + remainingHours > 0 -> String.format( + formats.hoursMinutesFormat, + remainingHours, + remainingMinutes, + ) + + else -> String.format(formats.minutesFormat, remainingMinutes) + } + } +} diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index cfe4c73c..75e74167 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -32,6 +32,11 @@ ํ†  ๊ณตํœด์ผ ์•Œ๋žŒ ๋„๊ธฐ + ๋ฏธ์…˜ + ํ”๋“ค๊ธฐ + ํ„ฐ์น˜ํ•˜๊ธฐ + ์—†์Œ + %s, %s ์•ˆ ํ•จ @@ -92,4 +97,9 @@ ์•Œ๋žŒ ๋ฏธ๋ฃจ๊ธฐ ๋‚จ์€ ์‹œ๊ฐ„ + + %1$d์ผ %2$d์‹œ๊ฐ„ ํ›„์— ์šธ๋ ค์š” + %1$d์‹œ๊ฐ„ %2$d๋ถ„ ํ›„์— ์šธ๋ ค์š” + %d๋ถ„ ํ›„์— ์šธ๋ ค์š” + ๊ณง ์šธ๋ ค์š” diff --git a/feature/home/src/test/kotlin/com/yapp/home/util/AlarmDateTimeFormatterTest.kt b/feature/home/src/test/kotlin/com/yapp/home/util/AlarmDateTimeFormatterTest.kt new file mode 100644 index 00000000..c6ab9554 --- /dev/null +++ b/feature/home/src/test/kotlin/com/yapp/home/util/AlarmDateTimeFormatterTest.kt @@ -0,0 +1,205 @@ +package com.yapp.home.util + +import com.yapp.domain.model.Alarm +import com.yapp.domain.model.AlarmDay +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.time.Clock +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +class AlarmDateTimeFormatterTest { + + private lateinit var formatter: AlarmDateTimeFormatter + private val fixedNow = LocalDateTime.of(2023, 10, 26, 10, 0, 0) // ๋ชฉ์š”์ผ + private val fixedClock = Clock.fixed(fixedNow.atZone(ZoneId.of("Asia/Seoul")).toInstant(), ZoneId.of("Asia/Seoul")) + private val testLocale: Locale = Locale.KOREA + + @Before + fun `ํ…Œ์ŠคํŠธ_์ค€๋น„`() { + formatter = AlarmDateTimeFormatter(clock = fixedClock, displayLocale = testLocale) + } + + private fun getLocalizedFormatter(pattern: String): DateTimeFormatter { + return DateTimeFormatter.ofPattern(pattern).withLocale(testLocale) + } + + private val deliveryFormats = AlarmDateTimeFormatter.DeliveryTimeFormats( + noAlarm = "๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” ์šด์„ธ๊ฐ€ ์—†์–ด์š”", + today = "%1\$s ๋„์ฐฉ", + tomorrow = "๋‚ด์ผ %1\$s ๋„์ฐฉ", + thisYear = "%1\$s ๋„์ฐฉ", + otherYear = "%1\$s ๋„์ฐฉ", + todayTimePattern = "a h:mm", + thisYearDatePattern = "M์›” d์ผ a h:mm", + otherYearDatePattern = "yy๋…„ M์›” d์ผ a h:mm" + ) + + @Test + fun `๊ฐ€์žฅ๋น ๋ฅธ_์•Œ๋žŒ์‹œ๊ฐ„_ํฌ๋งทํŒ…_ํ™œ์„ฑ์•Œ๋žŒ_์—†์œผ๋ฉด_์ˆ˜์ •๋œ_์•Œ๋žŒ์—†์Œ_๋ฐ˜ํ™˜`() { + // given + val alarms = listOf(Alarm(id = 1, hour = 14, minute = 0, repeatDays = 0, isAlarmActive = false)) + + // when + val result = formatter.getFormattedEarliestUpcomingAlarmDeliveryTime(alarms, deliveryFormats, fixedNow) + + // then + assertEquals(deliveryFormats.noAlarm, result) + } + + @Test + fun `๊ฐ€์žฅ๋น ๋ฅธ_์•Œ๋žŒ์‹œ๊ฐ„_ํฌ๋งทํŒ…_์˜ค๋Š˜_๋ฏธ๋ž˜_ํ™œ์„ฑ์•Œ๋žŒ_ํ•˜๋‚˜๋ฉด_์ˆ˜์ •๋œ_์˜ค๋Š˜ํ˜•์‹_๋ฐ˜ํ™˜`() { + // given + val alarms = listOf(Alarm(id = 1, hour = 14, minute = 30, repeatDays = 0, isAlarmActive = true)) + val expectedTime = LocalDateTime.of(2023, 10, 26, 14, 30) + val expected = String.format(deliveryFormats.today, expectedTime.format(getLocalizedFormatter(deliveryFormats.todayTimePattern))) + + // when + val result = formatter.getFormattedEarliestUpcomingAlarmDeliveryTime(alarms, deliveryFormats, fixedNow) + + // then + assertEquals(expected, result) + } + + @Test + fun `๊ฐ€์žฅ๋น ๋ฅธ_์•Œ๋žŒ์‹œ๊ฐ„_ํฌ๋งทํŒ…_๋‚ด์ผ_ํ™œ์„ฑ์•Œ๋žŒ_ํ•˜๋‚˜๋ฉด_์ˆ˜์ •๋œ_๋‚ด์ผํ˜•์‹_๋ฐ˜ํ™˜`() { + // given + val alarms = listOf(Alarm(id = 1, hour = 8, minute = 0, repeatDays = 0, isAlarmActive = true)) + val expectedTime = fixedNow.toLocalDate().plusDays(1).atTime(8, 0) + val expected = String.format(deliveryFormats.tomorrow, expectedTime.format(getLocalizedFormatter(deliveryFormats.todayTimePattern))) + + // when + val result = formatter.getFormattedEarliestUpcomingAlarmDeliveryTime(alarms, deliveryFormats, fixedNow) + + // then + assertEquals(expected, result) + } + + @Test + fun `๊ฐ€์žฅ๋น ๋ฅธ_์•Œ๋žŒ์‹œ๊ฐ„_ํฌ๋งทํŒ…_์˜ฌํ•ด_๋‹ค๋ฅธ๋‚ ์งœ๋ฉด_์ˆ˜์ •๋œ_์˜ฌํ•ดํ˜•์‹_๋ฐ˜ํ™˜`() { + // given + val alarms = listOf(Alarm(id = 1, hour = 14, minute = 30, repeatDays = AlarmDay.SUN.bitValue, isAlarmActive = true)) + val expectedTime = LocalDateTime.of(2023, 10, 29, 14, 30) + val expected = String.format(deliveryFormats.thisYear, expectedTime.format(getLocalizedFormatter(deliveryFormats.thisYearDatePattern))) + + // when + val result = formatter.getFormattedEarliestUpcomingAlarmDeliveryTime(alarms, deliveryFormats, fixedNow) + + // then + assertEquals(expected, result) + } + + @Test + fun `๊ฐ€์žฅ๋น ๋ฅธ_์•Œ๋žŒ์‹œ๊ฐ„_ํฌ๋งทํŒ…_๋‹ค๋ฅธํ•ด๋ฉด_์ˆ˜์ •๋œ_๋‹ค๋ฅธํ•ดํ˜•์‹_๋ฐ˜ํ™˜`() { + // given + val now = LocalDateTime.of(2023, 12, 31, 10, 0, 0) + val alarms = listOf(Alarm(id = 1, hour = 9, minute = 0, repeatDays = 0, isAlarmActive = true)) + val expectedTime = LocalDateTime.of(2024, 1, 1, 9, 0) + val expected = String.format(deliveryFormats.otherYear, expectedTime.format(getLocalizedFormatter(deliveryFormats.otherYearDatePattern))) + + // when + val result = formatter.getFormattedEarliestUpcomingAlarmDeliveryTime(alarms, deliveryFormats, now) + + // then + assertEquals(expected, result) + } + + @Test + fun `๊ฐ€์žฅ๋น ๋ฅธ_์•Œ๋žŒ์‹œ๊ฐ„_ํฌ๋งทํŒ…_์—ฌ๋Ÿฌ_ํ™œ์„ฑ์•Œ๋žŒ์ค‘_๊ฐ€์žฅ๋น ๋ฅธ๊ฒƒ_์ •ํ™•ํžˆ_ํฌ๋งทํŒ…_์ˆ˜์ •๋œํ˜•์‹`() { + // given + val alarms = listOf( + Alarm(id = 1, hour = 15, minute = 0, repeatDays = 0, isAlarmActive = true), // ์˜ค๋Š˜ 15:00 + Alarm(id = 2, hour = 12, minute = 0, repeatDays = 0, isAlarmActive = true), // ์˜ค๋Š˜ 12:00 (์ด๊ฒŒ ๋” ๋น ๋ฆ„) + Alarm(id = 3, hour = 9, minute = 0, repeatDays = 0, isAlarmActive = false), + Alarm(id = 4, hour = 8, minute = 0, repeatDays = AlarmDay.FRI.bitValue, isAlarmActive = true) // ๋‚ด์ผ 08:00 + ) + val expectedTime = LocalDateTime.of(2023, 10, 26, 12, 0) + val expected = String.format(deliveryFormats.today, expectedTime.format(getLocalizedFormatter(deliveryFormats.todayTimePattern))) + + // when + val result = formatter.getFormattedEarliestUpcomingAlarmDeliveryTime(alarms, deliveryFormats, fixedNow) + + // then + assertEquals(expected, result) + } + + @Test + fun `๋‚ ์งœ์‹œ๊ฐ„๋ฌธ์ž์—ด_ํฌ๋งทํŒ…_์ž˜๋ชป๋œ_๋‚ ์งœํ˜•์‹์ด๋ฉด_์ˆ˜์ •๋œ_์•Œ๋žŒ์—†์Œ_๋ฐ˜ํ™˜`() { + // given + val alarms = emptyList() + + // when + val result = formatter.getFormattedEarliestUpcomingAlarmDeliveryTime(alarms, deliveryFormats, fixedNow) + + // then + assertEquals(deliveryFormats.noAlarm, result) + } + + private val timeFormats = AlarmDateTimeFormatter.TimeDifferenceFormats( + daysHoursMinutesFormat = "%1\$d์ผ %2\$d์‹œ๊ฐ„ %3\$d๋ถ„ ํ›„์— ์šธ๋ ค์š”", + hoursMinutesFormat = "%1\$d์‹œ๊ฐ„ %2\$d๋ถ„ ํ›„์— ์šธ๋ ค์š”", + minutesFormat = "%1\$d๋ถ„ ํ›„์— ์šธ๋ ค์š”", + soonFormat = "๊ณง ์šธ๋ ค์š”" + ) + + @Test + fun `์‹œ๊ฐ„์ฐจ์ด_ํฌ๋งทํŒ…_์ฐจ์ด์—†๊ฑฐ๋‚˜_๊ณผ๊ฑฐ๋ฉด_๊ณง์šธ๋ ค์š”_๋ฐ˜ํ™˜`() { + // when & then + assertEquals(timeFormats.soonFormat, formatter.formatTimeDifference(fixedNow, fixedNow, timeFormats)) + assertEquals(timeFormats.soonFormat, formatter.formatTimeDifference(fixedNow, fixedNow.minusMinutes(1), timeFormats)) + } + + @Test + fun `์‹œ๊ฐ„์ฐจ์ด_ํฌ๋งทํŒ…_1๋ถ„๋ฏธ๋งŒ_์ฐจ์ด๋ฉด_๊ณง์šธ๋ ค์š”_๋ฐ˜ํ™˜`() { + // given + val future = fixedNow.plusSeconds(30) + + // when + val result = formatter.formatTimeDifference(fixedNow, future, timeFormats) + + // then + assertEquals(timeFormats.soonFormat, result) + } + + @Test + fun `์‹œ๊ฐ„์ฐจ์ด_ํฌ๋งทํŒ…_25๋ถ„_์ฐจ์ด๋ฉด_์ •ํ™•ํ•œ_๋ฌธ์ž์—ด_๋ฐ˜ํ™˜`() { + // given + val futureTime = fixedNow.plusMinutes(25) + val expected = String.format(testLocale, timeFormats.minutesFormat, 25L) + + // when + val result = formatter.formatTimeDifference(fixedNow, futureTime, timeFormats) + + // then + assertEquals(expected, result) + } + + @Test + fun `์‹œ๊ฐ„์ฐจ์ด_ํฌ๋งทํŒ…_70๋ถ„_์ฐจ์ด๋ฉด_์ •ํ™•ํ•œ_๋ฌธ์ž์—ด_๋ฐ˜ํ™˜`() { + // given + val futureTime = fixedNow.plusMinutes(70) + val expected = String.format(testLocale, timeFormats.hoursMinutesFormat, 1L, 10L) + + // when + val result = formatter.formatTimeDifference(fixedNow, futureTime, timeFormats) + + // then + assertEquals(expected, result) + } + + @Test + fun `์‹œ๊ฐ„์ฐจ์ด_ํฌ๋งทํŒ…_1์ผ_1์‹œ๊ฐ„_5๋ถ„_์ฐจ์ด๋ฉด_์ •ํ™•ํ•œ_๋ฌธ์ž์—ด_๋ฐ˜ํ™˜`() { + // given + val futureTime = fixedNow.plusDays(1).plusHours(1).plusMinutes(5) + val expected = String.format(testLocale, timeFormats.daysHoursMinutesFormat, 1L, 1L, 5L) + + // when + val result = formatter.formatTimeDifference(fixedNow, futureTime, timeFormats) + + // then + assertEquals(expected, result) + } +} diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionScreen.kt b/feature/mission/src/main/java/com/yapp/mission/MissionScreen.kt index a958e72e..d9059532 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionScreen.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionScreen.kt @@ -1,5 +1,6 @@ package com.yapp.mission +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.compose.animation.Crossfade @@ -100,7 +101,7 @@ fun MissionScreen( } Box(modifier = Modifier.fillMaxSize()) { - if (state.isMissionTypeLoading) { + if (state.isMissionTypeLoading || state.missionType == MissionType.NONE) { MissionLoadingScreen() return } @@ -159,6 +160,10 @@ fun MissionContent( MissionType.TAP -> { MissionClickCard(state, eventDispatcher) } + + MissionType.NONE -> { + Log.e("MissionContent", "Invalid or NONE MissionType: ${state.missionType}") + } } } } @@ -211,6 +216,7 @@ fun MissionProgressBarSection(state: MissionContract.State) { currentProgress = when (state.missionType) { MissionType.SHAKE -> state.shakeCount MissionType.TAP -> state.clickCount + else -> 0 }, totalProgress = 10, modifier = Modifier @@ -318,6 +324,7 @@ fun ExitDialog( AnalyticsEvent.MissionPropertiesKeys.MISSION_TYPE to when (state.missionType) { MissionType.SHAKE -> "shake" MissionType.TAP -> "click" + else -> "" }, ), ), diff --git a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingAlarmTimeSelectionScreen.kt b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingAlarmTimeSelectionScreen.kt index 963b03ba..8ccd007f 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingAlarmTimeSelectionScreen.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingAlarmTimeSelectionScreen.kt @@ -20,6 +20,7 @@ import com.yapp.designsystem.theme.OrbitTheme import com.yapp.ui.component.timepicker.OrbitPicker import com.yapp.ui.utils.heightForScreenPercentage import feature.onboarding.R +import java.time.LocalTime @Composable fun OnboardingAlarmTimeSelectionRoute( @@ -57,8 +58,8 @@ fun OnboardingAlarmTimeSelectionRoute( ) }, onBackClick = { viewModel.processAction(OnboardingContract.Action.PreviousStep) }, - setAlarmTime = { isAm, hour, minute -> - viewModel.processAction(OnboardingContract.Action.SetAlarmTime(isAm, hour, minute)) + setAlarmTime = { newTime -> + viewModel.processAction(OnboardingContract.Action.SetAlarmTime(newTime)) }, ) } @@ -69,7 +70,7 @@ fun OnboardingAlarmTimeSelectionScreen( totalSteps: Int, onNextClick: () -> Unit, onBackClick: () -> Unit, - setAlarmTime: (String, Int, Int) -> Unit, + setAlarmTime: (LocalTime) -> Unit, ) { OnboardingScreen( currentStep = currentStep, @@ -100,8 +101,8 @@ fun OnboardingAlarmTimeSelectionScreen( OrbitPicker( modifier = Modifier.padding(top = 90.dp), - ) { amPm, hour, minute -> - setAlarmTime(amPm, hour, minute) + ) { newTime -> + setAlarmTime(newTime) } } } @@ -116,7 +117,7 @@ fun OnboardingAlarmTimeSelectionScreenPreview() { totalSteps = 0, onNextClick = {}, onBackClick = {}, - setAlarmTime = { _, _, _ -> }, + setAlarmTime = { _ -> }, ) } } diff --git a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingContract.kt b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingContract.kt index 5e8b83c2..86bf3029 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingContract.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingContract.kt @@ -1,12 +1,13 @@ package com.yapp.onboarding import com.yapp.ui.base.UiState +import java.time.LocalTime sealed class OnboardingContract { data class State( val currentStep: Int = 1, - val timeState: AlarmTimeState = AlarmTimeState(), + val selectedTime: LocalTime = LocalTime.of(1, 0), val textFieldValue: String = "", val showWarning: Boolean = false, val isButtonEnabled: Boolean = false, @@ -43,16 +44,10 @@ sealed class OnboardingContract { } } - data class AlarmTimeState( - val selectedAmPm: String = "์˜ค์ „", - val selectedHour: Int = 1, - val selectedMinute: Int = 0, - ) - sealed class Action { data object NextStep : Action() data object PreviousStep : Action() - data class SetAlarmTime(val isAm: String, val hour: Int, val minute: Int) : Action() + data class SetAlarmTime(val newTime: LocalTime) : Action() data object CreateAlarm : Action() data class UpdateField(val value: String, val fieldType: FieldType) : Action() data object Reset : Action() diff --git a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt index fcfb105b..7281a9a5 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt @@ -21,6 +21,7 @@ import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container +import java.time.LocalTime import javax.inject.Inject import kotlin.reflect.KClass @@ -49,7 +50,7 @@ class OnboardingViewModel @Inject constructor( when (action) { is OnboardingContract.Action.NextStep -> moveToNextStep() is OnboardingContract.Action.PreviousStep -> moveToPreviousStep() - is OnboardingContract.Action.SetAlarmTime -> setAlarmTime(action.isAm, action.hour, action.minute) + is OnboardingContract.Action.SetAlarmTime -> setAlarmTime(action.newTime) is OnboardingContract.Action.CreateAlarm -> createAlarm() is OnboardingContract.Action.UpdateField -> updateField(action.value, action.fieldType) is OnboardingContract.Action.UpdateBirthDate -> updateBirthDate(action.lunar, action.year, action.month, action.day) @@ -123,19 +124,10 @@ class OnboardingViewModel @Inject constructor( } } - private fun setAlarmTime(amPm: String, hour: Int, minute: Int) = intent { + private fun setAlarmTime(newTime: LocalTime) = intent { hapticFeedbackManager.performHapticFeedback(HapticType.LIGHT_TICK) - val newTimeState = state.timeState.copy( - selectedAmPm = amPm, - selectedHour = hour, - selectedMinute = minute, - ) - reduce { - state.copy( - timeState = newTimeState, - ) - } + reduce { state.copy(selectedTime = newTime) } } private fun createAlarm() = intent { @@ -144,9 +136,8 @@ class OnboardingViewModel @Inject constructor( val defaultSoundUri = sounds[defaultSoundIndex] val newAlarm = Alarm( - isAm = state.timeState.selectedAmPm == "์˜ค์ „", - hour = state.timeState.selectedHour, - minute = state.timeState.selectedMinute, + hour = state.selectedTime.hour, + minute = state.selectedTime.minute, repeatDays = setOf(AlarmDay.MON, AlarmDay.TUE, AlarmDay.WED, AlarmDay.THU, AlarmDay.FRI).toRepeatDays(), isSnoozeEnabled = true, snoozeInterval = 5, diff --git a/gradle/dependencyGraph.gradle b/gradle/dependencyGraph.gradle index 86641c1e..6f117039 100644 --- a/gradle/dependencyGraph.gradle +++ b/gradle/dependencyGraph.gradle @@ -7,7 +7,7 @@ tasks.register('projectDependencyGraph') { dot << 'digraph {\n' dot << " graph [label=\"${rootProject.name}\\n \",labelloc=t,fontsize=30,ranksep=1.4];\n" - dot << ' node [style=filled, fillcolor="#bbbbbb"];\n' + dot << ' node [style=filled, fillcolor="#bbbbbb"];\n' // ๊ธฐ๋ณธ ๋…ธ๋“œ ์ƒ‰์ƒ dot << ' rankdir=TB;\n' def rootProjects = [] @@ -27,27 +27,41 @@ tasks.register('projectDependencyGraph') { def androidDynamicFeatureProjects = [] def javaProjects = [] + // --- ๋ชจ๋“ˆ ์œ ํ˜•์„ ์ €์žฅํ•  ๋ฆฌ์ŠคํŠธ ์ถ”๊ฐ€ --- + def featureModules = [] + def coreModules = [] + def dataModules = [] + def domainModules = [] // domain ๋ชจ๋“ˆ ๋ฆฌ์ŠคํŠธ ์ถ”๊ฐ€ + queue = [rootProject] while (!queue.isEmpty()) { def project = queue.remove(0) queue.addAll(project.childProjects.values()) - if (project.plugins.hasPlugin('org.jetbrains.kotlin.multiplatform')) { - multiplatformProjects.add(project) + // --- ๋ชจ๋“ˆ ๊ฒฝ๋กœ/์ด๋ฆ„์„ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ชจ๋“ˆ ์œ ํ˜• ์‹๋ณ„ --- + // ํ”„๋กœ์ ํŠธ์˜ ๋ชจ๋“ˆ ๋ช…๋ช… ๊ทœ์น™์— ๋งž๊ฒŒ ์กฐ๊ฑด์„ ์ˆ˜์ •ํ•˜์„ธ์š”. + // ์šฐ์„ ์ˆœ์œ„๋ฅผ ๊ณ ๋ คํ•˜์—ฌ ๋ฐฐ์น˜ (๋” ๊ตฌ์ฒด์ ์ธ ์กฐ๊ฑด์ด ์œ„๋กœ) + if (project.path.startsWith(':feature')) { + featureModules.add(project) + } else if (project.path.contains(':domain')) { // domain ๋ชจ๋“ˆ ์‹๋ณ„ ์กฐ๊ฑด (์˜ˆ: ':user:domain', ':product:domain') + domainModules.add(project) + } else if (project.path.contains(':core')) { + coreModules.add(project) + } else if (project.path.startsWith(':data')) { + dataModules.add(project) } - if (project.plugins.hasPlugin('kotlin2js')) { + // --- ๊ธฐ์กด ํ”Œ๋Ÿฌ๊ทธ์ธ ๊ธฐ๋ฐ˜ ์‹๋ณ„ ๋กœ์ง ์œ ์ง€ --- + else if (project.plugins.hasPlugin('org.jetbrains.kotlin.multiplatform')) { + multiplatformProjects.add(project) + } else if (project.plugins.hasPlugin('kotlin2js')) { jsProjects.add(project) - } - if (project.plugins.hasPlugin('com.android.application')) { + } else if (project.plugins.hasPlugin('com.android.application')) { androidProjects.add(project) - } - if (project.plugins.hasPlugin('com.android.library')) { + } else if (project.plugins.hasPlugin('com.android.library')) { androidLibraryProjects.add(project) - } - if (project.plugins.hasPlugin('com.android.dynamic-feature')) { + } else if (project.plugins.hasPlugin('com.android.dynamic-feature')) { androidDynamicFeatureProjects.add(project) - } - if (project.plugins.hasPlugin('java-library') || project.plugins.hasPlugin('java')) { + } else if (project.plugins.hasPlugin('java-library') || project.plugins.hasPlugin('java')) { javaProjects.add(project) } @@ -81,7 +95,18 @@ tasks.register('projectDependencyGraph') { traits.add('shape=box') } - if (multiplatformProjects.contains(project)) { + // --- ํŠน์ • ๋ชจ๋“ˆ ์œ ํ˜• ์ƒ‰์ƒ ์šฐ์„  ์ง€์ • --- + if (featureModules.contains(project)) { + traits.add('fillcolor="#FFC0CB"') // ํ•‘ํฌ (Feature) + } else if (domainModules.contains(project)) { + traits.add('fillcolor="#DAF7A6"') // ์˜ˆ: ๋ผ์ดํŠธ ๊ทธ๋ฆฐ/์˜๋กœ์šฐ (Domain) - ์ƒ‰์ƒ ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ + } else if (coreModules.contains(project)) { + traits.add('fillcolor="#ADD8E6"') // ๋ผ์ดํŠธ ๋ธ”๋ฃจ (Core) + } else if (dataModules.contains(project)) { + traits.add('fillcolor="#90EE90"') // ๋ผ์ดํŠธ ๊ทธ๋ฆฐ (Data) + } + // --- ๊ธฐ์กด ํ”Œ๋Ÿฌ๊ทธ์ธ ๊ธฐ๋ฐ˜ ์ƒ‰์ƒ ์ง€์ • ๋กœ์ง --- + else if (multiplatformProjects.contains(project)) { traits.add('fillcolor="#ffd2b3"') } else if (jsProjects.contains(project)) { traits.add('fillcolor="#ffffba"') @@ -94,7 +119,7 @@ tasks.register('projectDependencyGraph') { } else if (javaProjects.contains(project)) { traits.add('fillcolor="#ffb3ba"') } else { - traits.add('fillcolor="#eeeeee"') + traits.add('fillcolor="#eeeeee"') // ๊ทธ ์™ธ ๊ธฐ๋ณธ ์ƒ‰์ƒ } dot << " \"${project.path}\" [${traits.join(", ")}];\n" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 855c60e8..c10bb87c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,6 @@ ktlint = "11.5.1" kotlin = "2.0.0" kotlinx-serialization-json = "1.7.0" kotlinx-coroutines = "1.9.0-RC" -kotlinx-datetime = "0.4.0" kotlinx-collections = "0.3.7" ## AndroidX @@ -88,7 +87,6 @@ ksp-gradle-plugin = { group = "com.google.devtools.ksp", name = "com.google.devt ## Kotlin Libraries kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } -kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } diff --git a/project.dot.png b/project.dot.png index 65f883c3..070d91b3 100644 Binary files a/project.dot.png and b/project.dot.png differ