diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index 1f1a7173..3e073b75 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -7,13 +7,6 @@ on: - '**/*.kt' - 'build.gradle' - 'app/**' - push: - branches: - - 'release/**' - paths: - - '**/*.kt' - - 'build.gradle' - - 'app/**' jobs: build: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b955d234..6c350ede 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,10 +8,11 @@ plugins { android { namespace = "com.yapp.orbit" + compileSdk = 35 defaultConfig { - versionCode = 5 - versionName = "1.0.3" + versionCode = 6 + versionName = "1.1.3" targetSdk = 35 } @@ -53,4 +54,6 @@ dependencies { implementation(libs.firebase.crashlytics) implementation(libs.play.services.ads) implementation(libs.kotlin.reflect) + implementation(libs.hilt.worker) + implementation(libs.androidx.work.runtime) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 780b61bb..e7abd75a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + - + @@ -80,5 +79,15 @@ + + + + diff --git a/app/src/main/java/com/yapp/orbit/OrbitApplication.kt b/app/src/main/java/com/yapp/orbit/OrbitApplication.kt index 7391cf6e..b06538f8 100644 --- a/app/src/main/java/com/yapp/orbit/OrbitApplication.kt +++ b/app/src/main/java/com/yapp/orbit/OrbitApplication.kt @@ -1,13 +1,24 @@ package com.yapp.orbit import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import com.google.android.gms.ads.MobileAds import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject @HiltAndroidApp -class OrbitApplication : Application() { +class OrbitApplication() : Application(), Configuration.Provider { + + @Inject lateinit var workerFactory: HiltWorkerFactory + override fun onCreate() { super.onCreate() MobileAds.initialize(this) } + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() } diff --git a/app/src/main/java/com/yapp/orbit/OrbitNavHost.kt b/app/src/main/java/com/yapp/orbit/OrbitNavHost.kt index 69bc23ac..5dd9ce4b 100644 --- a/app/src/main/java/com/yapp/orbit/OrbitNavHost.kt +++ b/app/src/main/java/com/yapp/orbit/OrbitNavHost.kt @@ -6,24 +6,16 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex import androidx.navigation.compose.NavHost import com.yapp.common.navigation.OrbitNavigator import com.yapp.common.navigation.rememberOrbitNavigator @@ -34,9 +26,9 @@ import com.yapp.mission.missionScreen import com.yapp.onboarding.onboardingNavGraph import com.yapp.setting.settingNavGraph import com.yapp.splash.splashScreen -import com.yapp.ui.component.bottomsheet.OrbitBottomSheetLayout import com.yapp.ui.component.bottomsheet.OrbitBottomSheetState import com.yapp.ui.component.bottomsheet.rememberOrbitBottomSheetState +import com.yapp.ui.component.navigation.NavigationBarScrim import com.yapp.ui.component.snackbar.CustomSnackBarVisuals import com.yapp.ui.component.snackbar.OrbitSnackBar import com.yapp.webview.webViewScreen @@ -51,18 +43,16 @@ internal fun OrbitNavHost( val snackBarHostState = remember { SnackbarHostState() } Box { - OrbitBottomSheetLayout(sheetState = bottomSheetState) { - Scaffold( - modifier = modifier, - snackbarHost = { OrbitSnackBarHost(snackBarHostState) }, - containerColor = OrbitTheme.colors.gray_900, - ) { - OrbitNavigationGraph( - navigator = navigator, - bottomSheetState = bottomSheetState, - snackBarHostState = snackBarHostState, - ) - } + Scaffold( + modifier = modifier, + snackbarHost = { OrbitSnackBarHost(snackBarHostState) }, + containerColor = OrbitTheme.colors.gray_900, + ) { + OrbitNavigationGraph( + navigator = navigator, + bottomSheetState = bottomSheetState, + snackBarHostState = snackBarHostState, + ) } NavigationBarScrim() @@ -76,6 +66,7 @@ private fun OrbitNavigationGraph( snackBarHostState: SnackbarHostState, ) { NavHost( + modifier = Modifier.navigationBarsPadding(), navController = navigator.navController, startDestination = navigator.startDestination, ) { @@ -89,18 +80,6 @@ private fun OrbitNavigationGraph( } } -@Composable -private fun BoxScope.NavigationBarScrim() { - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .windowInsetsBottomHeight(WindowInsets.navigationBars) - .background(Color.Black) - .zIndex(1f), - ) -} - @Composable private fun OrbitSnackBarHost( snackBarHostState: SnackbarHostState, diff --git a/app/src/main/java/com/yapp/orbit/di/AppVersionModule.kt b/app/src/main/java/com/yapp/orbit/di/AppVersionModule.kt new file mode 100644 index 00000000..6297ecc3 --- /dev/null +++ b/app/src/main/java/com/yapp/orbit/di/AppVersionModule.kt @@ -0,0 +1,18 @@ +package com.yapp.orbit.di + +import com.yapp.orbit.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppVersionModule { + @Provides + @Singleton + @Named("appVersion") + fun provideAppVersion(): String = BuildConfig.VERSION_NAME +} diff --git a/build-logic/src/main/java/com/yapp/convention/HiltAndroid.kt b/build-logic/src/main/java/com/yapp/convention/HiltAndroid.kt index e266704f..15db09ff 100644 --- a/build-logic/src/main/java/com/yapp/convention/HiltAndroid.kt +++ b/build-logic/src/main/java/com/yapp/convention/HiltAndroid.kt @@ -14,7 +14,9 @@ internal fun Project.configureHiltAndroid() { dependencies { "implementation"(libs.findLibrary("hilt.android").get()) "ksp"(libs.findLibrary("hilt.android.compiler").get()) + "ksp"(libs.findLibrary("androidx-hilt-compiler").get()) "implementation"(libs.findLibrary("hilt-navigation-compose").get()) + "implementation"(libs.findLibrary("hilt-worker").get()) } } diff --git a/build-logic/src/main/java/orbit.android.feature.gradle.kts b/build-logic/src/main/java/orbit.android.feature.gradle.kts index aa5803c4..47d5c072 100644 --- a/build-logic/src/main/java/orbit.android.feature.gradle.kts +++ b/build-logic/src/main/java/orbit.android.feature.gradle.kts @@ -1,4 +1,3 @@ -import com.yapp.convention.configureHiltAndroid import com.yapp.convention.libs plugins { @@ -6,8 +5,6 @@ plugins { id("orbit.android.compose") } -configureHiltAndroid() - dependencies { implementation(project(":core:designsystem")) implementation(project(":core:ui")) 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 2691e78b..49c4581a 100644 --- a/core/alarm/src/main/java/com/yapp/alarm/AndroidAlarmScheduler.kt +++ b/core/alarm/src/main/java/com/yapp/alarm/AndroidAlarmScheduler.kt @@ -2,6 +2,7 @@ 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 @@ -16,6 +17,15 @@ class AndroidAlarmScheduler @Inject constructor( private val alarmTimeCalculator: AlarmTimeCalculator, ) : AlarmScheduler { + private fun logSchedule(tag: String, alarm: Alarm, triggerMillis: Long, extra: String = "") { + Log.d("ScheduleTrace", "scheduleAlarm Called", Throwable()) + Log.d( + "AlarmSchedule", + "[$tag] id=${alarm.id}, repeatDays=${alarm.repeatDays}, " + + "time=${java.time.Instant.ofEpochMilli(triggerMillis)} $extra", + ) + } + override fun scheduleAlarm(alarm: Alarm) { val selectedDays = alarm.repeatDays.toAlarmDays() @@ -31,7 +41,7 @@ class AndroidAlarmScheduler @Inject constructor( private fun setRepeatingAlarm(day: AlarmDay, alarm: Alarm) { val triggerMillis = alarmTimeCalculator.calculateNextRepeatingTimeMillis(alarm, day) val pendingIntent = createAlarmReceiverPendingIntentForSchedule(app, alarm, day) - + logSchedule("REPEAT", alarm, triggerMillis, "day=$day") alarmManager.setExactAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, triggerMillis, @@ -42,7 +52,7 @@ class AndroidAlarmScheduler @Inject constructor( private fun setNonRepeatingAlarm(alarm: Alarm) { val triggerMillis = alarmTimeCalculator.calculateNonRepeatingTimeMillis(alarm) val pendingIntent = createAlarmReceiverPendingIntentForSchedule(app, alarm) - + logSchedule("NON_REPEAT", alarm, triggerMillis) alarmManager.setExactAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, triggerMillis, @@ -53,7 +63,7 @@ class AndroidAlarmScheduler @Inject constructor( fun rescheduleUpcomingWeeklyAlarm(alarm: Alarm, day: AlarmDay) { val triggerMillis = alarmTimeCalculator.calculateNextWeeklyRescheduledTimeMillis(alarm, day) val pendingIntent = createAlarmReceiverPendingIntentForSchedule(app, alarm, day) - + logSchedule("RESCHEDULE_WEEKLY", alarm, triggerMillis, "day=$day") alarmManager.setExactAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, triggerMillis, diff --git a/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmInteractionActivityReceiver.kt b/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmInteractionActivityReceiver.kt index d7f38c3e..029072a4 100644 --- a/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmInteractionActivityReceiver.kt +++ b/core/alarm/src/main/java/com/yapp/alarm/receivers/AlarmInteractionActivityReceiver.kt @@ -3,18 +3,18 @@ package com.yapp.alarm.receivers import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.util.Log import androidx.activity.ComponentActivity import androidx.core.net.toUri import com.yapp.alarm.AlarmConstants +import com.yapp.domain.model.FortuneCreateStatus +import com.yapp.domain.model.MissionType import com.yapp.domain.repository.FortuneRepository import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import java.time.LocalDate -import java.time.format.DateTimeFormatter +import kotlinx.coroutines.withContext import javax.inject.Inject @AndroidEntryPoint @@ -31,29 +31,69 @@ class AlarmInteractionActivityReceiver(private val activity: ComponentActivity) if (!isSnoozed) { val notificationId = intent.getLongExtra(AlarmConstants.EXTRA_NOTIFICATION_ID, -1L) - val missionType = intent.getIntExtra(AlarmConstants.EXTRA_MISSION_TYPE, -1) + val missionTypeRaw = intent.getIntExtra(AlarmConstants.EXTRA_MISSION_TYPE, -1) val missionCount = intent.getIntExtra(AlarmConstants.EXTRA_MISSION_COUNT, -1) - if (notificationId == -1L || missionType == -1 || missionCount == -1) { - Log.e("AlarmInteraction", "필수 값 누락") - return - } + val missionType = MissionType.fromInt(missionTypeRaw) + + val hasValidMissionData = ( + notificationId != -1L && + missionType != MissionType.NONE && + missionCount != -1 + ) + + val pending = goAsync() + CoroutineScope(Dispatchers.Main).launch { + try { + if (!hasValidMissionData) { + val (fortuneCreateStatus, hasUnseenFortune) = withContext(Dispatchers.IO) { + val status = fortuneRepository.fortuneCreateStatusFlow.first() + val unseen = fortuneRepository.hasUnseenFortuneFlow.first() + status to unseen + } - CoroutineScope(Dispatchers.IO).launch { - val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - - if (fortuneDate != todayDate) { - context?.let { - val uriString = - "orbitapp://mission?notificationId=$notificationId&missionType=$missionType&missionCount=$missionCount" - val missionIntent = - Intent(Intent.ACTION_VIEW, uriString.toUri()).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - setPackage(context.packageName) + when (fortuneCreateStatus) { + is FortuneCreateStatus.Creating -> { + context?.let { ctx -> + val uri = "orbitapp://fortune".toUri() + val fortuneIntent = Intent(Intent.ACTION_VIEW, uri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + setPackage(ctx.packageName) + } + ctx.startActivity(fortuneIntent) + } } - it.startActivity(missionIntent) + + is FortuneCreateStatus.Success -> { + if (hasUnseenFortune) { + context?.let { ctx -> + val uri = "orbitapp://fortune".toUri() + val fortuneIntent = + Intent(Intent.ACTION_VIEW, uri).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + setPackage(ctx.packageName) + } + ctx.startActivity(fortuneIntent) + } + } + } + + FortuneCreateStatus.Failure, FortuneCreateStatus.Idle -> { } + } + } else { + context?.let { ctx -> + val uriString = + "orbitapp://mission?notificationId=$notificationId&missionType=${missionType.value}&missionCount=$missionCount" + val missionIntent = + Intent(Intent.ACTION_VIEW, uriString.toUri()).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + setPackage(ctx.packageName) + } + ctx.startActivity(missionIntent) + } } + } finally { + pending.finish() } } } 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 edd18466..011dadef 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 @@ -12,6 +12,7 @@ import com.yapp.alarm.services.AlarmService import com.yapp.analytics.AnalyticsEvent import com.yapp.analytics.AnalyticsHelper import com.yapp.domain.model.Alarm +import com.yapp.domain.model.toAlarmDay import com.yapp.domain.model.toTimeString import com.yapp.domain.repository.FortuneRepository import com.yapp.domain.usecase.AlarmUseCase @@ -19,8 +20,8 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch +import java.time.LocalDate import java.time.LocalDateTime import javax.inject.Inject @@ -105,17 +106,31 @@ class AlarmReceiver : BroadcastReceiver() { androidAlarmScheduler.cancelSnoozedAlarm(notificationId) context.stopService(alarmServiceIntent) - sendBroadCastToCloseAlarmInteractionActivity( - context = context, - notificationId = notificationId, - missionType = missionType, - missionCount = missionCount, - ) - CoroutineScope(Dispatchers.IO).launch { - val alarms = alarmUseCase.getAllAlarms().first().sortedBy { it.isAlarmActive } - val isFirstAlarm = alarms.firstOrNull()?.id == notificationId + val alarms = alarmUseCase.getAllAlarms().first() + + val isSnoozeId = notificationId >= AlarmConstants.SNOOZE_ID_OFFSET + + fun Alarm.ringsToday(): Boolean { + if (repeatDays == 0) return true + + val todayAlarmDay = LocalDate.now().dayOfWeek.toAlarmDay() + return (repeatDays and todayAlarmDay.bitValue) != 0 + } + val earliestIdToday: Long? = alarms + .asSequence() + .filter { (it.isAlarmActive || it.id == notificationId) && it.ringsToday() } + .sortedWith(compareBy({ it.hour }, { it.minute }, { it.second })) + .firstOrNull() + ?.id + + val isEarliestAlarmDismissedToday = + !isSnoozeId && (earliestIdToday == notificationId) + + if (isEarliestAlarmDismissedToday) fortuneRepository.markFirstAlarmDismissedToday() + + val isFirstAlarm = earliestIdToday == notificationId analyticsHelper.logEvent( AnalyticsEvent( type = "alarm_dismiss", @@ -126,12 +141,12 @@ class AlarmReceiver : BroadcastReceiver() { ), ) - val existingId = fortuneRepository.firstDismissedAlarmIdFlow.firstOrNull() - if (existingId == null) { - fortuneRepository.saveFirstDismissedAlarmId(notificationId) - } else if (existingId != notificationId) { - fortuneRepository.clearDismissedAlarmId() - } + sendBroadCastToCloseAlarmInteractionActivity( + context = context, + notificationId = notificationId, + missionType = missionType, + missionCount = missionCount, + ) } Toast.makeText(context, "알람이 해제되었어요", Toast.LENGTH_SHORT).show() diff --git a/core/alarm/src/main/java/com/yapp/alarm/receivers/RescheduleAlarmReceiver.kt b/core/alarm/src/main/java/com/yapp/alarm/receivers/RescheduleAlarmReceiver.kt index 9d21144d..bdd47fe8 100644 --- a/core/alarm/src/main/java/com/yapp/alarm/receivers/RescheduleAlarmReceiver.kt +++ b/core/alarm/src/main/java/com/yapp/alarm/receivers/RescheduleAlarmReceiver.kt @@ -8,6 +8,8 @@ import com.yapp.domain.usecase.AlarmUseCase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -25,16 +27,20 @@ class RescheduleAlarmReceiver : BroadcastReceiver() { intent ?: return if (intent.action == Intent.ACTION_BOOT_COMPLETED) { - rescheduleAlarm() + val pending = goAsync() + rescheduleAlarm(pending) } } - private fun rescheduleAlarm() { - CoroutineScope(Dispatchers.IO).launch { - alarmUseCase.getAllAlarms().collect { alarms -> - alarms.forEach { alarm -> - androidAlarmScheduler.scheduleAlarm(alarm) - } + private fun rescheduleAlarm(pendingResult: PendingResult) { + CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { + try { + val alarms = alarmUseCase.getAllAlarms().first() + alarms + .filter { it.isAlarmActive } + .forEach { alarm -> androidAlarmScheduler.scheduleAlarm(alarm) } + } finally { + pendingResult.finish() } } } diff --git a/core/alarm/src/main/java/com/yapp/alarm/scheduler/PostFortuneTaskScheduler.kt b/core/alarm/src/main/java/com/yapp/alarm/scheduler/PostFortuneTaskScheduler.kt new file mode 100644 index 00000000..e16e8dd4 --- /dev/null +++ b/core/alarm/src/main/java/com/yapp/alarm/scheduler/PostFortuneTaskScheduler.kt @@ -0,0 +1,5 @@ +package com.yapp.alarm.scheduler + +interface PostFortuneTaskScheduler { + fun enqueueOnceForToday() +} 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 cbea504e..56141679 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 @@ -23,9 +23,10 @@ import com.yapp.alarm.pendingIntent.interaction.createAlarmAlertPendingIntent import com.yapp.alarm.pendingIntent.interaction.createAlarmDismissPendingIntent import com.yapp.alarm.pendingIntent.interaction.createAlarmSnoozePendingIntent import com.yapp.alarm.pendingIntent.interaction.createNavigateToMissionPendingIntent +import com.yapp.alarm.scheduler.PostFortuneTaskScheduler import com.yapp.domain.model.Alarm import com.yapp.domain.model.AlarmDay -import com.yapp.domain.repository.FortuneRepository +import com.yapp.domain.model.MissionType import com.yapp.domain.usecase.AlarmUseCase import com.yapp.media.sound.SoundPlayer import dagger.hilt.android.AndroidEntryPoint @@ -33,10 +34,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch -import java.time.LocalDate -import java.time.format.DateTimeFormatter import javax.inject.Inject @AndroidEntryPoint @@ -54,7 +52,7 @@ class AlarmService : Service() { lateinit var androidAlarmScheduler: AndroidAlarmScheduler @Inject - lateinit var fortuneRepository: FortuneRepository + lateinit var postFortuneTaskScheduler: PostFortuneTaskScheduler private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -81,7 +79,7 @@ class AlarmService : Service() { super.onDestroy() } - private suspend fun handleIntent(intent: Intent) { + private fun handleIntent(intent: Intent) { val alarm: Alarm? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(AlarmConstants.EXTRA_ALARM, Alarm::class.java) } else { @@ -113,7 +111,7 @@ class AlarmService : Service() { false -> { startForeground( notificationId.toInt(), - createNotification(alarm, shouldNavigateToMission()), + createNotification(alarm, shouldNavigateToMission(alarm.missionType)), ) if (alarm.isVibrationEnabled) startVibration() if (alarm.isSoundEnabled) startSound(alarm.soundUri, alarm.soundVolume) @@ -123,12 +121,14 @@ class AlarmService : Service() { if (isOneTimeAlarm) { turnOffAlarm(alarmId = notificationId) } + + postFortuneTaskScheduler.enqueueOnceForToday() } - private suspend fun shouldNavigateToMission(): Boolean { - val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - return fortuneDate != todayDate + private fun shouldNavigateToMission( + missionType: MissionType, + ): Boolean { + return missionType != MissionType.NONE } private fun createNotification(alarm: Alarm, shouldNavigateToMission: Boolean): Notification { diff --git a/core/common/src/main/java/com/yapp/common/navigation/route/MissionRoute.kt b/core/common/src/main/java/com/yapp/common/navigation/route/MissionRoute.kt index 2ffd29d6..111c7788 100644 --- a/core/common/src/main/java/com/yapp/common/navigation/route/MissionRoute.kt +++ b/core/common/src/main/java/com/yapp/common/navigation/route/MissionRoute.kt @@ -8,8 +8,4 @@ data class MissionRoute( val missionType: String, val missionCount: String, val missionMode: String = MissionMode.REAL.name, -) { - companion object { - const val route = "mission" - } -} +) diff --git a/core/datastore/src/main/java/com/yapp/datastore/UserPreferences.kt b/core/datastore/src/main/java/com/yapp/datastore/UserPreferences.kt index 35298556..c9325870 100644 --- a/core/datastore/src/main/java/com/yapp/datastore/UserPreferences.kt +++ b/core/datastore/src/main/java/com/yapp/datastore/UserPreferences.kt @@ -1,6 +1,5 @@ package com.yapp.datastore -import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey @@ -14,7 +13,6 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import java.time.LocalDate -import java.time.format.DateTimeFormatter import javax.inject.Inject import javax.inject.Singleton @@ -26,15 +24,25 @@ class UserPreferences @Inject constructor( val USER_ID = longPreferencesKey("user_id") val USER_NAME = stringPreferencesKey("user_name") val ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed") + val FORTUNE_ID = longPreferencesKey("fortune_id") - val FORTUNE_DATE = stringPreferencesKey("fortune_date") + val FORTUNE_DATE_EPOCH = longPreferencesKey("fortune_date_epoch") val FORTUNE_IMAGE_ID = intPreferencesKey("fortune_image_id") val FORTUNE_SCORE = intPreferencesKey("fortune_score") - val FORTUNE_CHECKED = booleanPreferencesKey("fortune_checked") - val FIRST_DISMISSED_ALARM_ID = longPreferencesKey("first_dismissed_alarm_id") - val DISMISSED_DATE = stringPreferencesKey("dismissed_date") + val FORTUNE_SEEN = booleanPreferencesKey("fortune_seen") + val FORTUNE_TOOLTIP_SHOWN = booleanPreferencesKey("fortune_tooltip_shown") + val FORTUNE_CREATING = booleanPreferencesKey("fortune_creating") + val FORTUNE_FAILED = booleanPreferencesKey("fortune_failed") + + val FIRST_ALARM_DISMISSED_TODAY = booleanPreferencesKey("first_alarm_dismissed_today") + val FIRST_ALARM_DISMISSED_DATE_EPOCH = longPreferencesKey("first_alarm_dismissed_date_epoch") + + val UPDATE_NOTICE_DONT_SHOW_VERSION = stringPreferencesKey("update_notice_dont_show_version") + val UPDATE_NOTICE_LAST_SHOWN_DATE_EPOCH = longPreferencesKey("update_notice_last_shown_date_epoch") } + private fun todayEpoch(): Long = LocalDate.now().toEpochDay() + val userIdFlow: Flow = dataStore.data .catch { emit(emptyPreferences()) } .map { it[Keys.USER_ID] } @@ -55,9 +63,9 @@ class UserPreferences @Inject constructor( .map { it[Keys.FORTUNE_ID] } .distinctUntilChanged() - val fortuneDateFlow: Flow = dataStore.data + val fortuneDateEpochFlow: Flow = dataStore.data .catch { emit(emptyPreferences()) } - .map { it[Keys.FORTUNE_DATE] } + .map { it[Keys.FORTUNE_DATE_EPOCH] } .distinctUntilChanged() val fortuneImageIdFlow: Flow = dataStore.data @@ -70,108 +78,143 @@ class UserPreferences @Inject constructor( .map { it[Keys.FORTUNE_SCORE] } .distinctUntilChanged() - val hasNewFortuneFlow: Flow = dataStore.data + val hasUnseenFortuneFlow: Flow = dataStore.data .catch { emit(emptyPreferences()) } - .map { preferences -> - val savedDate = preferences[Keys.FORTUNE_DATE] - val isChecked = preferences[Keys.FORTUNE_CHECKED] ?: true - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - savedDate == todayDate && !isChecked + .map { pref -> + val isToday = pref[Keys.FORTUNE_DATE_EPOCH] == todayEpoch() + isToday && (pref[Keys.FORTUNE_ID] != null) && (pref[Keys.FORTUNE_SEEN] != true) } .distinctUntilChanged() - val firstDismissedAlarmIdFlow: Flow = dataStore.data + val shouldShowFortuneToolTipFlow: Flow = dataStore.data .catch { emit(emptyPreferences()) } - .map { preferences -> - val savedDate = preferences[Keys.DISMISSED_DATE] - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - - if (savedDate == todayDate) { - preferences[Keys.FIRST_DISMISSED_ALARM_ID] - } else { - null - } + .map { pref -> + val hasTodayFortune = (pref[Keys.FORTUNE_DATE_EPOCH] == todayEpoch()) && (pref[Keys.FORTUNE_ID] != null) + val tooltipShown = pref[Keys.FORTUNE_TOOLTIP_SHOWN] ?: false + hasTodayFortune && !tooltipShown } .distinctUntilChanged() - suspend fun saveUserId(userId: Long) { - dataStore.edit { preferences -> - preferences[Keys.USER_ID] = userId + val isFortuneCreatingFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { it[Keys.FORTUNE_CREATING] ?: false } + .distinctUntilChanged() + + val isFortuneFailedFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { it[Keys.FORTUNE_FAILED] ?: false } + .distinctUntilChanged() + + val isFirstAlarmDismissedTodayFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { pref -> + val flag = pref[Keys.FIRST_ALARM_DISMISSED_TODAY] ?: false + val isToday = pref[Keys.FIRST_ALARM_DISMISSED_DATE_EPOCH] == todayEpoch() + flag && isToday } + .distinctUntilChanged() + + val updateNoticeDontShowVersionFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { it[Keys.UPDATE_NOTICE_DONT_SHOW_VERSION] } + .distinctUntilChanged() + + val updateNoticeLastShownDateEpochFlow: Flow = dataStore.data + .catch { emit(emptyPreferences()) } + .map { it[Keys.UPDATE_NOTICE_LAST_SHOWN_DATE_EPOCH] } + .distinctUntilChanged() + + suspend fun saveUserId(userId: Long) { + dataStore.edit { it[Keys.USER_ID] = userId } } suspend fun saveUserName(userName: String) { - dataStore.edit { preferences -> - preferences[Keys.USER_NAME] = userName + dataStore.edit { it[Keys.USER_NAME] = userName } + } + + suspend fun setOnboardingCompleted() { + dataStore.edit { it[Keys.ONBOARDING_COMPLETED] = true } + } + + suspend fun markFortuneCreating() { + dataStore.edit { pref -> + pref[Keys.FORTUNE_CREATING] = true + pref[Keys.FORTUNE_FAILED] = false } } - suspend fun saveFortuneId(fortuneId: Long) { - val currentDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - dataStore.edit { preferences -> - preferences[Keys.FORTUNE_ID] = fortuneId - preferences[Keys.FORTUNE_DATE] = currentDate - preferences[Keys.FORTUNE_CHECKED] = false + suspend fun markFortuneCreated(fortuneId: Long) { + dataStore.edit { pref -> + val today = todayEpoch() + val prevDate = pref[Keys.FORTUNE_DATE_EPOCH] + val isNewForToday = (pref[Keys.FORTUNE_ID] != fortuneId) || (prevDate != today) + + pref[Keys.FORTUNE_ID] = fortuneId + pref[Keys.FORTUNE_DATE_EPOCH] = today + pref[Keys.FORTUNE_CREATING] = false + pref[Keys.FORTUNE_FAILED] = false + + if (isNewForToday) { + pref[Keys.FORTUNE_SEEN] = false + pref[Keys.FORTUNE_TOOLTIP_SHOWN] = false + } } } - suspend fun markFortuneAsChecked() { - dataStore.edit { preferences -> - preferences[Keys.FORTUNE_CHECKED] = true + suspend fun markFortuneFailed() { + dataStore.edit { pref -> + pref[Keys.FORTUNE_CREATING] = false + pref[Keys.FORTUNE_FAILED] = true } } + suspend fun markFortuneSeen() { + dataStore.edit { it[Keys.FORTUNE_SEEN] = true } + } + + suspend fun markFortuneTooltipShown() { + dataStore.edit { it[Keys.FORTUNE_TOOLTIP_SHOWN] = true } + } + suspend fun saveFortuneImageId(imageResId: Int) { - dataStore.edit { preferences -> - preferences[Keys.FORTUNE_IMAGE_ID] = imageResId - } + dataStore.edit { it[Keys.FORTUNE_IMAGE_ID] = imageResId } } suspend fun saveFortuneScore(score: Int) { - dataStore.edit { preferences -> - preferences[Keys.FORTUNE_SCORE] = score - } + dataStore.edit { it[Keys.FORTUNE_SCORE] = score } } - suspend fun saveFirstDismissedAlarmId(alarmId: Long) { - dataStore.edit { preferences -> - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - if (preferences[Keys.FIRST_DISMISSED_ALARM_ID] == null) { - preferences[Keys.FIRST_DISMISSED_ALARM_ID] = alarmId - preferences[Keys.DISMISSED_DATE] = todayDate - Log.d("UserPreferences", "첫 해제된 알람 ID 저장 완료: $alarmId (날짜: $todayDate)") - } else { - Log.d("UserPreferences", "이미 첫 알람 해제 ID가 저장되어 있음)") - } + suspend fun markFirstAlarmDismissedToday() { + dataStore.edit { pref -> + pref[Keys.FIRST_ALARM_DISMISSED_TODAY] = true + pref[Keys.FIRST_ALARM_DISMISSED_DATE_EPOCH] = todayEpoch() } } - suspend fun setOnboardingCompleted() { - dataStore.edit { preferences -> - preferences[Keys.ONBOARDING_COMPLETED] = true - } + suspend fun markUpdateNoticeDontShow(version: String) { + dataStore.edit { it[Keys.UPDATE_NOTICE_DONT_SHOW_VERSION] = version } } - suspend fun clearDismissedAlarmId() { - dataStore.edit { preferences -> - preferences.remove(Keys.FIRST_DISMISSED_ALARM_ID) - preferences.remove(Keys.DISMISSED_DATE) + suspend fun markUpdateNoticeShownToday() { + dataStore.edit { pref -> + pref[Keys.UPDATE_NOTICE_LAST_SHOWN_DATE_EPOCH] = todayEpoch() } } suspend fun clearUserData() { - dataStore.edit { preferences -> - preferences.clear() - } + dataStore.edit { it.clear() } } - suspend fun clearFortuneId() { - dataStore.edit { preferences -> - preferences.remove(Keys.FORTUNE_ID) - preferences.remove(Keys.FORTUNE_DATE) - preferences.remove(Keys.FORTUNE_IMAGE_ID) - preferences.remove(Keys.FORTUNE_SCORE) - preferences.remove(Keys.FORTUNE_CHECKED) + suspend fun clearFortuneData() { + dataStore.edit { pref -> + pref.remove(Keys.FORTUNE_ID) + pref.remove(Keys.FORTUNE_DATE_EPOCH) + pref.remove(Keys.FORTUNE_IMAGE_ID) + pref.remove(Keys.FORTUNE_SCORE) + pref.remove(Keys.FORTUNE_SEEN) + pref.remove(Keys.FORTUNE_TOOLTIP_SHOWN) + pref.remove(Keys.FORTUNE_CREATING) + pref.remove(Keys.FORTUNE_FAILED) } } } diff --git a/core/designsystem/src/main/res/drawable-xhdpi/ic_100_buble.png b/core/designsystem/src/main/res/drawable-xhdpi/ic_100_buble.png deleted file mode 100644 index d30beec6..00000000 Binary files a/core/designsystem/src/main/res/drawable-xhdpi/ic_100_buble.png and /dev/null differ diff --git a/core/designsystem/src/main/res/drawable-xhdpi/ic_fortune_delivering_speech_bubble.png b/core/designsystem/src/main/res/drawable-xhdpi/ic_fortune_delivering_speech_bubble.png new file mode 100644 index 00000000..c75d2db3 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xhdpi/ic_fortune_delivering_speech_bubble.png differ diff --git a/core/designsystem/src/main/res/drawable-xhdpi/ic_fortune_waiting_speech_bubble.png b/core/designsystem/src/main/res/drawable-xhdpi/ic_fortune_waiting_speech_bubble.png new file mode 100644 index 00000000..a119387d Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xhdpi/ic_fortune_waiting_speech_bubble.png differ diff --git a/core/designsystem/src/main/res/drawable-xxhdpi/ic_100_buble.png b/core/designsystem/src/main/res/drawable-xxhdpi/ic_100_buble.png deleted file mode 100644 index 469a7b3f..00000000 Binary files a/core/designsystem/src/main/res/drawable-xxhdpi/ic_100_buble.png and /dev/null differ diff --git a/core/designsystem/src/main/res/drawable-xxhdpi/ic_fortune_delivering_speech_bubble.png b/core/designsystem/src/main/res/drawable-xxhdpi/ic_fortune_delivering_speech_bubble.png new file mode 100644 index 00000000..318d11d6 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxhdpi/ic_fortune_delivering_speech_bubble.png differ diff --git a/core/designsystem/src/main/res/drawable-xxhdpi/ic_fortune_waiting_speech_bubble.png b/core/designsystem/src/main/res/drawable-xxhdpi/ic_fortune_waiting_speech_bubble.png new file mode 100644 index 00000000..83b665f4 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxhdpi/ic_fortune_waiting_speech_bubble.png differ diff --git a/core/designsystem/src/main/res/drawable/ic_100_buble.png b/core/designsystem/src/main/res/drawable/ic_100_buble.png deleted file mode 100644 index e69978c4..00000000 Binary files a/core/designsystem/src/main/res/drawable/ic_100_buble.png and /dev/null differ diff --git a/core/designsystem/src/main/res/raw/fortune_loading.json b/core/designsystem/src/main/res/raw/fortune_loading.json new file mode 100644 index 00000000..cb570812 --- /dev/null +++ b/core/designsystem/src/main/res/raw/fortune_loading.json @@ -0,0 +1 @@ +{"nm":"컴포지션 2","h":500,"w":700,"meta":{"g":"@lottiefiles/toolkit-js 0.66.4","tc":"#202f44"},"layers":[{"ty":2,"nm":"Group 1948760243.png","sr":1,"st":1,"op":45,"ip":0,"ln":"2856","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[151.5,137]},"s":{"a":0,"k":[61.6,61.6,101.65]},"p":{"a":1,"k":[{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[130.5,159,0],"t":0},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[167.139,178.955,0],"t":15},{"o":{"x":0,"y":0},"i":{"x":1,"y":1},"s":[233.8,193.529,0],"t":31},{"s":[285.485,192.267,0],"t":45}]},"r":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[17.4],"t":0},{"s":[32.4],"t":45}]},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"refId":"1","ind":1},{"ty":2,"nm":"Group 1948760248-1.png","sr":1,"st":0,"op":45,"ip":0,"ln":"2855","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[22,121.064]},"s":{"a":0,"k":[70.27,70.27,89.655]},"p":{"a":0,"k":[386,148,0]},"r":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[-9.304],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":45},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[10],"t":48},{"s":[0],"t":51}]},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"refId":"2","ind":2},{"ty":2,"nm":"Vector 27856.png","sr":1,"st":0,"op":45,"ip":0,"ln":"2854","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[93.94,9.052]},"s":{"a":0,"k":[75.628,72.572,100]},"p":{"a":0,"k":[310.5,242.5,0]},"r":{"a":0,"k":2},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"refId":"3","ind":3},{"ty":2,"nm":"Group 1948760248.png","sr":1,"st":0,"op":45,"ip":0,"ln":"2853","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[132.5,232]},"s":{"a":0,"k":[76.4,76.4,76.4]},"p":{"a":0,"k":[370,256,0]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"refId":"4","ind":4}],"v":"5.7.0","fr":30,"op":45,"ip":0,"assets":[{"id":"1","e":1,"w":303,"h":274,"p":"data:image/webp;base64,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","u":""},{"id":"2","e":1,"w":100,"h":148,"p":"data:image/webp;base64,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","u":""},{"id":"3","e":1,"w":154,"h":124,"p":"data:image/webp;base64,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","u":""},{"id":"4","e":1,"w":265,"h":464,"p":"data:image/webp;base64,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","u":""}]} \ No newline at end of file diff --git a/core/network/src/main/java/com/yapp/network/di/NetworkModule.kt b/core/network/src/main/java/com/yapp/network/di/NetworkModule.kt index 10276dfb..25dfb118 100644 --- a/core/network/src/main/java/com/yapp/network/di/NetworkModule.kt +++ b/core/network/src/main/java/com/yapp/network/di/NetworkModule.kt @@ -6,7 +6,6 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json -import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -39,21 +38,7 @@ object NetworkModule { @Provides @Singleton - @Auth - fun provideAuthOkHttpClient( - loggingInterceptor: HttpLoggingInterceptor, - authInterceptor: Interceptor, - ): OkHttpClient = - OkHttpClient.Builder() - .retryOnConnectionFailure(true) - .addInterceptor(loggingInterceptor) - .addInterceptor(authInterceptor) - .build() - - @Provides - @Singleton - @NoneAuth - fun provideNoneAuthOkHttpClient( + fun provideHttpClient( loggingInterceptor: HttpLoggingInterceptor, ): OkHttpClient = OkHttpClient.Builder() @@ -66,18 +51,8 @@ object NetworkModule { @Provides @Singleton - @Auth - fun provideAuthRetrofit(@Auth okHttpClient: OkHttpClient, buildConfigFieldProvider: BuildConfigFieldProvider): Retrofit = Retrofit.Builder() - .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) - .baseUrl(buildConfigFieldProvider.get().baseUrl) - .client(okHttpClient) - .build() - - @Provides - @Singleton - @NoneAuth - fun provideNoneAuthRetrofit( - @NoneAuth okHttpClient: OkHttpClient, + fun provideRetrofit( + okHttpClient: OkHttpClient, buildConfigFieldProvider: BuildConfigFieldProvider, json: Json, ): Retrofit = @@ -86,14 +61,4 @@ object NetworkModule { .baseUrl(buildConfigFieldProvider.get().baseUrl) .client(okHttpClient) .build() - - @Provides - @Singleton - @S3 - fun provideS3Retrofit(@NoneAuth okHttpClient: OkHttpClient, buildConfigFieldProvider: BuildConfigFieldProvider): Retrofit = - Retrofit.Builder() - .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) - .baseUrl(buildConfigFieldProvider.get().baseUrl) - .client(okHttpClient) - .build() } diff --git a/core/network/src/main/java/com/yapp/network/di/Qualifier.kt b/core/network/src/main/java/com/yapp/network/di/Qualifier.kt deleted file mode 100644 index 68817100..00000000 --- a/core/network/src/main/java/com/yapp/network/di/Qualifier.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.yapp.network.di - -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class NoneAuth - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class Auth - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class S3 diff --git a/core/ui/src/main/java/com/yapp/ui/component/navigation/NavigationBarScrim.kt b/core/ui/src/main/java/com/yapp/ui/component/navigation/NavigationBarScrim.kt new file mode 100644 index 00000000..c0bcb5a7 --- /dev/null +++ b/core/ui/src/main/java/com/yapp/ui/component/navigation/NavigationBarScrim.kt @@ -0,0 +1,26 @@ +package com.yapp.ui.component.navigation + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.zIndex + +@Composable +fun BoxScope.NavigationBarScrim() { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsBottomHeight(WindowInsets.navigationBars) + .background(Color.Black) + .zIndex(1f), + ) +} 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 eb9fb350..8ff592d2 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 @@ -5,8 +5,6 @@ import com.yapp.domain.model.Alarm import kotlinx.coroutines.flow.Flow interface AlarmLocalDataSource { - val firstDismissedAlarmIdFlow: Flow - fun getAllAlarms(): Flow> fun getAlarmsByTime(hour: Int, minute: Int): Flow> suspend fun insertAlarm(alarm: AlarmEntity): Long @@ -14,6 +12,4 @@ interface AlarmLocalDataSource { suspend fun updateAlarmActive(id: Long, active: Boolean): Int suspend fun getAlarm(id: Long): Alarm? suspend fun deleteAlarm(id: Long): Int - suspend fun saveFirstDismissedAlarmId(alarmId: Long) - suspend fun clearDismissedAlarmId() } 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 7c7425b2..03fecd55 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 @@ -3,7 +3,6 @@ package com.yapp.data.local.datasource import com.yapp.database.AlarmDao import com.yapp.database.AlarmEntity import com.yapp.database.toDomain -import com.yapp.datastore.UserPreferences import com.yapp.domain.model.Alarm import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -11,10 +10,7 @@ import javax.inject.Inject class AlarmLocalDataSourceImpl @Inject constructor( private val alarmDao: AlarmDao, - private val userPreferences: UserPreferences, ) : AlarmLocalDataSource { - override val firstDismissedAlarmIdFlow: Flow = userPreferences.firstDismissedAlarmIdFlow - override fun getAllAlarms(): Flow> { return alarmDao.getAllAlarms() .map { alarmEntities -> alarmEntities.map { it.toDomain() } } @@ -45,12 +41,4 @@ class AlarmLocalDataSourceImpl @Inject constructor( override suspend fun deleteAlarm(id: Long): Int { return alarmDao.deleteAlarm(id) } - - override suspend fun saveFirstDismissedAlarmId(alarmId: Long) { - userPreferences.saveFirstDismissedAlarmId(alarmId) - } - - override suspend fun clearDismissedAlarmId() { - userPreferences.clearDismissedAlarmId() - } } diff --git a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt index 149234f7..4e519ddb 100644 --- a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt +++ b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSource.kt @@ -1,20 +1,27 @@ package com.yapp.data.local.datasource +import com.yapp.domain.model.FortuneCreateStatus import kotlinx.coroutines.flow.Flow interface FortuneLocalDataSource { val fortuneIdFlow: Flow - val fortuneDateFlow: Flow + val fortuneDateEpochFlow: Flow val fortuneImageIdFlow: Flow val fortuneScoreFlow: Flow - val hasNewFortuneFlow: Flow - val firstDismissedAlarmIdFlow: Flow + val hasUnseenFortuneFlow: Flow + val shouldShowFortuneToolTipFlow: Flow + val isFirstAlarmDismissedTodayFlow: Flow - suspend fun saveFortuneId(fortuneId: Long) - suspend fun markFortuneAsChecked() + val fortuneCreateStatusFlow: Flow + + suspend fun markFortuneCreating() + suspend fun markFortuneCreated(fortuneId: Long) + suspend fun markFortuneFailed() + suspend fun markFortuneSeen() + suspend fun markFortuneTooltipShown() suspend fun saveFortuneImageId(imageResId: Int) suspend fun saveFortuneScore(score: Int) - suspend fun saveFirstDismissedAlarmId(alarmId: Long) - suspend fun clearDismissedAlarmId() - suspend fun clearFortuneId() + suspend fun markFirstAlarmDismissedToday() + + suspend fun clearFortuneData() } diff --git a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt index 6dbe10f9..b8ab799f 100644 --- a/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt +++ b/data/src/main/java/com/yapp/data/local/datasource/FortuneLocalDataSourceImpl.kt @@ -1,6 +1,10 @@ package com.yapp.data.local.datasource import com.yapp.datastore.UserPreferences +import com.yapp.domain.model.FortuneCreateStatus +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import java.time.LocalDate import javax.inject.Inject class FortuneLocalDataSourceImpl @Inject constructor( @@ -8,18 +12,47 @@ class FortuneLocalDataSourceImpl @Inject constructor( ) : FortuneLocalDataSource { override val fortuneIdFlow = userPreferences.fortuneIdFlow - override val fortuneDateFlow = userPreferences.fortuneDateFlow + override val fortuneDateEpochFlow = userPreferences.fortuneDateEpochFlow override val fortuneImageIdFlow = userPreferences.fortuneImageIdFlow override val fortuneScoreFlow = userPreferences.fortuneScoreFlow - override val hasNewFortuneFlow = userPreferences.hasNewFortuneFlow - override val firstDismissedAlarmIdFlow = userPreferences.firstDismissedAlarmIdFlow + override val hasUnseenFortuneFlow = userPreferences.hasUnseenFortuneFlow + override val shouldShowFortuneToolTipFlow = userPreferences.shouldShowFortuneToolTipFlow + override val isFirstAlarmDismissedTodayFlow = userPreferences.isFirstAlarmDismissedTodayFlow - override suspend fun saveFortuneId(fortuneId: Long) { - userPreferences.saveFortuneId(fortuneId) + override val fortuneCreateStatusFlow = combine( + userPreferences.fortuneIdFlow, + userPreferences.fortuneDateEpochFlow, + userPreferences.isFortuneCreatingFlow, + userPreferences.isFortuneFailedFlow, + ) { fortuneId, fortuneDate, isCreating, isFailed -> + when { + isFailed -> FortuneCreateStatus.Failure + isCreating -> FortuneCreateStatus.Creating + fortuneId != null && fortuneDate == todayEpoch() -> FortuneCreateStatus.Success(fortuneId) + else -> FortuneCreateStatus.Idle + } + }.distinctUntilChanged() + + private fun todayEpoch(): Long = LocalDate.now().toEpochDay() + + override suspend fun markFortuneCreating() { + userPreferences.markFortuneCreating() + } + + override suspend fun markFortuneCreated(fortuneId: Long) { + userPreferences.markFortuneCreated(fortuneId) + } + + override suspend fun markFortuneFailed() { + userPreferences.markFortuneFailed() + } + + override suspend fun markFortuneSeen() { + userPreferences.markFortuneSeen() } - override suspend fun markFortuneAsChecked() { - userPreferences.markFortuneAsChecked() + override suspend fun markFortuneTooltipShown() { + userPreferences.markFortuneTooltipShown() } override suspend fun saveFortuneImageId(imageResId: Int) { @@ -30,15 +63,11 @@ class FortuneLocalDataSourceImpl @Inject constructor( userPreferences.saveFortuneScore(score) } - override suspend fun saveFirstDismissedAlarmId(alarmId: Long) { - userPreferences.saveFirstDismissedAlarmId(alarmId) - } - - override suspend fun clearDismissedAlarmId() { - userPreferences.clearDismissedAlarmId() + override suspend fun markFirstAlarmDismissedToday() { + userPreferences.markFirstAlarmDismissedToday() } - override suspend fun clearFortuneId() { - userPreferences.clearFortuneId() + override suspend fun clearFortuneData() { + userPreferences.clearFortuneData() } } diff --git a/data/src/main/java/com/yapp/data/local/datasource/UserLocalDataSource.kt b/data/src/main/java/com/yapp/data/local/datasource/UserLocalDataSource.kt index 37b4fc5a..3ad851df 100644 --- a/data/src/main/java/com/yapp/data/local/datasource/UserLocalDataSource.kt +++ b/data/src/main/java/com/yapp/data/local/datasource/UserLocalDataSource.kt @@ -6,9 +6,13 @@ interface UserLocalDataSource { val userIdFlow: Flow val userNameFlow: Flow val onboardingCompletedFlow: Flow + val updateNoticeDontShowVersionFlow: Flow + val updateNoticeLastShownDateEpochFlow: Flow suspend fun saveUserId(userId: Long) suspend fun saveUserName(userName: String) suspend fun setOnboardingCompleted() + suspend fun markUpdateNoticeDontShow(version: String) + suspend fun markUpdateNoticeShownToday() suspend fun clearUserData() } diff --git a/data/src/main/java/com/yapp/data/local/datasource/UserLocalDataSourceImpl.kt b/data/src/main/java/com/yapp/data/local/datasource/UserLocalDataSourceImpl.kt index 7e7d4324..187a7a59 100644 --- a/data/src/main/java/com/yapp/data/local/datasource/UserLocalDataSourceImpl.kt +++ b/data/src/main/java/com/yapp/data/local/datasource/UserLocalDataSourceImpl.kt @@ -11,6 +11,8 @@ class UserLocalDataSourceImpl @Inject constructor( override val userIdFlow: Flow = userPreferences.userIdFlow override val userNameFlow: Flow = userPreferences.userNameFlow override val onboardingCompletedFlow: Flow = userPreferences.onboardingCompletedFlow + override val updateNoticeDontShowVersionFlow: Flow = userPreferences.updateNoticeDontShowVersionFlow + override val updateNoticeLastShownDateEpochFlow: Flow = userPreferences.updateNoticeLastShownDateEpochFlow override suspend fun saveUserId(userId: Long) { userPreferences.saveUserId(userId) @@ -24,6 +26,14 @@ class UserLocalDataSourceImpl @Inject constructor( userPreferences.setOnboardingCompleted() } + override suspend fun markUpdateNoticeDontShow(version: String) { + userPreferences.markUpdateNoticeDontShow(version) + } + + override suspend fun markUpdateNoticeShownToday() { + userPreferences.markUpdateNoticeShownToday() + } + override suspend fun clearUserData() { userPreferences.clearUserData() } diff --git a/data/src/main/java/com/yapp/data/remote/di/ServiceModule.kt b/data/src/main/java/com/yapp/data/remote/di/ServiceModule.kt index 458db14b..be0e97f5 100644 --- a/data/src/main/java/com/yapp/data/remote/di/ServiceModule.kt +++ b/data/src/main/java/com/yapp/data/remote/di/ServiceModule.kt @@ -1,7 +1,6 @@ package com.yapp.data.remote.di import com.yapp.data.remote.service.ApiService -import com.yapp.network.di.NoneAuth import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,6 +13,6 @@ import javax.inject.Singleton object ServiceModule { @Provides @Singleton - fun providesApiService(@NoneAuth retrofit: Retrofit): ApiService = + fun providesApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java) } 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 a1c72135..50385b4f 100644 --- a/data/src/main/java/com/yapp/data/repositoryimpl/AlarmRepositoryImpl.kt +++ b/data/src/main/java/com/yapp/data/repositoryimpl/AlarmRepositoryImpl.kt @@ -16,8 +16,6 @@ class AlarmRepositoryImpl @Inject constructor( private val ringtoneManagerHelper: RingtoneManagerHelper, private val soundPlayer: SoundPlayer, ) : AlarmRepository { - override val firstDismissedAlarmIdFlow: Flow = alarmLocalDataSource.firstDismissedAlarmIdFlow - override suspend fun getAlarmSounds(): Result> = runCatching { ringtoneManagerHelper.getAlarmSounds().map { (title, uri) -> AlarmSound(title, uri) @@ -93,12 +91,4 @@ class AlarmRepositoryImpl @Inject constructor( throw Exception("No rows deleted") } } - - override suspend fun saveFirstDismissedAlarmId(alarmId: Long) { - alarmLocalDataSource.saveFirstDismissedAlarmId(alarmId) - } - - override suspend fun clearDismissedAlarmId() { - alarmLocalDataSource.clearDismissedAlarmId() - } } diff --git a/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt b/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt index d3abb0e9..1c761ba6 100644 --- a/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt +++ b/data/src/main/java/com/yapp/data/repositoryimpl/FortuneRepositoryImpl.kt @@ -4,6 +4,7 @@ import com.yapp.data.local.datasource.FortuneLocalDataSource import com.yapp.data.remote.datasource.FortuneDataSource import com.yapp.data.remote.dto.response.toDomain import com.yapp.domain.model.Fortune +import com.yapp.domain.model.FortuneCreateStatus import com.yapp.domain.repository.FortuneRepository import kotlinx.coroutines.flow.Flow import javax.inject.Inject @@ -12,32 +13,35 @@ class FortuneRepositoryImpl @Inject constructor( private val fortuneLocalDataSource: FortuneLocalDataSource, private val fortuneRemoteDataSource: FortuneDataSource, ) : FortuneRepository { + override val fortuneIdFlow: Flow = fortuneLocalDataSource.fortuneIdFlow - override val fortuneDateFlow: Flow = fortuneLocalDataSource.fortuneDateFlow + override val fortuneDateEpochFlow: Flow = fortuneLocalDataSource.fortuneDateEpochFlow override val fortuneImageIdFlow: Flow = fortuneLocalDataSource.fortuneImageIdFlow override val fortuneScoreFlow: Flow = fortuneLocalDataSource.fortuneScoreFlow - override val hasNewFortuneFlow: Flow = fortuneLocalDataSource.hasNewFortuneFlow - override val firstDismissedAlarmIdFlow: Flow = fortuneLocalDataSource.firstDismissedAlarmIdFlow + override val hasUnseenFortuneFlow: Flow = fortuneLocalDataSource.hasUnseenFortuneFlow + override val shouldShowFortuneToolTipFlow: Flow = fortuneLocalDataSource.shouldShowFortuneToolTipFlow + override val isFirstAlarmDismissedTodayFlow: Flow = fortuneLocalDataSource.isFirstAlarmDismissedTodayFlow + + override val fortuneCreateStatusFlow: Flow = fortuneLocalDataSource.fortuneCreateStatusFlow - override suspend fun saveFortuneId(fortuneId: Long) = fortuneLocalDataSource.saveFortuneId(fortuneId) - override suspend fun markFortuneAsChecked() = fortuneLocalDataSource.markFortuneAsChecked() + override suspend fun markFortuneAsCreating() = fortuneLocalDataSource.markFortuneCreating() + override suspend fun markFortuneAsCreated(fortuneId: Long) = fortuneLocalDataSource.markFortuneCreated(fortuneId) + override suspend fun markFortuneAsFailed() = fortuneLocalDataSource.markFortuneFailed() + override suspend fun markFortuneSeen() = fortuneLocalDataSource.markFortuneSeen() + override suspend fun markFortuneTooltipShown() = fortuneLocalDataSource.markFortuneTooltipShown() override suspend fun saveFortuneImageId(imageResId: Int) = fortuneLocalDataSource.saveFortuneImageId(imageResId) override suspend fun saveFortuneScore(score: Int) = fortuneLocalDataSource.saveFortuneScore(score) - override suspend fun saveFirstDismissedAlarmId(alarmId: Long) = fortuneLocalDataSource.saveFirstDismissedAlarmId(alarmId) - override suspend fun clearDismissedAlarmId() = fortuneLocalDataSource.clearDismissedAlarmId() - override suspend fun clearFortuneId() = fortuneLocalDataSource.clearFortuneId() + override suspend fun markFirstAlarmDismissedToday() = fortuneLocalDataSource.markFirstAlarmDismissedToday() + + override suspend fun clearFortuneData() = fortuneLocalDataSource.clearFortuneData() override suspend fun postFortune(userId: Long): Result { return fortuneRemoteDataSource.postFortune(userId) - .mapCatching { fortuneResponse -> - fortuneResponse.toDomain() - } + .mapCatching { it.toDomain() } } override suspend fun getFortune(fortuneId: Long): Result { return fortuneRemoteDataSource.getFortune(fortuneId) - .mapCatching { fortuneResponse -> - fortuneResponse.toDomain() - } + .mapCatching { it.toDomain() } } } diff --git a/data/src/main/java/com/yapp/data/repositoryimpl/UserInfoRepositoryImpl.kt b/data/src/main/java/com/yapp/data/repositoryimpl/UserInfoRepositoryImpl.kt index d96ca6be..818e232d 100644 --- a/data/src/main/java/com/yapp/data/repositoryimpl/UserInfoRepositoryImpl.kt +++ b/data/src/main/java/com/yapp/data/repositoryimpl/UserInfoRepositoryImpl.kt @@ -17,10 +17,14 @@ class UserInfoRepositoryImpl @Inject constructor( override val userIdFlow: Flow = userLocalDataSource.userIdFlow override val userNameFlow: Flow = userLocalDataSource.userNameFlow override val onboardingCompletedFlow: Flow = userLocalDataSource.onboardingCompletedFlow + override val updateNoticeDontShowVersionFlow: Flow = userLocalDataSource.updateNoticeDontShowVersionFlow + override val updateNoticeLastShownDateEpochFlow: Flow = userLocalDataSource.updateNoticeLastShownDateEpochFlow override suspend fun saveUserId(userId: Long) = userLocalDataSource.saveUserId(userId) override suspend fun saveUserName(userName: String) = userLocalDataSource.saveUserName(userName) override suspend fun setOnboardingCompleted() = userLocalDataSource.setOnboardingCompleted() + override suspend fun markUpdateNoticeDontShow(version: String) = userLocalDataSource.markUpdateNoticeDontShow(version) + override suspend fun markUpdateNoticeShownToday() = userLocalDataSource.markUpdateNoticeShownToday() override suspend fun clearUserData() = userLocalDataSource.clearUserData() override suspend fun getUserInfo(userId: Long): Result { diff --git a/domain/src/main/java/com/yapp/domain/model/AlarmDay.kt b/domain/src/main/java/com/yapp/domain/model/AlarmDay.kt index beaead69..7f349ed5 100644 --- a/domain/src/main/java/com/yapp/domain/model/AlarmDay.kt +++ b/domain/src/main/java/com/yapp/domain/model/AlarmDay.kt @@ -1,5 +1,7 @@ package com.yapp.domain.model +import java.time.DayOfWeek + enum class AlarmDay(val bitValue: Int) { SUN(0b0000001), // 1 MON(0b0000010), // 2 @@ -11,8 +13,13 @@ enum class AlarmDay(val bitValue: Int) { ; } -fun AlarmDay.toDayOfWeek(): java.time.DayOfWeek { - return java.time.DayOfWeek.of(((this.ordinal + 6) % 7) + 1) +fun AlarmDay.toDayOfWeek(): DayOfWeek { + return DayOfWeek.of(((this.ordinal + 6) % 7) + 1) +} + +fun DayOfWeek.toAlarmDay(): AlarmDay { + val index = (this.value % 7) + return AlarmDay.entries[index] } fun Set.toRepeatDays(): Int { diff --git a/domain/src/main/java/com/yapp/domain/model/FortuneCreateStatus.kt b/domain/src/main/java/com/yapp/domain/model/FortuneCreateStatus.kt new file mode 100644 index 00000000..27ae9ad0 --- /dev/null +++ b/domain/src/main/java/com/yapp/domain/model/FortuneCreateStatus.kt @@ -0,0 +1,8 @@ +package com.yapp.domain.model + +sealed class FortuneCreateStatus { + data object Idle : FortuneCreateStatus() + data object Creating : FortuneCreateStatus() + data class Success(val fortuneId: Long) : FortuneCreateStatus() + data object Failure : FortuneCreateStatus() +} diff --git a/domain/src/main/java/com/yapp/domain/model/MissionMode.kt b/domain/src/main/java/com/yapp/domain/model/MissionMode.kt new file mode 100644 index 00000000..16009fc2 --- /dev/null +++ b/domain/src/main/java/com/yapp/domain/model/MissionMode.kt @@ -0,0 +1,13 @@ +package com.yapp.domain.model + +enum class MissionMode { + REAL, + PREVIEW, + ; + + companion object { + fun fromRaw(raw: String?): MissionMode { + return raw?.let { entries.find { it.name == raw } } ?: REAL + } + } +} 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 f7dac361..60123473 100644 --- a/domain/src/main/java/com/yapp/domain/repository/AlarmRepository.kt +++ b/domain/src/main/java/com/yapp/domain/repository/AlarmRepository.kt @@ -6,8 +6,6 @@ import com.yapp.domain.model.AlarmSound import kotlinx.coroutines.flow.Flow interface AlarmRepository { - val firstDismissedAlarmIdFlow: Flow - suspend fun getAlarmSounds(): Result> fun initializeSoundPlayer(uri: Uri) fun playAlarmSound(volume: Int) @@ -21,6 +19,4 @@ interface AlarmRepository { suspend fun updateAlarmActive(id: Long, active: Boolean): Result suspend fun getAlarm(id: Long): Result suspend fun deleteAlarm(id: Long): Result - suspend fun saveFirstDismissedAlarmId(alarmId: Long) - suspend fun clearDismissedAlarmId() } diff --git a/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt b/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt index 23598e3c..372fd5fe 100644 --- a/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt +++ b/domain/src/main/java/com/yapp/domain/repository/FortuneRepository.kt @@ -1,23 +1,31 @@ package com.yapp.domain.repository import com.yapp.domain.model.Fortune +import com.yapp.domain.model.FortuneCreateStatus import kotlinx.coroutines.flow.Flow interface FortuneRepository { val fortuneIdFlow: Flow - val fortuneDateFlow: Flow + val fortuneDateEpochFlow: Flow val fortuneImageIdFlow: Flow val fortuneScoreFlow: Flow - val hasNewFortuneFlow: Flow - val firstDismissedAlarmIdFlow: Flow + val hasUnseenFortuneFlow: Flow + val shouldShowFortuneToolTipFlow: Flow + val isFirstAlarmDismissedTodayFlow: Flow - suspend fun saveFortuneId(fortuneId: Long) - suspend fun markFortuneAsChecked() + val fortuneCreateStatusFlow: Flow + + suspend fun markFortuneAsCreating() + suspend fun markFortuneAsCreated(fortuneId: Long) + suspend fun markFortuneAsFailed() + suspend fun markFortuneSeen() + suspend fun markFortuneTooltipShown() suspend fun saveFortuneImageId(imageResId: Int) suspend fun saveFortuneScore(score: Int) - suspend fun saveFirstDismissedAlarmId(alarmId: Long) - suspend fun clearDismissedAlarmId() - suspend fun clearFortuneId() + suspend fun markFirstAlarmDismissedToday() + + suspend fun clearFortuneData() + suspend fun postFortune(userId: Long): Result suspend fun getFortune(fortuneId: Long): Result } diff --git a/domain/src/main/java/com/yapp/domain/repository/UserInfoRepository.kt b/domain/src/main/java/com/yapp/domain/repository/UserInfoRepository.kt index bda28291..a9df412e 100644 --- a/domain/src/main/java/com/yapp/domain/repository/UserInfoRepository.kt +++ b/domain/src/main/java/com/yapp/domain/repository/UserInfoRepository.kt @@ -8,10 +8,14 @@ interface UserInfoRepository { val userIdFlow: Flow val userNameFlow: Flow val onboardingCompletedFlow: Flow + val updateNoticeDontShowVersionFlow: Flow + val updateNoticeLastShownDateEpochFlow: Flow suspend fun saveUserId(userId: Long) suspend fun saveUserName(userName: String) suspend fun setOnboardingCompleted() + suspend fun markUpdateNoticeDontShow(version: String) + suspend fun markUpdateNoticeShownToday() suspend fun clearUserData() suspend fun getUserInfo(userId: Long): Result diff --git a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/AlarmInteractionActivity.kt b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/AlarmInteractionActivity.kt index 92d52cee..e95a93b2 100644 --- a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/AlarmInteractionActivity.kt +++ b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/AlarmInteractionActivity.kt @@ -10,7 +10,10 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier import androidx.core.util.Consumer import androidx.navigation.compose.NavHost import com.yapp.alarm.AlarmConstants @@ -18,6 +21,7 @@ import com.yapp.alarm.receivers.AlarmInteractionActivityReceiver import com.yapp.common.navigation.rememberOrbitNavigator import com.yapp.common.navigation.route.AlarmInteractionBaseRoute import com.yapp.domain.model.Alarm +import com.yapp.ui.component.navigation.NavigationBarScrim import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -45,14 +49,19 @@ class AlarmInteractionActivity : ComponentActivity() { setContent { val navigator = rememberOrbitNavigator() - NavHost( - navController = navigator.navController, - startDestination = AlarmInteractionBaseRoute, - ) { - alarmInteractionNavGraph( - navigator = navigator, - alarm = alarm, - ) + Box { + NavHost( + modifier = Modifier.navigationBarsPadding(), + navController = navigator.navController, + startDestination = AlarmInteractionBaseRoute, + ) { + alarmInteractionNavGraph( + navigator = navigator, + alarm = alarm, + ) + } + + NavigationBarScrim() } DisposableEffect(this, navigator.navController) { 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 ea4d0b68..9eee8bad 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 @@ -14,7 +14,7 @@ class AlarmActionContract { val snoozeEnabled: Boolean = true, val snoozeInterval: Int = 5, val snoozeCount: Int = 5, - val isFirstMission: Boolean? = null, + val shouldShowMissionStart: Boolean? = null, ) : UiState sealed class Action { diff --git a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionScreen.kt b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionScreen.kt index d58bdda1..8ff13cb9 100644 --- a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionScreen.kt +++ b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionScreen.kt @@ -84,7 +84,7 @@ internal fun AlarmActionScreen( snoozeEnabled = state.snoozeEnabled, snoozeInterval = state.snoozeInterval, snoozeCount = state.snoozeCount, - isFirstMission = state.isFirstMission, + isFirstMission = state.shouldShowMissionStart, onSnoozeClick = { processAction(AlarmActionContract.Action.Snooze) }, onDismissClick = { processAction(AlarmActionContract.Action.Dismiss) diff --git a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionViewModel.kt b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionViewModel.kt index a15a85c5..49182b15 100644 --- a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionViewModel.kt +++ b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/action/AlarmActionViewModel.kt @@ -6,10 +6,9 @@ import androidx.lifecycle.ViewModel import com.yapp.alarm.pendingIntent.interaction.createAlarmDismissIntent import com.yapp.alarm.pendingIntent.interaction.createAlarmSnoozeIntent import com.yapp.domain.model.Alarm -import com.yapp.domain.repository.FortuneRepository +import com.yapp.domain.model.MissionType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.firstOrNull import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent @@ -18,7 +17,6 @@ import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container import java.time.LocalDate import java.time.LocalTime -import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.util.Locale import javax.inject.Inject @@ -26,14 +24,13 @@ import javax.inject.Inject @HiltViewModel class AlarmActionViewModel @Inject constructor( private val app: Application, - private val fortuneRepository: FortuneRepository, savedStateHandle: SavedStateHandle, ) : ViewModel(), ContainerHost { override val container: Container = container( initialState = AlarmActionContract.State(), ) { - fetchIsFirstMission() + fetchShouldShowMissionStart() initializeAlarmState() startClock() } @@ -58,13 +55,9 @@ class AlarmActionViewModel @Inject constructor( } } - private fun fetchIsFirstMission() = intent { - val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - val isFirstMission = fortuneDate != todayDate - + private fun fetchShouldShowMissionStart() = intent { reduce { - state.copy(isFirstMission = isFirstMission) + state.copy(shouldShowMissionStart = (alarm?.missionType ?: MissionType.NONE) != MissionType.NONE) } } diff --git a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerViewModel.kt b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerViewModel.kt index 1f0e6d1b..05b6c798 100644 --- a/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerViewModel.kt +++ b/feature/alarm-interaction/src/main/java/com/yapp/alarm/interaction/snooze/AlarmSnoozeTimerViewModel.kt @@ -17,7 +17,6 @@ import org.orbitmvi.orbit.viewmodel.container import java.time.LocalDate import java.time.LocalDateTime import java.time.ZoneId -import java.time.format.DateTimeFormatter import javax.inject.Inject import kotlin.math.max @@ -45,8 +44,8 @@ class AlarmSnoozeTimerViewModel @Inject constructor( } private fun fetchIsFirstMission() = intent { - val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) + val fortuneDate = fortuneRepository.fortuneDateEpochFlow.firstOrNull() + val todayDate = LocalDate.now().toEpochDay() val isFirstMission = fortuneDate != todayDate reduce { diff --git a/feature/fortune/build.gradle.kts b/feature/fortune/build.gradle.kts index ae450155..543510e8 100644 --- a/feature/fortune/build.gradle.kts +++ b/feature/fortune/build.gradle.kts @@ -12,10 +12,14 @@ dependencies { implementation(projects.core.ui) implementation(projects.core.common) implementation(projects.core.analytics) + implementation(projects.core.alarm) implementation(libs.orbit.core) implementation(libs.orbit.compose) implementation(libs.orbit.viewmodel) implementation(libs.coil.compose) + implementation(libs.androidx.work.runtime) + testImplementation(libs.androidx.work.testing) + androidTestImplementation(libs.androidx.work.testing) implementation(projects.domain) implementation(projects.core.media) } diff --git a/feature/fortune/src/main/java/com/yapp/fortune/FortuneNavGraph.kt b/feature/fortune/src/main/java/com/yapp/fortune/FortuneNavGraph.kt index 5f1b5133..0a7b62ce 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/FortuneNavGraph.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/FortuneNavGraph.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable +import androidx.navigation.navDeepLink import androidx.navigation.navOptions import androidx.navigation.navigation import com.yapp.common.navigation.OrbitNavigator @@ -19,7 +20,11 @@ fun NavGraphBuilder.fortuneNavGraph( snackBarHostState: SnackbarHostState, ) { navigation(startDestination = FortuneDestination.Fortune) { - composable { backStackEntry -> + composable( + deepLinks = listOf( + navDeepLink { uriPattern = "orbitapp://fortune" }, + ), + ) { backStackEntry -> val viewModel = backStackEntry.sharedHiltViewModel(navigator.navController) val coroutineScope = rememberCoroutineScope() diff --git a/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt b/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt index f5109a3b..1eb97abc 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/FortuneScreen.kt @@ -3,11 +3,13 @@ package com.yapp.fortune import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable @@ -15,6 +17,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -34,6 +37,7 @@ import com.yapp.fortune.component.FortuneTopAppBar import com.yapp.fortune.component.SlidingIndicator import com.yapp.fortune.page.FortunePager import com.yapp.ui.component.lottie.LottieAnimation +import kotlinx.coroutines.delay import java.math.BigDecimal import java.math.RoundingMode @@ -184,21 +188,49 @@ fun FortuneScreen( @Composable fun FortuneLoadingScreen() { + var isDelivering by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + while (true) { + delay(2000) + isDelivering = !isDelivering + } + } + Box( modifier = Modifier .fillMaxSize() .background(OrbitTheme.colors.gray_900.copy(alpha = 0.7f)), contentAlignment = Alignment.Center, ) { - LottieAnimation( - modifier = Modifier - .size(70.dp), - resId = core.designsystem.R.raw.star_loading, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + val imageRes = if (isDelivering) { + core.designsystem.R.drawable.ic_fortune_delivering_speech_bubble + } else { + core.designsystem.R.drawable.ic_fortune_waiting_speech_bubble + } + Image( + painter = painterResource(id = imageRes), + contentDescription = null, + ) + + LottieAnimation( + modifier = Modifier + .width(375.dp) + .height(267.dp), + resId = core.designsystem.R.raw.fortune_loading, + ) + } } } @Composable @Preview -fun FortuneRoutePreview() { +private fun FortuneLoadingScreenPreview() { + OrbitTheme { + FortuneLoadingScreen() + } } diff --git a/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt b/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt index 034c1590..4a83a561 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/FortuneViewModel.kt @@ -4,11 +4,13 @@ import android.app.Application import android.util.Log import androidx.annotation.DrawableRes import androidx.lifecycle.ViewModel +import com.yapp.domain.model.FortuneCreateStatus import com.yapp.domain.repository.FortuneRepository import com.yapp.fortune.page.toFortunePages import com.yapp.media.decoder.ImageUtils import com.yapp.media.storage.ImageSaver import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost @@ -16,8 +18,6 @@ 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.LocalDate -import java.time.format.DateTimeFormatter import javax.inject.Inject @HiltViewModel @@ -30,7 +30,7 @@ class FortuneViewModel @Inject constructor( override val container: Container = container( initialState = FortuneContract.State(), ) { - loadFortune() + observeFortune() } fun processAction(action: FortuneContract.Action) { @@ -50,14 +50,31 @@ class FortuneViewModel @Inject constructor( } } - private fun loadFortune() = intent { - val fortuneId = fortuneRepository.fortuneIdFlow.firstOrNull() - val firstDismissedAlarmId = fortuneRepository.firstDismissedAlarmIdFlow.firstOrNull() - val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() - fortuneId?.let { fetchAndUpdateFortune(it, firstDismissedAlarmId, fortuneDate) } + private fun observeFortune() = intent { + fortuneRepository.fortuneCreateStatusFlow.collect { status -> + when (status) { + is FortuneCreateStatus.Creating -> { + reduce { state.copy(isLoading = true) } + } + + is FortuneCreateStatus.Success -> { + fetchAndUpdateFortune( + fortuneId = status.fortuneId, + isFirstAlarmDismissedToday = fortuneRepository.isFirstAlarmDismissedTodayFlow.first(), + ) + } + + is FortuneCreateStatus.Failure, FortuneCreateStatus.Idle -> { + postSideEffect(FortuneContract.SideEffect.NavigateToHome) + } + } + } } - private fun fetchAndUpdateFortune(fortuneId: Long, firstDismissedAlarmId: Long?, fortuneDate: String?) = intent { + private fun fetchAndUpdateFortune( + fortuneId: Long, + isFirstAlarmDismissedToday: Boolean, + ) = intent { reduce { state.copy(isLoading = true) } fortuneRepository.getFortune(fortuneId).onSuccess { fortune -> @@ -65,8 +82,9 @@ class FortuneViewModel @Inject constructor( val imageId = savedImageId ?: getRandomImage() val formattedTitle = fortune.dailyFortuneTitle.replace(",", ",\n").trim() - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) - val hasReward = (fortuneDate == todayDate) && (firstDismissedAlarmId != null) + + fortuneRepository.markFortuneSeen() + reduce { state.copy( isLoading = false, @@ -75,7 +93,7 @@ class FortuneViewModel @Inject constructor( avgFortuneScore = fortune.avgFortuneScore, fortunePages = fortune.toFortunePages(), fortuneImageId = imageId, - hasReward = hasReward, + hasReward = isFirstAlarmDismissedToday, ) } }.onFailure { error -> diff --git a/feature/fortune/src/main/java/com/yapp/fortune/di/SchedulerModule.kt b/feature/fortune/src/main/java/com/yapp/fortune/di/SchedulerModule.kt new file mode 100644 index 00000000..47fbd0b0 --- /dev/null +++ b/feature/fortune/src/main/java/com/yapp/fortune/di/SchedulerModule.kt @@ -0,0 +1,19 @@ +package com.yapp.fortune.di + +import com.yapp.alarm.scheduler.PostFortuneTaskScheduler +import com.yapp.fortune.scheduler.WorkManagerPostFortuneTaskScheduler +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class SchedulerModule { + @Binds + @Singleton + abstract fun bindsPostFortuneTaskScheduler( + postFortuneTaskScheduler: WorkManagerPostFortuneTaskScheduler, + ): PostFortuneTaskScheduler +} diff --git a/feature/fortune/src/main/java/com/yapp/fortune/page/FortunePager.kt b/feature/fortune/src/main/java/com/yapp/fortune/page/FortunePager.kt index 08b7cfc5..aa1dc78b 100644 --- a/feature/fortune/src/main/java/com/yapp/fortune/page/FortunePager.kt +++ b/feature/fortune/src/main/java/com/yapp/fortune/page/FortunePager.kt @@ -51,11 +51,13 @@ fun FortunePager( val index = (page - 1).coerceIn(0, state.fortunePages.lastIndex) FortunePageLayout(state.fortunePages[index]) } + 5 -> FortuneCompletePage( hasReward = state.hasReward, onCompleteClick = onNextStep, onNavigateToHome = onNavigateToHome, ) + else -> {} } } diff --git a/feature/fortune/src/main/java/com/yapp/fortune/scheduler/WorkManagerPostFortuneTaskScheduler.kt b/feature/fortune/src/main/java/com/yapp/fortune/scheduler/WorkManagerPostFortuneTaskScheduler.kt new file mode 100644 index 00000000..36e49c4f --- /dev/null +++ b/feature/fortune/src/main/java/com/yapp/fortune/scheduler/WorkManagerPostFortuneTaskScheduler.kt @@ -0,0 +1,32 @@ +package com.yapp.fortune.scheduler + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.yapp.alarm.scheduler.PostFortuneTaskScheduler +import com.yapp.fortune.worker.PostFortuneWorker +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.LocalDate +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class WorkManagerPostFortuneTaskScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) : PostFortuneTaskScheduler { + override fun enqueueOnceForToday() { + val name = "post_fortune_${LocalDate.now()}" + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val req = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(name, ExistingWorkPolicy.KEEP, req) + } +} diff --git a/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt b/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt new file mode 100644 index 00000000..2dd14f72 --- /dev/null +++ b/feature/fortune/src/main/java/com/yapp/fortune/worker/PostFortuneWorker.kt @@ -0,0 +1,63 @@ +package com.yapp.fortune.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.yapp.domain.model.FortuneCreateStatus +import com.yapp.domain.repository.FortuneRepository +import com.yapp.domain.repository.UserInfoRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull + +@HiltWorker +class PostFortuneWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val fortuneRepository: FortuneRepository, + private val userInfoRepository: UserInfoRepository, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + when (fortuneRepository.fortuneCreateStatusFlow.first()) { + is FortuneCreateStatus.Creating, + is FortuneCreateStatus.Success, + -> { + return Result.success() + } + FortuneCreateStatus.Failure, + FortuneCreateStatus.Idle, + -> { + val userId = userInfoRepository.userIdFlow.firstOrNull() + ?: run { + // 사용자 없으면 실패 상태 표시 후 실패 반환 + fortuneRepository.markFortuneAsFailed() + return Result.failure() + } + + return try { + fortuneRepository.markFortuneAsCreating() + + val result = fortuneRepository.postFortune(userId) + result.fold( + onSuccess = { fortune -> + fortuneRepository.markFortuneAsCreated(fortune.id) + fortuneRepository.saveFortuneScore(fortune.avgFortuneScore) + Result.success() + }, + onFailure = { + fortuneRepository.markFortuneAsFailed() + // WM 백오프 규칙에 따라 재시도 + Result.retry() + }, + ) + } catch (_: Throwable) { + fortuneRepository.markFortuneAsFailed() + Result.retry() + } + } + } + } +} diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 9ef0f667..0b90300c 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -19,4 +19,5 @@ dependencies { implementation(libs.orbit.viewmodel) implementation(libs.androidx.material.android) implementation(libs.androidx.annotation) + implementation(libs.coil.compose) } diff --git a/feature/home/src/main/AndroidManifest.xml b/feature/home/src/main/AndroidManifest.xml index 8bdb7e14..1c6dcf4e 100644 --- a/feature/home/src/main/AndroidManifest.xml +++ b/feature/home/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - + 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 dee0ecef..e4d15d19 100644 --- a/feature/home/src/main/java/com/yapp/home/HomeContract.kt +++ b/feature/home/src/main/java/com/yapp/home/HomeContract.kt @@ -18,6 +18,7 @@ sealed class HomeContract { val isDeleteDialogVisible: Boolean = false, val isNoActivatedAlarmDialogVisible: Boolean = false, val isNoDailyFortuneDialogVisible: Boolean = false, + val isUpdateNoticeVisible: Boolean = false, val hasNewFortune: Boolean = false, val isToolTipVisible: Boolean = false, val pendingAlarmToggle: Pair? = null, @@ -58,6 +59,8 @@ sealed class HomeContract { data object ShowNoDailyFortuneDialog : Action() data object HideNoDailyFortuneDialog : Action() data object HideToolTip : Action() + data object OnClickDontShowAgain : Action() + data object HideUpdateNotice : Action() data object RollbackPendingAlarmToggle : Action() data object ConfirmDeletion : Action() data class DeleteSingleAlarm(val alarmId: Long) : Action() 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 8c44e05a..10d74e52 100644 --- a/feature/home/src/main/java/com/yapp/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/yapp/home/HomeScreen.kt @@ -1,5 +1,6 @@ package com.yapp.home +import android.os.Build import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas @@ -74,6 +75,7 @@ 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.home.component.bottomsheet.UpdateNoticeBottomSheet import com.yapp.ui.component.dialog.OrbitDialog import com.yapp.ui.component.lottie.LottieAnimation import com.yapp.ui.component.snackbar.showCustomSnackBar @@ -129,8 +131,8 @@ fun HomeRoute( } HomeScreen( - stateProvider = { state }, - eventDispatcher = viewModel::processAction, + state = state, + processAction = viewModel::processAction, ) } @@ -178,23 +180,21 @@ private suspend fun handleSideEffect( @Composable fun HomeScreen( - stateProvider: () -> HomeContract.State, - eventDispatcher: (HomeContract.Action) -> Unit, + state: HomeContract.State, + processAction: (HomeContract.Action) -> Unit, ) { - val state = stateProvider() - if (state.initialLoading) { HomeLoadingScreen() } else if (state.alarms.isEmpty()) { HomeAlarmEmptyScreen( onSettingClick = { - eventDispatcher(HomeContract.Action.NavigateToSetting) + processAction(HomeContract.Action.NavigateToSetting) }, onMailClick = { - eventDispatcher(HomeContract.Action.ShowDailyFortune) + processAction(HomeContract.Action.ShowDailyFortune) }, onAddClick = { - eventDispatcher(HomeContract.Action.NavigateToAlarmCreation) + processAction(HomeContract.Action.NavigateToAlarmCreation) }, hasNewFortune = state.hasNewFortune, isTooltipVisible = state.isToolTipVisible, @@ -202,7 +202,7 @@ fun HomeScreen( } else { HomeContent( state = state, - eventDispatcher = eventDispatcher, + processAction = processAction, ) } @@ -213,10 +213,10 @@ fun HomeScreen( confirmText = stringResource(id = R.string.alarm_delete_dialog_btn_delete), cancelText = stringResource(id = R.string.alarm_delete_dialog_btn_cancel), onConfirm = { - eventDispatcher(HomeContract.Action.ConfirmDeletion) + processAction(HomeContract.Action.ConfirmDeletion) }, onCancel = { - eventDispatcher(HomeContract.Action.HideDeleteDialog) + processAction(HomeContract.Action.HideDeleteDialog) }, ) } @@ -228,10 +228,10 @@ fun HomeScreen( confirmText = stringResource(id = R.string.no_active_alarm_dialog_btn_confirm), cancelText = stringResource(id = R.string.no_active_alarm_dialog_btn_cancel), onConfirm = { - eventDispatcher(HomeContract.Action.HideNoActivatedAlarmDialog) + processAction(HomeContract.Action.HideNoActivatedAlarmDialog) }, onCancel = { - eventDispatcher(HomeContract.Action.RollbackPendingAlarmToggle) + processAction(HomeContract.Action.RollbackPendingAlarmToggle) }, ) } @@ -242,7 +242,18 @@ fun HomeScreen( message = stringResource(id = R.string.no_daily_fortune_dialog_message), confirmText = stringResource(id = R.string.no_daily_fortune_dialog_btn_confirm), onConfirm = { - eventDispatcher(HomeContract.Action.HideNoDailyFortuneDialog) + processAction(HomeContract.Action.HideNoDailyFortuneDialog) + }, + ) + } + + if (state.isUpdateNoticeVisible) { + UpdateNoticeBottomSheet( + onDontShowAgain = { + processAction(HomeContract.Action.OnClickDontShowAgain) + }, + onClose = { + processAction(HomeContract.Action.HideUpdateNotice) }, ) } @@ -279,7 +290,7 @@ private fun HomeLoadingScreen() { @Composable private fun HomeContent( state: HomeContract.State, - eventDispatcher: (HomeContract.Action) -> Unit, + processAction: (HomeContract.Action) -> Unit, ) { val screenHeight = LocalConfiguration.current.screenHeightDp.dp val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() @@ -291,7 +302,7 @@ private fun HomeContent( LaunchedEffect(state.lastAddedAlarmIndex) { state.lastAddedAlarmIndex?.let { index -> listState.scrollToItem(index) - eventDispatcher(HomeContract.Action.ResetLastAddedAlarmIndex) + processAction(HomeContract.Action.ResetLastAddedAlarmIndex) } } @@ -301,7 +312,7 @@ private fun HomeContent( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { - eventDispatcher(HomeContract.Action.HideToolTip) + processAction(HomeContract.Action.HideToolTip) }, ) { if (state.activeItemMenu != null) { @@ -312,7 +323,7 @@ private fun HomeContent( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { - eventDispatcher(HomeContract.Action.HideItemMenu) + processAction(HomeContract.Action.HideItemMenu) } .zIndex(1f), ) @@ -338,47 +349,50 @@ private fun HomeContent( halfExpandedHeight = sheetHalfExpandHeight, listState = listState, onClickAlarm = { alarmId -> - eventDispatcher(HomeContract.Action.EditAlarm(alarmId)) + processAction(HomeContract.Action.EditAlarm(alarmId)) }, onLongPressAlarm = { alarmId, x, y -> - eventDispatcher(HomeContract.Action.ShowItemMenu(alarmId, x, y)) + processAction(HomeContract.Action.ShowItemMenu(alarmId, x, y)) }, onClickAdd = { - eventDispatcher(HomeContract.Action.NavigateToAlarmCreation) + processAction(HomeContract.Action.NavigateToAlarmCreation) }, onClickMore = { if (state.dropdownMenuExpanded || state.sortDropDownMenuExpanded) { - eventDispatcher(HomeContract.Action.HideDropDownMenu) + processAction(HomeContract.Action.HideDropDownMenu) } else { - eventDispatcher(HomeContract.Action.ShowDropDownMenu) + processAction(HomeContract.Action.ShowDropDownMenu) } }, onClickCheckAll = { - eventDispatcher(HomeContract.Action.ToggleAllAlarmSelection) + processAction(HomeContract.Action.ToggleAllAlarmSelection) }, onClickClose = { - eventDispatcher(HomeContract.Action.ToggleMultiSelectionMode) + processAction(HomeContract.Action.ToggleMultiSelectionMode) }, onClickEdit = { - eventDispatcher(HomeContract.Action.ToggleMultiSelectionMode) + processAction(HomeContract.Action.ToggleMultiSelectionMode) }, onClickSort = { - eventDispatcher(HomeContract.Action.ShowSortDropDownMenu) + processAction(HomeContract.Action.ShowSortDropDownMenu) }, onSetSortOrder = { sortOrder -> - eventDispatcher(HomeContract.Action.SetSortOrder(sortOrder)) + processAction(HomeContract.Action.SetSortOrder(sortOrder)) }, onDismissRequest = { - eventDispatcher(HomeContract.Action.HideDropDownMenu) + processAction(HomeContract.Action.HideDropDownMenu) }, onToggleSelect = { alarmId -> - eventDispatcher(HomeContract.Action.ToggleAlarmSelection(alarmId)) + processAction(HomeContract.Action.ToggleAlarmSelection(alarmId)) }, onToggleActive = { alarmId -> - eventDispatcher(HomeContract.Action.ToggleAlarmActivation(alarmId)) + processAction(HomeContract.Action.ToggleAlarmActivation(alarmId)) }, onSwipe = { alarmId -> - eventDispatcher(HomeContract.Action.SwipeToDeleteAlarm(alarmId)) + processAction(HomeContract.Action.SwipeToDeleteAlarm(alarmId)) + }, + onExpanded = { + processAction(HomeContract.Action.HideToolTip) }, ) { Box( @@ -397,7 +411,15 @@ private fun HomeContent( .fillMaxWidth() .layout { measurable, constraints -> val placeable = measurable.measure(constraints) - sheetHalfExpandHeight = screenHeight - placeable.height.toDp() - statusBarHeight - navBarHeight + val contentHeight = placeable.height.toDp() + + val offset = if (Build.VERSION.SDK_INT < 35) { + 0.dp + } else { + statusBarHeight + navBarHeight + } + sheetHalfExpandHeight = screenHeight - contentHeight - offset + layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } @@ -420,8 +442,8 @@ private fun HomeContent( } HomeTopBar( - onSettingClick = { eventDispatcher(HomeContract.Action.NavigateToSetting) }, - onMailClick = { eventDispatcher(HomeContract.Action.ShowDailyFortune) }, + onSettingClick = { processAction(HomeContract.Action.NavigateToSetting) }, + onMailClick = { processAction(HomeContract.Action.ShowDailyFortune) }, hasNewFortune = state.hasNewFortune, isShowTooltip = state.isToolTipVisible, ) @@ -438,7 +460,7 @@ private fun HomeContent( .padding(bottom = 26.dp), selectedAlarmCount = state.selectedAlarmIds.size, onClick = { - eventDispatcher(HomeContract.Action.ShowDeleteDialog) + processAction(HomeContract.Action.ShowDeleteDialog) }, ) } @@ -451,7 +473,7 @@ private fun HomeContent( activeItemMenuPosition = state.activeItemMenuPosition, selectedAlarmIds = state.selectedAlarmIds, onDelete = { alarmId -> - eventDispatcher(HomeContract.Action.DeleteSingleAlarm(alarmId)) + processAction(HomeContract.Action.DeleteSingleAlarm(alarmId)) }, ) } @@ -938,18 +960,16 @@ private fun AlarmWithMenu( fun HomeScreenPreview() { OrbitTheme { HomeScreen( - stateProvider = { - HomeContract.State() - .copy( - initialLoading = false, - alarms = listOf( - Alarm(), - ), - activeItemMenu = 0L, - activeItemMenuPosition = Pair(0f, 0f), - ) - }, - eventDispatcher = {}, + state = HomeContract.State() + .copy( + initialLoading = false, + alarms = listOf( + Alarm(), + ), + activeItemMenu = 0L, + activeItemMenuPosition = Pair(0f, 0f), + ), + processAction = {}, ) } } 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 ae347cea..57b74428 100644 --- a/feature/home/src/main/java/com/yapp/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/yapp/home/HomeViewModel.kt @@ -1,5 +1,8 @@ package com.yapp.home +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.util.Log import androidx.lifecycle.ViewModel import com.yapp.common.util.ResourceProvider @@ -9,6 +12,7 @@ import com.yapp.domain.repository.UserInfoRepository import com.yapp.domain.usecase.AlarmUseCase import com.yapp.home.util.AlarmDateTimeFormatter import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import feature.home.R import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first @@ -21,8 +25,8 @@ 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.format.DateTimeFormatter import javax.inject.Inject +import javax.inject.Named @HiltViewModel class HomeViewModel @Inject constructor( @@ -31,6 +35,8 @@ class HomeViewModel @Inject constructor( private val alarmDateTimeFormatter: AlarmDateTimeFormatter, private val fortuneRepository: FortuneRepository, private val userInfoRepository: UserInfoRepository, + @Named("appVersion") private val appVersion: String, + @ApplicationContext private val context: Context, ) : ViewModel(), ContainerHost { override val container: Container = container( @@ -41,6 +47,7 @@ class HomeViewModel @Inject constructor( loadAllAlarms() loadDailyFortuneState() loadUserName() + loadUpdateNoticeVisibility() } } } @@ -63,6 +70,8 @@ class HomeViewModel @Inject constructor( HomeContract.Action.ShowNoDailyFortuneDialog -> showNoDailyFortuneDialog() HomeContract.Action.HideNoDailyFortuneDialog -> hideNoDailyFortuneDialog() HomeContract.Action.HideToolTip -> hideToolTip() + HomeContract.Action.HideUpdateNotice -> hideUpdateNotice() + HomeContract.Action.OnClickDontShowAgain -> setUpdateNoticeDontShowVersion() HomeContract.Action.RollbackPendingAlarmToggle -> rollbackAlarmActivation() HomeContract.Action.ConfirmDeletion -> confirmDeletion() is HomeContract.Action.DeleteSingleAlarm -> deleteSingleAlarm(action.alarmId) @@ -332,29 +341,29 @@ class HomeViewModel @Inject constructor( } private fun loadDailyFortune() = intent { - val fortuneDate = fortuneRepository.fortuneDateFlow.firstOrNull() - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) + val fortuneDate = fortuneRepository.fortuneDateEpochFlow.firstOrNull() + val todayDate = LocalDate.now().toEpochDay() if (fortuneDate != todayDate) { processAction(HomeContract.Action.ShowNoDailyFortuneDialog) } else { - fortuneRepository.markFortuneAsChecked() + fortuneRepository.markFortuneTooltipShown() postSideEffect(HomeContract.SideEffect.NavigateToFortune) } } private fun loadDailyFortuneState() = intent { - val todayDate = LocalDate.now().format(DateTimeFormatter.ISO_DATE) + val todayDate = LocalDate.now().toEpochDay() combine( - fortuneRepository.fortuneDateFlow, + fortuneRepository.fortuneDateEpochFlow, fortuneRepository.fortuneScoreFlow, - fortuneRepository.hasNewFortuneFlow, - ) { fortuneDate, fortuneScore, hasNewFortune -> + fortuneRepository.shouldShowFortuneToolTipFlow, + ) { fortuneDate, fortuneScore, shouldShowTooltip -> val isTodayFortuneAvailable = fortuneDate == todayDate val finalFortuneScore = if (isTodayFortuneAvailable) fortuneScore ?: -1 else -1 - Pair(finalFortuneScore, hasNewFortune) + Pair(finalFortuneScore, shouldShowTooltip) }.collect { (finalFortuneScore, hasNewFortune) -> reduce { state.copy( @@ -366,6 +375,39 @@ class HomeViewModel @Inject constructor( } } + private fun loadUpdateNoticeVisibility() = intent { + if (!isOnlineNow()) { + reduce { state.copy(isUpdateNoticeVisible = false) } + return@intent + } + + val dontShowVersion = + userInfoRepository.updateNoticeDontShowVersionFlow.firstOrNull() + val lastShownDate = + userInfoRepository.updateNoticeLastShownDateEpochFlow.firstOrNull() + + val today = LocalDate.now().toEpochDay() + + val shouldShow = when { + dontShowVersion != null && dontShowVersion == appVersion -> false + lastShownDate != null && lastShownDate == today -> false + else -> true + } + + if (shouldShow) userInfoRepository.markUpdateNoticeShownToday() + + reduce { state.copy(isUpdateNoticeVisible = shouldShow) } + } + + private fun setUpdateNoticeDontShowVersion() = intent { + userInfoRepository.markUpdateNoticeDontShow(appVersion) + reduce { state.copy(isUpdateNoticeVisible = false) } + } + + private fun hideUpdateNotice() = intent { + reduce { state.copy(isUpdateNoticeVisible = false) } + } + private fun loadUserName() = intent { userInfoRepository.userNameFlow.first { userName -> reduce { state.copy(name = userName ?: "") } @@ -411,4 +453,13 @@ class HomeViewModel @Inject constructor( reduce { state.copy(sortOrder = sortOrder) } hideDropDownMenu() } + + private fun isOnlineNow(): Boolean { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } } diff --git a/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditScreen.kt b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditScreen.kt index ec6dcefe..9d726ae3 100644 --- a/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditScreen.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/addedit/AlarmAddEditScreen.kt @@ -61,6 +61,7 @@ import com.yapp.home.alarm.component.bottomsheet.AlarmMissionBottomSheet 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.bottomsheet.OrbitBottomSheetLayout import com.yapp.ui.component.bottomsheet.OrbitBottomSheetState import com.yapp.ui.component.bottomsheet.rememberOrbitBottomSheetState import com.yapp.ui.component.button.OrbitButton @@ -282,50 +283,52 @@ fun AlarmAddEditContent( } } - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AlarmAddEditTopBar( - mode = state.mode, - title = state.timeState.alarmMessage, - onBack = { processAction(AlarmAddEditContract.Action.CheckUnsavedChangesBeforeExit) }, - onDelete = { processAction(AlarmAddEditContract.Action.ShowDeleteDialog) }, - ) - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center, + OrbitBottomSheetLayout(sheetState = bottomSheetState) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - OrbitPicker( - initialTime = state.timeState.initialTime, - ) { newTime -> - processAction(AlarmAddEditContract.Action.SetAlarmTime(newTime)) + AlarmAddEditTopBar( + mode = state.mode, + title = state.timeState.alarmMessage, + onBack = { processAction(AlarmAddEditContract.Action.CheckUnsavedChangesBeforeExit) }, + onDelete = { processAction(AlarmAddEditContract.Action.ShowDeleteDialog) }, + ) + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center, + ) { + OrbitPicker( + initialTime = state.timeState.initialTime, + ) { newTime -> + processAction(AlarmAddEditContract.Action.SetAlarmTime(newTime)) + } } + AlarmAddEditSelectDaysSection( + modifier = Modifier.padding(horizontal = 20.dp), + daysSelectionState = state.daySelectionState, + holidayState = state.holidayState, + processAction = processAction, + ) + Spacer(modifier = Modifier.height(12.dp)) + AlarmAddEditSettingsSection( + modifier = Modifier.padding(horizontal = 20.dp), + state = state, + processAction = processAction, + ) + Spacer(modifier = Modifier.height(24.dp)) + OrbitButton( + label = stringResource(R.string.alarm_add_edit_save), + onClick = { processAction(AlarmAddEditContract.Action.SaveAlarm) }, + enabled = true, + modifier = Modifier + .padding( + start = 20.dp, + end = 20.dp, + bottom = 12.dp, + ), + ) } - AlarmAddEditSelectDaysSection( - modifier = Modifier.padding(horizontal = 20.dp), - daysSelectionState = state.daySelectionState, - holidayState = state.holidayState, - processAction = processAction, - ) - Spacer(modifier = Modifier.height(12.dp)) - AlarmAddEditSettingsSection( - modifier = Modifier.padding(horizontal = 20.dp), - state = state, - processAction = processAction, - ) - Spacer(modifier = Modifier.height(24.dp)) - OrbitButton( - label = stringResource(R.string.alarm_add_edit_save), - onClick = { processAction(AlarmAddEditContract.Action.SaveAlarm) }, - enabled = true, - modifier = Modifier - .padding( - start = 20.dp, - end = 20.dp, - bottom = 12.dp, - ), - ) } if (state.isDeleteDialogVisible) { diff --git a/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmMissionBottomSheet.kt b/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmMissionBottomSheet.kt index 0c84f840..99656263 100644 --- a/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmMissionBottomSheet.kt +++ b/feature/home/src/main/java/com/yapp/home/alarm/component/bottomsheet/AlarmMissionBottomSheet.kt @@ -21,10 +21,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,6 +60,12 @@ private fun MissionType.displayData(): Pair = when (this) { else -> throw IllegalStateException("Invalid mission type") } +val StepStackSaver: Saver>, out Any> = + listSaver( + save = { state -> state.value.map { it.name } }, + restore = { restored -> mutableStateOf(restored.map { AlarmMissionSelectBottomSheetType.valueOf(it) }) }, + ) + @Composable internal fun AlarmMissionBottomSheet( missionState: AlarmAddEditContract.AlarmMissionState, @@ -64,9 +73,15 @@ internal fun AlarmMissionBottomSheet( onSaveMission: (MissionType, Int) -> Unit, onPreviewMission: (MissionType, Int) -> Unit, ) { - var stepStack by remember { mutableStateOf(listOf(AlarmMissionSelectBottomSheetType.MISSION_SETTING)) } - var selectedMissionType by remember { mutableStateOf(missionState.missionType) } - var selectedMissionCount by remember { mutableIntStateOf(missionState.missionCount) } + val initialMissionType = missionState.missionType + val initialMissionCount = missionState.missionCount + + var stepStack by rememberSaveable(saver = StepStackSaver) { + mutableStateOf(listOf(AlarmMissionSelectBottomSheetType.MISSION_SETTING)) + } + + var currentSelectedMissionType by rememberSaveable { mutableStateOf(initialMissionType) } + var currentSelectedMissionCount by rememberSaveable { mutableIntStateOf(initialMissionCount) } fun push(step: AlarmMissionSelectBottomSheetType) { stepStack = stepStack + step @@ -82,22 +97,22 @@ internal fun AlarmMissionBottomSheet( when (currentStep) { AlarmMissionSelectBottomSheetType.MISSION_SETTING -> { - if (selectedMissionType == MissionType.NONE) { + if (currentSelectedMissionType == MissionType.NONE) { MissionAddContent { push(AlarmMissionSelectBottomSheetType.MISSION_SELECT) } } else { MissionSettingContent( - missionType = selectedMissionType, - missionCount = selectedMissionCount, + missionType = currentSelectedMissionType, + missionCount = currentSelectedMissionCount, onDetail = { push(AlarmMissionSelectBottomSheetType.MISSION_DETAIL) }, onDelete = { - selectedMissionType = MissionType.NONE - onSaveMission(selectedMissionType, selectedMissionCount) + currentSelectedMissionType = MissionType.NONE + onSaveMission(currentSelectedMissionType, currentSelectedMissionCount) }, onChange = { push(AlarmMissionSelectBottomSheetType.MISSION_SELECT) }, onDone = { - onSaveMission(selectedMissionType, selectedMissionCount) + onSaveMission(currentSelectedMissionType, currentSelectedMissionCount) onDismiss() }, ) @@ -107,11 +122,10 @@ internal fun AlarmMissionBottomSheet( AlarmMissionSelectBottomSheetType.MISSION_SELECT -> { MissionSelectContent( onBack = { pop() }, - onClose = { - onDismiss() - }, - onSelect = { mission -> - selectedMissionType = mission + onClose = { onDismiss() }, + initialMission = currentSelectedMissionType, + onSelect = { selected -> + currentSelectedMissionType = selected push(AlarmMissionSelectBottomSheetType.MISSION_DETAIL) }, ) @@ -119,19 +133,17 @@ internal fun AlarmMissionBottomSheet( AlarmMissionSelectBottomSheetType.MISSION_DETAIL -> { MissionDetailContent( - missionType = selectedMissionType, - selectedMissionCount = selectedMissionCount, - onCountChange = { selectedMissionCount = it }, + missionType = currentSelectedMissionType, + selectedMissionCount = currentSelectedMissionCount, + onCountChange = { currentSelectedMissionCount = it }, onBack = { pop() }, - onClose = { - onDismiss() - }, + onClose = { onDismiss() }, onSave = { - onSaveMission(selectedMissionType, selectedMissionCount) + onSaveMission(currentSelectedMissionType, currentSelectedMissionCount) onDismiss() }, onPreview = { - onPreviewMission(selectedMissionType, selectedMissionCount) + onPreviewMission(currentSelectedMissionType, currentSelectedMissionCount) }, ) } @@ -379,6 +391,7 @@ private fun MissionCountChip( private fun MissionSelectContent( onBack: () -> Unit, onClose: () -> Unit, + initialMission: MissionType, onSelect: (MissionType) -> Unit, ) { Column( @@ -390,7 +403,7 @@ private fun MissionSelectContent( Spacer(modifier = Modifier.height(14.dp)) MissionSelectTopAppBar( - title = stringResource(id = feature.home.R.string.mission_bottom_sheet_title), + title = stringResource(id = feature.home.R.string.mission_select_content_title), onBack = onBack, onClose = onClose, ) @@ -400,12 +413,14 @@ private fun MissionSelectContent( ) { MissionTypeItem( missionType = MissionType.SHAKE, + selected = initialMission == MissionType.SHAKE, onClick = { onSelect(MissionType.SHAKE) }, ) MissionTypeItem( missionType = MissionType.TAP, + selected = initialMission == MissionType.TAP, onClick = { onSelect(MissionType.TAP) }, @@ -417,6 +432,7 @@ private fun MissionSelectContent( @Composable private fun MissionTypeItem( missionType: MissionType, + selected: Boolean, onClick: () -> Unit, ) { val (iconRes, titleRes) = missionType.displayData() @@ -425,6 +441,7 @@ private fun MissionTypeItem( Row( modifier = Modifier .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) .clickable( onClick = onClick, ) @@ -447,6 +464,28 @@ private fun MissionTypeItem( style = OrbitTheme.typography.headline2SemiBold, color = OrbitTheme.colors.white, ) + + if (selected) { + Spacer(modifier = Modifier.weight(1f)) + + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(id = R.drawable.ic_check), + tint = OrbitTheme.colors.white.copy(alpha = 0.5f), + contentDescription = null, + ) + + Text( + text = stringResource(id = feature.home.R.string.mission_select_content_selected), + style = OrbitTheme.typography.body2Medium, + color = OrbitTheme.colors.white.copy(alpha = 0.4f), + ) + } + } } } 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 9aaec0b3..e8ee3c58 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 @@ -36,6 +36,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -56,6 +57,8 @@ import com.yapp.home.component.AlarmListDropDownMenu import com.yapp.home.component.AlarmSortDropDownMenu import com.yapp.ui.component.checkbox.OrbitCheckBox import feature.home.R +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch enum class BottomSheetExpandState { @@ -87,23 +90,29 @@ internal fun AlarmListBottomSheet( onToggleSelect: (Long) -> Unit, onToggleActive: (Long) -> Unit, onSwipe: (Long) -> Unit, + onExpanded: () -> Unit, content: @Composable () -> Unit, ) { var expandedType by remember { mutableStateOf(BottomSheetExpandState.HALF_EXPANDED) } - val sheetState = rememberStandardBottomSheetState( - confirmValueChange = { - expandedType = when (it) { - SheetValue.Expanded -> BottomSheetExpandState.EXPANDED - else -> BottomSheetExpandState.HALF_EXPANDED - } - true - }, - ) - + val sheetState = rememberStandardBottomSheetState() val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState) val coroutineScope = rememberCoroutineScope() + LaunchedEffect(Unit) { + snapshotFlow { sheetState.currentValue } + .distinctUntilChanged() + .collectLatest { value -> + expandedType = when (value) { + SheetValue.Expanded -> { + onExpanded() + BottomSheetExpandState.EXPANDED + } + SheetValue.PartiallyExpanded, SheetValue.Hidden -> BottomSheetExpandState.HALF_EXPANDED + } + } + } + val nestedScrollConnection = remember { object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection { override fun onPreScroll( @@ -118,13 +127,6 @@ internal fun AlarmListBottomSheet( } } - LaunchedEffect(sheetState.currentValue) { - expandedType = when (sheetState.currentValue) { - SheetValue.Expanded -> BottomSheetExpandState.EXPANDED - else -> BottomSheetExpandState.HALF_EXPANDED - } - } - BottomSheetScaffold( scaffoldState = scaffoldState, sheetContent = { @@ -324,23 +326,19 @@ private fun AlarmListTopBar( onClick = onClickMore, ) - if (menuExpanded) { - AlarmListDropDownMenu( - expanded = menuExpanded, - onDismissRequest = onDismissRequest, - onClickEdit = onClickEdit, - onClickSort = onClickSort, - ) - } + AlarmListDropDownMenu( + expanded = menuExpanded, + onDismissRequest = onDismissRequest, + onClickEdit = onClickEdit, + onClickSort = onClickSort, + ) - if (sortDropDownMenuExpanded) { - AlarmSortDropDownMenu( - expanded = sortDropDownMenuExpanded, - sortOrder = sortOrder, - onDismissRequest = onDismissRequest, - onSetSortOrder = onSetSortOrder, - ) - } + AlarmSortDropDownMenu( + expanded = sortDropDownMenuExpanded, + sortOrder = sortOrder, + onDismissRequest = onDismissRequest, + onSetSortOrder = onSetSortOrder, + ) } } } diff --git a/feature/home/src/main/java/com/yapp/home/component/bottomsheet/UpdateNoticeBottomSheet.kt b/feature/home/src/main/java/com/yapp/home/component/bottomsheet/UpdateNoticeBottomSheet.kt new file mode 100644 index 00000000..fd38d5c2 --- /dev/null +++ b/feature/home/src/main/java/com/yapp/home/component/bottomsheet/UpdateNoticeBottomSheet.kt @@ -0,0 +1,150 @@ +package com.yapp.home.component.bottomsheet + +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.yapp.designsystem.theme.OrbitTheme +import feature.home.R + +private fun resolveVersionName(ctx: android.content.Context): String { + return runCatching { + val pm = ctx.packageManager + val packageName = ctx.packageName + val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pm.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(packageName, 0) + } + info.versionName ?: "" + }.getOrDefault("") +} + +private fun bannerUrl(versionName: String): String = + "https://www.orbitalarm.net/images/aos/$versionName/update-banner.png" + +@Composable +internal fun UpdateNoticeBottomSheet( + onDontShowAgain: () -> Unit, + onClose: () -> Unit, +) { + val context = LocalContext.current + val isPreview = LocalInspectionMode.current + + val versionName = remember(isPreview) { + if (isPreview) "preview" else resolveVersionName(context) + } + val imageUrl = remember(versionName) { bannerUrl(versionName.ifEmpty { "unknown" }) } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF17191F).copy(alpha = 0.85f)) + .clickable(onClick = onClose), + contentAlignment = Alignment.BottomCenter, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = OrbitTheme.colors.gray_900, + shape = RoundedCornerShape(topStart = 30.dp, topEnd = 30.dp), + ) + .clip(RoundedCornerShape(topStart = 30.dp, topEnd = 30.dp)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { }, + ) { + if (isPreview) { + // 프리뷰용 박스 + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background(color = OrbitTheme.colors.white), + ) + } else { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 20.dp, start = 20.dp, end = 20.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .weight(1f) + .clickable(onClick = onDontShowAgain) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(id = R.string.update_notice_bottom_sheet_dont_show_again), + style = OrbitTheme.typography.body1SemiBold, + color = OrbitTheme.colors.white, + ) + } + Box( + modifier = Modifier + .weight(1f) + .clickable(onClick = onClose) + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(id = R.string.update_notice_bottom_sheet_close), + style = OrbitTheme.typography.body1SemiBold, + color = OrbitTheme.colors.white, + ) + } + } + } + } +} + +@Preview +@Composable +private fun UpdateNoticeBottomSheetPreview() { + OrbitTheme { + UpdateNoticeBottomSheet( + onDontShowAgain = {}, + onClose = {}, + ) + } +} diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index cf91a124..b2266b16 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -48,6 +48,9 @@ 미션 변경 완료 + 미션 선택 + 선택됨 + %d회 횟수 @@ -121,4 +124,7 @@ %1$d시간 %2$d분 후에 울려요 %d분 후에 울려요 곧 울려요 + + 다시 보지 않기 + 닫기 diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt b/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt index 9aa81001..8e5c61d4 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionContract.kt @@ -27,7 +27,6 @@ sealed class MissionContract { data object ClickCard : Action() data object ShowExitDialog : Action() data object HideExitDialog : Action() - data object RetryPostFortune : Action() } sealed class SideEffect : com.yapp.ui.base.SideEffect { diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt b/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt index 247a9e5c..5befebf4 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionNavGraph.kt @@ -40,7 +40,7 @@ private fun handleSideEffect( MissionContract.SideEffect.NavigateToFortune -> { navigator.navigateToFortune( navOptions = navOptions { - popUpTo(MissionRoute.route) { + popUpTo { inclusive = true } }, @@ -50,7 +50,7 @@ private fun handleSideEffect( MissionContract.SideEffect.NavigateToHome -> { navigator.navigateToHome( navOptions = navOptions { - popUpTo(MissionRoute.route) { + popUpTo { inclusive = true } }, 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 ad178833..65249b8c 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionScreen.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionScreen.kt @@ -143,12 +143,6 @@ fun MissionScreen( MissionSuccessOverlay() } - state.errorMessage?.let { - ErrorDialog(message = it) { - eventDispatcher(MissionContract.Action.RetryPostFortune) - } - } - if (state.missionMode == MissionMode.PREVIEW) { val insets = WindowInsets.navigationBars.asPaddingValues() @@ -405,16 +399,6 @@ fun MissionSuccessOverlay() { } } -@Composable -fun ErrorDialog(message: String, onConfirm: () -> Unit) { - OrbitDialog( - title = stringResource(id = R.string.error), - message = message, - confirmText = stringResource(id = R.string.confirm), - onConfirm = onConfirm, - ) -} - @Composable fun MissionLoadingScreen() { Box( diff --git a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt index 9d8808e4..b5e1c45d 100644 --- a/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt +++ b/feature/mission/src/main/java/com/yapp/mission/MissionViewModel.kt @@ -7,16 +7,14 @@ import com.yapp.alarm.pendingIntent.interaction.createAlarmDismissIntent import com.yapp.analytics.AnalyticsEvent import com.yapp.analytics.AnalyticsHelper import com.yapp.domain.MissionMode +import com.yapp.domain.model.FortuneCreateStatus import com.yapp.domain.model.MissionType import com.yapp.domain.repository.FortuneRepository -import com.yapp.domain.repository.UserInfoRepository import com.yapp.media.haptic.HapticFeedbackManager import com.yapp.media.haptic.HapticType import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.first import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.syntax.simple.intent @@ -30,7 +28,6 @@ class MissionViewModel @Inject constructor( private val analyticsHelper: AnalyticsHelper, private val hapticFeedbackManager: HapticFeedbackManager, private val fortuneRepository: FortuneRepository, - private val userInfoRepository: UserInfoRepository, private val app: Application, private val savedStateHandle: SavedStateHandle, ) : ViewModel(), ContainerHost { @@ -53,7 +50,6 @@ class MissionViewModel @Inject constructor( is MissionContract.Action.ClickCard -> handleMissionProgress(MissionType.TAP) is MissionContract.Action.ShowExitDialog -> showExitDialog() is MissionContract.Action.HideExitDialog -> hideExitDialog() - is MissionContract.Action.RetryPostFortune -> retryPostFortune() } } @@ -137,36 +133,27 @@ class MissionViewModel @Inject constructor( private fun completeMission(type: String) = intent { performHapticSuccess() logMissionSuccess(type) - if (state.missionMode == MissionMode.REAL) { - postFortune() - } else { + + if (state.missionMode != MissionMode.REAL) { postSideEffect(MissionContract.SideEffect.NavigateBack) + return@intent } - } - private fun postFortune(isRetry: Boolean = false) = intent { - val userId = userInfoRepository.userIdFlow.firstOrNull() ?: return@intent + val fortuneCreateStatus = fortuneRepository.fortuneCreateStatusFlow.first() + val hasUnseenFortune = fortuneRepository.hasUnseenFortuneFlow.first() - val result = withContext(Dispatchers.IO) { - fortuneRepository.postFortune(userId) - } - - result.onSuccess { data -> - fortuneRepository.saveFortuneId(data.id) - fortuneRepository.saveFortuneScore(data.avgFortuneScore) + val shouldOpenFortune = ( + fortuneCreateStatus is FortuneCreateStatus.Creating || + fortuneCreateStatus is FortuneCreateStatus.Success && hasUnseenFortune + ) - postSideEffect(MissionContract.SideEffect.NavigateToFortune) - }.onFailure { error -> - if (isRetry) { - navigateToHome() + postSideEffect( + if (shouldOpenFortune) { + MissionContract.SideEffect.NavigateToFortune } else { - reduce { state.copy(errorMessage = error.message) } - } - } - } - - fun retryPostFortune() { - postFortune(isRetry = true) + MissionContract.SideEffect.NavigateBack + }, + ) } private fun logMissionSuccess(type: String) { @@ -183,8 +170,4 @@ class MissionViewModel @Inject constructor( private fun performHapticSuccess() { hapticFeedbackManager.performHapticFeedback(HapticType.SUCCESS) } - - private fun navigateToHome() = intent { - postSideEffect(MissionContract.SideEffect.NavigateToHome) - } } diff --git a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingAccessScreen.kt b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingAccessScreen.kt index abc83d5c..1485ed59 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingAccessScreen.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingAccessScreen.kt @@ -21,9 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -48,7 +46,6 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus -import com.google.accompanist.permissions.shouldShowRationale import com.yapp.analytics.AnalyticsEvent import com.yapp.analytics.LocalAnalyticsHelper import com.yapp.designsystem.theme.OrbitTheme @@ -238,8 +235,6 @@ fun OnboardingAccessScreen( modifier = Modifier .fillMaxSize() .background(OrbitTheme.colors.gray_900) - .statusBarsPadding() - .navigationBarsPadding() .imePadding(), ) { if (!hasRequestedPermission) { diff --git a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingBirthdayScreen.kt b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingBirthdayScreen.kt index 39868a89..ee9180dc 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingBirthdayScreen.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingBirthdayScreen.kt @@ -10,9 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -104,8 +102,6 @@ fun OnboardingBirthdayScreen( modifier = Modifier .fillMaxSize() .background(OrbitTheme.colors.gray_900) - .statusBarsPadding() - .navigationBarsPadding() .imePadding(), ) { OnBoardingTopAppBar( diff --git a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingCompleteScreen2.kt b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingCompleteScreen2.kt index f99e8771..f0e752e1 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingCompleteScreen2.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingCompleteScreen2.kt @@ -9,10 +9,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -57,8 +55,6 @@ fun OnboardingCompleteScreen2( modifier = Modifier .fillMaxSize() .background(OrbitTheme.colors.gray_900) - .statusBarsPadding() - .navigationBarsPadding() .imePadding(), ) { OnBoardingTopAppBar( diff --git a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingGenderScreen.kt b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingGenderScreen.kt index 06a2ec94..01f0df00 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingGenderScreen.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingGenderScreen.kt @@ -29,6 +29,7 @@ import com.yapp.common.navigation.OrbitNavigator import com.yapp.common.navigation.route.OnboardingBaseRoute import com.yapp.designsystem.theme.OrbitTheme import com.yapp.onboarding.component.UserInfoBottomSheet +import com.yapp.ui.component.bottomsheet.OrbitBottomSheetLayout import com.yapp.ui.component.bottomsheet.OrbitBottomSheetState import com.yapp.ui.component.bottomsheet.rememberOrbitBottomSheetState import com.yapp.ui.component.dialog.OrbitDialog @@ -120,7 +121,7 @@ private suspend fun handleSideEffect( OnboardingContract.SideEffect.OnboardingCompleted -> { navigator.navigateToHome( navOptions = navOptions { - popUpTo(OnboardingBaseRoute) { + popUpTo { inclusive = true } }, @@ -153,55 +154,57 @@ fun OnboardingGenderScreen( } } - OnboardingScreen( - currentStep = currentStep, - totalSteps = totalSteps, - isButtonEnabled = state.selectedGender != null, - onNextClick = { - processAction(OnboardingContract.Action.ShowBottomSheet) - }, - onBackClick = { - processAction(OnboardingContract.Action.PreviousStep) - }, - buttonLabel = "다음", - ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, + OrbitBottomSheetLayout(sheetState = bottomSheetState) { + OnboardingScreen( + currentStep = currentStep, + totalSteps = totalSteps, + isButtonEnabled = state.selectedGender != null, + onNextClick = { + processAction(OnboardingContract.Action.ShowBottomSheet) + }, + onBackClick = { + processAction(OnboardingContract.Action.PreviousStep) + }, + buttonLabel = "다음", ) { - Spacer(modifier = Modifier.heightForScreenPercentage(0.05f)) - Text( - text = stringResource(id = R.string.onboarding_step6_text_title), - style = OrbitTheme.typography.heading1SemiBold, - color = OrbitTheme.colors.white, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 38.dp) - .paddingForScreenPercentage(topPercentage = 0.11f), - horizontalArrangement = Arrangement.spacedBy(15.dp), + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, ) { - listOf("남성", "여성").forEach { gender -> - Box(modifier = Modifier.weight(1f)) { - OrbitGenderToggle( - label = gender, - isSelected = state.selectedGender == gender, - onToggle = { - logEvent( - AnalyticsEvent( - type = "onboarding_gender_select", - properties = mapOf( - AnalyticsEvent.OnboardingPropertiesKeys.GENDER to gender, + Spacer(modifier = Modifier.heightForScreenPercentage(0.05f)) + Text( + text = stringResource(id = R.string.onboarding_step6_text_title), + style = OrbitTheme.typography.heading1SemiBold, + color = OrbitTheme.colors.white, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 38.dp) + .paddingForScreenPercentage(topPercentage = 0.11f), + horizontalArrangement = Arrangement.spacedBy(15.dp), + ) { + listOf("남성", "여성").forEach { gender -> + Box(modifier = Modifier.weight(1f)) { + OrbitGenderToggle( + label = gender, + isSelected = state.selectedGender == gender, + onToggle = { + logEvent( + AnalyticsEvent( + type = "onboarding_gender_select", + properties = mapOf( + AnalyticsEvent.OnboardingPropertiesKeys.GENDER to gender, + ), ), - ), - ) - processAction(OnboardingContract.Action.UpdateGender(gender)) - }, - ) + ) + processAction(OnboardingContract.Action.UpdateGender(gender)) + }, + ) + } } } } diff --git a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingNavGraph.kt b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingNavGraph.kt index ff7417d7..a642d861 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingNavGraph.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingNavGraph.kt @@ -123,7 +123,7 @@ private fun handleOnboardingCommonSideEffect( OnboardingContract.SideEffect.OnboardingCompleted -> { navigator.navigateToHome( navOptions = navOptions { - popUpTo(OnboardingBaseRoute) { + popUpTo { inclusive = true } }, 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 24f3ba73..13fc35eb 100644 --- a/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt +++ b/feature/onboarding/src/main/java/com/yapp/onboarding/OnboardingViewModel.kt @@ -147,9 +147,7 @@ class OnboardingViewModel @Inject constructor( alarmUseCase.insertAlarm( alarm = newAlarm, - ).onSuccess { - postSideEffect(OnboardingContract.SideEffect.OnboardingCompleted) - }.onFailure { + ).onFailure { Log.e("OnboardingViewModel", "Failed to create alarm", it) } }.onFailure { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c10bb87c..2d1248b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ androidx-app-compat = "1.7.0" androidx-core = "1.15.0" androidx-datastore = "1.1.1" androidx-room = "2.7.2" +androidx-work = "2.10.3" androidx-lifecycle = "2.8.7" @@ -44,11 +45,12 @@ activity-compose = "1.9.3" ## Hilt hilt = "2.51.1" hilt-navigation-compose = "1.2.0" +hilt-work = "1.2.0" ## Third Party okhttp = "4.12.0" retrofit = "2.11.0" -coil = "2.4.0" +coil = "2.7.0" # Google Libraries Versions google-service = "4.4.2" @@ -102,6 +104,9 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "androidx-room" } androidx-room-paging = { group = "androidx.room", name = "room-paging", version.ref = "androidx-room" } androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "annotation" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hilt-work" } +androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidx-work" } +androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidx-work" } ## Compose Libraries activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } @@ -129,6 +134,7 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" } +hilt-worker = { group = "androidx.hilt", name = "hilt-work", version.ref = "hilt-work" } # Orbit orbit-core = { group = "org.orbit-mvi", name = "orbit-core", version.ref = "orbit" } diff --git a/project.dot.png b/project.dot.png index 070d91b3..77a616a5 100644 Binary files a/project.dot.png and b/project.dot.png differ