diff --git a/app/src/main/java/com/kiero/KieroApplication.kt b/app/src/main/java/com/kiero/KieroApplication.kt index 7a6f11e3..d15763b3 100644 --- a/app/src/main/java/com/kiero/KieroApplication.kt +++ b/app/src/main/java/com/kiero/KieroApplication.kt @@ -8,6 +8,9 @@ import coil.ImageLoader import coil.ImageLoaderFactory import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder +import coil.disk.DiskCache +import coil.memory.MemoryCache +import coil.util.DebugLogger import com.kakao.sdk.common.KakaoSdk import dagger.hilt.android.HiltAndroidApp import timber.log.Timber @@ -30,6 +33,7 @@ class KieroApplication : Application(), ImageLoaderFactory { private fun setDayMode() { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) } + private fun initKakaoSdk() { try { KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY) @@ -49,8 +53,25 @@ class KieroApplication : Application(), ImageLoaderFactory { add(GifDecoder.Factory()) } } + .memoryCache { + MemoryCache.Builder(this) + .maxSizePercent(0.25) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(cacheDir.resolve("image_cache")) + .maxSizeBytes(50L * 1024 * 1024) + .build() + } .crossfade(false) .bitmapConfig(Bitmap.Config.HARDWARE) + .respectCacheHeaders(false) + .apply { + if (BuildConfig.DEBUG) { + logger(DebugLogger()) + } + } .build() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/core/designsystem/component/KieroGifImage.kt b/app/src/main/java/com/kiero/core/designsystem/component/KieroGifImage.kt index 9ec02f54..2a9b947f 100644 --- a/app/src/main/java/com/kiero/core/designsystem/component/KieroGifImage.kt +++ b/app/src/main/java/com/kiero/core/designsystem/component/KieroGifImage.kt @@ -12,6 +12,7 @@ import coil.drawable.MovieDrawable import coil.request.ImageRequest import coil.request.onAnimationEnd import coil.request.repeatCount +import coil.size.Precision @Composable fun KieroGifImage( @@ -24,6 +25,7 @@ fun KieroGifImage( val imageRequest = remember(drawableId) { ImageRequest.Builder(context) .data(drawableId) + .precision(Precision.INEXACT) .bitmapConfig(Bitmap.Config.ARGB_8888) .allowHardware(false) .repeatCount(repeatCount) diff --git a/app/src/main/java/com/kiero/core/designsystem/component/button/KieroButtonMedium.kt b/app/src/main/java/com/kiero/core/designsystem/component/button/KieroButtonMedium.kt index 45a1efac..cd609075 100644 --- a/app/src/main/java/com/kiero/core/designsystem/component/button/KieroButtonMedium.kt +++ b/app/src/main/java/com/kiero/core/designsystem/component/button/KieroButtonMedium.kt @@ -39,8 +39,8 @@ fun KieroButtonMedium( enabled = isEnabled, modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), - color = if (isEnabled) containerColor else KieroTheme.colors.gray300, - contentColor = if (isEnabled) contentColor else KieroTheme.colors.gray600 + color = if (isEnabled) containerColor else KieroTheme.colors.gray900, + contentColor = if (isEnabled) contentColor else KieroTheme.colors.white ) { Row( modifier = Modifier.padding(vertical = 13.dp, horizontal = 16.dp), @@ -76,7 +76,7 @@ private fun KieroButtonMediumPreview() { horizontalAlignment = Alignment.CenterHorizontally ) { KieroButtonMedium( - text = "시작하기", onClick = { }) + text = "시작하기", onClick = { }, isEnabled = false) KieroButtonMedium( text = "일정 추가하기", leadingIcon = ImageVector.vectorResource(id = com.kiero.R.drawable.ic_kid_camera), diff --git a/app/src/main/java/com/kiero/core/designsystem/component/chip/action/KieroCoinAction.kt b/app/src/main/java/com/kiero/core/designsystem/component/chip/action/KieroCoinAction.kt index e31cee6b..8186dca5 100644 --- a/app/src/main/java/com/kiero/core/designsystem/component/chip/action/KieroCoinAction.kt +++ b/app/src/main/java/com/kiero/core/designsystem/component/chip/action/KieroCoinAction.kt @@ -60,7 +60,6 @@ class KieroCoinAction( contentDescription = null, modifier = Modifier .size(20.dp) - .forcePixelToDp(coin) ) Text( diff --git a/app/src/main/java/com/kiero/core/designsystem/component/dialog/KieroDialog.kt b/app/src/main/java/com/kiero/core/designsystem/component/dialog/KieroDialog.kt index c06782f0..83c76ab4 100644 --- a/app/src/main/java/com/kiero/core/designsystem/component/dialog/KieroDialog.kt +++ b/app/src/main/java/com/kiero/core/designsystem/component/dialog/KieroDialog.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -21,6 +22,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign @@ -87,13 +89,15 @@ fun KieroDialog( Spacer(modifier = Modifier.height(4.dp)) - Text( - text = title.orEmpty(), - color = KieroTheme.colors.white, - style = KieroTheme.typography.semiBold.title2, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center - ) + if (title != null) { + Text( + text = title.orEmpty(), + color = KieroTheme.colors.white, + style = KieroTheme.typography.semiBold.title2, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } if (content != null) { content() @@ -133,7 +137,7 @@ private fun KieroDialogPreview() { KieroTheme { KieroDialog( onDismiss = {}, - title = "제목", + title = null, subDescription = "로그아웃 하시겠습니까?", cancelAction = KieroCancelAction( @@ -144,25 +148,19 @@ private fun KieroDialogPreview() { text = "확인", onClick = {} ), - + isDisabled = true, content = { - Row(verticalAlignment = Alignment.CenterVertically) { - val coinImage = painterResource(R.drawable.img_kid_coin) - - Image( - painter = coinImage, - contentDescription = null, - modifier = Modifier.forcePixelToDp(coinImage) - ) - - Spacer(modifier = Modifier.width(10.dp)) - - Text( - text = "150 개", - color = KieroTheme.colors.main, - style = KieroTheme.typography.semiBold.title4, + val coinImage = painterResource(R.drawable.img_kid_camera_goblin) + + Image( + painter = coinImage, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size( + width = 62.dp, + height = 70.dp ) - } + ) } ) } diff --git a/app/src/main/java/com/kiero/core/designsystem/component/indicator/KieroLoadingIndicator.kt b/app/src/main/java/com/kiero/core/designsystem/component/indicator/KieroLoadingIndicator.kt index 2bb724d3..c0050538 100644 --- a/app/src/main/java/com/kiero/core/designsystem/component/indicator/KieroLoadingIndicator.kt +++ b/app/src/main/java/com/kiero/core/designsystem/component/indicator/KieroLoadingIndicator.kt @@ -6,12 +6,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import coil.drawable.MovieDrawable import com.kiero.R import com.kiero.core.designsystem.component.KieroGifImage import com.kiero.core.designsystem.theme.KieroTheme @Composable -fun KieroLoadingIndicator() { +fun KieroLoadingIndicator( + repeatCount : Int = MovieDrawable.REPEAT_INFINITE, + onSuccess: () -> Unit = {} +) { Box( modifier = Modifier .fillMaxSize() @@ -20,6 +24,8 @@ fun KieroLoadingIndicator() { ) { KieroGifImage( drawableId = R.drawable.gif_loading, + repeatCount = repeatCount, + onSuccess = onSuccess ) } } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/core/model/trigger/SnackbarState.kt b/app/src/main/java/com/kiero/core/model/trigger/SnackbarState.kt index cb0d2568..39020453 100644 --- a/app/src/main/java/com/kiero/core/model/trigger/SnackbarState.kt +++ b/app/src/main/java/com/kiero/core/model/trigger/SnackbarState.kt @@ -5,4 +5,5 @@ import androidx.compose.runtime.Immutable @Immutable data class SnackbarState( val message: String = "", + val bottomPadding: Int = 90 ) diff --git a/app/src/main/java/com/kiero/data/auth/remote/datasourceimpl/AuthDataSourceImpl.kt b/app/src/main/java/com/kiero/data/auth/remote/datasourceimpl/AuthDataSourceImpl.kt index f8e1ba28..914d8aeb 100644 --- a/app/src/main/java/com/kiero/data/auth/remote/datasourceimpl/AuthDataSourceImpl.kt +++ b/app/src/main/java/com/kiero/data/auth/remote/datasourceimpl/AuthDataSourceImpl.kt @@ -32,8 +32,13 @@ class AuthDataSourceImpl @Inject constructor( val accountCallback: (OAuthToken?, Throwable?) -> Unit = { token, error -> when { error != null -> { - Timber.e(error, "❌ 카카오 계정 로그인 실패") - continuation.resume(Result.failure(error)) + if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { + Timber.d("⚠️ 사용자 웹 로그인 취소") + continuation.resume(Result.failure(error)) + } else { + Timber.e(error, "❌ 카카오 계정 로그인 실패") + continuation.resume(Result.failure(error)) + } } token != null -> { Timber.i("✅ 카카오 계정 로그인 성공") @@ -89,4 +94,4 @@ class AuthDataSourceImpl @Inject constructor( override suspend fun postAuthKidLogin(authKidRequestDto: AuthKidRequestDto): BaseResponse = authService.postAuthKidLogin(body = authKidRequestDto) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/data/auth/serviceimpl/TokenRefreshServiceImpl.kt b/app/src/main/java/com/kiero/data/auth/serviceimpl/TokenRefreshServiceImpl.kt index 45735acf..99b0bcaf 100644 --- a/app/src/main/java/com/kiero/data/auth/serviceimpl/TokenRefreshServiceImpl.kt +++ b/app/src/main/java/com/kiero/data/auth/serviceimpl/TokenRefreshServiceImpl.kt @@ -17,7 +17,7 @@ class TokenRefreshServiceImpl @Inject constructor( ) : TokenRefreshService { override suspend fun refresh(refreshToken: String, role: UserRole): Result> = suspendRunCatching { val response = when (role) { - UserRole.PARENT -> reissueService.reissueAccessToken("Bearer $refreshToken") + UserRole.PARENT -> reissueService.reissueAccessToken("refreshToken=$refreshToken") UserRole.KID -> reissueService.reissueToken("refreshToken=$refreshToken") } diff --git a/app/src/main/java/com/kiero/data/demo/remote/api/DemoService.kt b/app/src/main/java/com/kiero/data/demo/remote/api/DemoService.kt index 157ac9ce..48793bb7 100644 --- a/app/src/main/java/com/kiero/data/demo/remote/api/DemoService.kt +++ b/app/src/main/java/com/kiero/data/demo/remote/api/DemoService.kt @@ -1,13 +1,13 @@ package com.kiero.data.demo.remote.api -import com.kiero.core.network.model.BaseResponse +import retrofit2.Response import retrofit2.http.DELETE import retrofit2.http.POST interface DemoService { @DELETE("api/v1/dummy") - suspend fun deleteDemo(): BaseResponse + suspend fun deleteDemo(): Response @POST("api/v1/dummy") - suspend fun postDemo(): BaseResponse + suspend fun postDemo(): Response } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/demo/remote/datasource/DemoDataSource.kt b/app/src/main/java/com/kiero/data/demo/remote/datasource/DemoDataSource.kt index 557b7373..231a3629 100644 --- a/app/src/main/java/com/kiero/data/demo/remote/datasource/DemoDataSource.kt +++ b/app/src/main/java/com/kiero/data/demo/remote/datasource/DemoDataSource.kt @@ -1,9 +1,9 @@ package com.kiero.data.demo.remote.datasource -import com.kiero.core.network.model.BaseResponse +import retrofit2.Response interface DemoDataSource { - suspend fun deleteDemo(): BaseResponse + suspend fun deleteDemo(): Response - suspend fun postDemo(): BaseResponse + suspend fun postDemo(): Response } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/demo/remote/datasourceimpl/DemoDataSourceImpl.kt b/app/src/main/java/com/kiero/data/demo/remote/datasourceimpl/DemoDataSourceImpl.kt index 6700b6ac..1029853d 100644 --- a/app/src/main/java/com/kiero/data/demo/remote/datasourceimpl/DemoDataSourceImpl.kt +++ b/app/src/main/java/com/kiero/data/demo/remote/datasourceimpl/DemoDataSourceImpl.kt @@ -1,14 +1,14 @@ package com.kiero.data.demo.remote.datasourceimpl -import com.kiero.core.network.model.BaseResponse import com.kiero.data.demo.remote.api.DemoService import com.kiero.data.demo.remote.datasource.DemoDataSource +import retrofit2.Response import javax.inject.Inject class DemoDataSourceImpl @Inject constructor( private val demoService: DemoService ) : DemoDataSource { - override suspend fun deleteDemo(): BaseResponse = demoService.deleteDemo() + override suspend fun deleteDemo(): Response = demoService.deleteDemo() - override suspend fun postDemo(): BaseResponse = demoService.postDemo() + override suspend fun postDemo(): Response = demoService.postDemo() } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/kid/schedule/di/ScheduleServiceModule.kt b/app/src/main/java/com/kiero/data/kid/schedule/di/ScheduleServiceModule.kt index 93770e48..b03e8fd1 100644 --- a/app/src/main/java/com/kiero/data/kid/schedule/di/ScheduleServiceModule.kt +++ b/app/src/main/java/com/kiero/data/kid/schedule/di/ScheduleServiceModule.kt @@ -1,11 +1,13 @@ package com.kiero.data.kid.schedule.di import com.kiero.core.network.di.AuthNetwork +import com.kiero.data.kid.schedule.remote.api.S3Service import com.kiero.data.kid.schedule.remote.api.ScheduleService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.create import javax.inject.Singleton @@ -19,4 +21,14 @@ object ScheduleServiceModule { @AuthNetwork retrofit: Retrofit ): ScheduleService = retrofit.create() + + @Provides + @Singleton + fun provideS3Service(): S3Service { + return Retrofit.Builder() + .baseUrl("https://s3.ap-northeast-2.amazonaws.com/") + .client(OkHttpClient.Builder().build()) + .build() + .create(S3Service::class.java) + } } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/kid/schedule/model/PresignedUrlModel.kt b/app/src/main/java/com/kiero/data/kid/schedule/model/PresignedUrlModel.kt new file mode 100644 index 00000000..e3a05856 --- /dev/null +++ b/app/src/main/java/com/kiero/data/kid/schedule/model/PresignedUrlModel.kt @@ -0,0 +1,13 @@ +package com.kiero.data.kid.schedule.model + +import com.kiero.data.kid.schedule.remote.dto.response.PresignedUrlResponse + +data class PresignedUrlModel( + val presignedUrl: String, + val fileName: String +) + +fun PresignedUrlResponse.toModel() = PresignedUrlModel( + presignedUrl = this.data.presignedUrl, + fileName = this.data.fileName +) diff --git a/app/src/main/java/com/kiero/data/kid/schedule/remote/api/S3Service.kt b/app/src/main/java/com/kiero/data/kid/schedule/remote/api/S3Service.kt new file mode 100644 index 00000000..8740dcf6 --- /dev/null +++ b/app/src/main/java/com/kiero/data/kid/schedule/remote/api/S3Service.kt @@ -0,0 +1,15 @@ +package com.kiero.data.kid.schedule.remote.api + +import okhttp3.RequestBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.PUT +import retrofit2.http.Url + +interface S3Service { + @PUT + suspend fun uploadImageToS3( + @Url url: String, + @Body image: RequestBody + ): Response +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/kid/schedule/remote/api/ScheduleService.kt b/app/src/main/java/com/kiero/data/kid/schedule/remote/api/ScheduleService.kt index 980a43db..5e96884d 100644 --- a/app/src/main/java/com/kiero/data/kid/schedule/remote/api/ScheduleService.kt +++ b/app/src/main/java/com/kiero/data/kid/schedule/remote/api/ScheduleService.kt @@ -7,10 +7,14 @@ import com.kiero.data.kid.schedule.remote.dto.response.ScheduleFireResponseDto import com.kiero.data.kid.schedule.remote.dto.response.ScheduleImageUploadResponseDto import com.kiero.data.kid.schedule.remote.dto.response.ScheduleSkipResponseDto import com.kiero.data.kid.schedule.remote.dto.response.ScheduleTodayResponseDto +import okhttp3.RequestBody +import retrofit2.Response import retrofit2.http.Body import retrofit2.http.PATCH import retrofit2.http.POST +import retrofit2.http.PUT import retrofit2.http.Path +import retrofit2.http.Url interface ScheduleService { @PATCH("api/v1/schedules/today") diff --git a/app/src/main/java/com/kiero/data/kid/schedule/remote/dto/response/PresignedUrlResponse.kt b/app/src/main/java/com/kiero/data/kid/schedule/remote/dto/response/PresignedUrlResponse.kt new file mode 100644 index 00000000..a4b799a5 --- /dev/null +++ b/app/src/main/java/com/kiero/data/kid/schedule/remote/dto/response/PresignedUrlResponse.kt @@ -0,0 +1,14 @@ +package com.kiero.data.kid.schedule.remote.dto.response + +import com.google.gson.annotations.SerializedName + +data class PresignedUrlResponse( + @SerializedName("status") val status: Int, + @SerializedName("message") val message: String, + @SerializedName("data") val data: PresignedUrlData +) + +data class PresignedUrlData( + @SerializedName("presignedUrl") val presignedUrl: String, + @SerializedName("fileName") val fileName: String +) \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/kid/schedule/repository/PresignedUrlRepository.kt b/app/src/main/java/com/kiero/data/kid/schedule/repository/PresignedUrlRepository.kt new file mode 100644 index 00000000..54641b04 --- /dev/null +++ b/app/src/main/java/com/kiero/data/kid/schedule/repository/PresignedUrlRepository.kt @@ -0,0 +1,51 @@ +package com.kiero.data.kid.schedule.repository + +import android.content.Context +import androidx.core.net.toUri +import com.kiero.data.kid.schedule.remote.api.S3Service +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody.Companion.toRequestBody +import timber.log.Timber +import javax.inject.Inject + +class PresignedUrlRepository @Inject constructor( + private val s3Service: S3Service, + @param:ApplicationContext private val context: Context +) { + suspend fun uploadImage(presignedUrl: String, uriString: String): Boolean { + try { + val uri = uriString.toUri() + Timber.e("uploadimage $uri") + + val inputStream = context.contentResolver.openInputStream(uri) + ?: run { + Timber.e("InputStream을 열 수 없습니다. URI: $uri") + return false + } + + val byteArray = inputStream.readBytes() + inputStream.close() + + val requestBody = byteArray.toRequestBody( + "image/jpeg".toMediaTypeOrNull(), + 0, + byteArray.size + ) + + val response = s3Service.uploadImageToS3(presignedUrl, requestBody) + + if (response.isSuccessful) { + Timber.d("S3 업로드 성공!") + return true + } else { + Timber.d("S3 업로드 실패: ${response.code()} - ${response.errorBody()?.string()}") + return false + } + + } catch (e: Exception) { + Timber.e(e, "S3 업로드 중 예외 발생") + return false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kiero/data/sse/manager/SseManager.kt b/app/src/main/java/com/kiero/data/sse/manager/SseManager.kt index 6a910976..2a59c9fa 100644 --- a/app/src/main/java/com/kiero/data/sse/manager/SseManager.kt +++ b/app/src/main/java/com/kiero/data/sse/manager/SseManager.kt @@ -3,12 +3,16 @@ package com.kiero.data.sse.manager import com.kiero.data.sse.model.SseEvent import com.kiero.data.sse.repository.SseRepository import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -16,30 +20,43 @@ import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException @Singleton class SseManager @Inject constructor( private val sseRepository: SseRepository ) { - private val scope = CoroutineScope(SupervisorJob()) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var sseJob: Job? = null private var tokenRefreshJob: Job? = null - + private var cachedAccessToken: String? = null private val mutex = Mutex() // 부모 이벤트 - private val _parentInviteEvents = MutableSharedFlow(replay = 1) + private val _parentInviteEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) val parentInviteEvents: SharedFlow = _parentInviteEvents.asSharedFlow() - private val _parentFeedEvents = MutableSharedFlow(replay = 1) + private val _parentFeedEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) val parentFeedEvents: SharedFlow = _parentFeedEvents.asSharedFlow() // 자녀 이벤트 - private val _childMissionEvents = MutableSharedFlow(replay = 1) + private val _childMissionEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) val childMissionEvents: SharedFlow = _childMissionEvents.asSharedFlow() - private val _childScheduleEvents = MutableSharedFlow(replay = 1) + private val _childScheduleEvents = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) val childScheduleEvents: SharedFlow = _childScheduleEvents.asSharedFlow() // 연결 상태 @@ -50,148 +67,141 @@ class SseManager @Inject constructor( private var isParentMode = true fun startParentSubscription() { + initSubscription(isParent = true) + } + + fun startChildSubscription() { + initSubscription(isParent = false) + } + + private fun initSubscription(isParent: Boolean) { scope.launch { mutex.withLock { - if (isSubscribed) { - Timber.d("SSE 이미 구독 중 - 중복 시작 방지") - return@launch - } + if (isSubscribed && isParentMode == isParent) return@launch + + stopSubscriptionInternal() isSubscribed = true - isParentMode = true - stopSubscription() + isParentMode = isParent + cachedAccessToken = null sseJob = launch { - sseRepository.issueSubscribeToken() - .onSuccess { accessToken -> - _connectionState.emit(true) - startTokenRefreshTimer() - - sseRepository.subscribeEvents(accessToken) - .collect { event -> - handleParentEvent(event) - } - } - .onFailure { e -> - Timber.e(e, "SSE 구독 실패") - isSubscribed = false - _connectionState.emit(false) - } + subscriptionLoop(isParent) } + + startTokenRefreshTimer() } } } + private suspend fun subscriptionLoop(isParent: Boolean) { + while (currentCoroutineContext().isActive && isSubscribed) { + try { + // 실패 시 재발급으로 continue + val token = getValidToken() ?: continue - fun startChildSubscription() { - scope.launch { - mutex.withLock { - if (isSubscribed) { - Timber.d("SSE 이미 구독 중 - 중복 시작 방지") - return@launch - } + Timber.d("🔄 SSE 연결 시도 (Token: ${token.take(10)}...)") - isSubscribed = true - isParentMode = false - stopSubscription() + sseRepository.subscribeEvents(token) + .collect { event -> + _connectionState.emit(true) + if (isParent) handleParentEvent(event) + else handleChildEvent(event) + } - sseJob = launch { - sseRepository.issueSubscribeToken() - .onSuccess { accessToken -> - _connectionState.emit(true) - startTokenRefreshTimer() - - sseRepository.subscribeEvents(accessToken) - .collect { event -> - handleChildEvent(event) - } - } - .onFailure { e -> - Timber.e(e, "SSE 구독 실패") - isSubscribed = false - _connectionState.emit(false) - } + Timber.w("SSE 스트림 종료됨. 즉시 재연결 시도.") + + } catch (e: Exception) { + // 상위에 에러 던지기 + if (e is CancellationException) throw e + + Timber.e(e, "SSE 연결 중 에러 발생") + _connectionState.emit(false) + + if (isTokenExpiredError(e)) { + Timber.w("토큰 만료 감지 -> 캐시 삭제 후 재발급 예정") + cachedAccessToken = null } + + delay(3000L) } } } + private fun startTokenRefreshTimer() { tokenRefreshJob?.cancel() - tokenRefreshJob = scope.launch { while (isActive) { delay(180_000L) - Timber.d("🔄 SSE 토큰 자동 갱신 (3분 주기)") - restartSubscription() - } - } - } + Timber.d("⏰ SSE 토큰 갱신 주기 도래 (3분)") - private fun restartSubscription() { - Timber.d("🔄 SSE 재연결 시작") - sseJob?.cancel() + mutex.withLock { + if (isSubscribed) { + cachedAccessToken = null - isSubscribed = false + // 현재 job 취소 + sseJob?.cancel() - if (isParentMode) { - startParentSubscription() - } else { - startChildSubscription() + sseJob = launch { + subscriptionLoop(isParentMode) + } + } + } + } } } - private suspend fun handleParentEvent(event: SseEvent) { - when (event) { - is SseEvent.Connected -> { - Timber.d("SSE 연결 완료") - } - - is SseEvent.Invite -> { - Timber.d("📨 초대 이벤트: childId=${event.data.childId}") - _parentInviteEvents.emit(event) - } - - is SseEvent.Feed -> { - Timber.d("📢 피드 이벤트: ${event.data.eventType}") - _parentFeedEvents.emit(event) - } + private suspend fun getValidToken(): String? { + cachedAccessToken?.let { return it } - is SseEvent.Mission, - is SseEvent.Schedule -> { - Timber.w("부모 SSE에서 자녀 이벤트 수신: $event") + return try { + val result = sseRepository.issueSubscribeToken() + result.getOrNull()?.also { newToken -> + cachedAccessToken = newToken + Timber.d("새 SSE 토큰 발급 완료") } + } catch (e: Exception) { + Timber.e(e, "토큰 발급 실패") + delay(5000L) + null } } - private suspend fun handleChildEvent(event: SseEvent) { - when (event) { - is SseEvent.Connected -> { - Timber.d("SSE 연결 완료") - } - - is SseEvent.Mission -> { - Timber.d("📋 미션 이벤트: ${event.data.missionName}, 보상: ${event.data.reward}금화") - _childMissionEvents.emit(event) - } - - is SseEvent.Schedule -> { - Timber.d("📅 일정 이벤트: ${event.data.scheduleName}") - _childScheduleEvents.emit(event) - } + private fun isTokenExpiredError(t: Throwable): Boolean { + return t.message?.contains("403") == true || t.message?.contains("Unauthorized") == true + } - is SseEvent.Invite, - is SseEvent.Feed -> { - Timber.w("자녀 SSE에서 부모 이벤트 수신: $event") + fun stopSubscription() { + scope.launch { + mutex.withLock { + stopSubscriptionInternal() } } } - fun stopSubscription() { + private suspend fun stopSubscriptionInternal() { isSubscribed = false - sseJob?.cancel() + cachedAccessToken = null + sseJob?.cancelAndJoin() tokenRefreshJob?.cancel() - scope.launch { - _connectionState.emit(false) + _connectionState.emit(false) + Timber.d("⛔ SSE 구독 완전 중지") + } + + private suspend fun handleParentEvent(event: SseEvent) { + when (event) { + is SseEvent.Connected -> Timber.d("부모 SSE Connected") + is SseEvent.Invite -> _parentInviteEvents.emit(event) + is SseEvent.Feed -> _parentFeedEvents.emit(event) + else -> Timber.w("부모 모드에서 알 수 없는 이벤트: $event") + } + } + + private suspend fun handleChildEvent(event: SseEvent) { + when (event) { + is SseEvent.Connected -> Timber.d("아이 SSE Connected") + is SseEvent.Mission -> _childMissionEvents.emit(event) + is SseEvent.Schedule -> _childScheduleEvents.emit(event) + else -> Timber.w("자녀 모드에서 알 수 없는 이벤트: $event") } - Timber.d("SSE 구독 중지") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/data/sse/model/SseEvent.kt b/app/src/main/java/com/kiero/data/sse/model/SseEvent.kt index b36911cb..f4f1528c 100644 --- a/app/src/main/java/com/kiero/data/sse/model/SseEvent.kt +++ b/app/src/main/java/com/kiero/data/sse/model/SseEvent.kt @@ -1,9 +1,9 @@ package com.kiero.data.sse.model -import com.kiero.data.sse.remote.dto.event.FeedDataDto -import com.kiero.data.sse.remote.dto.event.InviteDataDto -import com.kiero.data.sse.remote.dto.event.MissionDataDto -import com.kiero.data.sse.remote.dto.event.ScheduleDataDto +import com.kiero.data.sse.remote.dto.response.FeedDataDto +import com.kiero.data.sse.remote.dto.response.InviteDataDto +import com.kiero.data.sse.remote.dto.response.MissionDataDto +import com.kiero.data.sse.remote.dto.response.ScheduleDataDto sealed interface SseEvent { // 공통 diff --git a/app/src/main/java/com/kiero/data/sse/remote/datasourceimpl/SseDataSourceImpl.kt b/app/src/main/java/com/kiero/data/sse/remote/datasourceimpl/SseDataSourceImpl.kt index 25f4b6dd..2f739c50 100644 --- a/app/src/main/java/com/kiero/data/sse/remote/datasourceimpl/SseDataSourceImpl.kt +++ b/app/src/main/java/com/kiero/data/sse/remote/datasourceimpl/SseDataSourceImpl.kt @@ -31,12 +31,16 @@ class SseDataSourceImpl @Inject constructor( accessToken: String ): Flow = callbackFlow { val request = Request.Builder() - .url("${BuildConfig.BASE_URL}/api/v1/subscribe") + .url("${BuildConfig.BASE_URL}api/v1/subscribe") .header("Accept", "text/event-stream") .header("Authorization", "Bearer $accessToken") .build() - val eventSource = EventSources.createFactory(okHttpClient) + val sseClient = okHttpClient.newBuilder() + .readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS) + .build() + + val eventSource = EventSources.createFactory(sseClient) .newEventSource(request, object : EventSourceListener() { override fun onOpen(eventSource: EventSource, response: Response) { @@ -74,4 +78,4 @@ class SseDataSourceImpl @Inject constructor( eventSource.cancel() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/data/sse/remote/dto/response/ChildEventDto.kt b/app/src/main/java/com/kiero/data/sse/remote/dto/response/ChildEventDto.kt index 697c6694..e58f4a64 100644 --- a/app/src/main/java/com/kiero/data/sse/remote/dto/response/ChildEventDto.kt +++ b/app/src/main/java/com/kiero/data/sse/remote/dto/response/ChildEventDto.kt @@ -1,4 +1,4 @@ -package com.kiero.data.sse.remote.dto.event +package com.kiero.data.sse.remote.dto.response import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/app/src/main/java/com/kiero/data/sse/remote/dto/response/ParentEventDto.kt b/app/src/main/java/com/kiero/data/sse/remote/dto/response/ParentEventDto.kt index 4ef65da9..3c88dbf2 100644 --- a/app/src/main/java/com/kiero/data/sse/remote/dto/response/ParentEventDto.kt +++ b/app/src/main/java/com/kiero/data/sse/remote/dto/response/ParentEventDto.kt @@ -1,4 +1,4 @@ -package com.kiero.data.sse.remote.dto.event +package com.kiero.data.sse.remote.dto.response import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/app/src/main/java/com/kiero/data/sse/repositoryimpl/SseRepositoryImpl.kt b/app/src/main/java/com/kiero/data/sse/repositoryimpl/SseRepositoryImpl.kt index 302183b9..03b6e07c 100644 --- a/app/src/main/java/com/kiero/data/sse/repositoryimpl/SseRepositoryImpl.kt +++ b/app/src/main/java/com/kiero/data/sse/repositoryimpl/SseRepositoryImpl.kt @@ -1,14 +1,13 @@ package com.kiero.data.sse.repositoryimpl -import com.kiero.core.common.util.suspendRunCatching import com.kiero.data.sse.remote.datasource.SseDataSource import com.kiero.data.sse.model.RawSseEvent import com.kiero.data.sse.model.SseEvent import com.kiero.data.sse.model.SseEventType -import com.kiero.data.sse.remote.dto.event.FeedDataDto -import com.kiero.data.sse.remote.dto.event.InviteDataDto -import com.kiero.data.sse.remote.dto.event.MissionDataDto -import com.kiero.data.sse.remote.dto.event.ScheduleDataDto +import com.kiero.data.sse.remote.dto.response.FeedDataDto +import com.kiero.data.sse.remote.dto.response.InviteDataDto +import com.kiero.data.sse.remote.dto.response.MissionDataDto +import com.kiero.data.sse.remote.dto.response.ScheduleDataDto import com.kiero.data.sse.repository.SseRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.mapNotNull diff --git a/app/src/main/java/com/kiero/presentation/auth/kid/AuthKidSignupScreen.kt b/app/src/main/java/com/kiero/presentation/auth/kid/AuthKidSignupScreen.kt index 566384c3..c85f5e66 100644 --- a/app/src/main/java/com/kiero/presentation/auth/kid/AuthKidSignupScreen.kt +++ b/app/src/main/java/com/kiero/presentation/auth/kid/AuthKidSignupScreen.kt @@ -24,6 +24,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kiero.R import com.kiero.core.common.extension.collectSideEffect +import com.kiero.core.common.util.MaxLengthInputTransformation import com.kiero.core.designsystem.component.KieroTopbar import com.kiero.core.designsystem.component.button.KieroButtonMedium import com.kiero.core.designsystem.theme.KieroTheme @@ -62,14 +63,14 @@ fun AuthKidSignupRoute( AuthKidSignupScreen( paddingValues = paddingValues, state = state, - navigateUp = navigateUp, onSignupClick = viewmodel::onSignupClick, onDone = { focusManager.clearFocus() }, nextFocus = { focusManager.moveFocus(FocusDirection.Down) - } + }, + navigateUp = navigateUp ) } @@ -77,10 +78,10 @@ fun AuthKidSignupRoute( fun AuthKidSignupScreen( paddingValues: PaddingValues, state: KidSignUpState, - navigateUp: () -> Unit, onSignupClick: () -> Unit, nextFocus: () -> Unit, onDone: () -> Unit, + navigateUp: () -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current @@ -128,7 +129,9 @@ fun AuthKidSignupScreen( fieldInputText = "성을 입력해줘!", fieldState = lastName, isError = state.kidSignUpUiModel.lastName.text.isNotEmpty() && !validateLastName, - onImeAction = nextFocus + onImeAction = nextFocus, + imeAction = ImeAction.Next, + inputTransformation = MaxLengthInputTransformation(5) ) KidInputField( @@ -136,7 +139,9 @@ fun AuthKidSignupScreen( fieldInputText = "이름을 입력해줘!", fieldState = firstName, isError = state.kidSignUpUiModel.firstName.text.isNotEmpty() && !validateFirstName, - onImeAction = nextFocus + onImeAction = nextFocus, + imeAction = ImeAction.Next, + inputTransformation = MaxLengthInputTransformation(5) ) Spacer(modifier = Modifier.height(31.dp)) @@ -169,11 +174,11 @@ private fun KidSignupScreenPreview() { KieroTheme { AuthKidSignupScreen( state = KidSignUpState(), - navigateUp = {}, onSignupClick = {}, paddingValues = PaddingValues(), nextFocus = {}, - onDone = {} + onDone = {}, + navigateUp = {} ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/auth/kid/KidSignupViewModel.kt b/app/src/main/java/com/kiero/presentation/auth/kid/KidSignupViewModel.kt index 43ed9fb1..72cdfcc3 100644 --- a/app/src/main/java/com/kiero/presentation/auth/kid/KidSignupViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/auth/kid/KidSignupViewModel.kt @@ -2,11 +2,16 @@ package com.kiero.presentation.auth.kid import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.kiero.core.common.extension.toHandleErrorMessage +import com.kiero.core.localstorage.TokenManager import com.kiero.data.auth.repository.AuthRepository +import com.kiero.data.demo.repository.DemoRepository import com.kiero.presentation.auth.kid.model.toModel import com.kiero.presentation.auth.kid.state.KidSignUpState import com.kiero.presentation.auth.kid.state.KidSignupSideEffect +import com.kiero.presentation.signup.parent.state.ParentSignUpSideEffect import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -19,7 +24,9 @@ import javax.inject.Inject @HiltViewModel class KidSignupViewModel @Inject constructor( - private val authRepository: AuthRepository + private val authRepository: AuthRepository, + private val demoRepository: DemoRepository, + private val tokenRepository: TokenManager ) : ViewModel() { private val _state = MutableStateFlow(KidSignUpState()) val state: StateFlow = _state.asStateFlow() @@ -49,13 +56,42 @@ class KidSignupViewModel @Inject constructor( authRepository.postAuthKidLogin( request = currentState.toModel() - ).onSuccess { - Timber.d("postAuthKidLogin ${it.lastName}") + ).onSuccess { authResponse -> + Timber.d("postAuthKidLogin Success: ${authResponse.lastName}") + + tokenRepository.saveTokens( + accessToken = authResponse.accessToken, + refreshToken = authResponse.refreshToken + ) + + var currentAttempt = 0 + val maxRetries = 3 + var isDemoSuccess = false + + while (currentAttempt < maxRetries) { + val demoResult = demoRepository.postDemo() + + if (demoResult.isSuccess) { + Timber.d("postDemo 성공") + isDemoSuccess = true + break + } else { + currentAttempt++ + val exception = demoResult.exceptionOrNull() + Timber.e("postDemo 실패 ($currentAttempt/$maxRetries). 에러: ${exception?.message}") + + if (currentAttempt < maxRetries) { + delay(1000L) + } else { + Timber.e("3회 재시도 실패. 최종 종료.") + val errorMessage = exception?.toHandleErrorMessage() ?: "알 수 없는 오류" + _sideEffect.emit(KidSignupSideEffect.ShowSnackbar(errorMessage)) + } + } + } _sideEffect.emit(KidSignupSideEffect.NavigateToKidOnboarding) }.onFailure { - viewModelScope.launch { - _sideEffect.emit(KidSignupSideEffect.ShowSnackbar("이름이나 초대코드를 다시 확인해줘")) - } + _sideEffect.emit(KidSignupSideEffect.ShowSnackbar("이름이나 초대코드를 다시 확인해줘")) } } } diff --git a/app/src/main/java/com/kiero/presentation/auth/kid/component/KidInputField.kt b/app/src/main/java/com/kiero/presentation/auth/kid/component/KidInputField.kt index 1fbb44e8..ac707863 100644 --- a/app/src/main/java/com/kiero/presentation/auth/kid/component/KidInputField.kt +++ b/app/src/main/java/com/kiero/presentation/auth/kid/component/KidInputField.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -19,9 +20,11 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.kiero.R +import com.kiero.core.common.util.MaxLengthInputTransformation import com.kiero.core.designsystem.component.KieroTextField import com.kiero.core.designsystem.theme.KieroTheme @@ -34,6 +37,7 @@ fun KidInputField( modifier: Modifier = Modifier, isError: Boolean = false, imeAction: ImeAction = ImeAction.Next, + inputTransformation : InputTransformation? = null, ) { Column( modifier = modifier @@ -55,10 +59,15 @@ fun KidInputField( placeholder = fieldInputText, containerColor = KieroTheme.colors.gray900, isError = isError, - keyboardOptions = KeyboardOptions(imeAction = imeAction), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + autoCorrectEnabled = false, + imeAction = imeAction + ), onKeyboardAction = { performDefaultAction -> onImeAction() - } + }, + inputTransformation = inputTransformation ) Row( @@ -96,7 +105,8 @@ private fun KieroInputField() { fieldInputText = "성을 입력해줘!", fieldState = TextFieldState(), isError = true, - onImeAction = {} + onImeAction = {}, + inputTransformation = MaxLengthInputTransformation(10) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/auth/parent/AuthParentScreen.kt b/app/src/main/java/com/kiero/presentation/auth/parent/AuthParentScreen.kt index a80be6d5..8529442a 100644 --- a/app/src/main/java/com/kiero/presentation/auth/parent/AuthParentScreen.kt +++ b/app/src/main/java/com/kiero/presentation/auth/parent/AuthParentScreen.kt @@ -1,5 +1,6 @@ package com.kiero.presentation.auth.parent +import androidx.activity.compose.BackHandler import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -70,6 +71,11 @@ fun AuthParentRoute( } } + BackHandler( + enabled = true, + onBack = viewModel::navigateUp + ) + Box( modifier = Modifier .fillMaxSize() @@ -138,7 +144,7 @@ fun AuthParentScreen( message = "반가워요!", modifier = Modifier .align(BiasAlignment( - horizontalBias = -0.5f, + horizontalBias = -0.4f, verticalBias = -0.5f )) ) diff --git a/app/src/main/java/com/kiero/presentation/auth/parent/viewmodel/AuthParentViewModel.kt b/app/src/main/java/com/kiero/presentation/auth/parent/viewmodel/AuthParentViewModel.kt index b12286cd..c804e145 100644 --- a/app/src/main/java/com/kiero/presentation/auth/parent/viewmodel/AuthParentViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/auth/parent/viewmodel/AuthParentViewModel.kt @@ -3,6 +3,8 @@ package com.kiero.presentation.auth.parent.viewmodel import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.kakao.sdk.common.model.ClientError +import com.kakao.sdk.common.model.ClientErrorCause import com.kiero.core.common.extension.toHandleErrorMessage import com.kiero.core.localstorage.info.UserInfoManager import com.kiero.core.model.UiState @@ -47,11 +49,14 @@ class AuthParentViewModel @Inject constructor( val childrenResult = childrenDeferred.await() childrenResult.onSuccess { children -> - // 먼저 체크, 그 다음 저장 if (children.isNotEmpty()) { - userInfoManager.saveChildIdInfo( - childId = children.first().childId - ) + + children.firstOrNull()?.let { + userInfoManager.saveChildIdInfo( + childId = it.childId + ) + } + _sideEffect.emit( AuthSideEffect.NavigateToParentGraph ) @@ -79,10 +84,21 @@ class AuthParentViewModel @Inject constructor( }.onFailure { throwable -> Timber.e(throwable) + if (throwable is ClientError && throwable.reason == ClientErrorCause.Cancelled) { + _state.update { + it.copy(uiState = UiState.Failure("로그인이 취소되었습니다")) + } + _sideEffect.emit( + AuthSideEffect.ShowSnackbar( + message = "로그인이 취소되었습니다" + ) + ) + return@onFailure + } + _state.update { it.copy(uiState = UiState.Failure(throwable.toHandleErrorMessage())) } - _sideEffect.emit( AuthSideEffect.ShowSnackbar( message = throwable.toHandleErrorMessage() @@ -93,7 +109,9 @@ class AuthParentViewModel @Inject constructor( fun navigateUp() { viewModelScope.launch { + Timber.e("navigateUp") _sideEffect.emit(AuthSideEffect.NavigateUp) + _state.update { it.copy(uiState = UiState.Empty) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/kid/component/KidSpeechField.kt b/app/src/main/java/com/kiero/presentation/kid/component/KidSpeechField.kt index 775708f9..46db5f18 100644 --- a/app/src/main/java/com/kiero/presentation/kid/component/KidSpeechField.kt +++ b/app/src/main/java/com/kiero/presentation/kid/component/KidSpeechField.kt @@ -114,6 +114,7 @@ private fun SpeechFieldPreview() { KidSpeechField( name = "주완", isVisibleButton = true, + ) { Text("첫번째 줄 입니다", color = KieroTheme.colors.gray300) Text("두번째 줄 입니다", color = KieroTheme.colors.gray300) diff --git a/app/src/main/java/com/kiero/presentation/kid/journey/KidJourneyScreen.kt b/app/src/main/java/com/kiero/presentation/kid/journey/KidJourneyScreen.kt index e7054376..d92c634f 100644 --- a/app/src/main/java/com/kiero/presentation/kid/journey/KidJourneyScreen.kt +++ b/app/src/main/java/com/kiero/presentation/kid/journey/KidJourneyScreen.kt @@ -76,6 +76,7 @@ fun KidJourneyRoute( val refreshState = LocalRefreshState.current LaunchedEffect(Unit) { + refreshState.refreshEvent.collect { tab -> if (tab == KidMainTab.JOURNEY) { viewModel.fetchData() @@ -315,4 +316,4 @@ private fun KidJourneyScreenPreview() { onNextClick = {} ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/kid/journey/camera/KidCameraScreen.kt b/app/src/main/java/com/kiero/presentation/kid/journey/camera/KidCameraScreen.kt index 37cc93cb..69ead458 100644 --- a/app/src/main/java/com/kiero/presentation/kid/journey/camera/KidCameraScreen.kt +++ b/app/src/main/java/com/kiero/presentation/kid/journey/camera/KidCameraScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color @@ -45,7 +46,9 @@ import com.kiero.presentation.kid.journey.camera.component.StoneFloating import com.kiero.presentation.kid.journey.camera.state.KidCameraSideEffect import com.kiero.presentation.kid.journey.camera.viewModel.KidCameraViewModel import com.kiero.presentation.kid.journey.model.StoneUiType +import timber.log.Timber import java.io.File +import java.io.IOException @Composable fun KidCameraRoute( @@ -58,10 +61,22 @@ fun KidCameraRoute( LaunchedEffect(Unit) { if (state.successData?.tempUri == null) { val directory = File(context.cacheDir, "images") - directory.mkdirs() + if (!directory.exists()) { + directory.mkdirs() + } val file = File(directory, "${System.currentTimeMillis()}.jpg") - val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) - viewModel.updateTempUri(uri.toString()) + try { + file.createNewFile() + + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + viewModel.updateTempUri(uri.toString()) + } catch (e: IOException) { + Timber.e(e, "파일 생성 실패") + } } } @@ -72,7 +87,7 @@ fun KidCameraRoute( viewModel.updateImageUri() viewModel.postImage( - fileName = "schedule/${System.currentTimeMillis()}.jpg", + fileName = "${System.currentTimeMillis()}.jpg", contentType = "image/jpeg" ) } diff --git a/app/src/main/java/com/kiero/presentation/kid/journey/camera/viewModel/KidCameraViewModel.kt b/app/src/main/java/com/kiero/presentation/kid/journey/camera/viewModel/KidCameraViewModel.kt index 8f7e0023..b0368143 100644 --- a/app/src/main/java/com/kiero/presentation/kid/journey/camera/viewModel/KidCameraViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/kid/journey/camera/viewModel/KidCameraViewModel.kt @@ -7,6 +7,7 @@ import androidx.navigation.toRoute import com.kiero.core.common.extension.updateSuccess import com.kiero.core.common.util.successData import com.kiero.core.model.UiState +import com.kiero.data.kid.schedule.repository.PresignedUrlRepository import com.kiero.data.kid.schedule.repository.ScheduleRepository import com.kiero.presentation.kid.journey.camera.navigation.Camera import com.kiero.presentation.kid.journey.camera.state.KidCameraSideEffect @@ -26,7 +27,8 @@ import javax.inject.Inject @HiltViewModel class KidCameraViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val repository: ScheduleRepository + private val repository: ScheduleRepository, + private val presignedUrlRepository: PresignedUrlRepository ) : ViewModel() { private val camera = savedStateHandle.toRoute() @@ -61,6 +63,63 @@ class KidCameraViewModel @Inject constructor( ) { viewModelScope.launch { val currentState = _state.value.successData ?: return@launch + + if (currentState.imageUri?.isBlank() == true) { + Timber.e("이미지 URI가 없습니다.") + return@launch + } + + _state.updateSuccess { it.copy(isLoading = true) } + + val timerJob = async { + delay(4000L) + } + + val apiJob = async { + repository.postPresignedUrl( + fileName = fileName, + contentType = contentType + ).mapCatching { presignedModel -> + val isUploaded = presignedUrlRepository.uploadImage( + presignedUrl = presignedModel.presignedUrl, + uriString = currentState.imageUri.orEmpty() + ) + + if (!isUploaded) { + throw Exception("S3 Upload Failed") + } + + repository.patchScheduleComplete( + scheduleDetailId = currentState.scheduleDetailId, + imageUrl = presignedModel.presignedUrl.split("?").first() + ).getOrThrow() + } + } + + try { + timerJob.await() + val apiResult = apiJob.await() + + apiResult + .onSuccess { + _sideEffect.emit(KidCameraSideEffect.NavigateUp) + _state.updateSuccess { it.copy(isLoading = false) } + } + .onFailure { e -> + _state.updateSuccess { it.copy(isLoading = false) } + } + } catch (e: Exception) { + _state.updateSuccess { it.copy(isLoading = false) } + } + } + } + + /*fun postImage( + fileName: String, + contentType: String + ) { + viewModelScope.launch { + val currentState = _state.value.successData ?: return@launch _state.updateSuccess { it.copy(isLoading = true) } val timerJob = async { @@ -93,5 +152,5 @@ class KidCameraViewModel @Inject constructor( _state.updateSuccess { it.copy(isLoading = false) } } } - } + }*/ } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/presentation/kid/journey/viewmodel/KidJourneyViewModel.kt b/app/src/main/java/com/kiero/presentation/kid/journey/viewmodel/KidJourneyViewModel.kt index ebc8b116..0bdf25d9 100644 --- a/app/src/main/java/com/kiero/presentation/kid/journey/viewmodel/KidJourneyViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/kid/journey/viewmodel/KidJourneyViewModel.kt @@ -7,6 +7,7 @@ import com.kiero.core.model.UiState import com.kiero.data.kid.coin.repository.CoinRepository import com.kiero.data.kid.schedule.model.ScheduleStatus import com.kiero.data.kid.schedule.repository.ScheduleRepository +import com.kiero.data.sse.manager.SseManager import com.kiero.presentation.kid.journey.model.KidJourneyContentUiModel import com.kiero.presentation.kid.journey.model.KidJourneyHeaderUiModel import com.kiero.presentation.kid.journey.model.KidJourneyScheduleUiModel @@ -14,7 +15,6 @@ import com.kiero.presentation.kid.journey.model.StoneUiType import com.kiero.presentation.kid.journey.state.KidJourneySideEffect import com.kiero.presentation.kid.journey.state.KidJourneyState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -31,7 +30,8 @@ import javax.inject.Inject @HiltViewModel class KidJourneyViewModel @Inject constructor( private val repository: ScheduleRepository, - private val coinRepository: CoinRepository + private val coinRepository: CoinRepository, + private val sseManager: SseManager ) : ViewModel() { private val coin = coinRepository.myCoin @@ -73,7 +73,8 @@ class KidJourneyViewModel @Inject constructor( val sideEffect: SharedFlow = _sideEffect.asSharedFlow() init { - startAutoRefresh() + sseManager.startChildSubscription() + collectChildKidScheduleEvents() } fun fetchData() { @@ -81,11 +82,11 @@ class KidJourneyViewModel @Inject constructor( fetchCoin() } - private fun startAutoRefresh() { + fun collectChildKidScheduleEvents() { viewModelScope.launch { - while (isActive) { - fetchData() - delay(10_000L) + sseManager.childScheduleEvents.collect { event -> + Timber.e("collectChildKidScheduleEvents") + fetchTodaySchedule() } } } diff --git a/app/src/main/java/com/kiero/presentation/kid/mission/KidMissionScreen.kt b/app/src/main/java/com/kiero/presentation/kid/mission/KidMissionScreen.kt index a002f940..244c4af2 100644 --- a/app/src/main/java/com/kiero/presentation/kid/mission/KidMissionScreen.kt +++ b/app/src/main/java/com/kiero/presentation/kid/mission/KidMissionScreen.kt @@ -4,19 +4,27 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -29,7 +37,10 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.flowWithLifecycle import com.kiero.R import com.kiero.core.common.extension.forcePixelToDp import com.kiero.core.designsystem.component.chip.KieroChip @@ -41,10 +52,12 @@ import com.kiero.core.designsystem.component.indicator.KieroLoadingIndicator import com.kiero.core.designsystem.component.pulltorefresh.KieroPullToRefresh import com.kiero.core.designsystem.theme.KieroTheme import com.kiero.core.model.UiState +import com.kiero.core.trigger.LocalRefreshState import com.kiero.presentation.kid.component.KidProfileChip import com.kiero.presentation.kid.component.KidSpeechField import com.kiero.presentation.kid.mission.component.KidMissionItem import com.kiero.presentation.kid.mission.state.KidMissionState +import com.kiero.presentation.main.navigation.KidMainTab @Composable fun KidMissionRoute( @@ -53,10 +66,29 @@ fun KidMissionRoute( viewModel: KidMissionViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() + val refreshState = LocalRefreshState.current + val lifeCycleOwner = LocalLifecycleOwner.current + val listState = rememberLazyListState() + var isFirstEntry by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + refreshState.refreshEvent.flowWithLifecycle(lifeCycleOwner.lifecycle, Lifecycle.State.STARTED) + .collect { + if (it == KidMainTab.MISSION) { + if (isFirstEntry) { + isFirstEntry = false + return@collect + } + listState.animateScrollToItem(0) + viewModel.fetchMissions(isRefreshing = true) + } + } + } when (val state = state) { is UiState.Success -> { KidMissionScreen( + listState = listState, paddingValues = paddingValues, state = state.data, navigateUp = navigateUp, @@ -79,6 +111,7 @@ fun KidMissionRoute( @Composable private fun KidMissionScreen( paddingValues: PaddingValues, + listState: LazyListState, state: KidMissionState, navigateUp: () -> Unit, onRefresh: () -> Unit, @@ -101,74 +134,79 @@ private fun KidMissionScreen( .padding(paddingValues) .padding(horizontal = 16.dp, vertical = 25.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(11.dp) + state = listState ) { stickyHeader { - Row( + Column ( modifier = Modifier .fillMaxWidth() - .background(color = KieroTheme.colors.black), - verticalAlignment = Alignment.CenterVertically + .background(color = KieroTheme.colors.black) ) { - KidProfileChip( - kidName = state.kidName - ) - - Spacer(modifier = Modifier.weight(1f)) + Row( + modifier = Modifier + .fillMaxWidth() + .background(color = KieroTheme.colors.black), + verticalAlignment = Alignment.CenterVertically + ) { + KidProfileChip( + kidName = state.kidName + ) - KieroChip( - action = KieroCoinAction( - coinCount = 150, - isEnabled = true, - onClick = {} - ), - isEnabled = true - ) - } - } + Spacer(modifier = Modifier.weight(1f)) - item { - Spacer(modifier = Modifier.height(5.dp)) + KieroChip( + action = KieroCoinAction( + coinCount = state.coinUiModel.coinAmount, + isEnabled = true, + onClick = {} + ), + isEnabled = true + ) + } - Box( - contentAlignment = Alignment.Center - ) { - Image( - painter = painterGoblin, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .forcePixelToDp(painterGoblin) - ) + Spacer(modifier = Modifier.height(5.dp)) - KidSpeechField( - name = "꾸비", - isVisibleButton = false, - modifier = Modifier.padding(top = 100.dp) + Box( + contentAlignment = Alignment.Center ) { - Text( - text = buildAnnotatedString { - append("우리 함께 ") - withStyle(style = SpanStyle(color = KieroTheme.colors.main)) { - append("멋진 금화") - } - append(" 를 만들어볼까?") - }, - color = KieroTheme.colors.gray300 + Image( + painter = painterGoblin, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .forcePixelToDp(painterGoblin) + .offset(y = (-30).dp) ) + + KidSpeechField( + name = "꾸비", + isVisibleButton = false, + modifier = Modifier.padding(top = 40.dp) + ) { + Text( + text = buildAnnotatedString { + append("우리 함께 ") + withStyle(style = SpanStyle(color = KieroTheme.colors.main)) { + append("멋진 금화") + } + append(" 를 만들어볼까?") + }, + color = KieroTheme.colors.gray300 + ) + } } - } - Text( - text = "미션", - style = KieroTheme.typography.semiBold.title4, - color = KieroTheme.colors.gray200, - modifier = Modifier - .fillMaxWidth() - .alpha(alpha = if (state.kidMissionByDateList.missionsByDate.isEmpty()) 0f else 1f) - .padding(top = 12.dp), - textAlign = TextAlign.Start - ) + Text( + text = "미션", + style = KieroTheme.typography.semiBold.title4, + color = KieroTheme.colors.gray200, + modifier = Modifier + .fillMaxWidth() + .alpha(alpha = if (state.kidMissionByDateList.missionsByDate.isEmpty()) 0f else 1f) + .padding(top = 12.dp, bottom = 8.dp), + textAlign = TextAlign.Start + ) + } } if (state.kidMissionByDateList.missionsByDate.isEmpty()) { @@ -195,7 +233,8 @@ private fun KidMissionScreen( style = KieroTheme.typography.regular.body4, color = KieroTheme.colors.gray200, modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .padding(bottom = 18.dp), textAlign = TextAlign.Start ) } @@ -204,14 +243,16 @@ private fun KidMissionScreen( items = section.missions, key = { it.id }, ) { item -> - KidMissionItem( - missionTitle = item.name, - missionReward = item.reward, - isCompleted = item.isCompleted, - onClickButton = { - onMissionCompleted(item.id) - } - ) + Box(modifier = Modifier.padding(bottom = 11.dp)) { + KidMissionItem( + missionTitle = item.name, + missionReward = item.reward, + isCompleted = item.isCompleted, + onClickButton = { + onMissionCompleted(item.id) + } + ) + } } } } @@ -220,10 +261,11 @@ private fun KidMissionScreen( if (state.isVisibleDialog) { KieroDialog( onDismiss = dismissDialog, + isDisabled = state.isCompletedMission, title = if (!state.isCompletedMission) "[${state.selectedMissionItem!!.name}]" else null, subDescription = if (!state.isCompletedMission) "미션을 완료했다면\n" + "아래 버튼을 눌러줘!" else "금 나와라 뚝딱!\n" + - "금화 50개를 만들었어!", + "금화 ${state.selectedMissionItem?.reward}를 만들었어!", cancelAction = if (state.isCompletedMission) { null } else { @@ -233,10 +275,12 @@ private fun KidMissionScreen( }, confirmAction = KieroConfirmAction( text = "확인", - onClick = if (state.isCompletedMission) { - onClickConfirm - } else { - dismissDialog + onClick = { + if (state.isCompletedMission) { + dismissDialog() + } else { + onClickConfirm() + } } ) ) { @@ -246,9 +290,10 @@ private fun KidMissionScreen( Image( painter = coinImage, contentDescription = null, + contentScale = ContentScale.Crop, modifier = Modifier.size( - width = 62.dp, - height = 70.dp + width = 67.dp, + height = 75.dp ) ) } @@ -268,7 +313,8 @@ private fun KidWishScreenPreview() { state = KidMissionState(), onRefresh = {}, dismissDialog = {}, - onClickConfirm = {} + onClickConfirm = {}, + listState = rememberLazyListState() ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/kid/mission/KidMissionViewModel.kt b/app/src/main/java/com/kiero/presentation/kid/mission/KidMissionViewModel.kt index 49383f0b..dabf8df5 100644 --- a/app/src/main/java/com/kiero/presentation/kid/mission/KidMissionViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/kid/mission/KidMissionViewModel.kt @@ -7,6 +7,7 @@ import com.kiero.core.common.util.successData import com.kiero.core.model.UiState import com.kiero.data.kid.coin.repository.CoinRepository import com.kiero.data.mission.repository.MissionRepository +import com.kiero.data.sse.manager.SseManager import com.kiero.presentation.kid.mission.model.toUiModel import com.kiero.presentation.kid.mission.state.KidMissionSideEffect import com.kiero.presentation.kid.mission.state.KidMissionState @@ -27,8 +28,9 @@ import javax.inject.Inject @HiltViewModel class KidMissionViewModel @Inject constructor( - repository: CoinRepository, - private val missionRepository: MissionRepository + private val repository: CoinRepository, + private val missionRepository: MissionRepository, + private val sseManager: SseManager ) : ViewModel() { private val _state = MutableStateFlow>(UiState.Loading) val state: StateFlow> = combine( @@ -37,6 +39,7 @@ class KidMissionViewModel @Inject constructor( ) { uiState, coinData -> when (uiState) { is UiState.Success -> { + Timber.e("combine $coinData") UiState.Success( uiState.data.copy( coinUiModel = coinData.toUiModel() @@ -60,6 +63,17 @@ class KidMissionViewModel @Inject constructor( init { fetchMissions() + sseManager.startChildSubscription() + collectChildMissionEvents() + } + + fun collectChildMissionEvents() { + viewModelScope.launch { + sseManager.childMissionEvents.collect { event -> + Timber.e("collectChildMissionEvents $event") + fetchMissions() + } + } } fun fetchMissions(isRefreshing: Boolean = false) { @@ -118,6 +132,7 @@ class KidMissionViewModel @Inject constructor( isCompletedMission = true ) } + fetchCoin() } .onFailure { exception -> _sideEffect.emit(KidMissionSideEffect.ShowSnackbar(exception.message.toString())) @@ -125,8 +140,20 @@ class KidMissionViewModel @Inject constructor( } } + fun fetchCoin() { + viewModelScope.launch { + repository.getCurrentCoin() + .onSuccess { + Timber.d("fetchCoin: $it") + } + .onFailure { + Timber.e("fetchCoin fail: $it") + } + } + } + fun openMissionDialog(targetId: Long) { - val currentState = _state.value.successData ?: return + val currentState = state.value.successData ?: return val selectedMission = currentState.kidMissionByDateList.missionsByDate .flatMap { it.missions } @@ -136,6 +163,7 @@ class KidMissionViewModel @Inject constructor( _state.updateSuccess { state -> state.copy( isVisibleDialog = true, + isCompletedMission = false, selectedMissionItem = selectedMission ) } @@ -149,4 +177,4 @@ class KidMissionViewModel @Inject constructor( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/kid/mission/component/KidMissionItem.kt b/app/src/main/java/com/kiero/presentation/kid/mission/component/KidMissionItem.kt index 2ee91445..05c234b6 100644 --- a/app/src/main/java/com/kiero/presentation/kid/mission/component/KidMissionItem.kt +++ b/app/src/main/java/com/kiero/presentation/kid/mission/component/KidMissionItem.kt @@ -83,11 +83,27 @@ fun KidMissionItem( ) } } else { - Text( - text = "성공!", - color = KieroTheme.colors.gray500, - style = KieroTheme.typography.semiBold.title4 - ) + Box( + modifier = Modifier + .background( + color = KieroTheme.colors.white.copy(alpha = 0f), + shape = RoundedCornerShape(8.dp) + ) + .padding( + horizontal = 22.dp, + vertical = 10.dp + ) + .noRippleClickable( + onClick = onClickButton + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "성공!", + color = KieroTheme.colors.gray500, + style = KieroTheme.typography.semiBold.title4 + ) + } } } } diff --git a/app/src/main/java/com/kiero/presentation/kid/mission/state/KidMissionContract.kt b/app/src/main/java/com/kiero/presentation/kid/mission/state/KidMissionContract.kt index a867bc3a..cc4f2235 100644 --- a/app/src/main/java/com/kiero/presentation/kid/mission/state/KidMissionContract.kt +++ b/app/src/main/java/com/kiero/presentation/kid/mission/state/KidMissionContract.kt @@ -18,7 +18,7 @@ data class KidMissionState( val selectedMissionItem: KidMissionUiModel? = null ) { val kidName: String - get() = "${coinUiModel.lastName}${coinUiModel.firstName}" + get() = coinUiModel.firstName } sealed interface KidMissionSideEffect { diff --git a/app/src/main/java/com/kiero/presentation/kid/mission/util/createDateTitle.kt b/app/src/main/java/com/kiero/presentation/kid/mission/util/createDateTitle.kt index 1d2017bc..82370b11 100644 --- a/app/src/main/java/com/kiero/presentation/kid/mission/util/createDateTitle.kt +++ b/app/src/main/java/com/kiero/presentation/kid/mission/util/createDateTitle.kt @@ -7,11 +7,12 @@ fun createDateTitle(dueAt: String, dayOfWeek: String): String { return try { val date = LocalDate.parse(dueAt, DateTimeFormatter.ISO_DATE) val today = LocalDate.now() + val tomorrow = today.plusDays(1) - if (date.isEqual(today)) { - "오늘까지" - } else { - "${date.monthValue}월 ${date.dayOfMonth}일 ${dayOfWeek}까지" + when { + date.isEqual(today) -> "오늘까지" + date.isEqual(tomorrow) -> "내일까지" + else -> "${date.monthValue}월 ${date.dayOfMonth}일 ${dayOfWeek}까지" } } catch (e: Exception) { "$dueAt ${dayOfWeek}까지" diff --git a/app/src/main/java/com/kiero/presentation/kid/onboarding/KidOnboardingScreen.kt b/app/src/main/java/com/kiero/presentation/kid/onboarding/KidOnboardingScreen.kt index 72478b2f..99f648f6 100644 --- a/app/src/main/java/com/kiero/presentation/kid/onboarding/KidOnboardingScreen.kt +++ b/app/src/main/java/com/kiero/presentation/kid/onboarding/KidOnboardingScreen.kt @@ -50,7 +50,10 @@ fun KidOnboardingRoute( when (val uiState = state) { is UiState.Loading -> { - KieroLoadingIndicator() + KieroLoadingIndicator( + repeatCount = 0, + onSuccess = viewModel::startJourney + ) } is UiState.Success -> { KidOnboardingScreen( diff --git a/app/src/main/java/com/kiero/presentation/kid/onboarding/viewmodel/KidOnboardingViewModel.kt b/app/src/main/java/com/kiero/presentation/kid/onboarding/viewmodel/KidOnboardingViewModel.kt index 4687975d..4e14ac64 100644 --- a/app/src/main/java/com/kiero/presentation/kid/onboarding/viewmodel/KidOnboardingViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/kid/onboarding/viewmodel/KidOnboardingViewModel.kt @@ -4,11 +4,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kiero.core.localstorage.onboarding.OnboardingManager import com.kiero.core.model.UiState +import com.kiero.data.demo.repository.DemoRepository import com.kiero.data.kid.coin.repository.CoinRepository import com.kiero.presentation.kid.onboarding.state.KidOnboardingSideEffect import com.kiero.presentation.kid.onboarding.state.KidOnboardingState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -37,9 +37,6 @@ class KidOnboardingViewModel @Inject constructor( fun startJourney() { viewModelScope.launch { _state.value = UiState.Loading - - delay(2000) - onboardingManager.saveIsSawOnboarding(isSaw = true) _sideEffect.emit(KidOnboardingSideEffect.NavigateToKid) } @@ -53,7 +50,9 @@ class KidOnboardingViewModel @Inject constructor( .onSuccess { Timber.d("fetchCoin: $it") _state.value = UiState.Success( - KidOnboardingState(kidName = coin.value.firstName) + KidOnboardingState( + kidName = coin.value.firstName + ) ) } .onFailure { diff --git a/app/src/main/java/com/kiero/presentation/kid/wish/KidWishScreen.kt b/app/src/main/java/com/kiero/presentation/kid/wish/KidWishScreen.kt index d336b05d..292a79f8 100644 --- a/app/src/main/java/com/kiero/presentation/kid/wish/KidWishScreen.kt +++ b/app/src/main/java/com/kiero/presentation/kid/wish/KidWishScreen.kt @@ -1,6 +1,7 @@ package com.kiero.presentation.kid.wish import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -17,15 +18,23 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.flowWithLifecycle import com.kiero.R import com.kiero.core.common.extension.collectSideEffect import com.kiero.core.common.extension.forcePixelToDp @@ -40,6 +49,7 @@ import com.kiero.core.designsystem.theme.KieroTheme import com.kiero.core.model.UiState import com.kiero.core.model.trigger.SnackbarState import com.kiero.core.trigger.LocalGlobalUiEventTrigger +import com.kiero.core.trigger.LocalRefreshState import com.kiero.presentation.kid.component.KidProfileChip import com.kiero.presentation.kid.wish.component.KidWishDescription import com.kiero.presentation.kid.wish.component.KidWishGridList @@ -48,6 +58,7 @@ import com.kiero.presentation.kid.wish.preview.KidWishPreviewProvider import com.kiero.presentation.kid.wish.state.KidWishSideEffect import com.kiero.presentation.kid.wish.state.KidWishState import com.kiero.presentation.kid.wish.viewmodel.KidWishViewModel +import com.kiero.presentation.main.navigation.KidMainTab import kotlinx.collections.immutable.ImmutableList @Composable @@ -56,15 +67,35 @@ fun KidWishRoute( navigateUp: () -> Unit, viewModel: KidWishViewModel = hiltViewModel() ) { - val globalTrigger = LocalGlobalUiEventTrigger.current val state by viewModel.state.collectAsStateWithLifecycle() + val globalTrigger = LocalGlobalUiEventTrigger.current + val refreshState = LocalRefreshState.current + val lifeCycleOwner = LocalLifecycleOwner.current + val scrollState = rememberScrollState() + + var isFirstEntry by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + refreshState.refreshEvent.flowWithLifecycle(lifeCycleOwner.lifecycle, Lifecycle.State.STARTED) + .collect { + if (it == KidMainTab.WISH) { + if (isFirstEntry) { + isFirstEntry = false + return@collect + } + scrollState.animateScrollTo(0) + viewModel.fetchWish(isRefresh = true) + } + } + } viewModel.sideEffect.collectSideEffect { when(it) { is KidWishSideEffect.ShowSnackBar -> { globalTrigger.showSnackbar( SnackbarState( - message = it.message + message = it.message, + bottomPadding = 60 ) ) } @@ -82,6 +113,7 @@ fun KidWishRoute( onRefresh = { viewModel.fetchWish(isRefresh = true) } ) { KidWishScreen( + scrollState = scrollState, paddingValues = paddingValues, state = data, navigateUp = navigateUp, @@ -106,12 +138,13 @@ fun KidWishRoute( text = "확인", onClick = { if (data.isCompletedWish) { - viewModel.prayWish(data.selectedWishItem?.couponId ?: -1) - } else { viewModel.dismissDialog() + } else { + viewModel.prayWish(data.selectedWishItem?.couponId ?: -1) } } - ) + ), + isDisabled = data.isCompletedWish ) { if (!data.isCompletedWish) { Row( @@ -122,7 +155,7 @@ fun KidWishRoute( Image( painter = coinImage, contentDescription = null, - modifier = Modifier.forcePixelToDp(coinImage) + modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(10.dp)) @@ -139,6 +172,7 @@ fun KidWishRoute( Image( painter = coinImage, contentDescription = null, + contentScale = ContentScale.Crop, modifier = Modifier.size( width = 62.dp, height = 70.dp @@ -159,6 +193,7 @@ fun KidWishRoute( @Composable private fun KidWishScreen( paddingValues: PaddingValues, + scrollState: ScrollState, state: KidWishState, navigateUp: () -> Unit, onClickWish : (Long) -> Unit, @@ -167,7 +202,7 @@ private fun KidWishScreen( Column( modifier = modifier .fillMaxSize() - .verticalScroll(rememberScrollState()) + .verticalScroll(scrollState) .background( color = KieroTheme.colors.black ) @@ -212,7 +247,7 @@ private fun KidWishScreen( Spacer(modifier = Modifier.height(17.dp)) KidWishGridList( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxSize(), wishList = state.kidWishList, onClickWish = onClickWish ) @@ -235,7 +270,8 @@ private fun KidWishScreenPreview( paddingValues = PaddingValues(), state = state, navigateUp = {}, - onClickWish = {} + onClickWish = {}, + scrollState = rememberScrollState() ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/kid/wish/component/KidWishGridItem.kt b/app/src/main/java/com/kiero/presentation/kid/wish/component/KidWishGridItem.kt index b45f0713..2d851983 100644 --- a/app/src/main/java/com/kiero/presentation/kid/wish/component/KidWishGridItem.kt +++ b/app/src/main/java/com/kiero/presentation/kid/wish/component/KidWishGridItem.kt @@ -4,12 +4,14 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.kiero.core.common.extension.noRippleClickable @@ -48,6 +50,7 @@ fun KidWishGridItem( Box( modifier = Modifier + .fillMaxWidth() .background( color = KieroTheme.colors.white, shape = RoundedCornerShape(8.dp) @@ -58,7 +61,9 @@ fun KidWishGridItem( Text( text = "소원빌기", style = KieroTheme.typography.semiBold.title4, - color = KieroTheme.colors.black + color = KieroTheme.colors.black, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() ) } } @@ -74,4 +79,4 @@ private fun KidWishGridItemPreview() { onClickWish = {} ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/kid/wish/component/KidWishGridList.kt b/app/src/main/java/com/kiero/presentation/kid/wish/component/KidWishGridList.kt index 25dea33e..cc3fd327 100644 --- a/app/src/main/java/com/kiero/presentation/kid/wish/component/KidWishGridList.kt +++ b/app/src/main/java/com/kiero/presentation/kid/wish/component/KidWishGridList.kt @@ -3,6 +3,8 @@ package com.kiero.presentation.kid.wish.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -18,30 +20,35 @@ fun KidWishGridList( onClickWish: (Long) -> Unit, modifier: Modifier = Modifier ) { - Row( + Column( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(13.dp) ) { - wishList.chunked(2).forEach { columnItems -> - Column( - verticalArrangement = Arrangement.spacedBy(13.dp) + wishList.chunked(2).forEach { rowItems -> + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() ) { KidWishGridItem( - reward = columnItems[0].price, - missionTitle = columnItems[0].name, + reward = rowItems[0].price, + missionTitle = rowItems[0].name, onClickWish = { - onClickWish(columnItems[0].couponId) - } + onClickWish(rowItems[0].couponId) + }, + modifier = Modifier.weight(1f) ) - if (columnItems.size > 1) { + if (rowItems.size > 1) { KidWishGridItem( - reward = columnItems[1].price, - missionTitle = columnItems[1].name, + reward = rowItems[1].price, + missionTitle = rowItems[1].name, onClickWish = { - onClickWish(columnItems[1].couponId) - } + onClickWish(rowItems[1].couponId) + }, + modifier = Modifier.weight(1f) ) + } else { + Spacer(modifier = Modifier.weight(1f)) } } } diff --git a/app/src/main/java/com/kiero/presentation/kid/wish/state/KidWishContract.kt b/app/src/main/java/com/kiero/presentation/kid/wish/state/KidWishContract.kt index 5a6aa82a..0e6a3b18 100644 --- a/app/src/main/java/com/kiero/presentation/kid/wish/state/KidWishContract.kt +++ b/app/src/main/java/com/kiero/presentation/kid/wish/state/KidWishContract.kt @@ -16,7 +16,7 @@ data class KidWishState( val kidWishList: ImmutableList = persistentListOf(), ) { val kidName: String - get() = "${coinUiModel.lastName}${coinUiModel.firstName}" + get() = coinUiModel.firstName companion object { val FAKE = persistentListOf( diff --git a/app/src/main/java/com/kiero/presentation/kid/wish/viewmodel/KidWishViewModel.kt b/app/src/main/java/com/kiero/presentation/kid/wish/viewmodel/KidWishViewModel.kt index 9921b76b..f213459a 100644 --- a/app/src/main/java/com/kiero/presentation/kid/wish/viewmodel/KidWishViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/kid/wish/viewmodel/KidWishViewModel.kt @@ -2,6 +2,7 @@ package com.kiero.presentation.kid.wish.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.kiero.core.common.extension.toHandleErrorMessage import com.kiero.core.common.extension.updateSuccess import com.kiero.core.common.util.successData import com.kiero.core.model.UiState @@ -37,6 +38,7 @@ class KidWishViewModel @Inject constructor( ) { uiState, coinData -> when (uiState) { is UiState.Success -> { + Timber.e("combine $coinData") UiState.Success( uiState.data.copy( coinUiModel = coinData.toUiModel() @@ -66,9 +68,15 @@ class KidWishViewModel @Inject constructor( repository.getCurrentCoin() .onSuccess { Timber.d("fetchCoin: $it") + _state.updateSuccess { state -> + state.copy( + coinUiModel = it.toUiModel() + ) + } } .onFailure { Timber.e("fetchCoin fail: $it") + _sideEffect.emit(KidWishSideEffect.ShowSnackBar(it.toHandleErrorMessage())) } } } @@ -110,34 +118,37 @@ class KidWishViewModel @Inject constructor( ) { viewModelScope.launch { wishRepository.patchCoupon(couponId) - .onSuccess { - _state.updateSuccess { - it.copy( - isVisibleDialog = false, + .onSuccess { result -> + _state.updateSuccess { state -> + state.copy( isCompletedWish = true ) } - fetchCoin() } .onFailure { _sideEffect.emit(KidWishSideEffect.ShowSnackBar(it.message.toString())) + dismissDialog() } } } fun openDialogWithItem(targetId: Long) { - val currentState = _state.value.successData ?: return + val currentState = state.value.successData ?: return val selectedItem = currentState.kidWishList.find { it.couponId == targetId } ?: return val myCoin = currentState.coinUiModel.coinAmount val itemPrice = selectedItem.price + Timber.e("myCoin: $myCoin, itemPrice: $itemPrice") + if (myCoin >= itemPrice) { + Timber.e("openDialogWithItem $selectedItem") _state.updateSuccess { state -> state.copy( isVisibleDialog = true, + isCompletedWish = false, selectedWishItem = selectedItem ) } diff --git a/app/src/main/java/com/kiero/presentation/main/navigation/KieroNavHost.kt b/app/src/main/java/com/kiero/presentation/main/navigation/KieroNavHost.kt index 4c938e53..556c740d 100644 --- a/app/src/main/java/com/kiero/presentation/main/navigation/KieroNavHost.kt +++ b/app/src/main/java/com/kiero/presentation/main/navigation/KieroNavHost.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost import androidx.navigation.navOptions @@ -23,6 +24,15 @@ fun KieroNavHost( modifier: Modifier = Modifier, startDestination: Route = AuthGraph, ) { + val clearStackNavOptions = remember { + navOptions { + popUpTo(0) { + inclusive = true + } + launchSingleTop = true + } + } + NavHost( navController = appState.navController, startDestination = startDestination, @@ -35,49 +45,20 @@ fun KieroNavHost( splashNavGraph( navigateToAuth = appState::navigateToAuth, navigateToParentHome = { - Timber.e("navigateToParentHome") - val clearStackNavOptions = navOptions { - popUpTo(0) { - inclusive = true - } - launchSingleTop = true - } - - appState.navigateToSchedule( - navOptions = clearStackNavOptions - ) + Timber.d("Navigate to Parent Home (Clear Stack)") + appState.navigateToSchedule(clearStackNavOptions) }, navigateToKidHome = { - Timber.e("navigateToKidHome") - val clearStackNavOptions = navOptions { - popUpTo(0) { - inclusive = true - } - launchSingleTop = true - } - + Timber.d("Navigate to Kid Home (Clear Stack)") appState.navigateToJourney(clearStackNavOptions) }, navigateToParentGraph = { - Timber.e("navigateToAuthParent") - val clearStackNavOptions = navOptions { - popUpTo(0) { - inclusive = true - } - launchSingleTop = true - } - + // 부모 카카오 로그인 + Timber.d("Navigate to Parent Graph (Clear Stack)") appState.navigateToAuthParent(clearStackNavOptions) }, navigateToKidOnboarding = { - Timber.e("navigateToKidOnboarding") - val clearStackNavOptions = navOptions { - popUpTo(0) { - inclusive = true - } - launchSingleTop = true - } - + Timber.d("Navigate to Kid Onboarding (Clear Stack)") appState.navigateToKidOnboarding(clearStackNavOptions) } ) @@ -87,7 +68,13 @@ fun KieroNavHost( paddingValues = paddingValues, navigateUp = appState::navigateUp, navigateToParentGraph = appState::navigateToParentGraph, - navigateToParentSignUp = appState::navigateToParentSignUp, + navigateToParentSignUp = { parentName, parentProfileImage -> + appState.navigateToParentSignUp( + parentName = parentName, + parentProfileImage = parentProfileImage, + navOptions = clearStackNavOptions + ) + }, onEasterEggClick = appState::navigateToKidOnboarding ) diff --git a/app/src/main/java/com/kiero/presentation/main/navigation/MainAppState.kt b/app/src/main/java/com/kiero/presentation/main/navigation/MainAppState.kt index 8a097e87..3e610a1f 100644 --- a/app/src/main/java/com/kiero/presentation/main/navigation/MainAppState.kt +++ b/app/src/main/java/com/kiero/presentation/main/navigation/MainAppState.kt @@ -148,7 +148,10 @@ class MainAppState( fun navigateToAuth() { navController.navigate(AuthGraph) { - popUpTo(0) { inclusive = true } + popUpTo { + inclusive = true + } + launchSingleTop = true } } diff --git a/app/src/main/java/com/kiero/presentation/main/screen/MainScreen.kt b/app/src/main/java/com/kiero/presentation/main/screen/MainScreen.kt index 465d6bb1..f7c8b105 100644 --- a/app/src/main/java/com/kiero/presentation/main/screen/MainScreen.kt +++ b/app/src/main/java/com/kiero/presentation/main/screen/MainScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider 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 @@ -87,6 +88,10 @@ fun MainScreen( mutableStateOf(RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp)) } + var bottomPadding by remember { + mutableIntStateOf(90) + } + if (showParentBottomBar) { cachedTabs = ParentMainTab.entries.toImmutableList() cachedShape = RoundedCornerShape(topStart = 15.dp, topEnd = 15.dp) @@ -113,6 +118,7 @@ fun MainScreen( val onShowSnackbar: (SnackbarState) -> Unit = remember(scope, snackBarHostState) { { state -> currentSnackbarState = state + bottomPadding = state.bottomPadding scope.launch { snackBarHostState.currentSnackbarData?.dismiss() @@ -181,10 +187,9 @@ fun MainScreen( SnackbarHost(hostState = snackBarHostState) { data -> KieroSnackbar( message = data.visuals.message, - // TODO: 디자인 확정 후 스낵바 높이 및 패딩 수정 필요 modifier = Modifier .padding(horizontal = 16.dp) - .padding(bottom = 90.dp) + .padding(bottom = bottomPadding.dp) ) } }, @@ -213,6 +218,7 @@ fun MainScreen( if (dialogState.dialogState.isVisible) { KieroDialog( title = "인터넷 연결을 확인해주세요!", + isDisabled = true, confirmAction = KieroConfirmAction( text = "재시도", onClick = { diff --git a/app/src/main/java/com/kiero/presentation/parent/alarm/component/ParentAlarmCard.kt b/app/src/main/java/com/kiero/presentation/parent/alarm/component/ParentAlarmCard.kt index c779327c..8da2daa3 100644 --- a/app/src/main/java/com/kiero/presentation/parent/alarm/component/ParentAlarmCard.kt +++ b/app/src/main/java/com/kiero/presentation/parent/alarm/component/ParentAlarmCard.kt @@ -1,14 +1,11 @@ package com.kiero.presentation.parent.alarm.component import androidx.compose.animation.animateContentSize -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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -20,6 +17,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale @@ -60,7 +58,6 @@ fun ParentAlarmCard( modifier = Modifier .fillMaxWidth() .padding(15.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( text = time, @@ -68,6 +65,8 @@ fun ParentAlarmCard( color = KieroTheme.colors.gray400 ) + Spacer(modifier = Modifier.height(10.dp)) + Row( modifier = Modifier .fillMaxWidth() @@ -100,42 +99,35 @@ fun ParentAlarmCard( } } - if (!hasImage && coinUsed != null) { - Spacer( - modifier = Modifier - .height(6.dp) - ) KieroChip( - modifier = Modifier.align(Alignment.Start), + modifier = Modifier + .align(Alignment.Start) + .padding(top = 12.dp), isEnabled = false, isCompleted = true, action = KieroCoinAction( coinCount = coinUsed, isCompleted = true, isEnabled = false, viewType = DisplayType.PARENT, - onClick = { }) + onClick = { } + ) ) } if (hasImage && isExpanded) { Spacer( modifier = Modifier - .height(7.dp) + .height(15.dp) ) - Box( + AsyncImage( + model = imageUrl, + contentDescription = null, modifier = Modifier .fillMaxWidth() .aspectRatio(3f / 4f) - .animateContentSize() - .background(KieroTheme.colors.black) - ) { - AsyncImage( - model = imageUrl, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit - ) - } + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) } } } diff --git a/app/src/main/java/com/kiero/presentation/parent/alarm/screen/ParentAlarmScreen.kt b/app/src/main/java/com/kiero/presentation/parent/alarm/screen/ParentAlarmScreen.kt index a601af37..ea8fa7bb 100644 --- a/app/src/main/java/com/kiero/presentation/parent/alarm/screen/ParentAlarmScreen.kt +++ b/app/src/main/java/com/kiero/presentation/parent/alarm/screen/ParentAlarmScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -30,7 +29,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kiero.R import com.kiero.core.common.extension.collectSideEffect @@ -38,6 +37,7 @@ import com.kiero.core.designsystem.component.dialog.KieroDialog import com.kiero.core.designsystem.component.dialog.action.KieroCancelAction import com.kiero.core.designsystem.component.dialog.action.KieroConfirmAction import com.kiero.core.designsystem.component.indicator.KieroLoadingIndicator +import com.kiero.core.designsystem.component.pulltorefresh.KieroPullToRefresh import com.kiero.core.designsystem.theme.KieroTheme import com.kiero.core.trigger.LocalRefreshState import com.kiero.presentation.main.navigation.ParentMainTab @@ -49,7 +49,6 @@ import com.kiero.presentation.parent.alarm.viewmodel.ParentAlarmViewModel import com.kiero.presentation.parent.component.ParentUserSection import com.kiero.presentation.signup.parent.state.ParentSignUpSideEffect import com.kiero.presentation.signup.parent.state.ParentSignUpState -import kotlinx.coroutines.launch @Composable @@ -64,15 +63,12 @@ fun ParentAlarmRoute( val listState = rememberLazyListState() val refreshState = LocalRefreshState.current - val scope = rememberCoroutineScope() LaunchedEffect(Unit) { refreshState.refreshEvent.collect { tab -> if (tab == ParentMainTab.ALARM) { - scope.launch { - listState.animateScrollToItem(0) - } - viewModel.refresh() + listState.animateScrollToItem(0) + viewModel.refresh(isRefresh = true) } } } @@ -84,7 +80,10 @@ fun ParentAlarmRoute( } } - Box(modifier = Modifier.fillMaxSize()) { + KieroPullToRefresh( + isRefreshing = state.isRefreshing, + onRefresh = {viewModel.refresh(isRefresh = true)}, + ) { ParentAlarmScreen( state = state, authState = authState, diff --git a/app/src/main/java/com/kiero/presentation/parent/alarm/state/ParentAlarmContract.kt b/app/src/main/java/com/kiero/presentation/parent/alarm/state/ParentAlarmContract.kt index 6e34bd0c..ef36b8b8 100644 --- a/app/src/main/java/com/kiero/presentation/parent/alarm/state/ParentAlarmContract.kt +++ b/app/src/main/java/com/kiero/presentation/parent/alarm/state/ParentAlarmContract.kt @@ -14,6 +14,7 @@ data class AlarmFeedState( val alarms: ImmutableList = persistentListOf(), val errorMessage: String? = null, val isLoadingMore: Boolean = false, + val isRefreshing: Boolean = false, val hasMore: Boolean = true, val nextCursor: String? = null ) { diff --git a/app/src/main/java/com/kiero/presentation/parent/alarm/viewmodel/ParentAlarmViewModel.kt b/app/src/main/java/com/kiero/presentation/parent/alarm/viewmodel/ParentAlarmViewModel.kt index 1bc9a350..ee5897d4 100644 --- a/app/src/main/java/com/kiero/presentation/parent/alarm/viewmodel/ParentAlarmViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/parent/alarm/viewmodel/ParentAlarmViewModel.kt @@ -3,6 +3,7 @@ package com.kiero.presentation.parent.alarm.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kiero.core.common.extension.toHandleErrorMessage +import com.kiero.core.common.extension.updateSuccess import com.kiero.core.common.util.suspendRunCatching import com.kiero.core.localstorage.TokenManager import com.kiero.core.localstorage.info.UserInfoManager @@ -18,6 +19,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -65,6 +67,7 @@ class ParentAlarmViewModel @Inject constructor( }.toPersistentList(), errorMessage = localState.errorMessage, isLoadingMore = localState.isLoadingMore, + isRefreshing = localState.isRefreshing, hasMore = cursor != null, nextCursor = cursor ) @@ -119,19 +122,18 @@ class ParentAlarmViewModel @Inject constructor( } fun logOut() { + sseManager.stopSubscription() + Timber.e("로그아웃 되었습니다") viewModelScope.launch { - val logoutDeferred = async { - suspendRunCatching { authRepository.postLogout() } - } - val demoDeferred = async { - suspendRunCatching { demoRepository.deleteDemo() } - } - val tokenDeferred = async { - suspendRunCatching { tokenManager.clearTokens() } - } + val networkJobs = listOf( + async { suspendRunCatching { authRepository.postLogout() } }, + async { suspendRunCatching { demoRepository.deleteDemo() } } + ) + networkJobs.awaitAll() + sseManager.stopSubscription() - awaitAll(logoutDeferred, demoDeferred, tokenDeferred) + suspendRunCatching { tokenManager.clearTokens() } _authState.update { it.copy(isLoading = false) @@ -168,7 +170,12 @@ class ParentAlarmViewModel @Inject constructor( val id = childId ?: return viewModelScope.launch { - _localState.update { it.copy(isLoading = true, errorMessage = null) } + _localState.update { + it.copy( + isLoading = !refresh, + errorMessage = null + ) + } repository.loadAlarms(id, refresh = refresh) .onSuccess { _localState.update { it.copy(isLoading = false) } } .onFailure { error -> @@ -195,7 +202,25 @@ class ParentAlarmViewModel @Inject constructor( } } - fun refresh() = loadAlarms(refresh = true) + fun refresh(isRefresh: Boolean = false) { + viewModelScope.launch { + if (isRefresh) { + _localState.update { it.copy(isRefreshing = true) } + } + + val minLoadingTime = async { + if (isRefresh) delay(1000) + } + + loadAlarms(refresh = isRefresh) + + minLoadingTime.await() + + if (isRefresh) { + _localState.update { it.copy(isRefreshing = false) } + } + } + } fun toggleExpand(alarmId: String) { _localState.update { currentState -> @@ -211,7 +236,8 @@ class ParentAlarmViewModel @Inject constructor( private data class LocalState( val isLoading: Boolean = false, val isLoadingMore: Boolean = false, + val isRefreshing: Boolean = false, val errorMessage: String? = null, val expandedIds: Set = emptySet() ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/ParentScheduleScreen.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/ParentScheduleScreen.kt index 7d31b765..bb5b2cda 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/ParentScheduleScreen.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/ParentScheduleScreen.kt @@ -61,6 +61,7 @@ fun ParentScheduleRoute( LaunchedEffect(Unit) { viewModel.fetchSchedule() + viewModel.ensureChildIdAndStartSse() } viewModel.sideEffect.collectSideEffect { @@ -77,7 +78,7 @@ fun ParentScheduleRoute( ) { when (val state = uiState) { is UiState.Loading -> { - + KieroLoadingIndicator() } is UiState.Success -> { @@ -145,10 +146,6 @@ fun ParentScheduleRoute( content = {} ) } - - if (uiState.successData?.isLoading == true) { - KieroLoadingIndicator() - } } } @@ -182,7 +179,7 @@ private fun ParentScheduleScreen( .padding(paddingValues) ) { ParentUserSection( - userName = state.parentInfo.formattedParentName, + userName = state.parentInfo.parentName, profileImage = state.parentInfo.parentProfileImage, onUserNameClick = onUserNameClick, modifier = Modifier @@ -256,4 +253,4 @@ private fun ParentScheduleScreenPreview() { navigateToSelection = {} ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/MissionDateGroup.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/MissionDateGroup.kt index 6bafdd2e..7f8bb058 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/MissionDateGroup.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/MissionDateGroup.kt @@ -23,11 +23,6 @@ fun MissionDateGroup( modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp) ) { - MissionInfo( - dayOfWeek = missionsByDate.dueAt.toRelativeDayFromDate, - dueAt = missionsByDate.dueAt.formatWithDayOfWeek - ) - missionsByDate.missions.forEach { mission -> MissionListItem( missionTitle = mission.name, diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/ParentMissionScreen.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/ParentMissionScreen.kt index 3f6bff62..388d7559 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/ParentMissionScreen.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/ParentMissionScreen.kt @@ -30,6 +30,7 @@ import com.kiero.core.trigger.LocalRefreshState import com.kiero.presentation.main.navigation.ParentMainTab import com.kiero.presentation.parent.schedule.mission.component.missionmain.MissionInfo import com.kiero.presentation.parent.schedule.mission.component.missionmain.MissionListItem +import com.kiero.presentation.parent.schedule.mission.component.missionmain.MissionInfo import com.kiero.presentation.parent.schedule.mission.state.ParentMissionState import com.kiero.presentation.parent.schedule.mission.viewmodel.ParentMissionViewModel @@ -96,8 +97,7 @@ fun ParentMissionScreen( contentPadding = PaddingValues(16.dp) ) { state.kidMissionByDateList.missionsByDate.forEach { missionsByDate -> - - stickyHeader(key = missionsByDate.dueAt) { + stickyHeader { Box( modifier = Modifier .fillMaxWidth() @@ -110,14 +110,13 @@ fun ParentMissionScreen( ) } } - items( items = missionsByDate.missions, ) { mission -> Box(modifier = Modifier.padding(vertical = 6.dp)) { MissionListItem( missionTitle = mission.name, - reward = mission.reward, + reward = mission.reward ) } } diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/ParentAutoAddScreen.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/ParentAutoAddScreen.kt index 7a236be9..53058641 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/ParentAutoAddScreen.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/ParentAutoAddScreen.kt @@ -14,34 +14,29 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.kiero.R import com.kiero.core.common.extension.collectSideEffect import com.kiero.core.designsystem.component.KieroTopbar import com.kiero.core.designsystem.component.button.KieroButtonMedium import com.kiero.core.designsystem.theme.KieroTheme -import com.kiero.core.model.trigger.SnackbarState import com.kiero.core.trigger.LocalGlobalUiEventTrigger import com.kiero.presentation.parent.schedule.mission.auto.component.ScrollableAutoInputField import com.kiero.presentation.parent.schedule.mission.auto.state.AutoMissionSideEffect import com.kiero.presentation.parent.schedule.mission.auto.state.AutoMissionState import com.kiero.presentation.parent.schedule.mission.auto.viewmodel.AutoMissionViewModel -import kotlinx.coroutines.launch @Composable fun ParentAutoAddRoute( @@ -57,6 +52,7 @@ fun ParentAutoAddRoute( when (effect) { is AutoMissionSideEffect.ShowToast -> { // ParentAutoResultRoute에서 처리 + snackbarHostState.showSnackbar(effect.message) } is AutoMissionSideEffect.NavigateBack -> { @@ -114,8 +110,8 @@ fun ParentAutoAddScreen( modifier = modifier .fillMaxSize() .background(KieroTheme.colors.black) - .imePadding() .padding(paddingValues) + .imePadding() .pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() @@ -150,7 +146,6 @@ fun ParentAutoAddScreen( ) if (!isImeVisible) { - Spacer(Modifier.height(48.dp)) KieroButtonMedium( text = "분석하고 미션 추가하기", diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/ParentAutoResultScreen.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/ParentAutoResultScreen.kt index 7b8c887b..8c1c31a0 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/ParentAutoResultScreen.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/ParentAutoResultScreen.kt @@ -194,6 +194,7 @@ fun ParentAutoResultScreen( } } } + @Preview( showBackground = true, backgroundColor = 0xFF000000, diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoInputField.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoInputField.kt index fcc8dffa..9c9d3835 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoInputField.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoInputField.kt @@ -8,14 +8,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue @@ -24,10 +19,9 @@ import com.kiero.core.designsystem.theme.KieroTheme @Composable fun ParentAutoInputField( - text: String, - onTextChange: (String) -> Unit, + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, modifier: Modifier = Modifier, - onSelectionChange: ((TextRange) -> Unit)? = null, placeholder: String = "알림장 내용을 입력하세요.", maxLength: Int = 1000, maxLines: Int = Int.MAX_VALUE, @@ -36,21 +30,13 @@ fun ParentAutoInputField( textColor: Color = KieroTheme.colors.gray400 ) { val focusManager = LocalFocusManager.current - var selection by remember { mutableStateOf(TextRange(text.length)) } - val textFieldValue = TextFieldValue( - text = text, - selection = selection - ) TextField( - value = textFieldValue, // String 대신 TextFieldValue 사용 + value = value, onValueChange = { newTextFieldValue -> if (newTextFieldValue.text.length <= maxLength) { - if (text != newTextFieldValue.text) { - onTextChange(newTextFieldValue.text) - } - selection = newTextFieldValue.selection - onSelectionChange?.invoke(newTextFieldValue.selection) + // 값 변경을 상위로 즉시 전달 + onValueChange(newTextFieldValue) } }, placeholder = { diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoMissionAwardInfo.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoMissionAwardInfo.kt index 79a20339..ea2b0122 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoMissionAwardInfo.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoMissionAwardInfo.kt @@ -36,7 +36,7 @@ fun ParentAutoMissionAwardInfo( horizontalArrangement = Arrangement.Start ) { Image( - painter = painterResource(id = R.drawable.img_kid_coin), + painter = painterResource(id = R.drawable.img_coin), contentDescription = null, modifier = Modifier .size(24.dp) diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoMissionEditForm.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoMissionEditForm.kt index d839d9fc..0618460c 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoMissionEditForm.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ParentAutoMissionEditForm.kt @@ -13,9 +13,15 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.kiero.core.designsystem.theme.KieroTheme import com.kiero.presentation.parent.schedule.mission.auto.model.MissionUiModel @@ -39,6 +45,24 @@ fun ParentAutoMissionEditForm( targetDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd.(E)", Locale.KOREA)) } + var nameTextFieldValue by remember { + mutableStateOf( + TextFieldValue( + text = mission.name, + selection = TextRange(mission.name.length) + ) + ) + } + + LaunchedEffect(mission.name) { + if (nameTextFieldValue.text != mission.name) { + nameTextFieldValue = nameTextFieldValue.copy( + text = mission.name, + selection = TextRange(mission.name.length) + ) + } + } + Box( modifier = modifier .fillMaxSize() @@ -56,8 +80,11 @@ fun ParentAutoMissionEditForm( ) ) { ParentAutoInputField( - text = mission.name, - onTextChange = onMissionNameChange, + value = nameTextFieldValue, + onValueChange = { newValue -> + nameTextFieldValue = newValue + onMissionNameChange(newValue.text) + }, placeholder = "미션 이름을 입력해주세요.", maxLength = 15, singleLine = true diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ScrollableAutoInputField.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ScrollableAutoInputField.kt index 0308b3e6..cfad415a 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ScrollableAutoInputField.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/component/ScrollableAutoInputField.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.kiero.core.designsystem.theme.KieroTheme import kotlinx.coroutines.delay @@ -41,13 +42,20 @@ fun ScrollableAutoInputField( val scrollState = rememberScrollState() val isFocused = remember { mutableStateOf(false) } val isImeVisible = WindowInsets.isImeVisible - var currentSelection by remember { mutableStateOf(TextRange(text.length)) } + var textFieldValue by remember { + mutableStateOf(TextFieldValue(text = text, selection = TextRange(text.length))) + } + val previousLength = remember { mutableIntStateOf(text.length) } val shouldScrollToBottom = remember { mutableStateOf(false) } - LaunchedEffect(text) { - val lengthDiff = text.length - previousLength.intValue - previousLength.intValue = text.length + if (textFieldValue.text != text) { + textFieldValue = textFieldValue.copy(text = text) + } + } + LaunchedEffect(textFieldValue.text) { + val lengthDiff = textFieldValue.text.length - previousLength.intValue + previousLength.intValue = textFieldValue.text.length if (lengthDiff > 5) { shouldScrollToBottom.value = true } @@ -64,7 +72,7 @@ fun ScrollableAutoInputField( LaunchedEffect(isImeVisible, isFocused.value) { if (isImeVisible && isFocused.value) { delay(350) - val isCursorAtBottom = currentSelection.start == text.length + val isCursorAtBottom = textFieldValue.selection.start == textFieldValue.text.length if (scrollState.value > 0 && isCursorAtBottom) { scrollState.animateScrollTo(scrollState.maxValue) @@ -82,10 +90,10 @@ fun ScrollableAutoInputField( .verticalScroll(scrollState) ) { ParentAutoInputField( - text = text, - onTextChange = onTextChange, - onSelectionChange = { range -> - currentSelection = range + value = textFieldValue, + onValueChange = { newValue -> + textFieldValue = newValue + onTextChange(newValue.text) }, placeholder = placeholder, maxLength = maxLength, @@ -101,7 +109,7 @@ fun ScrollableAutoInputField( } ) - Spacer(Modifier.height(65.dp)) + Spacer(Modifier.height(60.dp)) } } } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/viewmodel/AutoMissionViewModel.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/viewmodel/AutoMissionViewModel.kt index ffffb554..837c36f5 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/viewmodel/AutoMissionViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/auto/viewmodel/AutoMissionViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import timber.log.Timber import java.time.LocalDate import javax.inject.Inject @@ -62,21 +63,35 @@ class AutoMissionViewModel @Inject constructor( autoMissionRepository.analyzeNotice(escapedText) .onSuccess { domainData -> - val uiMissions = domainData.suggestedMissions.map { suggested -> - MissionUiModel( - name = suggested.name, - reward = suggested.reward, - dueAt = LocalDate.parse(suggested.dueAt), - isCompleted = false - ) - } - _state.update { - it.copy( - missions = uiMissions, - currentIndex = 0, - hasViewedLastPage = uiMissions.size == 1, - isAnalyzing = false - ) + if (domainData.suggestedMissions.isEmpty()) { + Timber.e("message suggestedMissions") + _sideEffect.emit(AutoMissionSideEffect.ShowToast("알림장 내용을 분석하지 못했어요.")) + delay(2000L) + _state.update { + it.copy( + missions = emptyList(), + currentIndex = 0, + hasViewedLastPage = false, + isAnalyzing = false + ) + } + } else { + val uiMissions = domainData.suggestedMissions.map { suggested -> + MissionUiModel( + name = suggested.name, + reward = suggested.reward, + dueAt = LocalDate.parse(suggested.dueAt), + isCompleted = false + ) + } + _state.update { + it.copy( + missions = uiMissions, + currentIndex = 0, + hasViewedLastPage = uiMissions.size == 1, + isAnalyzing = false + ) + } } } .onFailure { e -> @@ -84,6 +99,7 @@ class AutoMissionViewModel @Inject constructor( is TimeoutCancellationException -> "잠시 후 다시 시도해주세요." else -> "알림장 내용을 분석하지 못했어요." } + Timber.e("message $message") _sideEffect.emit(AutoMissionSideEffect.ShowToast(message)) _state.update { it.copy(isAnalyzing = false) } } diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/component/missionadd/MissionAwardInfo.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/component/missionadd/MissionAwardInfo.kt index 706dfa32..afc0cdf8 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/mission/component/missionadd/MissionAwardInfo.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/mission/component/missionadd/MissionAwardInfo.kt @@ -36,7 +36,7 @@ fun MissionAwardInfo( horizontalArrangement = Arrangement.Start ) { Image( - painter = painterResource(id = R.drawable.img_kid_coin), + painter = painterResource(id = R.drawable.img_coin), contentDescription = null, modifier = Modifier .size(24.dp) diff --git a/app/src/main/java/com/kiero/presentation/parent/schedule/viewmodel/ParentScheduleViewModel.kt b/app/src/main/java/com/kiero/presentation/parent/schedule/viewmodel/ParentScheduleViewModel.kt index 9628646e..8d0f86f2 100644 --- a/app/src/main/java/com/kiero/presentation/parent/schedule/viewmodel/ParentScheduleViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/parent/schedule/viewmodel/ParentScheduleViewModel.kt @@ -1,6 +1,5 @@ package com.kiero.presentation.parent.schedule.viewmodel -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.kiero.core.common.extension.updateSuccess @@ -34,7 +33,6 @@ import javax.inject.Inject @HiltViewModel class ParentScheduleViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, private val planRepository: PlanRepository, private val authRepository: AuthRepository, private val userInfoManager: UserInfoManager, @@ -59,10 +57,9 @@ class ParentScheduleViewModel @Inject constructor( init { initFetchParentInfo() - ensureChildIdAndStartSse() + sseManager.startParentSubscription() } - - private fun ensureChildIdAndStartSse() { + fun ensureChildIdAndStartSse() { viewModelScope.launch { var childId = userInfoManager.getChildIdInfo() @@ -157,19 +154,18 @@ class ParentScheduleViewModel @Inject constructor( } fun logOut() { + sseManager.stopSubscription() + Timber.e("로그아웃 되었습니다") viewModelScope.launch { - val logoutDeferred = async { - suspendRunCatching { authRepository.postLogout() } - } - val demoDeferred = async { - suspendRunCatching { demoRepository.deleteDemo() } - } - val tokenDeferred = async { - suspendRunCatching { tokenManager.clearTokens() } - } + val networkJobs = listOf( + async { suspendRunCatching { authRepository.postLogout() } }, + async { suspendRunCatching { demoRepository.deleteDemo() } } + ) + networkJobs.awaitAll() + sseManager.stopSubscription() - awaitAll(logoutDeferred, demoDeferred, tokenDeferred) + suspendRunCatching { tokenManager.clearTokens() } _state.updateSuccess { it.copy(isLoading = false) diff --git a/app/src/main/java/com/kiero/presentation/signup/parent/ParentSignUpScreen.kt b/app/src/main/java/com/kiero/presentation/signup/parent/ParentSignUpScreen.kt index 914cc8d0..0382065c 100644 --- a/app/src/main/java/com/kiero/presentation/signup/parent/ParentSignUpScreen.kt +++ b/app/src/main/java/com/kiero/presentation/signup/parent/ParentSignUpScreen.kt @@ -4,6 +4,7 @@ import android.content.ClipData import android.os.Build import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -16,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalFocusManager @@ -72,7 +74,8 @@ fun ParentSignUpRoute( if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { globalTrigger.showSnackbar( SnackbarState( - message = it.message + message = it.message, + bottomPadding = 110 ) ) } @@ -167,17 +170,24 @@ private fun ParentSignInEntryScreen( onLogOut: () -> Unit, content: @Composable () -> Unit ) { + val focusManager = LocalFocusManager.current + Column( modifier = Modifier .fillMaxSize() .background( color = KieroTheme.colors.black ) + .pointerInput(Unit) { + detectTapGestures(onTap = { + focusManager.clearFocus() + }) + } .padding(paddingValues) .padding(horizontal = 16.dp, vertical = 25.dp) ) { ParentSignUpTopBar( - parentName = state.parentInfo.formattedParentName, + parentName = state.parentInfo.parentName, profileImage = state.parentInfo.parentProfileImage, onClickProfile = onLogOut ) @@ -209,4 +219,4 @@ private fun ParentSignInEntryPreview() { onLogOut = {} ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/signup/parent/component/ParentSignUpForm.kt b/app/src/main/java/com/kiero/presentation/signup/parent/component/ParentSignUpForm.kt index c7b6f330..aaaeab95 100644 --- a/app/src/main/java/com/kiero/presentation/signup/parent/component/ParentSignUpForm.kt +++ b/app/src/main/java/com/kiero/presentation/signup/parent/component/ParentSignUpForm.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -18,6 +19,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.kiero.R @@ -32,7 +34,7 @@ fun ParentSignUpForm( textState: TextFieldState, modifier: Modifier = Modifier, isError: Boolean = false, - imeAction: ImeAction = ImeAction.Next, + imeAction: ImeAction, onImeAction: () -> Unit = {} ) { Column ( @@ -51,10 +53,14 @@ fun ParentSignUpForm( placeholder = placeholder, isError = isError, containerColor = KieroTheme.colors.gray900, - keyboardOptions = KeyboardOptions(imeAction = imeAction), - onKeyboardAction = { performDefaultAction -> + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = imeAction, + autoCorrectEnabled = false, + ), + lineLimits = TextFieldLineLimits.SingleLine, + onKeyboardAction = { onImeAction() - performDefaultAction() }, inputTransformation = MaxLengthInputTransformation(5) ) @@ -92,6 +98,7 @@ private fun ParentSignUpFormPreview() { title = "아이의 성을 입력해주세요.", placeholder = "성", textState = TextFieldState(), - isError = true + isError = true, + imeAction = ImeAction.Next ) } \ No newline at end of file diff --git a/app/src/main/java/com/kiero/presentation/signup/parent/component/ParentSignUpInviteHolder.kt b/app/src/main/java/com/kiero/presentation/signup/parent/component/ParentSignUpInviteHolder.kt index 45f67d4b..79877bc1 100644 --- a/app/src/main/java/com/kiero/presentation/signup/parent/component/ParentSignUpInviteHolder.kt +++ b/app/src/main/java/com/kiero/presentation/signup/parent/component/ParentSignUpInviteHolder.kt @@ -138,8 +138,12 @@ fun ParentSignUpInviteHolder( Spacer(modifier = Modifier.height(9.dp)) Text( - text = "이 코드를 아이에게 알려주시고, \n" + - "회원가입 시 입력하도록 안내해주세요.", + text = if (!isExpired) { + "이 코드를 아이에게 알려주시고, \n" + + "회원가입 시 입력하도록 안내해주세요." + } else { + "코드가 만료되었습니다" + }, style = KieroTheme.typography.regular.body4, color = KieroTheme.colors.gray200, textAlign = TextAlign.Center @@ -159,4 +163,4 @@ private fun ParentSignUpInviteHolderPreview() { onReIssueClick = {} ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/kiero/presentation/signup/parent/model/ParentInfoUiModel.kt b/app/src/main/java/com/kiero/presentation/signup/parent/model/ParentInfoUiModel.kt index 85f175ce..778ac09c 100644 --- a/app/src/main/java/com/kiero/presentation/signup/parent/model/ParentInfoUiModel.kt +++ b/app/src/main/java/com/kiero/presentation/signup/parent/model/ParentInfoUiModel.kt @@ -6,7 +6,4 @@ import androidx.compose.runtime.Immutable data class ParentInfoUiModel( val parentName: String = "", val parentProfileImage: String = "", -) { - val formattedParentName: String - get() = parentName.drop(1) + "맘" -} +) diff --git a/app/src/main/java/com/kiero/presentation/signup/parent/screen/ParentSignUpAddChildScreen.kt b/app/src/main/java/com/kiero/presentation/signup/parent/screen/ParentSignUpAddChildScreen.kt index 2f8a4f91..d7cf8efc 100644 --- a/app/src/main/java/com/kiero/presentation/signup/parent/screen/ParentSignUpAddChildScreen.kt +++ b/app/src/main/java/com/kiero/presentation/signup/parent/screen/ParentSignUpAddChildScreen.kt @@ -49,6 +49,7 @@ fun ParentSignUpAddChildScreen( textState = state.childInfo.childLastName, isError = state.childInfo.childLastName.text.isNotEmpty() && !state.childInfo.validateLastName, onImeAction = nextFocus, + imeAction = ImeAction.Next ) Spacer(modifier = Modifier.height(18.dp)) diff --git a/app/src/main/java/com/kiero/presentation/signup/parent/screen/ParentSignUpInviteScreen.kt b/app/src/main/java/com/kiero/presentation/signup/parent/screen/ParentSignUpInviteScreen.kt index 3a3e3d99..07a87943 100644 --- a/app/src/main/java/com/kiero/presentation/signup/parent/screen/ParentSignUpInviteScreen.kt +++ b/app/src/main/java/com/kiero/presentation/signup/parent/screen/ParentSignUpInviteScreen.kt @@ -25,7 +25,7 @@ fun ParentSignUpInviteScreen( Spacer(modifier = Modifier.height(38.dp)) KieroTextField( - state = TextFieldState(state.childInfo.childFirstName.text.toString()), + state = TextFieldState("${state.childInfo.childLastName.text}${state.childInfo.childFirstName.text}"), placeholder = "", isError = false, enabled = false, @@ -48,8 +48,8 @@ fun ParentSignUpInviteScreen( text = "시작하기", onClick = onStartClick, isEnabled = state.isChildJoined, - containerColor = KieroTheme.colors.gray900, - contentColor = KieroTheme.colors.white + containerColor = KieroTheme.colors.main, + contentColor = KieroTheme.colors.black ) } } diff --git a/app/src/main/java/com/kiero/presentation/signup/parent/viewmodel/ParentSignUpViewModel.kt b/app/src/main/java/com/kiero/presentation/signup/parent/viewmodel/ParentSignUpViewModel.kt index 43600a95..b50d0566 100644 --- a/app/src/main/java/com/kiero/presentation/signup/parent/viewmodel/ParentSignUpViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/signup/parent/viewmodel/ParentSignUpViewModel.kt @@ -10,11 +10,12 @@ import com.kiero.core.common.util.suspendRunCatching import com.kiero.core.common.viewmodel.throttleFirst import com.kiero.core.localstorage.TokenManager import com.kiero.core.localstorage.info.UserInfoManager +import com.kiero.core.model.UiState import com.kiero.data.auth.repository.AuthRepository import com.kiero.data.demo.repository.DemoRepository import com.kiero.data.parent.signup.repository.ParentSignUpRepository import com.kiero.data.sse.manager.SseManager -import com.kiero.data.sse.repository.SseRepository +import com.kiero.presentation.kid.onboarding.state.KidOnboardingSideEffect import com.kiero.presentation.signup.parent.model.ParentSignUpStep import com.kiero.presentation.signup.parent.model.toUiModel import com.kiero.presentation.signup.parent.navigation.ParentSignUp @@ -31,9 +32,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -58,14 +57,13 @@ class ParentSignUpViewModel @Inject constructor( private var timerJob: Job? = null private var copyJob: Job? = null - private val TIMER_DURATION_SECONDS = 10 * 1 + private val TIMER_DURATION_SECONDS = 10 * 60 init { initFetchParentInfo( parentName = parentInfo.parentName, parentProfileImage = parentInfo.parentProfileImage ) - checkExistingChild() sseManager.startParentSubscription() collectInviteEvents() } @@ -81,8 +79,6 @@ class ParentSignUpViewModel @Inject constructor( ParentSignUpStep.INVITE -> { viewModelScope.launch { _sideEffect.emit(ParentSignUpSideEffect.NavigateToParent) - - demoRepository.postDemo() } } } @@ -177,17 +173,14 @@ class ParentSignUpViewModel @Inject constructor( Timber.e("로그아웃 되었습니다") viewModelScope.launch { - val logoutDeferred = async { - suspendRunCatching { authRepository.postLogout() } - } - val demoDeferred = async { - suspendRunCatching { demoRepository.deleteDemo() } - } - val tokenDeferred = async { - suspendRunCatching { tokenManager.clearTokens() } - } + val networkJobs = listOf( + async { suspendRunCatching { authRepository.postLogout() } }, + async { suspendRunCatching { demoRepository.deleteDemo() } } + ) + networkJobs.awaitAll() + sseManager.stopSubscription() - awaitAll(logoutDeferred, demoDeferred, tokenDeferred) + suspendRunCatching { tokenManager.clearTokens() } _state.update { it.copy(isLoading = false) @@ -251,54 +244,16 @@ class ParentSignUpViewModel @Inject constructor( } fun onBackClick() { - _state.update { - it.copy( - currentStep = ParentSignUpStep.ADDCHILD - ) - } - } - - private fun checkExistingChild() { - viewModelScope.launch { - val existingChildId = userInfoManager.getChildIdInfo() - - if (existingChildId != null) { - _state.update { - it.copy(isChildJoined = true) - } - Timber.d("기존 childId 존재: $existingChildId") - } else { - checkChildRegistration() - } - } - } - - private fun checkChildRegistration() { - viewModelScope.launch { - val lastName = _state.value.childInfo.childLastName.text.toString() - val firstName = _state.value.childInfo.childFirstName.text.toString() - - if (lastName.isEmpty() || firstName.isEmpty()) { - Timber.d("자녀 이름 미입력 - 연동 체크 건너뜀") - return@launch + Timber.e("onBackClick") + if (_state.value.currentStep == ParentSignUpStep.INVITE) { + _state.update { + it.copy( + currentStep = ParentSignUpStep.ADDCHILD + ) } - - repository.getLinkageKid( - childLastName = lastName, - childFirstName = firstName - ).onSuccess { result -> - if (result.isRegistered && result.childId != null) { - - userInfoManager.saveChildIdInfo(result.childId) - _state.update { - it.copy(isChildJoined = true) - } - Timber.d("자녀 연동 확인됨: ${result.childId}") - } else { - Timber.d("자녀 연동 대기 중") - } - }.onFailure { - Timber.e(it, "자녀 연동 여부 조회 실패") + } else { + viewModelScope.launch { + _sideEffect.emit(ParentSignUpSideEffect.NavigateToSelection) } } } @@ -306,7 +261,9 @@ class ParentSignUpViewModel @Inject constructor( private suspend fun handleChildJoined(childId: Long) { userInfoManager.saveChildIdInfo(childId) _state.update { - it.copy(isChildJoined = true) + it.copy( + isChildJoined = true + ) } _sideEffect.emit(ParentSignUpSideEffect.OnChildJoined(childId)) } diff --git a/app/src/main/java/com/kiero/presentation/splash/viewmodel/SplashViewModel.kt b/app/src/main/java/com/kiero/presentation/splash/viewmodel/SplashViewModel.kt index 28a9491e..cab069a2 100644 --- a/app/src/main/java/com/kiero/presentation/splash/viewmodel/SplashViewModel.kt +++ b/app/src/main/java/com/kiero/presentation/splash/viewmodel/SplashViewModel.kt @@ -28,55 +28,61 @@ class SplashViewModel @Inject constructor( private val _sideEffect = Channel() val sideEffect = _sideEffect.receiveAsFlow() - fun checkLoginState() { viewModelScope.launch { delay(2000) val accessToken = tokenManager.getAccessToken() val userRole = tokenManager.getUserRole() + val refreshToken = tokenManager.getRefreshToken() - if (!accessToken.isNullOrBlank() && userRole != null) { - when (userRole) { - UserRole.PARENT -> { - authRepository.getChildren() - .onSuccess { children -> - Timber.e("children $children") - if (children.isEmpty()) { - _sideEffect.send(SplashSideEffect.NavigateToParentGraph) - } else { - // Todo : 추후 스프린트 시 수정 지금은 first - Timber.e("children.first().childId ${children.first().childId}") - delay(1000L) - userInfoManager.saveChildIdInfo(children.first().childId) - - _sideEffect.send(SplashSideEffect.NavigateToParentHome) - } - } - .onFailure { t -> - Timber.e(t, "자녀 목록 조회 실패") - _sideEffect.send(SplashSideEffect.NavigateToAuth) - } - } - UserRole.KID -> { - reIssueManager.refresh( - refreshToken = tokenManager.getRefreshToken().orEmpty(), - role = UserRole.KID - ).onSuccess { - if (onboardingManager.getIsSawOnboarding()) { - _sideEffect.send(SplashSideEffect.NavigateToKidHome) - } else { - _sideEffect.send(SplashSideEffect.NavigateToKidOnboarding) - } - }.onFailure { t -> - Timber.e(t, "refreshn조회 실패") - _sideEffect.send(SplashSideEffect.NavigateToAuth) - } + if (!accessToken.isNullOrBlank() && userRole != null && !refreshToken.isNullOrBlank()) { + reIssueManager.refresh( + refreshToken = refreshToken, + role = userRole + ).onSuccess { + when (userRole) { + UserRole.PARENT -> handleParentLogin() + UserRole.KID -> handleKidLogin() } + }.onFailure { t -> + Timber.e(t, "토큰 갱신 실패 - 재로그인 필요") + _sideEffect.send(SplashSideEffect.NavigateToAuth) } } else { _sideEffect.send(SplashSideEffect.NavigateToAuth) } } } + + private suspend fun handleParentLogin() { + authRepository.getChildren() + .onSuccess { children -> + Timber.e("children $children") + if (children.isEmpty()) { + // 자녀가 없으면 카카오 로그인으로 + _sideEffect.send(SplashSideEffect.NavigateToParentGraph) + } else { + // 자녀가 있으면 첫 번째 자녀 정보 저장 후 스케줄로 + Timber.e("children.first().childId ${children.first().childId}") + userInfoManager.saveChildIdInfo(children.first().childId) + + _sideEffect.send(SplashSideEffect.NavigateToParentHome) + } + } + .onFailure { t -> + Timber.e(t, "자녀 목록 조회 실패") + _sideEffect.send(SplashSideEffect.NavigateToAuth) + } + } + + // 아이 로그인 처리 분리 + private suspend fun handleKidLogin() { + if (onboardingManager.getIsSawOnboarding()) { + // 온보딩을 봤다면 + _sideEffect.send(SplashSideEffect.NavigateToKidHome) + } else { + _sideEffect.send(SplashSideEffect.NavigateToKidOnboarding) + } + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/img_auth_parent_goblin.png b/app/src/main/res/drawable/img_auth_parent_goblin.png index 95043a03..92f5f30d 100644 Binary files a/app/src/main/res/drawable/img_auth_parent_goblin.png and b/app/src/main/res/drawable/img_auth_parent_goblin.png differ diff --git a/app/src/main/res/drawable/img_kid_coin.png b/app/src/main/res/drawable/img_kid_coin.png deleted file mode 100644 index 947dcd54..00000000 Binary files a/app/src/main/res/drawable/img_kid_coin.png and /dev/null differ