diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0812d8ce..c5627aeb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,8 @@ android { applicationId = "com.sopt.clody" minSdk = 28 targetSdk = 35 - versionCode = 28 - versionName = "1.4.0" + versionCode = 29 + versionName = "1.5.0" val kakaoApiKey: String = properties.getProperty("kakao.api.key") val amplitudeApiKey: String = properties.getProperty("amplitude.api.key") val googleAdmobAppId: String = properties.getProperty("GOOGLE_ADMOB_APP_ID", "") diff --git a/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt b/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt index d41f2a0b..987fd7f1 100644 --- a/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt +++ b/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt @@ -4,9 +4,8 @@ import com.sopt.clody.data.remote.datasource.RemoteConfigDataSource import com.sopt.clody.domain.appupdate.AppUpdateChecker import com.sopt.clody.domain.model.AppUpdateState import com.sopt.clody.domain.util.VersionComparator -import java.time.LocalDateTime -import java.time.format.TextStyle -import java.util.Locale +import java.time.ZoneId +import java.time.ZonedDateTime import javax.inject.Inject class AppUpdateCheckerImpl @Inject constructor( @@ -34,29 +33,35 @@ class AppUpdateCheckerImpl @Inject constructor( } } + /** + * Firebase RemoteConfig 로부터 점검 시간에 해당 하는지 검사하는 함수. + * + * @return 점검 시간 해당 여부 + * */ override suspend fun isUnderInspection(): Boolean { val start = remoteConfigDataSource.getInspectionStart() ?: return false val end = remoteConfigDataSource.getInspectionEnd() ?: return false - val now = LocalDateTime.now() - return now.isAfter(start) && now.isBefore(end) - } + val serverZone = ZoneId.of(SERVER_TIMEZONE) - override fun getInspectionTimeText(): String? { - val start = remoteConfigDataSource.getInspectionStart() - val end = remoteConfigDataSource.getInspectionEnd() - if (start == null || end == null) return null + val nowServer = ZonedDateTime.now(serverZone) + val startZ = start.atZone(serverZone) + val endZ = end.atZone(serverZone) - val startText = formatDateTimeWithDayOfWeek(start) - val endText = formatDateTimeWithDayOfWeek(end) - return "$startText ~ $endText" + return nowServer in startZ..endZ } - private fun formatDateTimeWithDayOfWeek(dateTime: LocalDateTime): String { - val dayOfWeek = dateTime.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.KOREAN) - val month = dateTime.monthValue - val day = dateTime.dayOfMonth - val hour = dateTime.hour.toString().padStart(2, '0') + /** + * Firebase RemoteConfig 로부터 점검 시간을 가져와 반환하는 함수. + * + * @return 점검 시작 시간과 종료 시간을 "2025-08-11T18:00:00" 형식으로 반환 + * */ + override suspend fun getInspectionTimeText(): Pair? { + val start = remoteConfigDataSource.getInspectionStart() ?: return null + val end = remoteConfigDataSource.getInspectionEnd() ?: return null + return start.toString() to end.toString() + } - return "$month/$day($dayOfWeek) ${hour}시" + companion object { + private const val SERVER_TIMEZONE = "Asia/Seoul" } } diff --git a/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt b/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt index de3f683f..2d2c25d9 100644 --- a/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt +++ b/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt @@ -5,5 +5,5 @@ import com.sopt.clody.domain.model.AppUpdateState interface AppUpdateChecker { suspend fun getAppUpdateState(currentVersion: String): AppUpdateState suspend fun isUnderInspection(): Boolean - fun getInspectionTimeText(): String? + suspend fun getInspectionTimeText(): Pair? } diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderScreen.kt index a00ce254..2c536ff8 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderScreen.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderScreen.kt @@ -1,10 +1,5 @@ package com.sopt.clody.presentation.ui.auth.timereminder -import android.Manifest -import android.content.pm.PackageManager -import android.os.Build -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -32,7 +27,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import com.sopt.clody.R import com.sopt.clody.presentation.ui.auth.component.container.PickerBox @@ -57,27 +51,6 @@ fun TimeReminderRoute( var showDialog by remember { mutableStateOf(false) } var dialogMessage by remember { mutableStateOf("") } - val isNotificationPermissionGranted = remember { mutableStateOf(false) } - - val requestPermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { isGranted: Boolean -> - isNotificationPermissionGranted.value = isGranted - } - // 알림 권한 요청 - LaunchedEffect(Unit) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val notificationPermission = Manifest.permission.POST_NOTIFICATIONS - if (ContextCompat.checkSelfPermission(context, notificationPermission) != PackageManager.PERMISSION_GRANTED) { - requestPermissionLauncher.launch(notificationPermission) - } else { - isNotificationPermissionGranted.value = true - } - } else { - isNotificationPermissionGranted.value = true - } - } - // 알림 권한 요청 결과에 따른 처리 LaunchedEffect(timeReminderState) { when (val result = timeReminderState) { @@ -103,14 +76,14 @@ fun TimeReminderRoute( TimeReminderScreen( onStartClick = { viewModel.setSelectedTime(TimePeriod.PM, "9", "30") - viewModel.sendNotification(context, isNotificationPermissionGranted.value) + viewModel.sendNotification(context) }, onTimeSelected = { period, hour, minute -> viewModel.setSelectedTime(period, hour, minute) }, onCompleteClick = { AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.ONBOARDING_ALARM) - viewModel.sendNotification(context, isNotificationPermissionGranted.value) + viewModel.sendNotification(context) }, isLoading = timeReminderState is TimeReminderState.Loading, ) diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderViewModel.kt index 07fba0d3..ff1c41ae 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderViewModel.kt @@ -33,7 +33,7 @@ class TimeReminderViewModel @Inject constructor( var selectedTime by mutableStateOf("21:30") private set - fun sendNotification(context: Context, isPermissionGranted: Boolean) { + fun sendNotification(context: Context) { viewModelScope.launch { if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) { _timeReminderState.value = TimeReminderState.Failure(errorMessageProvider.getNetworkError()) @@ -47,9 +47,9 @@ class TimeReminderViewModel @Inject constructor( } val requestDto = SendNotificationRequestDto( - isDiaryAlarm = isPermissionGranted, + isDiaryAlarm = true, isDraftAlarm = false, - isReplyAlarm = isPermissionGranted, + isReplyAlarm = true, time = selectedTime, fcmToken = fcmToken, ) diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/InspectionDialog.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/InspectionDialog.kt index fb885bf5..5b94a2f6 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/InspectionDialog.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/InspectionDialog.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -63,14 +64,14 @@ fun InspectionDialog( ) Spacer(modifier = Modifier.height(20.dp)) Text( - text = "보다 안정적인 클로디 서비스를 위해\n시스템 점검 중이에요. 곧 다시 만나요!", + text = stringResource(R.string.dialog_inspection_title), color = ClodyTheme.colors.gray03, textAlign = TextAlign.Center, style = ClodyTheme.typography.body3Medium, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "점검시간 : $inspectionTime", + text = stringResource(R.string.dialog_inspection_description, inspectionTime), color = ClodyTheme.colors.gray04, textAlign = TextAlign.Center, style = ClodyTheme.typography.body3Medium, @@ -84,7 +85,7 @@ fun InspectionDialog( colors = ButtonDefaults.buttonColors(ClodyTheme.colors.mainYellow), ) { Text( - text = "확인", + text = stringResource(R.string.dialog_inspection_confirm), color = ClodyTheme.colors.gray02, style = ClodyTheme.typography.body3SemiBold, ) @@ -100,7 +101,7 @@ fun InspectionDialog( private fun PreviewInspectionDialog() { BasePreview { InspectionDialog( - inspectionTime = "", + inspectionTime = "Dec 14 (Wed) 12:00 PM ~ Mar 15 (Wed) 10:00 PM", onDismiss = {}, ) } diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeScreen.kt index a5f9d9a0..83772e74 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeScreen.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeScreen.kt @@ -1,7 +1,12 @@ package com.sopt.clody.presentation.ui.home.screen +import android.Manifest import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -26,6 +31,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sopt.clody.R @@ -40,7 +46,6 @@ import com.sopt.clody.presentation.ui.component.dialog.ClodyDialog import com.sopt.clody.presentation.ui.component.popup.ClodyPopupBottomSheet import com.sopt.clody.presentation.ui.component.timepicker.YearMonthPicker import com.sopt.clody.presentation.ui.component.toast.ClodyToastMessage -import com.sopt.clody.presentation.ui.home.calendar.model.DiaryDateData import com.sopt.clody.presentation.ui.home.component.DiaryStateButton import com.sopt.clody.presentation.ui.home.component.HomeTopAppBar import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints @@ -49,8 +54,6 @@ import com.sopt.clody.presentation.utils.extension.toLocalizedMonthLabel import com.sopt.clody.presentation.utils.extension.toLocalizedYearLabel import com.sopt.clody.presentation.utils.navigation.Route import com.sopt.clody.ui.theme.ClodyTheme -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import java.time.LocalDate @Composable @@ -84,6 +87,12 @@ fun HomeRoute( val showYearMonthPickerState by homeViewModel.showYearMonthPickerState.collectAsStateWithLifecycle() val hasDraft by homeViewModel.hasDraft.collectAsStateWithLifecycle() + val requestPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted: Boolean -> + homeViewModel.sendNotification(isGranted) + } + LaunchedEffect(Unit) { AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.HOME) @@ -93,32 +102,33 @@ fun HomeRoute( } } + // 알림 권한 요청 LaunchedEffect(Unit) { - val year = selectedDiaryDate.year - val month = selectedDiaryDate.month - val day = selectedDate.dayOfMonth - - try { - coroutineScope { - val calendarDeferred = async { homeViewModel.loadCalendarData(year, month) } - val dailyDeferred = async { homeViewModel.loadDailyDiariesData(year, month, day) } - - calendarDeferred.await() - dailyDeferred.await() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermission = Manifest.permission.POST_NOTIFICATIONS + if (ContextCompat.checkSelfPermission(context, notificationPermission) != PackageManager.PERMISSION_GRANTED) { + requestPermissionLauncher.launch(notificationPermission) + } else { + homeViewModel.sendNotification(true) } - } catch (e: Exception) { - homeViewModel.setErrorState(true, "데이터를 불러오는데 실패했습니다.") + } else { + homeViewModel.sendNotification(true) } } + + LaunchedEffect(Unit) { + homeViewModel.updateYearMonthAndLoadData( + selectedDiaryDate.year, + selectedDiaryDate.month, + selectedDate.dayOfMonth, + ) + } + if (isError) { FailureScreen( message = errorMessage, confirmAction = { - homeViewModel.refreshCalendarDataCalendarData( - selectedDiaryDate.year, - selectedDiaryDate.month, - ) - homeViewModel.loadDailyDiariesData( + homeViewModel.updateYearMonthAndLoadData( selectedDiaryDate.year, selectedDiaryDate.month, selectedDate.dayOfMonth, @@ -273,10 +283,11 @@ fun HomeRoute( confirmOption = stringResource(R.string.dialog_diary_delete_confirm), dismissOption = stringResource(R.string.dialog_diary_delete_dismiss), confirmAction = { + val d = homeViewModel.selectedDate.value homeViewModel.deleteDailyDiary( - selectedDiaryDate.year, - selectedDiaryDate.month, - selectedDate.dayOfMonth, + d.year, + d.monthValue, + d.dayOfMonth, ) homeViewModel.setShowDiaryDeleteDialog(false) }, @@ -320,8 +331,7 @@ fun HomeScreen( FailureScreen( message = errorMessage, confirmAction = { - homeViewModel.refreshCalendarDataCalendarData(selectedYear, selectedMonth) - homeViewModel.loadDailyDiariesData(selectedYear, selectedMonth, selectedDate.dayOfMonth) + homeViewModel.updateYearMonthAndLoadData(selectedYear, selectedMonth, selectedDate.dayOfMonth) }, ) } else { @@ -388,8 +398,7 @@ fun HomeScreen( LoadingScreen() } - is DeleteDiaryState.Success -> { - } + is DeleteDiaryState.Success -> {} is DeleteDiaryState.Failure -> { homeViewModel.setErrorState(true, stringResource(R.string.home_error_delete_diary)) @@ -433,8 +442,7 @@ fun HomeScreen( selectedYear = selectedYear, selectedMonth = selectedMonth, onYearMonthSelected = { year, month -> - homeViewModel.updateSelectedDiaryDate(DiaryDateData(year, month)) - homeViewModel.loadCalendarData(year, month) + homeViewModel.updateYearMonthAndLoadData(year, month) }, ) } diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeViewModel.kt index fc5121fb..b37f7330 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeViewModel.kt @@ -18,10 +18,17 @@ import com.sopt.clody.presentation.ui.home.calendar.model.DiaryDateData import com.sopt.clody.presentation.ui.setting.notificationsetting.screen.NotificationChangeState import com.sopt.clody.presentation.utils.network.ErrorMessageProvider import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import java.time.LocalDate import java.time.ZoneId import javax.inject.Inject @@ -40,9 +47,8 @@ class HomeViewModel @Inject constructor( private val _calendarState = MutableStateFlow>(CalendarState.Idle) val calendarState: StateFlow> get() = _calendarState - private val _dailyDiariesState = MutableStateFlow>( - DailyDiariesState.Idle, - ) + private val _dailyDiariesState = + MutableStateFlow>(DailyDiariesState.Idle) val dailyDiariesState: StateFlow> get() = _dailyDiariesState private val _deleteDiaryState = MutableStateFlow(DeleteDiaryState.Idle) @@ -84,7 +90,8 @@ class HomeViewModel @Inject constructor( private val _showFirstDraftPopup = MutableStateFlow(draftRepository.getIsFirstUse()) val showFirstDraftPopup: StateFlow = _showFirstDraftPopup - private val _draftAlarmChangeState = MutableStateFlow(NotificationChangeState.Idle) + private val _draftAlarmChangeState = + MutableStateFlow(NotificationChangeState.Idle) val draftAlarmChangeState: StateFlow = _draftAlarmChangeState private val _draftAlarmEnableToast = MutableStateFlow(false) @@ -93,7 +100,7 @@ class HomeViewModel @Inject constructor( private val _showInAppReviewPopup = MutableStateFlow(reviewRepository.getShouldShowPopup()) val showInAppReviewPopup: StateFlow get() = _showInAppReviewPopup - private val _errorState = MutableStateFlow>(false to "") + private val _errorState = MutableStateFlow(false to "") val errorState: StateFlow> = _errorState private val _hasDraft = MutableStateFlow(false) @@ -109,6 +116,7 @@ class HomeViewModel @Inject constructor( if (!isInitialized) { val now = LocalDate.now() _selectedDiaryDate.value = DiaryDateData(now.year, now.monthValue) + _selectedDate.value = now isInitialized = true } } @@ -117,58 +125,57 @@ class HomeViewModel @Inject constructor( _errorState.value = isError to message } - fun loadCalendarData(year: Int, month: Int) { - viewModelScope.launch { - if (!isNetworkAvailable()) { - setErrorState(true, errorMessageProvider.getNetworkError()) - return@launch - } - - _calendarState.value = CalendarState.Loading - val result = diaryRepository.getMonthlyCalendarData(year, month) - _calendarState.value = result.fold( - onSuccess = { - setErrorState(false) - CalendarState.Success(it) - }, - onFailure = { exception -> - setErrorState(true, errorMessageProvider.getTemporaryError()) - CalendarState.Error(errorMessageProvider.getTemporaryError()) - }, - ) + private suspend fun loadCalendarData(year: Int, month: Int) { + if (!isNetworkAvailable()) { + setErrorState(true, errorMessageProvider.getNetworkError()) + return + } + _calendarState.value = CalendarState.Loading + val result = withContext(Dispatchers.IO) { + diaryRepository.getMonthlyCalendarData(year, month) } + _calendarState.value = result.fold( + onSuccess = { + setErrorState(false) + CalendarState.Success(it) + }, + onFailure = { + setErrorState(true, errorMessageProvider.getTemporaryError()) + CalendarState.Error(errorMessageProvider.getTemporaryError()) + }, + ) } - fun loadDailyDiariesData(year: Int, month: Int, date: Int) { - viewModelScope.launch { - if (!isNetworkAvailable()) { - setErrorState(true, errorMessageProvider.getNetworkError()) - return@launch - } - - _dailyDiariesState.value = DailyDiariesState.Loading - val result = diaryRepository.getDailyDiariesData(year, month, date) - _dailyDiariesState.value = result.fold( - onSuccess = { dailyResponse -> - _hasDraft.value = dailyResponse.isDraft - _diaryCount.value = dailyResponse.diaries.size - _isDeleted.value = dailyResponse.isDeleted - - setErrorState(false) - DailyDiariesState.Success(dailyResponse) - }, - onFailure = { exception -> - setErrorState(true, errorMessageProvider.getTemporaryError()) - DailyDiariesState.Error(errorMessageProvider.getTemporaryError()) - }, - ) + private suspend fun loadDailyDiariesData(year: Int, month: Int, date: Int) { + if (!isNetworkAvailable()) { + setErrorState(true, errorMessageProvider.getNetworkError()) + return + } + _dailyDiariesState.value = DailyDiariesState.Loading + val result = withContext(Dispatchers.IO) { + diaryRepository.getDailyDiariesData(year, month, date) } + _dailyDiariesState.value = result.fold( + onSuccess = { dailyResponse -> + _hasDraft.value = dailyResponse.isDraft + _diaryCount.value = dailyResponse.diaries.size + _isDeleted.value = dailyResponse.isDeleted + setErrorState(false) + DailyDiariesState.Success(dailyResponse) + }, + onFailure = { + setErrorState(true, errorMessageProvider.getTemporaryError()) + DailyDiariesState.Error(errorMessageProvider.getTemporaryError()) + }, + ) } fun deleteDailyDiary(year: Int, month: Int, day: Int) { viewModelScope.launch { _deleteDiaryResult.value = DeleteDiaryState.Loading - val result = diaryRepository.deleteDailyDiary(year, month, day) + val result = withContext(Dispatchers.IO) { + diaryRepository.deleteDailyDiary(year, month, day) + } _deleteDiaryResult.value = result.fold( onSuccess = { loadCalendarData(year, month) @@ -176,7 +183,6 @@ class HomeViewModel @Inject constructor( _diaryCount.value = 0 _isDeleted.value = false _replyStatus.value = ReplyStatus.UNREADY - DeleteDiaryState.Success }, onFailure = { @@ -186,24 +192,64 @@ class HomeViewModel @Inject constructor( } } - fun refreshCalendarDataCalendarData(year: Int, month: Int) { - if (calendarState.value is CalendarState.Success && - _selectedDiaryDate.value.year == year && - _selectedDiaryDate.value.month == month - ) { - return + private val loadDataMutex = Mutex() + fun updateYearMonthAndLoadData(year: Int, month: Int) { + viewModelScope.launch { + loadDataMutex.withLock { + val sameYm = _selectedDiaryDate.value.year == year && + _selectedDiaryDate.value.month == month + + val calendarLoaded = calendarState.value is CalendarState.Success + val dailyLoaded = dailyDiariesState.value is DailyDiariesState.Success + val selectedIsFirst = _selectedDate.value.dayOfMonth == 1 + val alreadyLoaded = sameYm && calendarLoaded && (selectedIsFirst && dailyLoaded) + + if (alreadyLoaded) return@withLock + + _selectedDiaryDate.value = DiaryDateData(year, month) + _selectedDate.value = LocalDate.of(year, month, 1) + + coroutineScope { + awaitAll( + async { loadCalendarData(year, month) }, + async { loadDailyDiariesData(year, month, 1) }, + ) + } + } } - _selectedDiaryDate.value = DiaryDateData(year, month) - loadCalendarData(year, month) } - fun updateSelectedDate(date: LocalDate) { - _selectedDate.value = date - loadDailyDiariesData(date.year, date.monthValue, date.dayOfMonth) + fun updateYearMonthAndLoadData(year: Int, month: Int, day: Int) { + viewModelScope.launch { + loadDataMutex.withLock { + val sameYmd = _selectedDiaryDate.value.year == year && + _selectedDiaryDate.value.month == month && + _selectedDate.value.dayOfMonth == day + + val calendarLoaded = calendarState.value is CalendarState.Success + val dailyLoaded = dailyDiariesState.value is DailyDiariesState.Success + val alreadyLoaded = sameYmd && calendarLoaded && dailyLoaded + + if (alreadyLoaded) return@withLock + + _selectedDiaryDate.value = DiaryDateData(year, month) + _selectedDate.value = LocalDate.of(year, month, day) + + coroutineScope { + awaitAll( + async { loadCalendarData(year, month) }, + async { loadDailyDiariesData(year, month, day) }, + ) + } + } + } } - fun updateSelectedDiaryDate(diaryDate: DiaryDateData) { - _selectedDiaryDate.value = diaryDate + fun updateSelectedDate(date: LocalDate) { + _selectedDate.value = date + viewModelScope.launch { + loadDailyDiariesData(date.year, date.monthValue, date.dayOfMonth) + } } fun updateDiaryState(diaries: List) { @@ -293,13 +339,16 @@ class HomeViewModel @Inject constructor( ) private suspend fun sendDraftAlarmRequest(request: SendNotificationRequestDto) { - notificationRepository.sendNotification(request).fold( + withContext(Dispatchers.IO) { + notificationRepository.sendNotification(request) + }.fold( onSuccess = { _draftAlarmEnableToast.value = true _draftAlarmChangeState.value = NotificationChangeState.Success(it) }, onFailure = { - _draftAlarmChangeState.value = NotificationChangeState.Failure(errorMessageProvider.getTemporaryError()) + _draftAlarmChangeState.value = + NotificationChangeState.Failure(errorMessageProvider.getTemporaryError()) }, ) } @@ -312,4 +361,19 @@ class HomeViewModel @Inject constructor( reviewRepository.setShouldShowPopup(state) _showInAppReviewPopup.value = state } + + fun sendNotification(isGranted: Boolean) { + viewModelScope.launch { + val fcmToken = fcmTokenProvider.getToken().orEmpty() + val notificationInfo = getNotificationInfo() ?: return@launch + val requestDto = SendNotificationRequestDto( + isDiaryAlarm = isGranted, + isDraftAlarm = notificationInfo.isDraftAlarm, + isReplyAlarm = isGranted, + time = notificationInfo.time, + fcmToken = fcmToken, + ) + notificationRepository.sendNotification(requestDto) + } + } } diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSettingTimePicker.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSettingTimePicker.kt index 23c3ecde..e6fae3c2 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSettingTimePicker.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSettingTimePicker.kt @@ -36,14 +36,21 @@ fun NotificationSettingTimePicker( onDismissRequest: () -> Unit, onConfirm: (TimePeriod, String, String) -> Unit, ) { - val amPmItemsLabel = TimePeriod.entries.map { it.getLabel() } - val amPmItems = remember { amPmItemsLabel } + val amPmEnumItems = listOf(TimePeriod.AM, TimePeriod.PM) + val amPmLabelItems = amPmEnumItems.map { it.getLabel() } + val hourItems = remember { (1..12).map { it.toString() } } val minuteItems = remember { listOf("00", "10", "20", "30", "40", "50") } - val amPmPickerState = rememberPickerState() - val hourPickerState = rememberPickerState() - val minutePickerState = rememberPickerState() + val amPmPickerState = rememberPickerState().apply { + selectedItem = amPmLabelItems[1] + } + val hourPickerState = rememberPickerState().apply { + selectedItem = "9" + } + val minutePickerState = rememberPickerState().apply { + selectedItem = "30" + } Surface( modifier = Modifier @@ -105,7 +112,7 @@ fun NotificationSettingTimePicker( Spacer(Modifier.weight(1f)) ClodyPicker( state = amPmPickerState, - items = amPmItems, + items = amPmLabelItems, startIndex = 1, visibleItemsCount = 3, infiniteScroll = false, @@ -138,8 +145,12 @@ fun NotificationSettingTimePicker( } ClodyButton( onClick = { - val selectedPeriod = if (amPmPickerState.selectedItem == "오전") TimePeriod.AM else TimePeriod.PM - onConfirm(selectedPeriod, hourPickerState.selectedItem, minutePickerState.selectedItem) + val selectedLabel = amPmPickerState.selectedItem + val selectedPeriod = amPmEnumItems.getOrElse(amPmLabelItems.indexOf(selectedLabel)) { TimePeriod.PM } + val selectedHour = hourPickerState.selectedItem + val selectedMinute = minutePickerState.selectedItem + + onConfirm(selectedPeriod, selectedHour, selectedMinute) }, text = stringResource(R.string.bottom_sheet_notification_time_change_confirm), enabled = true, diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt index db366f59..1b302dc3 100644 --- a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt +++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt @@ -10,6 +10,7 @@ import com.sopt.clody.domain.model.AppUpdateState import com.sopt.clody.domain.repository.TokenRepository import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils +import com.sopt.clody.presentation.utils.language.LanguageProvider import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -25,6 +26,7 @@ class SplashViewModel @AssistedInject constructor( @Assisted initialState: SplashContract.SplashState, private val tokenRepository: TokenRepository, private val appUpdateChecker: AppUpdateChecker, + private val languageProvider: LanguageProvider, ) : MavericksViewModel(initialState) { private val _intents = Channel(BUFFERED) @@ -64,7 +66,10 @@ class SplashViewModel @AssistedInject constructor( private suspend fun checkInspectionAndHandle(): Boolean { if (appUpdateChecker.isUnderInspection()) { - val inspectionText = appUpdateChecker.getInspectionTimeText() + val inspectionTextRaw = appUpdateChecker.getInspectionTimeText() + val inspectionText = inspectionTextRaw?.let { (start, end) -> + languageProvider.getInspectionTimeText(start, end) + } setState { copy( showInspectionDialog = true, diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProvider.kt b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProvider.kt index 57a7889a..1f3fc771 100644 --- a/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProvider.kt +++ b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProvider.kt @@ -5,6 +5,7 @@ import com.sopt.clody.presentation.ui.setting.screen.SettingOptionUrls interface LanguageProvider { fun getCurrentLanguageTag(): String + fun getInspectionTimeText(start: String, end: String): String? fun getLoginType(): OAuthProvider fun getNicknameMaxLength(): Int fun getDiaryMaxLength(): Int diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProviderImpl.kt b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProviderImpl.kt index 933ecc97..5abea87b 100644 --- a/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProviderImpl.kt +++ b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProviderImpl.kt @@ -2,6 +2,10 @@ package com.sopt.clody.presentation.utils.language import com.sopt.clody.data.datastore.OAuthProvider import com.sopt.clody.presentation.ui.setting.screen.SettingOptionUrls +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter import java.util.Locale import javax.inject.Inject @@ -13,6 +17,31 @@ class LanguageProviderImpl @Inject constructor() : LanguageProvider { override fun getCurrentLanguageTag(): String = locale.toLanguageTag() // e.g., "ko-KR" or "en-US" + override fun getInspectionTimeText(start: String, end: String): String? { + return runCatching { + val serverZone = ZoneId.of(SERVER_TIMEZONE) + val userZone = ZoneId.systemDefault() + + val startUser = LocalDateTime.parse(start).atZone(serverZone).withZoneSameInstant(userZone) + val endUser = LocalDateTime.parse(end).atZone(serverZone).withZoneSameInstant(userZone) + + formatInspectionTime(startUser, endUser) + }.getOrNull() + } + + private fun formatInspectionTime(startUser: ZonedDateTime, endUser: ZonedDateTime): String { + return if (isKorean()) { + val koPattern = DateTimeFormatter.ofPattern(INSPECTION_TIME_FORMAT_KO, Locale.KOREAN) + "${startUser.format(koPattern)} ~ ${endUser.format(koPattern)}" + } else { + val enDateFormatter = DateTimeFormatter.ofPattern(INSPECTION_DATE_FORMAT_EN, Locale.ENGLISH) + val enTimeFormatter = DateTimeFormatter.ofPattern(INSPECTION_TIME_FORMAT_EN, Locale.ENGLISH) + val left = "${startUser.format(enDateFormatter)}, ${startUser.format(enTimeFormatter)}" + val right = "${endUser.format(enDateFormatter)} ${endUser.format(enTimeFormatter)}" + "$left ~ $right" + } + } + override fun getLoginType(): OAuthProvider = if (isKorean()) OAuthProvider.KAKAO else OAuthProvider.GOOGLE @@ -26,10 +55,14 @@ class LanguageProviderImpl @Inject constructor() : LanguageProvider { if (isKorean()) option.koUrl else option.enUrl companion object { - const val LANGUAGE_KO = "ko" - const val NICKNAME_MAX_LENGTH_EN = 15 - const val NICKNAME_MAX_LENGTH_KO = 10 - const val DIARY_MAX_LENGTH_EN = 100 - const val DIARY_MAX_LENGTH_KO = 50 + private const val LANGUAGE_KO = "ko" + private const val SERVER_TIMEZONE = "Asia/Seoul" + private const val INSPECTION_TIME_FORMAT_KO = "M/d(E) HH시mm분" + private const val INSPECTION_DATE_FORMAT_EN = "MMM d (EEE)" + private const val INSPECTION_TIME_FORMAT_EN = "HH:mm" + private const val NICKNAME_MAX_LENGTH_EN = 15 + private const val NICKNAME_MAX_LENGTH_KO = 10 + private const val DIARY_MAX_LENGTH_EN = 100 + private const val DIARY_MAX_LENGTH_KO = 50 } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 913d128c..b36cb2b7 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -159,6 +159,10 @@ 탈퇴할래요 아니요 + 보다 안정적인 클로디 서비스를 위해\n시스템 점검 중이에요. 곧 다시 만나요! + 점검시간 : %1$s + 확인 + 이어쓰기 알림 설정을 완료했어요. 최대 5개까지 작성할 수 있어요. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5db7f1b3..dbfce628 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -160,6 +160,10 @@ Withdraw Cancel + We\'re conducting system maintenance\nto improve your experience on Clody.\nSee you again soon! + [Maintenance Time]\n%1$s + OK + Continue writing reminders are now on! Field required to send.