diff --git a/.github/workflows/android_cd.yml b/.github/workflows/android_cd.yml
index b6f4a12b..27ba113d 100644
--- a/.github/workflows/android_cd.yml
+++ b/.github/workflows/android_cd.yml
@@ -45,12 +45,18 @@ jobs:
- name: Install Firebase CLI
run: curl -sL https://firebase.tools | bash
- # google-services.json
- - name: Decode google-services.json
+ # google-services.json (prod)
+ - name: Decode Prod google-services.json
env:
FIREBASE_SECRET: ${{ secrets.FIREBASE_SECRET }}
run: echo $FIREBASE_SECRET | base64 --decode > app/google-services.json
+ # google-services.json (debug)
+ - name: Decode Debug google-services.json
+ env:
+ FIREBASE_DEBUG_SECRET: ${{ secrets.FIREBASE_DEBUG_SECRET }}
+ run: echo $FIREBASE_DEBUG_SECRET | base64 --decode > app/src/debug/google-services.json
+
# keystore 복호화
- name: Decode keystore file
env:
@@ -62,28 +68,32 @@ jobs:
# local.properties
- name: Generate local.properties
env:
- BASE_URL: ${{ secrets.BASE_URL }}
+ CLODY_BASE_URL: ${{ secrets.CLODY_BASE_URL }}
+ CLODY_TEST_URL: ${{ secrets.CLODY_TEST_URL }}
KAKAO_API_KEY: ${{ secrets.KAKAO_API_KEY }}
AMPLITUDE_API_KEY: ${{ secrets.AMPLITUDE_API_KEY }}
GOOGLE_ADMOB_APP_ID: ${{ secrets.GOOGLE_ADMOB_APP_ID }}
GOOGLE_ADMOB_UNIT_ID: ${{ secrets.GOOGLE_ADMOB_UNIT_ID }}
+ GOOGLE_AUTH_WEB_CLIENT_ID: ${{ secrets.GOOGLE_AUTH_WEB_CLIENT_ID }}
STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
- echo "baseUrl=$BASE_URL" >> local.properties
+ echo "clody.base.url=$CLODY_BASE_URL" >> local.properties
+ echo "clody.test.url=$CLODY_TEST_URL" >> local.properties
echo "kakao.api.key=$KAKAO_API_KEY" >> local.properties
echo "amplitude.api.key=$AMPLITUDE_API_KEY" >> local.properties
- echo "googleAdmob.app.id=$GOOGLE_ADMOB_APP_ID" >> local.properties
- echo "googleAdmob.unit.id=$GOOGLE_ADMOB_UNIT_ID" >> local.properties
+ echo "GOOGLE_ADMOB_APP_ID=$GOOGLE_ADMOB_APP_ID" >> local.properties
+ echo "GOOGLE_ADMOB_UNIT_ID=$GOOGLE_ADMOB_UNIT_ID" >> local.properties
+ echo "GOOGLE_AUTH_WEB_CLIENT_ID=$GOOGLE_AUTH_WEB_CLIENT_ID" >> local.properties
echo "storeFile=keystore/clody_release.jks" >> local.properties
echo "storePassword=$STORE_PASSWORD" >> local.properties
echo "keyAlias=$KEY_ALIAS" >> local.properties
echo "keyPassword=$KEY_PASSWORD" >> local.properties
- # Release APK 빌드
- - name: Build Release APK
- run: ./gradlew assembleRelease --stacktrace
+ # Debug APK 빌드
+ - name: Build Debug APK
+ run: ./gradlew assembleDebug --stacktrace
# Set up Firebase Service Account Credentials
- name: Set up Firebase Service Account Credentials
@@ -112,13 +122,13 @@ jobs:
- name: Upload APK to Firebase App Distribution
env:
GOOGLE_APPLICATION_CREDENTIALS: $HOME/firebase-credentials.json
- FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
+ FIREBASE_DEBUG_APP_ID: ${{ secrets.FIREBASE_DEBUG_APP_ID }}
run: |
- echo "🔥 FIREBASE_APP_ID 확인: $FIREBASE_APP_ID"
+ echo "🔥 FIREBASE_DEBUG_APP_ID 확인: $FIREBASE_DEBUG_APP_ID"
- # 만약 FIREBASE_APP_ID가 없으면 에러 출력 후 종료
- if [ -z "$FIREBASE_APP_ID" ]; then
- echo "❌ ERROR: FIREBASE_APP_ID가 설정되지 않았습니다. GitHub Secrets에서 확인하세요."
+ # 만약 FIREBASE_DEBUG_APP_ID가 없으면 에러 출력 후 종료
+ if [ -z "$FIREBASE_DEBUG_APP_ID" ]; then
+ echo "❌ ERROR: FIREBASE_DEBUG_APP_ID가 설정되지 않았습니다. GitHub Secrets에서 확인하세요."
exit 1
fi
@@ -126,8 +136,8 @@ jobs:
export GOOGLE_APPLICATION_CREDENTIALS=$HOME/firebase-credentials.json
echo "GOOGLE_APPLICATION_CREDENTIALS=$GOOGLE_APPLICATION_CREDENTIALS"
- firebase appdistribution:distribute app/build/outputs/apk/release/app-release.apk \
- --app "$FIREBASE_APP_ID" \
+ firebase appdistribution:distribute app/build/outputs/apk/debug/app-debug.apk \
+ --app "$FIREBASE_DEBUG_APP_ID" \
--release-notes "🍀 새로운 테스트 버전이 업로드되었습니다~" \
--groups "clody-tester-group"
diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml
index 7ab18cd7..9776c421 100644
--- a/.github/workflows/android_ci.yml
+++ b/.github/workflows/android_ci.yml
@@ -63,6 +63,14 @@ jobs:
FIREBASE_SECRET: ${{ secrets.FIREBASE_SECRET }} # base64로 암호화된 json 사용
run: echo $FIREBASE_SECRET | base64 --decode > app/google-services.json
+ # Firebase dubug google-services.json 복호화 및 설정
+ - name: Decode debug google-services.json
+ env:
+ FIREBASE_DEBUG_SECRET: ${{ secrets.FIREBASE_DEBUG_SECRET }}
+ run: |
+ mkdir -p app/src/debug
+ echo "$FIREBASE_DEBUG_SECRET" | base64 --decode > app/src/debug/google-services.json
+
# keystore 복호화
- name: Decode keystore file
env:
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5c3ff226..0812d8ce 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -25,16 +25,19 @@ android {
applicationId = "com.sopt.clody"
minSdk = 28
targetSdk = 35
- versionCode = 25
- versionName = "1.1.1"
+ versionCode = 28
+ versionName = "1.4.0"
val kakaoApiKey: String = properties.getProperty("kakao.api.key")
val amplitudeApiKey: String = properties.getProperty("amplitude.api.key")
val googleAdmobAppId: String = properties.getProperty("GOOGLE_ADMOB_APP_ID", "")
val googleAdmobUnitId: String = properties.getProperty("GOOGLE_ADMOB_UNIT_ID", "")
+ val googleAuthWebClientId: String = properties.getProperty("GOOGLE_AUTH_WEB_CLIENT_ID", "")
+
buildConfigField("String", "GOOGLE_ADMOB_APP_ID", "\"$googleAdmobAppId\"")
buildConfigField("String", "GOOGLE_ADMOB_UNIT_ID", "\"$googleAdmobUnitId\"")
buildConfigField("String", "KAKAO_API_KEY", "\"$kakaoApiKey\"")
buildConfigField("String", "AMPLITUDE_API_KEY", "\"$amplitudeApiKey\"")
+ buildConfigField("String", "GOOGLE_AUTH_WEB_CLIENT_ID", "\"$googleAuthWebClientId\"")
manifestPlaceholders["kakaoRedirectUri"] = "kakao$kakaoApiKey"
manifestPlaceholders["GOOGLE_ADMOB_APP_ID"] = googleAdmobAppId
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -51,8 +54,13 @@ android {
buildTypes {
debug {
+ applicationIdSuffix = ".dev"
+
isMinifyEnabled = false
- buildConfigField("String", "CLODY_BASE_URL", properties["clody.base.url"].toString())
+ buildConfigField("String", "CLODY_BASE_URL", properties["clody.test.url"].toString())
+
+ manifestPlaceholders["appLabel"] = "@string/app_name_dev"
+ manifestPlaceholders["appIcon"] = "@mipmap/ic_launcher_dev"
}
release {
@@ -64,6 +72,9 @@ android {
"proguard-rules.pro",
)
signingConfig = signingConfigs.getByName("release")
+
+ manifestPlaceholders["appLabel"] = "@string/app_name"
+ manifestPlaceholders["appIcon"] = "@mipmap/ic_launcher"
}
}
compileOptions {
@@ -77,6 +88,11 @@ android {
buildConfig = true
compose = true
}
+ testOptions {
+ unitTests.all {
+ it.useJUnitPlatform()
+ }
+ }
}
dependencies {
@@ -120,10 +136,26 @@ dependencies {
implementation(libs.accompanist.systemuicontroller)
implementation(libs.accompanist.insets)
+ // Mavericks
+ implementation(libs.bundles.mavericks)
+
+ // Kotest
+ testImplementation(libs.bundles.kotest)
+ testImplementation(libs.mockk)
+ testImplementation(libs.coroutines.test)
+
+ // Play Store
+ implementation(libs.bundles.plays)
+
// ETC
implementation(libs.timber)
implementation(libs.lottie.compose)
implementation(libs.coil)
implementation(libs.kakao.user)
implementation(libs.kotlinx.datetime)
+
+ implementation(libs.androidx.credentials.play.services.auth)
+ implementation(libs.google.auth)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.airbridge)
}
diff --git a/app/src/debug/res/values-ko/strings.xml b/app/src/debug/res/values-ko/strings.xml
new file mode 100644
index 00000000..4128eb16
--- /dev/null
+++ b/app/src/debug/res/values-ko/strings.xml
@@ -0,0 +1,4 @@
+
+
+ 돈키
+
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
new file mode 100644
index 00000000..9b7d4301
--- /dev/null
+++ b/app/src/debug/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ Donkey
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ff9e0c90..95012ebc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,22 +3,24 @@
xmlns:tools="http://schemas.android.com/tools">
+
diff --git a/app/src/main/ic_launcher_dev-playstore.png b/app/src/main/ic_launcher_dev-playstore.png
new file mode 100644
index 00000000..605ac04d
Binary files /dev/null and b/app/src/main/ic_launcher_dev-playstore.png differ
diff --git a/app/src/main/java/com/sopt/clody/ClodyApp.kt b/app/src/main/java/com/sopt/clody/ClodyApplication.kt
similarity index 59%
rename from app/src/main/java/com/sopt/clody/ClodyApp.kt
rename to app/src/main/java/com/sopt/clody/ClodyApplication.kt
index 5bfaed10..8eb4394c 100644
--- a/app/src/main/java/com/sopt/clody/ClodyApp.kt
+++ b/app/src/main/java/com/sopt/clody/ClodyApplication.kt
@@ -1,6 +1,9 @@
package com.sopt.clody
import android.app.Application
+import co.ab180.airbridge.Airbridge
+import co.ab180.airbridge.AirbridgeOptionBuilder
+import com.airbnb.mvrx.Mavericks
import com.google.firebase.FirebaseApp
import com.kakao.sdk.common.KakaoSdk
import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils.initAmplitude
@@ -8,16 +11,23 @@ import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
@HiltAndroidApp
-class ClodyApp : Application() {
+class ClodyApplication : Application() {
override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree())
initKakaoSdk()
FirebaseApp.initializeApp(this)
+ Mavericks.initialize(this)
initAmplitude(applicationContext)
+ initAirBridge()
}
private fun initKakaoSdk() {
KakaoSdk.init(this, BuildConfig.KAKAO_API_KEY)
}
+
+ private fun initAirBridge() {
+ val option = AirbridgeOptionBuilder("clody", "3ba2277abcd044f29356dc0ee32165ff").build()
+ Airbridge.initializeSDK(this, option)
+ }
}
diff --git a/app/src/main/java/com/sopt/clody/core/RewardAdShower.kt b/app/src/main/java/com/sopt/clody/core/ad/RewardAdShower.kt
similarity index 82%
rename from app/src/main/java/com/sopt/clody/core/RewardAdShower.kt
rename to app/src/main/java/com/sopt/clody/core/ad/RewardAdShower.kt
index cd9dc6c5..b85e9b79 100644
--- a/app/src/main/java/com/sopt/clody/core/RewardAdShower.kt
+++ b/app/src/main/java/com/sopt/clody/core/ad/RewardAdShower.kt
@@ -1,4 +1,4 @@
-package com.sopt.clody.core
+package com.sopt.clody.core.ad
import android.app.Activity
diff --git a/app/src/main/java/com/sopt/clody/core/fcm/FcmTokenProvider.kt b/app/src/main/java/com/sopt/clody/core/fcm/FcmTokenProvider.kt
new file mode 100644
index 00000000..ae142941
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/fcm/FcmTokenProvider.kt
@@ -0,0 +1,18 @@
+package com.sopt.clody.core.fcm
+
+import com.google.firebase.ktx.Firebase
+import com.google.firebase.messaging.ktx.messaging
+import kotlinx.coroutines.tasks.await
+import timber.log.Timber
+import javax.inject.Inject
+
+class FcmTokenProvider @Inject constructor() {
+ suspend fun getToken(): String? {
+ return try {
+ Firebase.messaging.token.await()
+ } catch (e: Exception) {
+ Timber.e("FCM 토큰 수신 실패: ${e.message}")
+ null
+ }
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/core/login/KakaoAccessToken.kt b/app/src/main/java/com/sopt/clody/core/login/KakaoAccessToken.kt
new file mode 100644
index 00000000..546a021e
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/login/KakaoAccessToken.kt
@@ -0,0 +1,4 @@
+package com.sopt.clody.core.login
+
+@JvmInline
+value class KakaoAccessToken(override val value: String) : LoginAccessToken
diff --git a/app/src/main/java/com/sopt/clody/core/login/KakaoLoginSdk.kt b/app/src/main/java/com/sopt/clody/core/login/KakaoLoginSdk.kt
new file mode 100644
index 00000000..8abc67bd
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/login/KakaoLoginSdk.kt
@@ -0,0 +1,97 @@
+package com.sopt.clody.core.login
+
+import android.content.Context
+import com.kakao.sdk.auth.model.OAuthToken
+import com.kakao.sdk.common.model.AuthError
+import com.kakao.sdk.common.model.ClientError
+import com.kakao.sdk.common.model.ClientErrorCause
+import com.kakao.sdk.user.UserApiClient
+import com.sopt.clody.R
+import com.sopt.clody.core.security.LoginSecurityChecker
+import kotlinx.coroutines.suspendCancellableCoroutine
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+@Singleton
+class KakaoLoginSdk @Inject constructor(
+ private val securityChecker: LoginSecurityChecker,
+) : LoginSdk {
+ override suspend fun login(context: Context): Result = runCatching {
+ if (!securityChecker.isChromeInstalled(context)) {
+ throw LoginException.AuthException(context.getString(R.string.error_login_requires_chrome))
+ }
+
+ if (securityChecker.isDeviceRooted()) {
+ throw LoginException.AuthException(context.getString(R.string.error_login_rooted_device))
+ }
+
+ suspendCancellableCoroutine { continuation ->
+ val callback: (OAuthToken?, Throwable?) -> Unit = callback@{ token, throwable ->
+ if (!continuation.isActive) {
+ return@callback
+ }
+
+ when {
+ throwable != null -> {
+ if (throwable is ClientError && throwable.reason == ClientErrorCause.Cancelled) {
+ continuation.resumeWithException(LoginException.CancelException(throwable.message))
+ return@callback
+ }
+
+ continuation.resumeWithException(LoginException.AuthException(throwable.message))
+ }
+
+ token != null -> continuation.resume(KakaoAccessToken(token.accessToken))
+ }
+ }
+
+ val userApiClient = UserApiClient.instance
+
+ if (userApiClient.isKakaoTalkLoginAvailable(context)) {
+ userApiClient.loginWithKakaoTalk(
+ context,
+ callback = callback@{ oAuthToken, throwable ->
+ // 카카오톡이 설치되어 있으나 로그인되어 있지 않은 경우 대응
+ if (throwable is AuthError && throwable.statusCode == 302) {
+ userApiClient.loginWithKakaoAccount(context, callback = callback)
+ return@callback
+ }
+
+ callback(oAuthToken, throwable)
+ },
+ )
+ } else {
+ // 카카오톡 웹 로그인
+ userApiClient.loginWithKakaoAccount(context, callback = callback)
+ }
+ }
+ }
+
+ override suspend fun logout(): Result = runCatching {
+ suspendCancellableCoroutine { continuation ->
+ val userApiClient = UserApiClient.instance
+
+ userApiClient.logout { throwable ->
+ when {
+ throwable != null -> continuation.resumeWithException(LoginException.AuthException(throwable.message))
+ else -> continuation.resume(Unit)
+ }
+ }
+ }
+ }
+
+ override suspend fun unlink(): Result = runCatching {
+ suspendCancellableCoroutine { continuation ->
+ val userApiClient = UserApiClient.instance
+
+ userApiClient.unlink { throwable ->
+ when {
+ throwable != null -> continuation.resumeWithException(LoginException.AuthException(throwable.message))
+ else -> continuation.resume(Unit)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/core/login/LoginAccessToken.kt b/app/src/main/java/com/sopt/clody/core/login/LoginAccessToken.kt
new file mode 100644
index 00000000..b5f3caa6
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/login/LoginAccessToken.kt
@@ -0,0 +1,5 @@
+package com.sopt.clody.core.login
+
+interface LoginAccessToken {
+ val value: String
+}
diff --git a/app/src/main/java/com/sopt/clody/core/login/LoginException.kt b/app/src/main/java/com/sopt/clody/core/login/LoginException.kt
new file mode 100644
index 00000000..6c175e51
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/login/LoginException.kt
@@ -0,0 +1,6 @@
+package com.sopt.clody.core.login
+
+sealed class LoginException(override val message: String?) : Exception(message) {
+ class CancelException(message: String?) : LoginException(message)
+ class AuthException(message: String?) : LoginException(message)
+}
diff --git a/app/src/main/java/com/sopt/clody/core/login/LoginModule.kt b/app/src/main/java/com/sopt/clody/core/login/LoginModule.kt
new file mode 100644
index 00000000..9a9359d2
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/login/LoginModule.kt
@@ -0,0 +1,16 @@
+package com.sopt.clody.core.login
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class LoginModule {
+
+ @Binds
+ abstract fun bindLoginSdk(
+ kakaoLoginSdk: KakaoLoginSdk,
+ ): LoginSdk
+}
diff --git a/app/src/main/java/com/sopt/clody/core/login/LoginSdk.kt b/app/src/main/java/com/sopt/clody/core/login/LoginSdk.kt
new file mode 100644
index 00000000..ddee77b8
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/login/LoginSdk.kt
@@ -0,0 +1,9 @@
+package com.sopt.clody.core.login
+
+import android.content.Context
+
+interface LoginSdk {
+ suspend fun login(context: Context): Result
+ suspend fun logout(): Result
+ suspend fun unlink(): Result
+}
diff --git a/app/src/main/java/com/sopt/clody/core/network/NetworkConnectivityModule.kt b/app/src/main/java/com/sopt/clody/core/network/NetworkConnectivityModule.kt
new file mode 100644
index 00000000..9ce6bca0
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/network/NetworkConnectivityModule.kt
@@ -0,0 +1,22 @@
+package com.sopt.clody.core.network
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkConnectivityModule {
+
+ @Provides
+ @Singleton
+ fun provideNetworkConnectivityObserver(
+ @ApplicationContext context: Context,
+ ): NetworkConnectivityObserver {
+ return NetworkConnectivityObserver(context)
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/core/network/NetworkConnectivityObserver.kt b/app/src/main/java/com/sopt/clody/core/network/NetworkConnectivityObserver.kt
new file mode 100644
index 00000000..626e4b43
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/network/NetworkConnectivityObserver.kt
@@ -0,0 +1,79 @@
+package com.sopt.clody.core.network
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import javax.inject.Inject
+
+/**
+ * 네트워크 연결 상태를 관찰하는 Observer.
+ *
+ * - `Available`: 인터넷에 연결되어 있음
+ * - `Unavailable`: 인터넷 연결이 끊긴 상태
+ *
+ */
+class NetworkConnectivityObserver @Inject constructor(
+ @ApplicationContext private val context: Context,
+) {
+ private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+ /**
+ * 네트워크 상태를 실시간으로 스트리밍하는 Flow.
+ *
+ * - 최초 구독 시 현재 상태를 먼저 전송 ->
+ * - 이후 네트워크 변경 이벤트를 수신하여 상태를 전송
+ * - 중복 상태 전송은 [distinctUntilChanged]로 방지 하도록 함.
+ */
+ val networkStatus: Flow = callbackFlow {
+ val callback = object : ConnectivityManager.NetworkCallback() {
+
+ /**
+ * 네트워크가 변경되었을 때 호출됨.
+ * 유효한 인터넷 연결이 있는지 확인하여 상태를 전송.
+ */
+ override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
+ val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
+ capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ trySend(if (hasInternet) NetworkStatus.Available else NetworkStatus.Unavailable)
+ }
+
+ /**
+ * 네트워크 연결이 완전히 끊겼을 때 호출.
+ */
+ override fun onLost(network: Network) {
+ trySend(NetworkStatus.Unavailable)
+ }
+ }
+
+ trySend(if (isCurrentlyAvailable()) NetworkStatus.Available else NetworkStatus.Unavailable)
+
+ val request = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+
+ connectivityManager.registerNetworkCallback(request, callback)
+
+ awaitClose {
+ connectivityManager.unregisterNetworkCallback(callback)
+ }
+ }.distinctUntilChanged()
+
+ /**
+ * 현재 활성 네트워크가 인터넷에 연결되어 있는지를 반환
+ *
+ * @return 인터넷 연결 여부
+ */
+ private fun isCurrentlyAvailable(): Boolean {
+ val network = connectivityManager.activeNetwork ?: return false
+ val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
+ capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/core/network/NetworkStatus.kt b/app/src/main/java/com/sopt/clody/core/network/NetworkStatus.kt
new file mode 100644
index 00000000..9f28341a
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/network/NetworkStatus.kt
@@ -0,0 +1,6 @@
+package com.sopt.clody.core.network
+
+sealed class NetworkStatus {
+ data object Available : NetworkStatus()
+ data object Unavailable : NetworkStatus()
+}
diff --git a/app/src/main/java/com/sopt/clody/core/review/InAppReviewManager.kt b/app/src/main/java/com/sopt/clody/core/review/InAppReviewManager.kt
new file mode 100644
index 00000000..c19a02af
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/review/InAppReviewManager.kt
@@ -0,0 +1,29 @@
+package com.sopt.clody.core.review
+
+import android.app.Activity
+import com.google.android.play.core.review.ReviewManagerFactory
+import com.sopt.clody.presentation.utils.appupdate.AppUpdateUtils
+import timber.log.Timber
+
+object InAppReviewManager {
+ fun showPopup(activity: Activity) {
+ if (activity.isFinishing || activity.isDestroyed) return
+
+ val reviewManager = ReviewManagerFactory.create(activity)
+ val request = reviewManager.requestReviewFlow()
+
+ request.addOnCompleteListener { task ->
+ if (task.isSuccessful) {
+ val reviewInfo = task.result
+ reviewManager.launchReviewFlow(activity, reviewInfo)
+ } else {
+ try {
+ AppUpdateUtils.navigateToMarket(activity)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ Timber.e(e, "Failed to open store for app review")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/core/security/DefaultLoginSecurityChecker.kt b/app/src/main/java/com/sopt/clody/core/security/DefaultLoginSecurityChecker.kt
new file mode 100644
index 00000000..21f4e87f
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/security/DefaultLoginSecurityChecker.kt
@@ -0,0 +1,61 @@
+package com.sopt.clody.core.security
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import java.io.File
+import javax.inject.Inject
+
+/**
+ * 기본 보안 점검 구현체.
+ *
+ * 디바이스 루팅 여부 및 Chrome 브라우저 설치 여부를 확인하는 기능 구현.
+ *
+ */
+
+class DefaultLoginSecurityChecker @Inject constructor() : LoginSecurityChecker {
+
+ /**
+ * 디바이스가 루팅되었는지 여부를 검사.
+ * Step
+ * - `Build.TAGS`에 `test-keys`가 포함되어 있는지 확인
+ * - 루팅에 사용되는 바이너리 또는 앱의 존재 여부 검사
+ * - `which su` 명령어 실행 결과를 통해 `su` 명령어의 존재 여부 확인
+ *
+ * @return 디바이스가 루팅되었다면 `true`, 그렇지 않다면 `false`
+ */
+ override fun isDeviceRooted(): Boolean {
+ val buildTags = Build.TAGS
+ if (buildTags != null && buildTags.contains("test-keys")) return true
+
+ val paths = arrayOf(
+ "/system/app/Superuser.apk",
+ "/sbin/su", "/system/bin/su", "/system/xbin/su",
+ "/data/local/xbin/su", "/data/local/bin/su",
+ "/system/sd/xbin/su", "/system/bin/failsafe/su",
+ "/data/local/su",
+ )
+ if (paths.any { File(it).exists() }) return true
+
+ return try {
+ Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))
+ .inputStream.bufferedReader().readLine() != null
+ } catch (e: Exception) {
+ false
+ }
+ }
+
+ /**
+ * 디바이스에 Chrome 브라우저가 설치되어 있는지 여부
+ *
+ * `com.android.chrome` 패키지의 존재 여부로 Chrome 설치 여부를 판단.
+ *
+ * @return Chrome이 설치되어 있다면 `true`, 아니라면 `false`
+ */
+ override fun isChromeInstalled(context: Context): Boolean = try {
+ context.packageManager.getPackageInfo("com.android.chrome", 0)
+ true
+ } catch (e: PackageManager.NameNotFoundException) {
+ false
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/core/security/LoginSecurityChecker.kt b/app/src/main/java/com/sopt/clody/core/security/LoginSecurityChecker.kt
new file mode 100644
index 00000000..023bff55
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/security/LoginSecurityChecker.kt
@@ -0,0 +1,8 @@
+package com.sopt.clody.core.security
+
+import android.content.Context
+
+interface LoginSecurityChecker {
+ fun isDeviceRooted(): Boolean
+ fun isChromeInstalled(context: Context): Boolean
+}
diff --git a/app/src/main/java/com/sopt/clody/core/security/SecurityModule.kt b/app/src/main/java/com/sopt/clody/core/security/SecurityModule.kt
new file mode 100644
index 00000000..0f514829
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/core/security/SecurityModule.kt
@@ -0,0 +1,18 @@
+package com.sopt.clody.core.security
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class SecurityModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindLoginSecurityChecker(
+ impl: DefaultLoginSecurityChecker,
+ ): LoginSecurityChecker
+}
diff --git a/app/src/main/java/com/sopt/clody/data/ad/RewardAdShowerImpl.kt b/app/src/main/java/com/sopt/clody/data/ad/RewardAdShowerImpl.kt
index 747ddfe1..54335e19 100644
--- a/app/src/main/java/com/sopt/clody/data/ad/RewardAdShowerImpl.kt
+++ b/app/src/main/java/com/sopt/clody/data/ad/RewardAdShowerImpl.kt
@@ -3,7 +3,7 @@ package com.sopt.clody.data.ad
import android.app.Activity
import com.google.android.gms.ads.AdError
import com.google.android.gms.ads.FullScreenContentCallback
-import com.sopt.clody.core.RewardAdShower
+import com.sopt.clody.core.ad.RewardAdShower
import com.sopt.clody.data.remote.datasource.AdRemoteDataSource
import javax.inject.Inject
diff --git a/app/src/main/java/com/sopt/clody/data/datastore/DataStoreModule.kt b/app/src/main/java/com/sopt/clody/data/datastore/DataStoreModule.kt
new file mode 100644
index 00000000..68f1472c
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/datastore/DataStoreModule.kt
@@ -0,0 +1,22 @@
+package com.sopt.clody.data.datastore
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataStoreModule {
+
+ @Provides
+ @Singleton
+ fun provideOAuthDataStore(
+ @ApplicationContext context: Context,
+ ): OAuthDataStore {
+ return OAuthDataStore(context)
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStore.kt b/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStore.kt
new file mode 100644
index 00000000..9e6c97e4
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStore.kt
@@ -0,0 +1,47 @@
+package com.sopt.clody.data.datastore
+
+import android.content.Context
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+
+class OAuthDataStore @Inject constructor(@ApplicationContext context: Context) {
+ private val Context.dataStore by preferencesDataStore(name = "oauth_pref")
+ private val dataStore = context.dataStore
+
+ suspend fun saveIdToken(platform: String, token: String) {
+ if (platform == OAuthProvider.GOOGLE.platform) {
+ dataStore.edit { it[OAuthDataStoreKeys.GOOGLE_ID_TOKEN] = token }
+ } else {
+ dataStore.edit { it[OAuthDataStoreKeys.KAKAO_ID_TOKEN] = token }
+ }
+ }
+
+ suspend fun getIdToken(platform: String): String? {
+ return if (platform == OAuthProvider.GOOGLE.platform) {
+ dataStore.data.first()[OAuthDataStoreKeys.GOOGLE_ID_TOKEN]
+ } else {
+ dataStore.data.first()[OAuthDataStoreKeys.KAKAO_ID_TOKEN]
+ }
+ }
+
+ suspend fun savePlatform(provider: OAuthProvider) {
+ dataStore.edit { it[OAuthDataStoreKeys.OAUTH_PLATFORM] = provider.name }
+ }
+
+ suspend fun getPlatform(): OAuthProvider? {
+ return dataStore.data.first()[OAuthDataStoreKeys.OAUTH_PLATFORM]?.let {
+ runCatching { OAuthProvider.valueOf(it) }.getOrNull()
+ }
+ }
+
+ suspend fun clear() {
+ dataStore.edit {
+ it.remove(OAuthDataStoreKeys.GOOGLE_ID_TOKEN)
+ it.remove(OAuthDataStoreKeys.KAKAO_ID_TOKEN)
+ it.remove(OAuthDataStoreKeys.OAUTH_PLATFORM)
+ }
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStoreKeys.kt b/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStoreKeys.kt
new file mode 100644
index 00000000..97af4495
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/datastore/OAuthDataStoreKeys.kt
@@ -0,0 +1,9 @@
+package com.sopt.clody.data.datastore
+
+import androidx.datastore.preferences.core.stringPreferencesKey
+
+object OAuthDataStoreKeys {
+ val OAUTH_PLATFORM = stringPreferencesKey("oauth_platform")
+ val GOOGLE_ID_TOKEN = stringPreferencesKey("google_id_token")
+ val KAKAO_ID_TOKEN = stringPreferencesKey("kakao_id_token")
+}
diff --git a/app/src/main/java/com/sopt/clody/data/datastore/OAuthProvider.kt b/app/src/main/java/com/sopt/clody/data/datastore/OAuthProvider.kt
new file mode 100644
index 00000000..e4151077
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/datastore/OAuthProvider.kt
@@ -0,0 +1,6 @@
+package com.sopt.clody.data.datastore
+
+enum class OAuthProvider(val platform: String) {
+ GOOGLE("google"),
+ KAKAO("kakao"),
+}
diff --git a/app/src/main/java/com/sopt/clody/data/datastore/TokenDataStoreImpl.kt b/app/src/main/java/com/sopt/clody/data/datastore/TokenDataStoreImpl.kt
index c71dda3f..04f62ef6 100644
--- a/app/src/main/java/com/sopt/clody/data/datastore/TokenDataStoreImpl.kt
+++ b/app/src/main/java/com/sopt/clody/data/datastore/TokenDataStoreImpl.kt
@@ -1,10 +1,11 @@
package com.sopt.clody.data.datastore
import android.content.SharedPreferences
+import com.sopt.clody.di.qualifier.TokenPrefs
import javax.inject.Inject
class TokenDataStoreImpl @Inject constructor(
- private val sharedPreferences: SharedPreferences,
+ @TokenPrefs private val sharedPreferences: SharedPreferences,
) : TokenDataStore {
override var accessToken: String
get() = sharedPreferences.getString(ACCESS_TOKEN, "") ?: ""
diff --git a/app/src/main/java/com/sopt/clody/data/local/datasource/AppReviewLocalDataSource.kt b/app/src/main/java/com/sopt/clody/data/local/datasource/AppReviewLocalDataSource.kt
new file mode 100644
index 00000000..4c2c5992
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/local/datasource/AppReviewLocalDataSource.kt
@@ -0,0 +1,5 @@
+package com.sopt.clody.data.local.datasource
+
+interface AppReviewLocalDataSource {
+ var shouldShowPopup: Boolean
+}
diff --git a/app/src/main/java/com/sopt/clody/data/local/datasource/FirstDraftLocalDataSource.kt b/app/src/main/java/com/sopt/clody/data/local/datasource/FirstDraftLocalDataSource.kt
new file mode 100644
index 00000000..07ca4f3b
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/local/datasource/FirstDraftLocalDataSource.kt
@@ -0,0 +1,11 @@
+package com.sopt.clody.data.local.datasource
+
+/**
+ * 임시 저장 최초 사용 여부 판단을 위한 SharedPreferences
+ * @property isDraftUsed 임시 저장 사용 여부
+ * @property isFirstUse 임시 저장 최초 사용 여부
+ */
+interface FirstDraftLocalDataSource {
+ var isDraftUsed: Boolean
+ var isFirstUse: Boolean
+}
diff --git a/app/src/main/java/com/sopt/clody/data/local/datasourceimpl/AppReviewLocalDataSourceImpl.kt b/app/src/main/java/com/sopt/clody/data/local/datasourceimpl/AppReviewLocalDataSourceImpl.kt
new file mode 100644
index 00000000..684eb9f6
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/local/datasourceimpl/AppReviewLocalDataSourceImpl.kt
@@ -0,0 +1,20 @@
+package com.sopt.clody.data.local.datasourceimpl
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import com.sopt.clody.data.local.datasource.AppReviewLocalDataSource
+import com.sopt.clody.di.qualifier.ReviewPrefs
+import javax.inject.Inject
+
+class AppReviewLocalDataSourceImpl @Inject constructor(
+ @ReviewPrefs private val sharedPreferences: SharedPreferences,
+) : AppReviewLocalDataSource {
+
+ override var shouldShowPopup: Boolean
+ get() = sharedPreferences.getBoolean(SHOULD_SHOW_POPUP, true)
+ set(value) = sharedPreferences.edit { putBoolean(SHOULD_SHOW_POPUP, value) }
+
+ companion object {
+ private const val SHOULD_SHOW_POPUP = "shouldShowPopup"
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/data/local/datasourceimpl/FirstDraftLocalDataSourceImpl.kt b/app/src/main/java/com/sopt/clody/data/local/datasourceimpl/FirstDraftLocalDataSourceImpl.kt
new file mode 100644
index 00000000..2ada28ae
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/local/datasourceimpl/FirstDraftLocalDataSourceImpl.kt
@@ -0,0 +1,24 @@
+package com.sopt.clody.data.local.datasourceimpl
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import com.sopt.clody.data.local.datasource.FirstDraftLocalDataSource
+import com.sopt.clody.di.qualifier.FirstDraftPrefs
+import javax.inject.Inject
+
+class FirstDraftLocalDataSourceImpl @Inject constructor(
+ @FirstDraftPrefs private val sharedPreferences: SharedPreferences,
+) : FirstDraftLocalDataSource {
+ override var isDraftUsed: Boolean
+ get() = sharedPreferences.getBoolean(IS_DRAFT_USED, false)
+ set(value) = sharedPreferences.edit { putBoolean(IS_DRAFT_USED, value) }
+
+ override var isFirstUse: Boolean
+ get() = sharedPreferences.getBoolean(IS_FIRST_USE, false)
+ set(value) = sharedPreferences.edit { putBoolean(IS_FIRST_USE, value) }
+
+ companion object {
+ private const val IS_DRAFT_USED = "IS_DRAFT_USED"
+ private const val IS_FIRST_USE = "IS_FIRST_USE"
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/api/AuthService.kt b/app/src/main/java/com/sopt/clody/data/remote/api/AuthService.kt
index af1ae417..7229df5a 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/api/AuthService.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/api/AuthService.kt
@@ -1,6 +1,7 @@
package com.sopt.clody.data.remote.api
import com.sopt.clody.data.remote.dto.base.ApiResponse
+import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto
import com.sopt.clody.data.remote.dto.request.LoginRequestDto
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
import com.sopt.clody.data.remote.dto.response.LoginResponseDto
@@ -21,4 +22,9 @@ interface AuthService {
@Header("Authorization") authorization: String,
@Body signUpRequestDto: SignUpRequestDto,
): ApiResponse
+
+ @POST("api/v1/auth/oauth2/google")
+ suspend fun signUpWithGoogle(
+ @Body body: GoogleSignUpRequestDto,
+ ): ApiResponse
}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/api/DiaryService.kt b/app/src/main/java/com/sopt/clody/data/remote/api/DiaryService.kt
index 8a702f80..62540db6 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/api/DiaryService.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/api/DiaryService.kt
@@ -1,9 +1,11 @@
package com.sopt.clody.data.remote.api
import com.sopt.clody.data.remote.dto.base.ApiResponse
+import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto
import com.sopt.clody.data.remote.dto.request.WriteDiaryRequestDto
import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto
import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto
+import com.sopt.clody.data.remote.dto.response.DraftDiariesResponseDto
import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto
import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto
@@ -11,12 +13,14 @@ import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
+import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Query
interface DiaryService {
@POST("api/v1/diary")
suspend fun writeDiary(
+ @Header("Accept-Language") lang: String,
@Body writeDiaryRequestDto: WriteDiaryRequestDto,
): ApiResponse
@@ -59,4 +63,16 @@ interface DiaryService {
@Query("month") month: Int,
@Query("date") date: Int,
): ApiResponse
+
+ @GET("api/v1/draft")
+ suspend fun fetchDraftDiary(
+ @Query("year") year: Int,
+ @Query("month") month: Int,
+ @Query("date") date: Int,
+ ): ApiResponse
+
+ @POST("api/v1/draft")
+ suspend fun saveDraftDiary(
+ @Body request: SaveDraftDiaryRequestDto,
+ ): ApiResponse
}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt b/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt
index ef137767..d41f2a0b 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/appupdate/AppUpdateCheckerImpl.kt
@@ -4,6 +4,9 @@ import com.sopt.clody.data.remote.datasource.RemoteConfigDataSource
import com.sopt.clody.domain.appupdate.AppUpdateChecker
import com.sopt.clody.domain.model.AppUpdateState
import com.sopt.clody.domain.util.VersionComparator
+import java.time.LocalDateTime
+import java.time.format.TextStyle
+import java.util.Locale
import javax.inject.Inject
class AppUpdateCheckerImpl @Inject constructor(
@@ -30,4 +33,30 @@ class AppUpdateCheckerImpl @Inject constructor(
}
}
}
+
+ override suspend fun isUnderInspection(): Boolean {
+ val start = remoteConfigDataSource.getInspectionStart() ?: return false
+ val end = remoteConfigDataSource.getInspectionEnd() ?: return false
+ val now = LocalDateTime.now()
+ return now.isAfter(start) && now.isBefore(end)
+ }
+
+ override fun getInspectionTimeText(): String? {
+ val start = remoteConfigDataSource.getInspectionStart()
+ val end = remoteConfigDataSource.getInspectionEnd()
+ if (start == null || end == null) return null
+
+ val startText = formatDateTimeWithDayOfWeek(start)
+ val endText = formatDateTimeWithDayOfWeek(end)
+ return "$startText ~ $endText"
+ }
+
+ private fun formatDateTimeWithDayOfWeek(dateTime: LocalDateTime): String {
+ val dayOfWeek = dateTime.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.KOREAN)
+ val month = dateTime.monthValue
+ val day = dateTime.dayOfMonth
+ val hour = dateTime.hour.toString().padStart(2, '0')
+
+ return "$month/$day($dayOfWeek) ${hour}시"
+ }
}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/datasource/AuthDataSource.kt b/app/src/main/java/com/sopt/clody/data/remote/datasource/AuthDataSource.kt
index e4dfe411..d3f03fc1 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/datasource/AuthDataSource.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/datasource/AuthDataSource.kt
@@ -1,6 +1,7 @@
package com.sopt.clody.data.remote.datasource
import com.sopt.clody.data.remote.dto.base.ApiResponse
+import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto
import com.sopt.clody.data.remote.dto.request.LoginRequestDto
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
import com.sopt.clody.data.remote.dto.response.LoginResponseDto
@@ -9,4 +10,5 @@ import com.sopt.clody.data.remote.dto.response.SignUpResponseDto
interface AuthDataSource {
suspend fun signIn(authorization: String, requestSignInDto: LoginRequestDto): ApiResponse
suspend fun signUp(authorization: String, requestSignUpDto: SignUpRequestDto): ApiResponse
+ suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): ApiResponse
}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/datasource/DiaryRemoteDataSource.kt b/app/src/main/java/com/sopt/clody/data/remote/datasource/DiaryRemoteDataSource.kt
index 19dae1a7..457471f1 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/datasource/DiaryRemoteDataSource.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/datasource/DiaryRemoteDataSource.kt
@@ -1,19 +1,22 @@
package com.sopt.clody.data.remote.datasource
-import com.sopt.clody.data.remote.dto.base.ApiResponse
+import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto
import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto
import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto
+import com.sopt.clody.data.remote.dto.response.DraftDiariesResponseDto
import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto
import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto
import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto
interface DiaryRemoteDataSource {
- suspend fun writeDiary(date: String, content: List): ApiResponse
- suspend fun deleteDailyDiary(year: Int, month: Int, date: Int): ApiResponse
- suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): ApiResponse
- suspend fun getDiaryTime(year: Int, month: Int, date: Int): ApiResponse
- suspend fun getMonthlyCalendarData(year: Int, month: Int): ApiResponse
- suspend fun getMonthlyDiary(year: Int, month: Int): ApiResponse
- suspend fun getReplyDiary(year: Int, month: Int, date: Int): ApiResponse
+ suspend fun writeDiary(lang: String, date: String, content: List): Result
+ suspend fun deleteDailyDiary(year: Int, month: Int, date: Int): Result
+ suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): Result
+ suspend fun getDiaryTime(year: Int, month: Int, date: Int): Result
+ suspend fun getMonthlyCalendarData(year: Int, month: Int): Result
+ suspend fun getMonthlyDiary(year: Int, month: Int): Result
+ suspend fun getReplyDiary(year: Int, month: Int, date: Int): Result
+ suspend fun fetchDraftDiary(year: Int, month: Int, date: Int): Result
+ suspend fun saveDraftDiary(request: SaveDraftDiaryRequestDto): Result
}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/datasource/RemoteConfigDataSource.kt b/app/src/main/java/com/sopt/clody/data/remote/datasource/RemoteConfigDataSource.kt
index 8561623a..42e12ba6 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/datasource/RemoteConfigDataSource.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/datasource/RemoteConfigDataSource.kt
@@ -3,6 +3,8 @@ package com.sopt.clody.data.remote.datasource
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.sopt.clody.BuildConfig
import kotlinx.coroutines.tasks.await
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
@@ -20,8 +22,22 @@ class RemoteConfigDataSource @Inject constructor(
fun getMinimumVersion(): String =
remoteConfig.getString(KEY_MINIMUM_VERSION).ifEmpty { BuildConfig.VERSION_NAME }
+ fun getInspectionStart(): LocalDateTime? =
+ remoteConfig.getString(KEY_INSPECTION_START).takeIf { it.isNotBlank() }?.let {
+ runCatching { LocalDateTime.parse(it, formatter) }.getOrNull()
+ }
+
+ fun getInspectionEnd(): LocalDateTime? =
+ remoteConfig.getString(KEY_INSPECTION_END).takeIf { it.isNotBlank() }?.let {
+ runCatching { LocalDateTime.parse(it, formatter) }.getOrNull()
+ }
+
companion object {
private const val KEY_LATEST_VERSION = "latest_version"
private const val KEY_MINIMUM_VERSION = "min_required_version"
+ private const val KEY_INSPECTION_START = "inspection_start_android"
+ private const val KEY_INSPECTION_END = "inspection_end_android"
+
+ private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
}
}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/AuthDataSourceImpl.kt b/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/AuthDataSourceImpl.kt
index 91670e80..926d01a1 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/AuthDataSourceImpl.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/AuthDataSourceImpl.kt
@@ -3,6 +3,7 @@ package com.sopt.clody.data.remote.datasourceimpl
import com.sopt.clody.data.remote.api.AuthService
import com.sopt.clody.data.remote.datasource.AuthDataSource
import com.sopt.clody.data.remote.dto.base.ApiResponse
+import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto
import com.sopt.clody.data.remote.dto.request.LoginRequestDto
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
import com.sopt.clody.data.remote.dto.response.LoginResponseDto
@@ -17,4 +18,7 @@ class AuthDataSourceImpl @Inject constructor(
override suspend fun signUp(authorization: String, requestSignUpDto: SignUpRequestDto): ApiResponse =
authService.signUp(authorization, requestSignUpDto)
+
+ override suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): ApiResponse =
+ authService.signUpWithGoogle(googleSignUpRequestDto)
}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/DiaryRemoteDataSourceImpl.kt b/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/DiaryRemoteDataSourceImpl.kt
index cbbb7b53..f898978d 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/DiaryRemoteDataSourceImpl.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/datasourceimpl/DiaryRemoteDataSourceImpl.kt
@@ -2,37 +2,41 @@ package com.sopt.clody.data.remote.datasourceimpl
import com.sopt.clody.data.remote.api.DiaryService
import com.sopt.clody.data.remote.datasource.DiaryRemoteDataSource
-import com.sopt.clody.data.remote.dto.base.ApiResponse
+import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto
import com.sopt.clody.data.remote.dto.request.WriteDiaryRequestDto
-import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto
-import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto
-import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
-import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto
-import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto
-import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto
+import com.sopt.clody.data.remote.util.safeApiCall
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
import javax.inject.Inject
class DiaryRemoteDataSourceImpl @Inject constructor(
private val diaryService: DiaryService,
+ private val errorMessageProvider: ErrorMessageProvider,
) : DiaryRemoteDataSource {
- override suspend fun writeDiary(date: String, content: List): ApiResponse =
- diaryService.writeDiary(WriteDiaryRequestDto(date, content))
- override suspend fun deleteDailyDiary(year: Int, month: Int, date: Int): ApiResponse =
- diaryService.deleteDailyDiary(year = year, month = month, date = date)
+ override suspend fun writeDiary(lang: String, date: String, content: List) =
+ safeApiCall(errorMessageProvider) { diaryService.writeDiary(lang, WriteDiaryRequestDto(date, content)) }
- override suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): ApiResponse =
- diaryService.getDailyDiariesData(year = year, month = month, date = date)
+ override suspend fun deleteDailyDiary(year: Int, month: Int, date: Int) =
+ safeApiCall(errorMessageProvider) { diaryService.deleteDailyDiary(year, month, date) }
- override suspend fun getDiaryTime(year: Int, month: Int, date: Int): ApiResponse =
- diaryService.getDiaryTime(year = year, month = month, date = date)
+ override suspend fun getDailyDiariesData(year: Int, month: Int, date: Int) =
+ safeApiCall(errorMessageProvider) { diaryService.getDailyDiariesData(year, month, date) }
- override suspend fun getMonthlyCalendarData(year: Int, month: Int): ApiResponse =
- diaryService.getMonthlyCalendarData(year = year, month = month)
+ override suspend fun getDiaryTime(year: Int, month: Int, date: Int) =
+ safeApiCall(errorMessageProvider) { diaryService.getDiaryTime(year, month, date) }
- override suspend fun getMonthlyDiary(year: Int, month: Int): ApiResponse =
- diaryService.getMonthlyDiary(year = year, month = month)
+ override suspend fun getMonthlyCalendarData(year: Int, month: Int) =
+ safeApiCall(errorMessageProvider) { diaryService.getMonthlyCalendarData(year, month) }
- override suspend fun getReplyDiary(year: Int, month: Int, date: Int): ApiResponse =
- diaryService.getReplyDiary(year = year, month = month, date = date)
+ override suspend fun getMonthlyDiary(year: Int, month: Int) =
+ safeApiCall(errorMessageProvider) { diaryService.getMonthlyDiary(year, month) }
+
+ override suspend fun getReplyDiary(year: Int, month: Int, date: Int) =
+ safeApiCall(errorMessageProvider) { diaryService.getReplyDiary(year, month, date) }
+
+ override suspend fun fetchDraftDiary(year: Int, month: Int, date: Int) =
+ safeApiCall(errorMessageProvider) { diaryService.fetchDraftDiary(year, month, date) }
+
+ override suspend fun saveDraftDiary(request: SaveDraftDiaryRequestDto) =
+ safeApiCall(errorMessageProvider) { diaryService.saveDraftDiary(request) }
}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/request/GoogleSignUpRequestDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/request/GoogleSignUpRequestDto.kt
new file mode 100644
index 00000000..f4ed6c28
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/remote/dto/request/GoogleSignUpRequestDto.kt
@@ -0,0 +1,10 @@
+package com.sopt.clody.data.remote.dto.request
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class GoogleSignUpRequestDto(
+ @SerialName("idToken") val idToken: String,
+ @SerialName("fcmToken") val fcmToken: String,
+)
diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/request/SaveDraftDiaryRequestDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/request/SaveDraftDiaryRequestDto.kt
new file mode 100644
index 00000000..0fc706af
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/remote/dto/request/SaveDraftDiaryRequestDto.kt
@@ -0,0 +1,10 @@
+package com.sopt.clody.data.remote.dto.request
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SaveDraftDiaryRequestDto(
+ @SerialName("date") val date: String,
+ @SerialName("draftDiaries") val draftDiaries: List,
+)
diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/request/SendNotificationRequestDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/request/SendNotificationRequestDto.kt
index 8a051a8d..3b09e539 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/dto/request/SendNotificationRequestDto.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/dto/request/SendNotificationRequestDto.kt
@@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class SendNotificationRequestDto(
@SerialName("isDiaryAlarm") val isDiaryAlarm: Boolean,
+ @SerialName("isDraftAlarm") val isDraftAlarm: Boolean,
@SerialName("isReplyAlarm") val isReplyAlarm: Boolean,
@SerialName("time") val time: String,
@SerialName("fcmToken") val fcmToken: String,
diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/request/WriteDiaryRequestDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/request/WriteDiaryRequestDto.kt
index 7026db09..e813934e 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/dto/request/WriteDiaryRequestDto.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/dto/request/WriteDiaryRequestDto.kt
@@ -6,5 +6,5 @@ import kotlinx.serialization.Serializable
@Serializable
data class WriteDiaryRequestDto(
@SerialName("date") val date: String,
- @SerialName("content")val content: List,
+ @SerialName("content") val content: List,
)
diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/response/DailyDiariesResponseDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/response/DailyDiariesResponseDto.kt
index 1f995a09..006e019e 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/dto/response/DailyDiariesResponseDto.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/dto/response/DailyDiariesResponseDto.kt
@@ -6,6 +6,8 @@ import kotlinx.serialization.Serializable
@Serializable
data class DailyDiariesResponseDto(
@SerialName("diaries") val diaries: List,
+ @SerialName("isDeleted") val isDeleted: Boolean,
+ @SerialName("isDraft") val isDraft: Boolean,
) {
@Serializable
data class Diary(
diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/response/DraftDiariesResponseDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/response/DraftDiariesResponseDto.kt
new file mode 100644
index 00000000..6bb522b5
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/remote/dto/response/DraftDiariesResponseDto.kt
@@ -0,0 +1,14 @@
+package com.sopt.clody.data.remote.dto.response
+
+import com.sopt.clody.domain.model.DraftDiaryContents
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class DraftDiariesResponseDto(
+ @SerialName("draftDiaries") val draftDiaries: List,
+) {
+ fun toDomain() = DraftDiaryContents(
+ draftDiaries = draftDiaries ?: emptyList(),
+ )
+}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/response/MonthlyCalendarResponseDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/response/MonthlyCalendarResponseDto.kt
index 43eaaa12..2a4e63ec 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/dto/response/MonthlyCalendarResponseDto.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/dto/response/MonthlyCalendarResponseDto.kt
@@ -1,5 +1,6 @@
package com.sopt.clody.data.remote.dto.response
+import com.sopt.clody.domain.model.ReplyStatus
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -11,7 +12,8 @@ data class MonthlyCalendarResponseDto(
@Serializable
data class Diary(
@SerialName("diaryCount") val diaryCount: Int,
- @SerialName("replyStatus") val replyStatus: String,
+ @SerialName("replyStatus") val replyStatus: ReplyStatus,
@SerialName("isDeleted") val isDeleted: Boolean,
+ @SerialName("date") val date: String,
)
}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/response/MonthlyDiaryResponseDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/response/MonthlyDiaryResponseDto.kt
index 27b0883a..695d959c 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/dto/response/MonthlyDiaryResponseDto.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/dto/response/MonthlyDiaryResponseDto.kt
@@ -1,5 +1,6 @@
package com.sopt.clody.data.remote.dto.response
+import com.sopt.clody.domain.model.ReplyStatus
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -11,7 +12,7 @@ data class MonthlyDiaryResponseDto(
@Serializable
data class DailyDiary(
@SerialName("diaryCount") val diaryCount: Int,
- @SerialName("replyStatus") val replyStatus: String,
+ @SerialName("replyStatus") val replyStatus: ReplyStatus,
@SerialName("date") val date: String,
@SerialName("diary") val diary: List,
@SerialName("isDeleted") val isDeleted: Boolean,
diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/response/NotificationInfoResponseDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/response/NotificationInfoResponseDto.kt
index 641a8ca2..d7020108 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/dto/response/NotificationInfoResponseDto.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/dto/response/NotificationInfoResponseDto.kt
@@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class NotificationInfoResponseDto(
@SerialName("isDiaryAlarm") val isDiaryAlarm: Boolean,
+ @SerialName("isDraftAlarm") val isDraftAlarm: Boolean,
@SerialName("isReplyAlarm") val isReplyAlarm: Boolean,
@SerialName("time") val time: String,
)
diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/response/SendNotificationResponseDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/response/SendNotificationResponseDto.kt
index 4c29fc62..44e0e760 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/dto/response/SendNotificationResponseDto.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/dto/response/SendNotificationResponseDto.kt
@@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class SendNotificationResponseDto(
@SerialName("isDiaryAlarm") val isDiaryAlarm: Boolean,
+ @SerialName("isDraftAlarm") val isDraftAlarm: Boolean,
@SerialName("isReplyAlarm") val isReplyAlarm: Boolean,
@SerialName("time") val time: String,
@SerialName("fcmToken") val fcmToken: String,
diff --git a/app/src/main/java/com/sopt/clody/data/remote/dto/response/WriteDiaryResponseDto.kt b/app/src/main/java/com/sopt/clody/data/remote/dto/response/WriteDiaryResponseDto.kt
index 31b4b1cb..5e0e04bb 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/dto/response/WriteDiaryResponseDto.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/dto/response/WriteDiaryResponseDto.kt
@@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable
data class WriteDiaryResponseDto(
val createdAt: String,
val replyType: String,
+ val isFromDraft: Boolean,
)
diff --git a/app/src/main/java/com/sopt/clody/data/remote/util/ApiError.kt b/app/src/main/java/com/sopt/clody/data/remote/util/ApiError.kt
new file mode 100644
index 00000000..caf6a66b
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/remote/util/ApiError.kt
@@ -0,0 +1,5 @@
+package com.sopt.clody.data.remote.util
+
+data class ApiError(
+ override val message: String,
+) : Exception()
diff --git a/app/src/main/java/com/sopt/clody/data/remote/util/AuthInterceptor.kt b/app/src/main/java/com/sopt/clody/data/remote/util/AuthInterceptor.kt
index f3e1fac4..7ebc6934 100644
--- a/app/src/main/java/com/sopt/clody/data/remote/util/AuthInterceptor.kt
+++ b/app/src/main/java/com/sopt/clody/data/remote/util/AuthInterceptor.kt
@@ -49,6 +49,7 @@ class AuthInterceptor @Inject constructor(
private fun shouldAddAuthorization(url: String): Boolean {
return !url.contains("api/v1/auth/signin") &&
!url.contains("api/v1/auth/signup") &&
+ !url.contains("api/v1/auth/oauth2/google") &&
!url.contains("api/v1/auth/reissue")
}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/util/NetworkUtil.kt b/app/src/main/java/com/sopt/clody/data/remote/util/NetworkUtil.kt
deleted file mode 100644
index 798737f1..00000000
--- a/app/src/main/java/com/sopt/clody/data/remote/util/NetworkUtil.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.sopt.clody.data.remote.util
-
-import android.net.ConnectivityManager
-import android.net.NetworkCapabilities
-
-class NetworkUtil(private val connectivityManager: ConnectivityManager) {
- fun isNetworkAvailable(): Boolean {
- val network = connectivityManager.activeNetwork ?: return false
- val networkCapabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
- return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/util/SafeApiCall.kt b/app/src/main/java/com/sopt/clody/data/remote/util/SafeApiCall.kt
new file mode 100644
index 00000000..612c20d2
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/remote/util/SafeApiCall.kt
@@ -0,0 +1,25 @@
+package com.sopt.clody.data.remote.util
+
+import com.sopt.clody.data.remote.dto.base.ApiResponse
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
+import java.io.IOException
+import kotlin.coroutines.cancellation.CancellationException
+
+suspend fun safeApiCall(
+ errorMessageProvider: ErrorMessageProvider,
+ action: suspend () -> ApiResponse,
+): Result {
+ return try {
+ val response = action()
+ response.data?.let { Result.success(it) }
+ ?: Result.failure(ApiError(errorMessageProvider.getTemporaryError()))
+ } catch (exception: Throwable) {
+ if (exception is CancellationException) throw exception
+
+ val error = when (exception) {
+ is IOException -> ApiError(errorMessageProvider.getNetworkError())
+ else -> ApiError(errorMessageProvider.getTemporaryError())
+ }
+ Result.failure(error)
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/data/remote/util/TimeZoneInterceptor.kt b/app/src/main/java/com/sopt/clody/data/remote/util/TimeZoneInterceptor.kt
new file mode 100644
index 00000000..c1ee58a7
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/remote/util/TimeZoneInterceptor.kt
@@ -0,0 +1,39 @@
+package com.sopt.clody.data.remote.util
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import java.time.ZoneId
+import javax.inject.Inject
+
+class TimeZoneInterceptor @Inject constructor() : Interceptor {
+
+ private val userTimeZone = ZoneId.systemDefault().id
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+ val url = originalRequest.url.toString()
+
+ return if (shouldAddTimeZoneHeader(url)) {
+ proceedWithTimeZoneHeader(chain, originalRequest)
+ } else {
+ chain.proceed(originalRequest)
+ }
+ }
+
+ private fun shouldAddTimeZoneHeader(url: String) =
+ url.contains("api/v1/diary") ||
+ url.contains("api/v1/diary/time") ||
+ url.contains("api/v1/calendar") ||
+ url.contains("api/v1/calendar/list") ||
+ url.contains("api/v1/reply") ||
+ url.contains("api/v1/reply/ad/start") ||
+ url.contains("api/v1/reply/ad/end") ||
+ url.contains("api/v1/draft")
+
+ private fun proceedWithTimeZoneHeader(chain: Interceptor.Chain, request: okhttp3.Request): Response =
+ chain.proceed(request.newBuilder().addHeader(TIME_ZONE, userTimeZone).build())
+
+ companion object {
+ private const val TIME_ZONE = "Time-Zone"
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/data/repositoryimpl/AuthRepositoryImpl.kt b/app/src/main/java/com/sopt/clody/data/repositoryimpl/AuthRepositoryImpl.kt
index 854fcaf7..824acfcd 100644
--- a/app/src/main/java/com/sopt/clody/data/repositoryimpl/AuthRepositoryImpl.kt
+++ b/app/src/main/java/com/sopt/clody/data/repositoryimpl/AuthRepositoryImpl.kt
@@ -1,6 +1,7 @@
package com.sopt.clody.data.repositoryimpl
import com.sopt.clody.data.remote.datasource.AuthDataSource
+import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto
import com.sopt.clody.data.remote.dto.request.LoginRequestDto
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
import com.sopt.clody.data.remote.dto.response.LoginResponseDto
@@ -21,4 +22,9 @@ class AuthRepositoryImpl @Inject constructor(
runCatching {
authDataSource.signUp(authorization, requestSignUpDto).handleApiResponse().getOrThrow()
}
+
+ override suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): Result =
+ runCatching {
+ authDataSource.signUpWithGoogle(googleSignUpRequestDto).handleApiResponse().getOrThrow()
+ }
}
diff --git a/app/src/main/java/com/sopt/clody/data/repositoryimpl/DiaryRepositoryImpl.kt b/app/src/main/java/com/sopt/clody/data/repositoryimpl/DiaryRepositoryImpl.kt
index 20d14634..98d02247 100644
--- a/app/src/main/java/com/sopt/clody/data/repositoryimpl/DiaryRepositoryImpl.kt
+++ b/app/src/main/java/com/sopt/clody/data/repositoryimpl/DiaryRepositoryImpl.kt
@@ -1,83 +1,40 @@
package com.sopt.clody.data.repositoryimpl
import com.sopt.clody.data.remote.datasource.DiaryRemoteDataSource
-import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto
-import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto
-import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
-import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto
-import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto
-import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto
-import com.sopt.clody.data.remote.util.handleApiResponse
+import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto
import com.sopt.clody.domain.repository.DiaryRepository
-import com.sopt.clody.presentation.utils.network.ErrorMessages
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE
-import retrofit2.HttpException
import javax.inject.Inject
class DiaryRepositoryImpl @Inject constructor(
private val diaryRemoteDataSource: DiaryRemoteDataSource,
) : DiaryRepository {
- override suspend fun writeDiary(date: String, content: List): Result =
- runCatching {
- diaryRemoteDataSource.writeDiary(date, content).handleApiResponse().getOrThrow()
- }
- override suspend fun deleteDailyDiary(year: Int, month: Int, day: Int): Result =
- runCatching {
- diaryRemoteDataSource.deleteDailyDiary(year, month, day).handleApiResponse().getOrThrow()
- }
+ override suspend fun writeDiary(lang: String, date: String, content: List) =
+ diaryRemoteDataSource.writeDiary(lang, date, content)
- override suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): Result =
- runCatching {
- diaryRemoteDataSource.getDailyDiariesData(year, month, date).handleApiResponse()
- }.getOrElse { exception ->
- val errorMessage = when (exception) {
- is HttpException -> {
- when (exception.code()) {
- in 400..499 -> ErrorMessages.FAILURE_TEMPORARY_MESSAGE
- in 500..599 -> ErrorMessages.FAILURE_SERVER_MESSAGE
- else -> ErrorMessages.UNKNOWN_ERROR
- }
- }
- else -> exception.message ?: ErrorMessages.UNKNOWN_ERROR
- }
- Result.failure(Exception(errorMessage))
- }
+ override suspend fun deleteDailyDiary(year: Int, month: Int, day: Int) =
+ diaryRemoteDataSource.deleteDailyDiary(year, month, day)
- override suspend fun getDiaryTime(year: Int, month: Int, date: Int): Result =
- runCatching {
- diaryRemoteDataSource.getDiaryTime(year, month, date).data
- }
+ override suspend fun getDailyDiariesData(year: Int, month: Int, date: Int) =
+ diaryRemoteDataSource.getDailyDiariesData(year, month, date)
- override suspend fun getMonthlyCalendarData(year: Int, month: Int): Result =
- runCatching {
- diaryRemoteDataSource.getMonthlyCalendarData(year, month).handleApiResponse()
- }.getOrElse { exception ->
- val errorMessage = when (exception) {
- is HttpException -> {
- when (exception.code()) {
- in 400..499 -> ErrorMessages.FAILURE_TEMPORARY_MESSAGE
- in 500..599 -> ErrorMessages.FAILURE_SERVER_MESSAGE
- else -> ErrorMessages.UNKNOWN_ERROR
- }
- }
+ override suspend fun getDiaryTime(year: Int, month: Int, date: Int) =
+ diaryRemoteDataSource.getDiaryTime(year, month, date)
- else -> exception.message ?: ErrorMessages.UNKNOWN_ERROR
- }
- Result.failure(Exception(errorMessage))
- }
+ override suspend fun getMonthlyCalendarData(year: Int, month: Int) =
+ diaryRemoteDataSource.getMonthlyCalendarData(year, month)
- override suspend fun getMonthlyDiary(year: Int, month: Int): Result =
- runCatching {
- diaryRemoteDataSource.getMonthlyDiary(year, month).handleApiResponse().getOrThrow()
- }
+ override suspend fun getMonthlyDiary(year: Int, month: Int) =
+ diaryRemoteDataSource.getMonthlyDiary(year, month)
- override suspend fun getReplyDiary(year: Int, month: Int, date: Int): Result =
- runCatching {
- val response = diaryRemoteDataSource.getReplyDiary(year, month, date).data
- if (response.content == null) {
- throw IllegalStateException(FAILURE_TEMPORARY_MESSAGE)
- }
- response
- }
+ override suspend fun getReplyDiary(year: Int, month: Int, date: Int) =
+ diaryRemoteDataSource.getReplyDiary(year, month, date)
+
+ override suspend fun fetchDraftDiary(year: Int, month: Int, date: Int) =
+ diaryRemoteDataSource.fetchDraftDiary(year, month, date).map { it.toDomain() }
+
+ override suspend fun saveDraftDiary(date: String, contents: List): Result {
+ val request = SaveDraftDiaryRequestDto(date, contents)
+ return diaryRemoteDataSource.saveDraftDiary(request)
+ }
}
diff --git a/app/src/main/java/com/sopt/clody/data/repositoryimpl/DraftRepositoryImpl.kt b/app/src/main/java/com/sopt/clody/data/repositoryimpl/DraftRepositoryImpl.kt
new file mode 100644
index 00000000..2f0872f1
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/repositoryimpl/DraftRepositoryImpl.kt
@@ -0,0 +1,21 @@
+package com.sopt.clody.data.repositoryimpl
+
+import com.sopt.clody.data.local.datasource.FirstDraftLocalDataSource
+import com.sopt.clody.domain.repository.DraftRepository
+import javax.inject.Inject
+
+class DraftRepositoryImpl @Inject constructor(
+ private val firstDraftLocalDataSource: FirstDraftLocalDataSource,
+) : DraftRepository {
+ override fun getIsDraftUsed(): Boolean = firstDraftLocalDataSource.isDraftUsed
+
+ override fun setIsDraftUsed(state: Boolean) {
+ firstDraftLocalDataSource.isDraftUsed = state
+ }
+
+ override fun getIsFirstUse(): Boolean = firstDraftLocalDataSource.isFirstUse
+
+ override fun setIsFirstUse(state: Boolean) {
+ firstDraftLocalDataSource.isFirstUse = state
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/data/repositoryimpl/ReviewRepositoryImpl.kt b/app/src/main/java/com/sopt/clody/data/repositoryimpl/ReviewRepositoryImpl.kt
new file mode 100644
index 00000000..7421396a
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/data/repositoryimpl/ReviewRepositoryImpl.kt
@@ -0,0 +1,15 @@
+package com.sopt.clody.data.repositoryimpl
+
+import com.sopt.clody.data.local.datasource.AppReviewLocalDataSource
+import com.sopt.clody.domain.repository.ReviewRepository
+import javax.inject.Inject
+
+class ReviewRepositoryImpl @Inject constructor(
+ private val appReviewLocalDataSource: AppReviewLocalDataSource,
+) : ReviewRepository {
+ override fun getShouldShowPopup(): Boolean = appReviewLocalDataSource.shouldShowPopup
+
+ override fun setShouldShowPopup(state: Boolean) {
+ appReviewLocalDataSource.shouldShowPopup = state
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/di/AdModule.kt b/app/src/main/java/com/sopt/clody/di/AdModule.kt
index 928e59cc..b6ed4393 100644
--- a/app/src/main/java/com/sopt/clody/di/AdModule.kt
+++ b/app/src/main/java/com/sopt/clody/di/AdModule.kt
@@ -1,6 +1,6 @@
package com.sopt.clody.di
-import com.sopt.clody.core.RewardAdShower
+import com.sopt.clody.core.ad.RewardAdShower
import com.sopt.clody.data.ad.RewardAdShowerImpl
import dagger.Binds
import dagger.Module
diff --git a/app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt b/app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt
index f584941f..dcb8ea68 100644
--- a/app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt
+++ b/app/src/main/java/com/sopt/clody/di/AppUpdateModule.kt
@@ -1,6 +1,7 @@
package com.sopt.clody.di
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
+import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings
import com.sopt.clody.data.remote.appupdate.AppUpdateCheckerImpl
import com.sopt.clody.data.remote.datasource.RemoteConfigDataSource
import com.sopt.clody.domain.appupdate.AppUpdateChecker
@@ -16,8 +17,14 @@ object AppUpdateModule {
@Provides
@Singleton
- fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig =
- FirebaseRemoteConfig.getInstance()
+ fun provideFirebaseRemoteConfig(): FirebaseRemoteConfig {
+ val remoteConfig = FirebaseRemoteConfig.getInstance()
+ val configSettings = FirebaseRemoteConfigSettings.Builder()
+ .setMinimumFetchIntervalInSeconds(0)
+ .build()
+ remoteConfig.setConfigSettingsAsync(configSettings)
+ return remoteConfig
+ }
@Provides
@Singleton
diff --git a/app/src/main/java/com/sopt/clody/di/LocalDataSourceModule.kt b/app/src/main/java/com/sopt/clody/di/LocalDataSourceModule.kt
new file mode 100644
index 00000000..648f66dd
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/di/LocalDataSourceModule.kt
@@ -0,0 +1,37 @@
+package com.sopt.clody.di
+
+import android.content.SharedPreferences
+import com.sopt.clody.data.datastore.TokenDataStore
+import com.sopt.clody.data.datastore.TokenDataStoreImpl
+import com.sopt.clody.data.local.datasource.AppReviewLocalDataSource
+import com.sopt.clody.data.local.datasource.FirstDraftLocalDataSource
+import com.sopt.clody.data.local.datasourceimpl.AppReviewLocalDataSourceImpl
+import com.sopt.clody.data.local.datasourceimpl.FirstDraftLocalDataSourceImpl
+import com.sopt.clody.di.qualifier.FirstDraftPrefs
+import com.sopt.clody.di.qualifier.ReviewPrefs
+import com.sopt.clody.di.qualifier.TokenPrefs
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object LocalDataSourceModule {
+
+ @Provides
+ @Singleton
+ fun provideTokenDataStore(@TokenPrefs sharedPreferences: SharedPreferences): TokenDataStore =
+ TokenDataStoreImpl(sharedPreferences)
+
+ @Provides
+ @Singleton
+ fun provideFirstDraftLocalDataSource(@FirstDraftPrefs sharedPreferences: SharedPreferences): FirstDraftLocalDataSource =
+ FirstDraftLocalDataSourceImpl(sharedPreferences)
+
+ @Provides
+ @Singleton
+ fun provideAppReviewLocalDataSource(@ReviewPrefs sharedPreferences: SharedPreferences): AppReviewLocalDataSource =
+ AppReviewLocalDataSourceImpl(sharedPreferences)
+}
diff --git a/app/src/main/java/com/sopt/clody/di/NetworkModule.kt b/app/src/main/java/com/sopt/clody/di/NetworkModule.kt
index 4399784d..3fc7aeb5 100644
--- a/app/src/main/java/com/sopt/clody/di/NetworkModule.kt
+++ b/app/src/main/java/com/sopt/clody/di/NetworkModule.kt
@@ -1,12 +1,11 @@
package com.sopt.clody.di
import android.content.Context
-import android.net.ConnectivityManager
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.sopt.clody.BuildConfig
import com.sopt.clody.data.datastore.TokenDataStore
import com.sopt.clody.data.remote.util.AuthInterceptor
-import com.sopt.clody.data.remote.util.NetworkUtil
+import com.sopt.clody.data.remote.util.TimeZoneInterceptor
import com.sopt.clody.domain.repository.TokenReissueRepository
import dagger.Module
import dagger.Provides
@@ -41,15 +40,21 @@ object NetworkModule {
return AuthInterceptor(tokenReissueRepository, tokenDataStore, context)
}
+ @Provides
+ @Singleton
+ fun provideTimeZoneInterceptor(): TimeZoneInterceptor = TimeZoneInterceptor()
+
@Provides
@Singleton
fun provideClodyOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
oauthInterceptor: AuthInterceptor,
+ timeZoneInterceptor: TimeZoneInterceptor,
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(oauthInterceptor)
+ .addInterceptor(timeZoneInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
@@ -72,11 +77,4 @@ object NetworkModule {
.client(okHttpClient)
.build()
}
-
- @Provides
- @Singleton
- fun provideNetworkUtil(@ApplicationContext context: Context): NetworkUtil {
- val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
- return NetworkUtil(connectivityManager)
- }
}
diff --git a/app/src/main/java/com/sopt/clody/di/DataSourceModule.kt b/app/src/main/java/com/sopt/clody/di/RemoteDataSourceModule.kt
similarity index 97%
rename from app/src/main/java/com/sopt/clody/di/DataSourceModule.kt
rename to app/src/main/java/com/sopt/clody/di/RemoteDataSourceModule.kt
index 4d570e72..fa6da505 100644
--- a/app/src/main/java/com/sopt/clody/di/DataSourceModule.kt
+++ b/app/src/main/java/com/sopt/clody/di/RemoteDataSourceModule.kt
@@ -18,7 +18,7 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
-abstract class DataSourceModule {
+abstract class RemoteDataSourceModule {
@Binds
@Singleton
abstract fun bindAuthDataSource(
diff --git a/app/src/main/java/com/sopt/clody/di/RepositoryModule.kt b/app/src/main/java/com/sopt/clody/di/RepositoryModule.kt
index 006a0064..008046e6 100644
--- a/app/src/main/java/com/sopt/clody/di/RepositoryModule.kt
+++ b/app/src/main/java/com/sopt/clody/di/RepositoryModule.kt
@@ -4,14 +4,18 @@ import com.sopt.clody.data.repositoryimpl.AccountManagementRepositoryImpl
import com.sopt.clody.data.repositoryimpl.AdRepositoryImpl
import com.sopt.clody.data.repositoryimpl.AuthRepositoryImpl
import com.sopt.clody.data.repositoryimpl.DiaryRepositoryImpl
+import com.sopt.clody.data.repositoryimpl.DraftRepositoryImpl
import com.sopt.clody.data.repositoryimpl.NotificationRepositoryImpl
+import com.sopt.clody.data.repositoryimpl.ReviewRepositoryImpl
import com.sopt.clody.data.repositoryimpl.TokenReissueRepositoryImpl
import com.sopt.clody.data.repositoryimpl.TokenRepositoryImpl
import com.sopt.clody.domain.repository.AccountManagementRepository
import com.sopt.clody.domain.repository.AdRepository
import com.sopt.clody.domain.repository.AuthRepository
import com.sopt.clody.domain.repository.DiaryRepository
+import com.sopt.clody.domain.repository.DraftRepository
import com.sopt.clody.domain.repository.NotificationRepository
+import com.sopt.clody.domain.repository.ReviewRepository
import com.sopt.clody.domain.repository.TokenReissueRepository
import com.sopt.clody.domain.repository.TokenRepository
import dagger.Binds
@@ -64,4 +68,16 @@ abstract class RepositoryModule {
abstract fun bindAdRepository(
adRepositoryImpl: AdRepositoryImpl,
): AdRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindDraftRepository(
+ draftRepositoryImpl: DraftRepositoryImpl,
+ ): DraftRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindReviewRepository(
+ reviewRepositoryImpl: ReviewRepositoryImpl,
+ ): ReviewRepository
}
diff --git a/app/src/main/java/com/sopt/clody/di/SharedPreferencesModule.kt b/app/src/main/java/com/sopt/clody/di/SharedPreferencesModule.kt
index 4ea8c970..795a2b89 100644
--- a/app/src/main/java/com/sopt/clody/di/SharedPreferencesModule.kt
+++ b/app/src/main/java/com/sopt/clody/di/SharedPreferencesModule.kt
@@ -2,6 +2,9 @@ package com.sopt.clody.di
import android.content.Context
import android.content.SharedPreferences
+import com.sopt.clody.di.qualifier.FirstDraftPrefs
+import com.sopt.clody.di.qualifier.ReviewPrefs
+import com.sopt.clody.di.qualifier.TokenPrefs
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -12,9 +15,25 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SharedPreferencesModule {
+
@Provides
@Singleton
- fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
+ @TokenPrefs
+ fun provideTokenSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
return context.getSharedPreferences("token_prefs", Context.MODE_PRIVATE)
}
+
+ @Provides
+ @Singleton
+ @FirstDraftPrefs
+ fun provideFirstDraftSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
+ return context.getSharedPreferences("first_draft_prefs", Context.MODE_PRIVATE)
+ }
+
+ @Provides
+ @Singleton
+ @ReviewPrefs
+ fun provideReviewSharedPreferences(@ApplicationContext context: Context): SharedPreferences {
+ return context.getSharedPreferences("review_prefs", Context.MODE_PRIVATE)
+ }
}
diff --git a/app/src/main/java/com/sopt/clody/di/TokenDataStoreModule.kt b/app/src/main/java/com/sopt/clody/di/TokenDataStoreModule.kt
deleted file mode 100644
index 3f1462ad..00000000
--- a/app/src/main/java/com/sopt/clody/di/TokenDataStoreModule.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.sopt.clody.di
-
-import android.content.SharedPreferences
-import com.sopt.clody.data.datastore.TokenDataStore
-import com.sopt.clody.data.datastore.TokenDataStoreImpl
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.components.SingletonComponent
-import javax.inject.Singleton
-
-@Module
-@InstallIn(SingletonComponent::class)
-object TokenDataStoreModule {
- @Provides
- @Singleton
- fun provideTokenDataStore(sharedPreferences: SharedPreferences): TokenDataStore {
- return TokenDataStoreImpl(sharedPreferences)
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/di/qualifier/Qualifier.kt b/app/src/main/java/com/sopt/clody/di/qualifier/Qualifier.kt
new file mode 100644
index 00000000..8af33e80
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/di/qualifier/Qualifier.kt
@@ -0,0 +1,15 @@
+package com.sopt.clody.di.qualifier
+
+import javax.inject.Qualifier
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class TokenPrefs
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class FirstDraftPrefs
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class ReviewPrefs
diff --git a/app/src/main/java/com/sopt/clody/domain/Notification.kt b/app/src/main/java/com/sopt/clody/domain/Notification.kt
new file mode 100644
index 00000000..5135ab84
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/domain/Notification.kt
@@ -0,0 +1,5 @@
+package com.sopt.clody.domain
+
+enum class Notification {
+ DIARY, DRAFT, REPLY
+}
diff --git a/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt b/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt
index e8f3e7d6..de3f683f 100644
--- a/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt
+++ b/app/src/main/java/com/sopt/clody/domain/appupdate/AppUpdateChecker.kt
@@ -4,4 +4,6 @@ import com.sopt.clody.domain.model.AppUpdateState
interface AppUpdateChecker {
suspend fun getAppUpdateState(currentVersion: String): AppUpdateState
+ suspend fun isUnderInspection(): Boolean
+ fun getInspectionTimeText(): String?
}
diff --git a/app/src/main/java/com/sopt/clody/domain/model/DraftDiaryContents.kt b/app/src/main/java/com/sopt/clody/domain/model/DraftDiaryContents.kt
new file mode 100644
index 00000000..45ddcbd1
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/domain/model/DraftDiaryContents.kt
@@ -0,0 +1,5 @@
+package com.sopt.clody.domain.model
+
+data class DraftDiaryContents(
+ val draftDiaries: List,
+)
diff --git a/app/src/main/java/com/sopt/clody/domain/model/ReplyStatus.kt b/app/src/main/java/com/sopt/clody/domain/model/ReplyStatus.kt
new file mode 100644
index 00000000..f1c5885f
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/domain/model/ReplyStatus.kt
@@ -0,0 +1,11 @@
+package com.sopt.clody.domain.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+enum class ReplyStatus {
+ UNREADY, READY_READ, READY_NOT_READ, HAS_DRAFT, INVALID_DRAFT;
+
+ val isUnreadOrNotRead: Boolean
+ get() = this == UNREADY || this == READY_NOT_READ
+}
diff --git a/app/src/main/java/com/sopt/clody/domain/repository/AuthRepository.kt b/app/src/main/java/com/sopt/clody/domain/repository/AuthRepository.kt
index e9b822ec..55f1053c 100644
--- a/app/src/main/java/com/sopt/clody/domain/repository/AuthRepository.kt
+++ b/app/src/main/java/com/sopt/clody/domain/repository/AuthRepository.kt
@@ -1,5 +1,6 @@
package com.sopt.clody.domain.repository
+import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto
import com.sopt.clody.data.remote.dto.request.LoginRequestDto
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
import com.sopt.clody.data.remote.dto.response.LoginResponseDto
@@ -8,4 +9,5 @@ import com.sopt.clody.data.remote.dto.response.SignUpResponseDto
interface AuthRepository {
suspend fun signIn(authorization: String, requestSignInDto: LoginRequestDto): Result
suspend fun signUp(authorization: String, requestSignUpDto: SignUpRequestDto): Result
+ suspend fun signUpWithGoogle(googleSignUpRequestDto: GoogleSignUpRequestDto): Result
}
diff --git a/app/src/main/java/com/sopt/clody/domain/repository/DiaryRepository.kt b/app/src/main/java/com/sopt/clody/domain/repository/DiaryRepository.kt
index 9c95565e..0ab3ff7f 100644
--- a/app/src/main/java/com/sopt/clody/domain/repository/DiaryRepository.kt
+++ b/app/src/main/java/com/sopt/clody/domain/repository/DiaryRepository.kt
@@ -6,13 +6,16 @@ import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto
import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto
import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto
+import com.sopt.clody.domain.model.DraftDiaryContents
interface DiaryRepository {
- suspend fun writeDiary(date: String, content: List): Result
+ suspend fun writeDiary(lang: String, date: String, content: List): Result
suspend fun deleteDailyDiary(year: Int, month: Int, day: Int): Result
suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): Result
suspend fun getDiaryTime(year: Int, month: Int, date: Int): Result
suspend fun getMonthlyCalendarData(year: Int, month: Int): Result
suspend fun getMonthlyDiary(year: Int, month: Int): Result
suspend fun getReplyDiary(year: Int, month: Int, date: Int): Result
+ suspend fun fetchDraftDiary(year: Int, month: Int, date: Int): Result
+ suspend fun saveDraftDiary(date: String, contents: List): Result
}
diff --git a/app/src/main/java/com/sopt/clody/domain/repository/DraftRepository.kt b/app/src/main/java/com/sopt/clody/domain/repository/DraftRepository.kt
new file mode 100644
index 00000000..7e193136
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/domain/repository/DraftRepository.kt
@@ -0,0 +1,8 @@
+package com.sopt.clody.domain.repository
+
+interface DraftRepository {
+ fun getIsDraftUsed(): Boolean
+ fun setIsDraftUsed(state: Boolean)
+ fun getIsFirstUse(): Boolean
+ fun setIsFirstUse(state: Boolean)
+}
diff --git a/app/src/main/java/com/sopt/clody/domain/repository/ReviewRepository.kt b/app/src/main/java/com/sopt/clody/domain/repository/ReviewRepository.kt
new file mode 100644
index 00000000..044a0cf5
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/domain/repository/ReviewRepository.kt
@@ -0,0 +1,6 @@
+package com.sopt.clody.domain.repository
+
+interface ReviewRepository {
+ fun getShouldShowPopup(): Boolean
+ fun setShouldShowPopup(state: Boolean)
+}
diff --git a/app/src/main/java/com/sopt/clody/domain/usecase/FetchDraftDiaryUseCase.kt b/app/src/main/java/com/sopt/clody/domain/usecase/FetchDraftDiaryUseCase.kt
new file mode 100644
index 00000000..382be324
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/domain/usecase/FetchDraftDiaryUseCase.kt
@@ -0,0 +1,11 @@
+package com.sopt.clody.domain.usecase
+
+import com.sopt.clody.domain.repository.DiaryRepository
+import javax.inject.Inject
+
+class FetchDraftDiaryUseCase @Inject constructor(
+ private val diaryRepository: DiaryRepository,
+) {
+ suspend operator fun invoke(year: Int, month: Int, day: Int) =
+ diaryRepository.fetchDraftDiary(year, month, day)
+}
diff --git a/app/src/main/java/com/sopt/clody/domain/usecase/SaveDraftDiaryUseCase.kt b/app/src/main/java/com/sopt/clody/domain/usecase/SaveDraftDiaryUseCase.kt
new file mode 100644
index 00000000..e37e19cc
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/domain/usecase/SaveDraftDiaryUseCase.kt
@@ -0,0 +1,11 @@
+package com.sopt.clody.domain.usecase
+
+import com.sopt.clody.domain.repository.DiaryRepository
+import javax.inject.Inject
+
+class SaveDraftDiaryUseCase @Inject constructor(
+ private val diaryRepository: DiaryRepository,
+) {
+ suspend operator fun invoke(date: String, contents: List) =
+ diaryRepository.saveDraftDiary(date, contents)
+}
diff --git a/app/src/main/java/com/sopt/clody/domain/usecase/WriteDiaryUseCase.kt b/app/src/main/java/com/sopt/clody/domain/usecase/WriteDiaryUseCase.kt
new file mode 100644
index 00000000..56e43446
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/domain/usecase/WriteDiaryUseCase.kt
@@ -0,0 +1,11 @@
+package com.sopt.clody.domain.usecase
+
+import com.sopt.clody.domain.repository.DiaryRepository
+import javax.inject.Inject
+
+class WriteDiaryUseCase @Inject constructor(
+ private val diaryRepository: DiaryRepository,
+) {
+ suspend operator fun invoke(lang: String, date: String, content: List) =
+ diaryRepository.writeDiary(lang, date, content)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/di/LanguageModule.kt b/app/src/main/java/com/sopt/clody/presentation/di/LanguageModule.kt
new file mode 100644
index 00000000..81d46845
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/di/LanguageModule.kt
@@ -0,0 +1,18 @@
+package com.sopt.clody.presentation.di
+
+import com.sopt.clody.presentation.utils.language.LanguageProvider
+import com.sopt.clody.presentation.utils.language.LanguageProviderImpl
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface LanguageModule {
+
+ @Binds
+ fun bindLanguageProvider(
+ languageProviderImpl: LanguageProviderImpl,
+ ): LanguageProvider
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/di/ViewModelsModule.kt b/app/src/main/java/com/sopt/clody/presentation/di/ViewModelsModule.kt
new file mode 100644
index 00000000..420e1f68
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/di/ViewModelsModule.kt
@@ -0,0 +1,38 @@
+package com.sopt.clody.presentation.di
+
+import com.airbnb.mvrx.hilt.AssistedViewModelFactory
+import com.airbnb.mvrx.hilt.MavericksViewModelComponent
+import com.airbnb.mvrx.hilt.ViewModelKey
+import com.sopt.clody.presentation.ui.auth.signup.SignUpViewModel
+import com.sopt.clody.presentation.ui.login.LoginViewModel
+import com.sopt.clody.presentation.ui.splash.SplashViewModel
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.multibindings.IntoMap
+
+@Module
+@InstallIn(MavericksViewModelComponent::class)
+interface ViewModelsModule {
+
+ @Binds
+ @IntoMap
+ @ViewModelKey(SplashViewModel::class)
+ fun bindSplashViewModelFactory(
+ factory: SplashViewModel.Factory,
+ ): AssistedViewModelFactory<*, *>
+
+ @Binds
+ @IntoMap
+ @ViewModelKey(LoginViewModel::class)
+ fun bindLoginViewModelFactory(
+ factory: LoginViewModel.Factory,
+ ): AssistedViewModelFactory<*, *>
+
+ @Binds
+ @IntoMap
+ @ViewModelKey(SignUpViewModel::class)
+ fun bindSignUpViewModelFactory(
+ factory: SignUpViewModel.Factory,
+ ): AssistedViewModelFactory<*, *>
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/button/GoogleButton.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/button/GoogleButton.kt
new file mode 100644
index 00000000..d5b2b7f1
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/button/GoogleButton.kt
@@ -0,0 +1,63 @@
+package com.sopt.clody.presentation.ui.auth.component.button
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.sopt.clody.R
+import com.sopt.clody.ui.theme.ClodyTheme
+
+@Composable
+fun GoogleButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Button(
+ onClick = onClick,
+ colors = ButtonDefaults.buttonColors(containerColor = ClodyTheme.colors.gray08),
+ shape = RoundedCornerShape(10.dp),
+ modifier = modifier.height(48.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.img_google_button_logo),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ Text(
+ text = text,
+ style = ClodyTheme.typography.body2SemiBold,
+ color = ClodyTheme.colors.gray01,
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun GoogleButtonPreview() {
+ GoogleButton(
+ text = "Sign Up With Google",
+ onClick = { /*TODO*/ },
+ )
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/button/KaKaoButton.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/button/KaKaoButton.kt
index a1fe84d0..38791744 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/button/KaKaoButton.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/button/KaKaoButton.kt
@@ -5,9 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -33,10 +31,7 @@ fun KaKaoButton(
onClick = onClick,
colors = ButtonDefaults.buttonColors(containerColor = ClodyTheme.colors.kakaoYellow),
shape = RoundedCornerShape(10.dp),
- modifier = modifier
- .fillMaxWidth()
- .height(48.dp)
- .padding(horizontal = 24.dp),
+ modifier = modifier.height(48.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/button/NextButton.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/button/NextButton.kt
deleted file mode 100644
index 70816f4c..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/button/NextButton.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.sopt.clody.presentation.ui.auth.component.button
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
-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.res.painterResource
-import androidx.compose.ui.unit.dp
-
-@Composable
-fun NextButton(
- onClick: () -> Unit,
- imageResource: Int,
- contentDescription: String? = null,
-) {
- Box(
- modifier = Modifier.run {
- size(23.dp)
- .clip(CircleShape)
- .clickable(
- onClick = onClick,
- )
- },
- contentAlignment = Alignment.Center,
- ) {
- Image(
- painter = painterResource(id = imageResource),
- contentDescription = contentDescription,
- )
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/textfield/NickNameTextField.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/textfield/NickNameTextField.kt
index 906a87bf..711fff7d 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/textfield/NickNameTextField.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/textfield/NickNameTextField.kt
@@ -3,8 +3,6 @@ package com.sopt.clody.presentation.ui.auth.component.textfield
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -12,25 +10,26 @@ 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.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sopt.clody.R
import com.sopt.clody.ui.theme.ClodyTheme
+
@Composable
fun NickNameTextField(
- value: TextFieldValue,
- onValueChange: (TextFieldValue) -> Unit,
+ value: String,
+ onValueChange: (String) -> Unit,
+ maxLength: Int,
isFocused: Boolean,
isValid: Boolean,
onRemove: () -> Unit,
@@ -38,86 +37,71 @@ fun NickNameTextField(
modifier: Modifier = Modifier,
hint: String = "",
) {
- val maxLength = 10
-
- Box(modifier = modifier) {
- BasicTextField(
- value = value,
- onValueChange = {
- if (it.text.length <= maxLength) {
- onValueChange(it)
- }
+ BasicTextField(
+ value = value,
+ onValueChange = {
+ if (it.length <= maxLength) {
+ onValueChange(it)
+ }
+ },
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .onFocusChanged { focusState ->
+ onFocusChanged(focusState.isFocused)
},
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp)
- .onFocusChanged { focusState ->
- onFocusChanged(focusState.isFocused)
- },
- singleLine = true,
- textStyle = TextStyle(color = ClodyTheme.colors.gray01),
- cursorBrush = SolidColor(ClodyTheme.colors.gray01),
- decorationBox = { innerTextField ->
- Column(
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 0.dp, vertical = 0.dp),
- verticalArrangement = Arrangement.Center,
+ singleLine = true,
+ textStyle = TextStyle(color = ClodyTheme.colors.gray01),
+ cursorBrush = SolidColor(ClodyTheme.colors.gray01),
+ decorationBox = { innerTextField ->
+ Column {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Box(modifier = Modifier.weight(1f)) {
- if (value.text.isEmpty() && !isFocused) {
- Text(
- text = hint,
- style = ClodyTheme.typography.body1Medium,
- color = ClodyTheme.colors.gray05,
- )
- }
- innerTextField() // 실제 입력 필드
- }
- Box(
- modifier = Modifier
- .clickable(
- onClick = onRemove,
- indication = null,
- interactionSource = remember { MutableInteractionSource() },
- ),
- contentAlignment = Alignment.Center,
- ) {
- Image(
- painter = painterResource(id = R.drawable.ic_nickname_delete),
- contentDescription = null,
+ Box(modifier = Modifier.weight(1f)) {
+ if (value.isEmpty() && !isFocused) {
+ Text(
+ text = hint,
+ style = ClodyTheme.typography.body1Medium,
+ color = ClodyTheme.colors.gray05,
)
}
+ innerTextField()
}
- Spacer(modifier = Modifier.height(4.dp))
- Box(
- modifier = Modifier
- .height(2.dp)
- .fillMaxWidth()
- .background(
- when {
- isValid.not() -> ClodyTheme.colors.red
- isFocused -> ClodyTheme.colors.mainYellow
- else -> ClodyTheme.colors.gray08
- },
- ),
+ Spacer(modifier = Modifier.width(8.dp))
+ Image(
+ painter = painterResource(id = R.drawable.ic_nickname_delete),
+ contentDescription = null,
+ modifier = Modifier.clickable { onRemove() },
)
}
- },
- )
- }
+ Spacer(modifier = Modifier.height(4.dp))
+ Box(
+ modifier = Modifier
+ .height(2.dp)
+ .fillMaxWidth()
+ .background(
+ when {
+ value.isEmpty() -> ClodyTheme.colors.gray08
+ !isValid -> ClodyTheme.colors.red
+ isFocused -> ClodyTheme.colors.mainYellow
+ else -> ClodyTheme.colors.gray08
+ },
+ ),
+ )
+ }
+ },
+ )
}
@Preview(showBackground = true)
@Composable
fun PreviewNickNameTextField() {
NickNameTextField(
- value = TextFieldValue(""),
+ value = "닉네임",
onValueChange = {},
+ maxLength = 15,
isFocused = false,
isValid = true,
onRemove = {},
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/timepicker/BottomSheetTimePicker.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/timepicker/BottomSheetTimePicker.kt
index 6e486ad9..d35dcf10 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/timepicker/BottomSheetTimePicker.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/component/timepicker/BottomSheetTimePicker.kt
@@ -6,6 +6,7 @@ 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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -28,13 +29,30 @@ import com.sopt.clody.R
import com.sopt.clody.presentation.ui.component.button.ClodyButton
import com.sopt.clody.presentation.ui.component.timepicker.ClodyPicker
import com.sopt.clody.presentation.ui.component.timepicker.rememberPickerState
+import com.sopt.clody.presentation.utils.extension.TimePeriod
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun BottomSheetTimePicker(
onDismissRequest: () -> Unit,
- onRemindTimeSelected: (String, String, String) -> Unit,
+ onRemindTimeSelected: (TimePeriod, String, String) -> Unit,
) {
+ val amPmEnumItems = listOf(TimePeriod.AM, TimePeriod.PM)
+ val amPmLabelItems = amPmEnumItems.map { it.getLabel() }
+
+ val hourItems = remember { (1..12).map { it.toString() } }
+ val minuteItems = remember { listOf("00", "10", "20", "30", "40", "50") }
+
+ val amPmPickerState = rememberPickerState().apply {
+ selectedItem = amPmLabelItems[1]
+ }
+ val hourPickerState = rememberPickerState().apply {
+ selectedItem = "9"
+ }
+ val minutePickerState = rememberPickerState().apply {
+ selectedItem = "30"
+ }
+
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -49,7 +67,6 @@ fun BottomSheetTimePicker(
.wrapContentSize()
.background(color = ClodyTheme.colors.white)
.padding(horizontal = 24.dp),
-
) {
Box(
modifier = Modifier
@@ -57,7 +74,7 @@ fun BottomSheetTimePicker(
.padding(top = 16.dp, bottom = 30.dp),
) {
Text(
- stringResource(id = R.string.time_picker_title),
+ stringResource(id = R.string.bottom_sheet_time_reminder_picker_title),
style = ClodyTheme.typography.head4,
color = ClodyTheme.colors.gray01,
modifier = Modifier.align(Alignment.Center),
@@ -76,18 +93,7 @@ fun BottomSheetTimePicker(
}
}
- val amPmItems = remember { listOf("오후", "오전") }
- val hourItems = remember { (1..12).map { it.toString() } }
- val minuteItems = remember { listOf("00", "10", "20", "30", "40", "50") }
-
- val amPmPickerState = rememberPickerState()
- val hourPickerState = rememberPickerState()
- val minutePickerState = rememberPickerState()
-
- Box(
- modifier = Modifier
- .fillMaxWidth(),
- ) {
+ Box(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -96,18 +102,17 @@ fun BottomSheetTimePicker(
.background(ClodyTheme.colors.gray08, shape = RoundedCornerShape(8.dp)),
)
Row(
- modifier = Modifier
- .fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
+ Spacer(Modifier.weight(1f))
ClodyPicker(
state = amPmPickerState,
- items = amPmItems,
+ items = amPmLabelItems,
startIndex = 1,
visibleItemsCount = 3,
infiniteScroll = false,
- modifier = Modifier
- .weight(1f),
+ modifier = Modifier.weight(1f),
textModifier = Modifier.padding(8.dp),
)
ClodyPicker(
@@ -116,8 +121,7 @@ fun BottomSheetTimePicker(
startIndex = 8,
visibleItemsCount = 5,
infiniteScroll = true,
- modifier = Modifier
- .weight(1f),
+ modifier = Modifier.weight(1f),
textModifier = Modifier.padding(8.dp),
)
ClodyPicker(
@@ -126,22 +130,23 @@ fun BottomSheetTimePicker(
startIndex = 3,
visibleItemsCount = 5,
infiniteScroll = true,
- modifier = Modifier
- .weight(1f),
+ modifier = Modifier.weight(1f),
textModifier = Modifier.padding(8.dp),
)
+ Spacer(Modifier.weight(1f))
}
}
ClodyButton(
onClick = {
- onRemindTimeSelected(
- amPmPickerState.selectedItem,
- hourPickerState.selectedItem,
- minutePickerState.selectedItem,
- )
+ val selectedLabel = amPmPickerState.selectedItem
+ val selectedPeriod = amPmEnumItems.getOrElse(amPmLabelItems.indexOf(selectedLabel)) { TimePeriod.PM }
+ val selectedHour = hourPickerState.selectedItem
+ val selectedMinute = minutePickerState.selectedItem
+
+ onRemindTimeSelected(selectedPeriod, selectedHour, selectedMinute)
onDismissRequest()
},
- text = "완료",
+ text = stringResource(R.string.bottom_sheet_year_month_picker_btn_confirm),
enabled = true,
modifier = Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/screen/GuideScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/guide/GuideScreen.kt
similarity index 78%
rename from app/src/main/java/com/sopt/clody/presentation/ui/auth/screen/GuideScreen.kt
rename to app/src/main/java/com/sopt/clody/presentation/ui/auth/guide/GuideScreen.kt
index 7a21c233..ddaa52c5 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/screen/GuideScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/guide/GuideScreen.kt
@@ -1,18 +1,17 @@
-package com.sopt.clody.presentation.ui.auth.screen
+package com.sopt.clody.presentation.ui.auth.guide
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
-import androidx.compose.foundation.ExperimentalFoundationApi
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.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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -38,21 +37,18 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sopt.clody.R
-import com.sopt.clody.presentation.ui.auth.navigation.AuthNavigator
import com.sopt.clody.presentation.ui.component.button.ClodyButton
-import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage
import com.sopt.clody.ui.theme.ClodyTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun GuideRoute(
- navigator: AuthNavigator,
+ navigateToHome: () -> Unit,
) {
- GuideScreen(onNextButtonClick = { navigator.navigateHome() })
+ GuideScreen(onNextButtonClick = navigateToHome)
}
-@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GuideScreen(
onNextButtonClick: () -> Unit,
@@ -60,25 +56,21 @@ fun GuideScreen(
val pages = listOf(
BoardingPage(
title = stringResource(R.string.guide_page1_title),
- subtitle = stringResource(R.string.guide_page1_subtitle),
description = stringResource(R.string.guide_page1_description),
imageRes = R.drawable.img_guide_first,
),
BoardingPage(
title = stringResource(R.string.guide_page2_title),
- subtitle = stringResource(R.string.guide_page2_subtitle),
description = stringResource(R.string.guide_page2_description),
imageRes = R.drawable.img_guide_second,
),
BoardingPage(
title = stringResource(R.string.guide_page3_title),
- subtitle = stringResource(R.string.guide_page3_subtitle),
description = stringResource(R.string.guide_page3_description),
imageRes = R.drawable.img_guide_third,
),
BoardingPage(
title = stringResource(R.string.guide_page4_title),
- subtitle = stringResource(R.string.guide_page4_subtitle),
description = stringResource(R.string.guide_page4_description),
imageRes = R.drawable.img_guide_fourth,
),
@@ -107,9 +99,9 @@ fun GuideScreen(
}
},
text = if (pagerState.currentPage < pages.size - 1) {
- stringResource(id = R.string.guide_next)
+ stringResource(id = R.string.guide_btn_next)
} else {
- stringResource(id = R.string.guide_start)
+ stringResource(id = R.string.guide_btn_start)
},
enabled = true,
)
@@ -118,18 +110,16 @@ fun GuideScreen(
AnimatedVisibility(
visible = !isExiting,
exit = fadeOut(animationSpec = tween(1000)), // 1초 페이드 아웃
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding),
+ modifier = Modifier.padding(innerPadding),
) {
Column(
- modifier = Modifier
- .fillMaxSize(),
+ modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
- Spacer(modifier = Modifier.heightForScreenPercentage(0.21f))
+ Spacer(modifier = Modifier.weight(1f))
HorizontalPager(
state = pagerState,
+ modifier = Modifier.weight(4f),
) { page ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -141,20 +131,14 @@ fun GuideScreen(
color = ClodyTheme.colors.gray01,
textAlign = TextAlign.Center,
)
- Text(
- text = pages[page].subtitle,
- style = ClodyTheme.typography.head1,
- color = ClodyTheme.colors.gray01,
- textAlign = TextAlign.Center,
- )
- Spacer(modifier = Modifier.heightForScreenPercentage(0.02f))
+ Spacer(modifier = Modifier.height(20.dp))
Text(
text = pages[page].description,
style = ClodyTheme.typography.body1Medium,
color = ClodyTheme.colors.gray05,
textAlign = TextAlign.Center,
)
- Spacer(modifier = Modifier.heightForScreenPercentage(0.04f))
+ Spacer(modifier = Modifier.weight(1f))
Image(
painter = painterResource(id = pages[page].imageRes),
contentDescription = null,
@@ -162,12 +146,11 @@ fun GuideScreen(
.fillMaxWidth(),
contentScale = ContentScale.Fit,
)
+ Spacer(modifier = Modifier.weight(1f))
}
}
-
- Spacer(modifier = Modifier.heightForScreenPercentage(0.2f))
Row(
- horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(vertical = 50.dp),
) {
repeat(pagerState.pageCount) { iteration ->
val color = if (pagerState.currentPage == iteration) ClodyTheme.colors.gray03 else ClodyTheme.colors.gray07
@@ -195,7 +178,6 @@ fun GuideScreen(
data class BoardingPage(
val title: String,
- val subtitle: String,
val description: String,
val imageRes: Int,
)
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/guide/navigation/GuideNavigation.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/guide/navigation/GuideNavigation.kt
new file mode 100644
index 00000000..65f9590e
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/guide/navigation/GuideNavigation.kt
@@ -0,0 +1,24 @@
+package com.sopt.clody.presentation.ui.auth.guide.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptionsBuilder
+import androidx.navigation.compose.composable
+import com.sopt.clody.presentation.ui.auth.guide.GuideRoute
+import com.sopt.clody.presentation.utils.navigation.Route
+
+fun NavGraphBuilder.guideScreen(
+ navigateToHome: () -> Unit,
+) {
+ composable {
+ GuideRoute(
+ navigateToHome = navigateToHome,
+ )
+ }
+}
+
+fun NavController.navigateToGuide(
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) {
+ navigate(Route.Guide, navOptions)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/navigation/AuthNavGraph.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/navigation/AuthNavGraph.kt
deleted file mode 100644
index 563ec440..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/navigation/AuthNavGraph.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.sopt.clody.presentation.ui.auth.navigation
-
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.compose.composable
-import androidx.navigation.navigation
-import com.sopt.clody.presentation.ui.auth.screen.GuideRoute
-import com.sopt.clody.presentation.ui.auth.screen.NicknameRoute
-import com.sopt.clody.presentation.ui.auth.screen.TermsOfServiceRoute
-import com.sopt.clody.presentation.ui.auth.signup.SignUpRoute
-import com.sopt.clody.presentation.ui.auth.timereminder.TimeReminderRoute
-
-fun NavGraphBuilder.registerNavGraph(
- navigator: AuthNavigator,
-) {
- navigation(startDestination = "register", route = "register_graph") {
- composable("register") {
- SignUpRoute(navigator)
- }
- }
-}
-
-fun NavGraphBuilder.termsOfServiceNavGraph(
- navigator: AuthNavigator,
-) {
- composable("terms_of_service") {
- TermsOfServiceRoute(navigator)
- }
-}
-
-fun NavGraphBuilder.nicknameNavGraph(
- navigator: AuthNavigator,
-) {
- composable("nickname") {
- NicknameRoute(navigator)
- }
-}
-
-fun NavGraphBuilder.guidNavGraph(
- navigator: AuthNavigator,
-) {
- composable("guide") {
- GuideRoute(navigator)
- }
-}
-
-fun NavGraphBuilder.timeReminderNavGraph(
- navigator: AuthNavigator,
-) {
- composable("time_reminder") {
- TimeReminderRoute(navigator)
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/navigation/AuthNavigator.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/navigation/AuthNavigator.kt
deleted file mode 100644
index 693ceaaa..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/navigation/AuthNavigator.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.sopt.clody.presentation.ui.auth.navigation
-
-import androidx.navigation.NavHostController
-import java.time.LocalDate
-
-class AuthNavigator(
- val navController: NavHostController,
-) {
- val startDestination = "register_graph"
- fun navigateTermsOfService() {
- navController.navigate("terms_of_service")
- }
- fun navigateNickname() {
- navController.navigate("nickname")
- }
- fun navigateGuide() {
- navController.navigate("guide")
- }
-
- fun navigateTimeReminder() {
- navController.navigate("time_reminder")
- }
-
- fun navigateHome(selectedYear: Int = LocalDate.now().year, selectedMonth: Int = LocalDate.now().monthValue) {
- navController.navigate("home/$selectedYear/$selectedMonth")
- }
-
- fun navigateBack() {
- navController.navigateUp()
- }
- fun navigateToSignupScreen() {
- navController.navigate("register") {
- popUpTo(navController.graph.startDestinationId) { inclusive = true }
- }
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/screen/NicknameScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/screen/NicknameScreen.kt
deleted file mode 100644
index b5626c91..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/screen/NicknameScreen.kt
+++ /dev/null
@@ -1,233 +0,0 @@
-package com.sopt.clody.presentation.ui.auth.screen
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-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.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.imePadding
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.Scaffold
-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.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.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.input.TextFieldValue
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.text.withStyle
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
-import com.sopt.clody.R
-import com.sopt.clody.presentation.ui.auth.component.textfield.NickNameTextField
-import com.sopt.clody.presentation.ui.auth.navigation.AuthNavigator
-import com.sopt.clody.presentation.ui.auth.signup.SignUpViewModel
-import com.sopt.clody.presentation.ui.component.LoadingScreen
-import com.sopt.clody.presentation.ui.component.button.ClodyButton
-import com.sopt.clody.presentation.ui.component.dialog.FailureDialog
-import com.sopt.clody.presentation.utils.base.UiState
-import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage
-import com.sopt.clody.ui.theme.ClodyTheme
-
-@Composable
-fun NicknameRoute(
- navigator: AuthNavigator,
- viewModel: SignUpViewModel = hiltViewModel(),
-) {
- val nickname by viewModel.nickname.collectAsState()
- val isValidNickname by viewModel.isValidNickname.collectAsState()
- val nicknameMessage by viewModel.nicknameMessage.collectAsState()
- val signUpState by viewModel.signUpState.collectAsState()
- val context = LocalContext.current
- var showDialog by remember { mutableStateOf(false) }
- var dialogMessage by remember { mutableStateOf("") }
-
- NicknameScreen(
- nickname = nickname,
- onNicknameChange = viewModel::setNickname,
- onCompleteClick = { viewModel.proceedWithSignUp(context) },
- onBackClick = { navigator.navigateBack() },
- isLoading = signUpState.uiState is UiState.Loading,
- isValidNickname = isValidNickname,
- nicknameMessage = nicknameMessage,
- )
-
- LaunchedEffect(signUpState) {
- when (val result = signUpState.uiState) {
- is UiState.Success -> {
- navigator.navigateTimeReminder()
- }
-
- is UiState.Failure -> {
- showDialog = true
- dialogMessage = result.msg
- }
-
- else -> {}
- }
- }
-
- if (showDialog) {
- FailureDialog(
- message = dialogMessage,
- onDismiss = {
- showDialog = false
- viewModel.resetSignUpState()
- },
- )
- }
-}
-
-@Composable
-fun NicknameScreen(
- nickname: String,
- onNicknameChange: (String) -> Unit,
- onCompleteClick: () -> Unit,
- onBackClick: () -> Unit,
- isLoading: Boolean,
- isValidNickname: Boolean,
- nicknameMessage: String,
-) {
- var nicknameTextField by remember { mutableStateOf(TextFieldValue(nickname)) }
- val focusRequester = remember { FocusRequester() }
- var isFocused by remember { mutableStateOf(false) }
- val focusManager = LocalFocusManager.current
-
- val annotatedString = buildAnnotatedString {
- withStyle(style = SpanStyle(color = ClodyTheme.colors.gray04)) {
- append("${nicknameTextField.text.length}")
- }
- withStyle(style = SpanStyle(color = ClodyTheme.colors.gray06)) {
- append(" / ")
- }
- withStyle(style = SpanStyle(color = ClodyTheme.colors.gray06)) {
- append("10")
- }
- }
-
- Scaffold(
- topBar = {
- IconButton(
- onClick = { onBackClick() },
- modifier = Modifier
- .statusBarsPadding()
- .padding(start = 8.dp),
- ) {
- Image(
- painter = painterResource(id = R.drawable.ic_nickname_back),
- contentDescription = null,
- )
- }
- },
- bottomBar = {
- ClodyButton(
- modifier = Modifier
- .fillMaxWidth()
- .navigationBarsPadding()
- .padding(horizontal = 24.dp)
- .padding(bottom = 28.dp)
- .imePadding(),
- onClick = {
- focusManager.clearFocus()
- onCompleteClick()
- },
- text = stringResource(id = R.string.nickname_next),
- enabled = nicknameTextField.text.isNotEmpty() && isValidNickname,
- )
- },
- content = { paddingValues ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .background(color = ClodyTheme.colors.white)
- .padding(paddingValues)
- .padding(horizontal = 24.dp),
- horizontalAlignment = Alignment.Start,
- ) {
- Spacer(modifier = Modifier.heightForScreenPercentage(0.056f))
- Text(
- text = stringResource(id = R.string.nickname_title),
- style = ClodyTheme.typography.head1,
- color = ClodyTheme.colors.gray01,
- )
- Spacer(modifier = Modifier.heightForScreenPercentage(0.06f))
- NickNameTextField(
- value = nicknameTextField,
- onValueChange = {
- nicknameTextField = it
- onNicknameChange(it.text)
- },
- hint = stringResource(id = R.string.nickname_input_hint),
- isFocused = isFocused,
- isValid = isValidNickname,
- onFocusChanged = { isFocused = it },
- onRemove = { nicknameTextField = TextFieldValue("") },
- modifier = Modifier
- .focusRequester(focusRequester)
- .clickable { focusRequester.requestFocus() }
- .fillMaxWidth(),
- )
- Spacer(modifier = Modifier.heightForScreenPercentage(0.005f))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = nicknameMessage,
- style = ClodyTheme.typography.detail1Regular,
- color = when {
- nicknameTextField.text.isEmpty() -> ClodyTheme.colors.gray04
- isValidNickname -> ClodyTheme.colors.gray04
- else -> ClodyTheme.colors.red
- },
- )
- Text(
- text = annotatedString,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = ClodyTheme.typography.detail1Medium,
- )
- }
- }
- },
- )
-
- if (isLoading) {
- LoadingScreen()
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-fun NicknameScreenPreview() {
- NicknameScreen(
- nickname = "닉네임",
- onNicknameChange = {},
- onCompleteClick = {},
- onBackClick = {},
- isLoading = false,
- isValidNickname = true,
- nicknameMessage = "특수문자, 띄어쓰기 없이 작성해주세요",
- )
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/screen/TermsOfServiceScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/screen/TermsOfServiceScreen.kt
deleted file mode 100644
index 7c1dd556..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/screen/TermsOfServiceScreen.kt
+++ /dev/null
@@ -1,217 +0,0 @@
-package com.sopt.clody.presentation.ui.auth.screen
-
-import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Column
-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.navigationBarsPadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.Scaffold
-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.platform.LocalContext
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import com.sopt.clody.R
-import com.sopt.clody.presentation.ui.auth.component.button.NextButton
-import com.sopt.clody.presentation.ui.auth.component.checkbox.CustomCheckbox
-import com.sopt.clody.presentation.ui.auth.navigation.AuthNavigator
-import com.sopt.clody.presentation.ui.component.button.ClodyButton
-import com.sopt.clody.presentation.ui.home.calendar.component.HorizontalDivider
-import com.sopt.clody.presentation.ui.setting.screen.SettingOptionUrls
-import com.sopt.clody.presentation.ui.setting.screen.onClickSettingOption
-import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage
-import com.sopt.clody.ui.theme.ClodyTheme
-import kotlinx.coroutines.delay
-
-@Composable
-fun TermsOfServiceRoute(
- navigator: AuthNavigator,
-) {
- var backPressCount by remember { mutableStateOf(0) }
-
- LaunchedEffect(backPressCount) {
- if (backPressCount > 0) {
- delay(2000) // 2 seconds delay
- backPressCount = 0
- }
- }
-
- BackHandler {
- if (backPressCount == 1) {
- navigator.navigateToSignupScreen()
- } else {
- backPressCount++
- }
- }
- TermsOfServiceScreen(
- onAgreeClick = { navigator.navigateNickname() },
- onBackClick = { navigator.navigateToSignupScreen() },
- )
-}
-
-@Composable
-fun TermsOfServiceScreen(
- onAgreeClick: () -> Unit,
- onBackClick: () -> Unit,
-) {
- var allChecked by remember { mutableStateOf(false) }
- var serviceChecked by remember { mutableStateOf(false) }
- var privacyChecked by remember { mutableStateOf(false) }
-
- val isAgreeButtonEnabled = serviceChecked && privacyChecked
- val context = LocalContext.current
-
- Scaffold(
- topBar = {
- IconButton(
- onClick = { onBackClick() },
- modifier = Modifier
- .statusBarsPadding()
- .padding(start = 8.dp),
- ) {
- Image(
- painter = painterResource(id = R.drawable.ic_nickname_back),
- contentDescription = null,
- )
- }
- },
- bottomBar = {
- ClodyButton(
- modifier = Modifier
- .fillMaxWidth()
- .navigationBarsPadding()
- .padding(horizontal = 24.dp)
- .padding(bottom = 28.dp),
- onClick = onAgreeClick,
- text = stringResource(id = R.string.terms_next),
- enabled = isAgreeButtonEnabled,
- )
- },
- content = { paddingValues ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .background(color = ClodyTheme.colors.white)
- .padding(paddingValues)
- .padding(horizontal = 24.dp),
- ) {
- Spacer(modifier = Modifier.heightForScreenPercentage(0.056f))
- Text(
- text = stringResource(id = R.string.terms_title),
- style = ClodyTheme.typography.head1,
- color = ClodyTheme.colors.gray01,
- )
- Spacer(modifier = Modifier.heightForScreenPercentage(0.06f))
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(
- text = stringResource(id = R.string.terms_agree_all),
- style = ClodyTheme.typography.head3,
- color = ClodyTheme.colors.gray01,
- modifier = Modifier.weight(1f),
- )
- CustomCheckbox(
- checked = allChecked,
- onCheckedChange = { checked ->
- allChecked = checked
- serviceChecked = checked
- privacyChecked = checked
- },
- size = 25.dp,
- checkedImageRes = R.drawable.ic_terms_check_on_25,
- uncheckedImageRes = R.drawable.ic_terms_check_off_25,
- )
- }
-
- Spacer(modifier = Modifier.heightForScreenPercentage(0.02f))
- HorizontalDivider(color = ClodyTheme.colors.gray07, thickness = 1.dp)
- Spacer(modifier = Modifier.heightForScreenPercentage(0.02f))
-
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth(),
- ) {
- Text(
- stringResource(id = R.string.terms_service_use),
- style = ClodyTheme.typography.body1Medium,
- color = ClodyTheme.colors.gray01,
- )
- NextButton(
- onClick = { onClickSettingOption(context, SettingOptionUrls.TERMS_OF_SERVICE_URL) },
- imageResource = R.drawable.ic_terms_next,
- contentDescription = null,
- )
- Spacer(modifier = Modifier.weight(1f))
- CustomCheckbox(
- checked = serviceChecked,
- onCheckedChange = { checked ->
- serviceChecked = checked
- if (!checked) allChecked = false
- if (checked && privacyChecked) allChecked = true
- },
- size = 23.dp,
- checkedImageRes = R.drawable.ic_terms_check_on_23,
- uncheckedImageRes = R.drawable.ic_terms_check_off_23,
- )
- }
-
- Spacer(modifier = Modifier.heightForScreenPercentage(0.02f))
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(
- stringResource(id = R.string.terms_service_privacy),
- style = ClodyTheme.typography.body1Medium,
- color = ClodyTheme.colors.gray01,
- )
- NextButton(
- onClick = { onClickSettingOption(context, SettingOptionUrls.PRIVACY_POLICY_URL) },
- imageResource = R.drawable.ic_terms_next,
- contentDescription = null,
- )
- Spacer(modifier = Modifier.weight(1f))
- CustomCheckbox(
- checked = privacyChecked,
- onCheckedChange = { checked ->
- privacyChecked = checked
- if (!checked) allChecked = false
- if (checked && serviceChecked) allChecked = true
- },
- size = 23.dp,
- checkedImageRes = R.drawable.ic_terms_check_on_23,
- uncheckedImageRes = R.drawable.ic_terms_check_off_23,
- )
- }
- }
- },
- )
-}
-
-@Preview(showBackground = true)
-@Composable
-fun TermsOfServiceScreenPreview() {
- TermsOfServiceScreen(
- onAgreeClick = { },
- onBackClick = { },
- )
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/NicknameMessage.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/NicknameMessage.kt
new file mode 100644
index 00000000..b00de9e2
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/NicknameMessage.kt
@@ -0,0 +1,17 @@
+package com.sopt.clody.presentation.ui.auth.signup
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.sopt.clody.R
+
+enum class NicknameMessage(
+ @StringRes val message: Int,
+) {
+ DEFAULT(R.string.nickname_message_default),
+ INVALID(R.string.nickname_message_invalid),
+ ;
+
+ @Composable
+ fun getMessage(): String = stringResource(message)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignInState.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignInState.kt
deleted file mode 100644
index f3201b61..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignInState.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.sopt.clody.presentation.ui.auth.signup
-
-import com.sopt.clody.presentation.utils.base.UiState
-
-data class SignInState(
- val uiState: UiState = UiState.Empty,
-)
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpContract.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpContract.kt
new file mode 100644
index 00000000..0103393a
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpContract.kt
@@ -0,0 +1,51 @@
+package com.sopt.clody.presentation.ui.auth.signup
+
+import android.content.Context
+import com.airbnb.mvrx.MavericksState
+import com.sopt.clody.data.datastore.OAuthProvider
+
+class SignUpContract {
+ data class SignUpState(
+ val currentStep: Step = Step.TERMS,
+ val nickname: String = "",
+ val isNicknameFocused: Boolean = false,
+ val isValidNickname: Boolean = true,
+ val nicknameMaxLength: Int = 15,
+ val nicknameMessage: NicknameMessage = NicknameMessage.DEFAULT,
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+ val serviceChecked: Boolean = false,
+ val serviceUrl: String = "",
+ val privacyUrl: String = "",
+ val privacyChecked: Boolean = false,
+ val platform: OAuthProvider = OAuthProvider.KAKAO,
+ ) : MavericksState {
+ val allChecked: Boolean
+ get() = serviceChecked && privacyChecked
+
+ enum class Step {
+ TERMS, NICKNAME
+ }
+ }
+
+ sealed class SignUpIntent {
+ data class SetNickname(val value: String) : SignUpIntent()
+ data class SetNicknameFocus(val isFocused: Boolean) : SignUpIntent()
+ data object SetNicknameMaxLength : SignUpIntent()
+ data object ProceedTerms : SignUpIntent()
+ data class CompleteSignUp(val context: Context) : SignUpIntent()
+ data object ClearError : SignUpIntent()
+ data class ToggleAllChecked(val checked: Boolean) : SignUpIntent()
+ data class ToggleServiceChecked(val checked: Boolean) : SignUpIntent()
+ data class TogglePrivacyChecked(val checked: Boolean) : SignUpIntent()
+ data object SetWebViewUrl : SignUpIntent()
+ data class OpenWebView(val url: String) : SignUpIntent()
+ data object BackToTerms : SignUpIntent()
+ }
+
+ sealed interface SignUpSideEffect {
+ data object NavigateToTimeReminder : SignUpSideEffect
+ data class NavigateToWebView(val url: String) : SignUpSideEffect
+ data class ShowMessage(val message: String) : SignUpSideEffect
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpScreen.kt
index 8736d01f..3937e5d7 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpScreen.kt
@@ -1,129 +1,94 @@
package com.sopt.clody.presentation.ui.auth.signup
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
+import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
-import com.google.accompanist.systemuicontroller.rememberSystemUiController
-import com.sopt.clody.R
-import com.sopt.clody.presentation.ui.auth.component.button.KaKaoButton
-import com.sopt.clody.presentation.ui.auth.navigation.AuthNavigator
-import com.sopt.clody.presentation.ui.component.LoadingScreen
-import com.sopt.clody.presentation.utils.base.UiState
-import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage
-import com.sopt.clody.ui.theme.ClodyTheme
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.airbnb.mvrx.compose.collectAsState
+import com.airbnb.mvrx.compose.mavericksViewModel
+import com.sopt.clody.presentation.ui.auth.signup.page.NickNamePage
+import com.sopt.clody.presentation.ui.auth.signup.page.TermsOfServicePage
+import com.sopt.clody.presentation.ui.component.dialog.FailureDialog
+import com.sopt.clody.presentation.utils.extension.repeatOnStarted
+import com.sopt.clody.presentation.utils.openExternalBrowser
@Composable
fun SignUpRoute(
- authNavigator: AuthNavigator,
+ viewModel: SignUpViewModel = mavericksViewModel(),
+ navigateToHome: () -> Unit,
+ navigateToPrevious: () -> Unit,
) {
- val viewModel: SignUpViewModel = hiltViewModel()
- val signInState by viewModel.signInState.collectAsState()
+ val state by viewModel.collectAsState()
val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
- LaunchedEffect(signInState) {
- when (signInState.uiState) {
- is UiState.Success -> {
- authNavigator.navigateHome()
+ LaunchedEffect(viewModel) {
+ lifecycleOwner.repeatOnStarted {
+ viewModel.sideEffects.collect { effect ->
+ when (effect) {
+ is SignUpContract.SignUpSideEffect.NavigateToTimeReminder -> navigateToHome()
+ is SignUpContract.SignUpSideEffect.NavigateToWebView -> { openExternalBrowser(context, effect.url) }
+ is SignUpContract.SignUpSideEffect.ShowMessage -> {}
+ }
}
-
- is UiState.Failure -> {
- authNavigator.navigateTermsOfService()
- }
-
- else -> {}
}
}
SignUpScreen(
- isLoading = signInState.uiState is UiState.Loading,
- onSignInClick = { viewModel.signInWithKakao(context) },
+ state = state,
+ onIntent = { viewModel.postIntent(it) },
+ context = context,
+ navigateToPrevious = navigateToPrevious,
)
+
+ state.errorMessage?.let {
+ FailureDialog(message = it) {
+ viewModel.postIntent(SignUpContract.SignUpIntent.ClearError)
+ }
+ }
}
@Composable
fun SignUpScreen(
- isLoading: Boolean,
- onSignInClick: () -> Unit,
+ state: SignUpContract.SignUpState,
+ onIntent: (SignUpContract.SignUpIntent) -> Unit,
+ context: Context,
+ navigateToPrevious: () -> Unit,
) {
- val systemUiController = rememberSystemUiController()
- val backgroundColor = ClodyTheme.colors.white
-
- LaunchedEffect(Unit) {
- systemUiController.setStatusBarColor(
- color = backgroundColor,
- darkIcons = true,
- )
- }
-
- Scaffold(
- bottomBar = {
- KaKaoButton(
- text = stringResource(id = R.string.signup_btn_kakao),
- onClick = onSignInClick,
- modifier = Modifier
- .fillMaxWidth()
- .navigationBarsPadding()
- .padding(bottom = 40.dp),
+ when (state.currentStep) {
+ SignUpContract.SignUpState.Step.TERMS -> {
+ TermsOfServicePage(
+ allChecked = state.allChecked,
+ serviceChecked = state.serviceChecked,
+ privacyChecked = state.privacyChecked,
+ serviceUrl = state.serviceUrl,
+ privacyUrl = state.privacyUrl,
+ onToggleAll = { onIntent(SignUpContract.SignUpIntent.ToggleAllChecked(it)) },
+ onToggleService = { onIntent(SignUpContract.SignUpIntent.ToggleServiceChecked(it)) },
+ onTogglePrivacy = { onIntent(SignUpContract.SignUpIntent.TogglePrivacyChecked(it)) },
+ onAgreeClick = { onIntent(SignUpContract.SignUpIntent.ProceedTerms) },
+ navigateToPrevious = navigateToPrevious,
+ navigateToWebView = { url -> onIntent(SignUpContract.SignUpIntent.OpenWebView(url)) },
)
- },
- content = { innerPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .background(color = backgroundColor)
- .padding(innerPadding),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Spacer(modifier = Modifier.heightForScreenPercentage(0.38f))
- Image(
- painter = painterResource(id = R.drawable.ic_signup_logo),
- contentDescription = null,
- contentScale = ContentScale.Crop,
- )
- Spacer(modifier = Modifier.heightForScreenPercentage(0.02f))
- Image(
- painter = painterResource(id = R.drawable.ic__signup_title),
- contentDescription = null,
- )
- Spacer(modifier = Modifier.heightForScreenPercentage(0.01f))
- Image(
- painter = painterResource(id = R.drawable.ic_signup_logotitle),
- contentDescription = null,
- contentScale = ContentScale.Crop,
- )
- }
- },
- )
+ }
- if (isLoading) {
- LoadingScreen()
+ SignUpContract.SignUpState.Step.NICKNAME -> {
+ NickNamePage(
+ nickname = state.nickname,
+ onNicknameChange = { onIntent(SignUpContract.SignUpIntent.SetNickname(it)) },
+ onCompleteClick = {
+ onIntent(SignUpContract.SignUpIntent.CompleteSignUp(context))
+ },
+ onBackClick = { onIntent(SignUpContract.SignUpIntent.BackToTerms) },
+ isLoading = state.isLoading,
+ isValidNickname = state.isValidNickname,
+ nicknameMaxLength = state.nicknameMaxLength,
+ nicknameMessage = state.nicknameMessage,
+ isFocused = state.isNicknameFocused,
+ onFocusChanged = { onIntent(SignUpContract.SignUpIntent.SetNicknameFocus(it)) },
+ )
+ }
}
}
-
-@Preview(showBackground = true)
-@Composable
-fun RegisterScreenPreview() {
- SignUpScreen(
- isLoading = false,
- onSignInClick = {},
- )
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpState.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpState.kt
deleted file mode 100644
index e4d57f0b..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpState.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.sopt.clody.presentation.ui.auth.signup
-
-import com.sopt.clody.presentation.utils.base.UiState
-
-data class SignUpState(
- val uiState: UiState = UiState.Empty,
-)
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpViewModel.kt
index a8b29806..0fde1ef3 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpViewModel.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/SignUpViewModel.kt
@@ -1,208 +1,222 @@
package com.sopt.clody.presentation.ui.auth.signup
import android.content.Context
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import com.kakao.sdk.auth.model.OAuthToken
-import com.kakao.sdk.user.UserApiClient
-import com.sopt.clody.ClodyFirebaseMessagingService
-import com.sopt.clody.data.remote.dto.request.LoginRequestDto
+import com.airbnb.mvrx.MavericksViewModel
+import com.airbnb.mvrx.MavericksViewModelFactory
+import com.airbnb.mvrx.hilt.AssistedViewModelFactory
+import com.airbnb.mvrx.hilt.hiltMavericksViewModelFactory
+import com.airbnb.mvrx.withState
+import com.sopt.clody.core.fcm.FcmTokenProvider
+import com.sopt.clody.core.login.LoginSdk
+import com.sopt.clody.core.network.NetworkConnectivityObserver
+import com.sopt.clody.core.network.NetworkStatus
+import com.sopt.clody.data.datastore.OAuthDataStore
+import com.sopt.clody.data.datastore.OAuthProvider
import com.sopt.clody.data.remote.dto.request.SignUpRequestDto
-import com.sopt.clody.data.remote.util.NetworkUtil
+import com.sopt.clody.data.remote.dto.response.SignUpResponseDto
import com.sopt.clody.domain.repository.AuthRepository
import com.sopt.clody.domain.repository.TokenRepository
-import com.sopt.clody.presentation.utils.base.UiState
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_NETWORK_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.UNKNOWN_ERROR
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.flow.debounce
+import com.sopt.clody.presentation.ui.setting.screen.SettingOptionUrls
+import com.sopt.clody.presentation.utils.language.LanguageProvider
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.BUFFERED
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
-import kotlinx.coroutines.suspendCancellableCoroutine
-import javax.inject.Inject
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-
-@OptIn(FlowPreview::class)
-@HiltViewModel
-class SignUpViewModel @Inject constructor(
+
+class SignUpViewModel @AssistedInject constructor(
+ @Assisted initialState: SignUpContract.SignUpState,
+ private val loginSdk: LoginSdk,
private val authRepository: AuthRepository,
private val tokenRepository: TokenRepository,
- private val networkUtil: NetworkUtil,
-) : ViewModel() {
+ private val fcmTokenProvider: FcmTokenProvider,
+ private val oAuthDataStore: OAuthDataStore,
+ private val networkConnectivityObserver: NetworkConnectivityObserver,
+ private val languageProvider: LanguageProvider,
+ private val errorMessageProvider: ErrorMessageProvider,
+) : MavericksViewModel(initialState) {
- private val _signInState = MutableStateFlow(SignInState())
- val signInState: StateFlow = _signInState
+ private val _intents = Channel(BUFFERED)
+ private val _sideEffects = Channel(BUFFERED)
+ val sideEffects = _sideEffects.receiveAsFlow()
- private val _signUpState = MutableStateFlow(SignUpState())
- val signUpState: StateFlow = _signUpState
+ init {
+ _intents
+ .receiveAsFlow()
+ .onEach(::handleIntent)
+ .launchIn(viewModelScope)
+ postIntent(SignUpContract.SignUpIntent.SetNicknameMaxLength)
+ postIntent(SignUpContract.SignUpIntent.SetWebViewUrl)
+ }
- private var accessToken: String? = null
+ fun postIntent(intent: SignUpContract.SignUpIntent) {
+ viewModelScope.launch { _intents.send(intent) }
+ }
- private val _nickname = MutableStateFlow("")
- val nickname: StateFlow = _nickname
+ private suspend fun handleIntent(intent: SignUpContract.SignUpIntent) {
+ when (intent) {
+ is SignUpContract.SignUpIntent.SetNickname -> handleSetNickname(intent)
+ is SignUpContract.SignUpIntent.SetNicknameFocus -> handleSetNicknameFocus(intent)
+ is SignUpContract.SignUpIntent.SetNicknameMaxLength -> setNicknameMaxLength()
+ is SignUpContract.SignUpIntent.ProceedTerms -> handleProceedTerms()
+ is SignUpContract.SignUpIntent.CompleteSignUp -> signUp(intent.context)
+ is SignUpContract.SignUpIntent.ClearError -> clearError()
+ is SignUpContract.SignUpIntent.ToggleAllChecked -> handleToggleAllChecked(intent)
+ is SignUpContract.SignUpIntent.ToggleServiceChecked -> handleToggleServiceChecked(intent)
+ is SignUpContract.SignUpIntent.TogglePrivacyChecked -> handleTogglePrivacyChecked(intent)
+ is SignUpContract.SignUpIntent.SetWebViewUrl -> setWebViewUrl()
+ is SignUpContract.SignUpIntent.OpenWebView -> handleOpenWebView(intent.url)
+ SignUpContract.SignUpIntent.BackToTerms -> handleBackToTerms()
+ }
+ }
- private val _isValidNickname = MutableStateFlow(true)
- val isValidNickname: StateFlow = _isValidNickname
+ private fun handleSetNickname(intent: SignUpContract.SignUpIntent.SetNickname) {
+ val isValid = validateNickname(intent.value)
+ setState {
+ copy(
+ nickname = intent.value,
+ isValidNickname = isValid,
+ nicknameMessage = if (intent.value.isEmpty() || isValid) {
+ NicknameMessage.DEFAULT
+ } else {
+ NicknameMessage.INVALID
+ },
+ )
+ }
+ }
- private val _nicknameMessage = MutableStateFlow(DEFAULT_NICKNAME_MESSAGE)
- val nicknameMessage: StateFlow = _nicknameMessage
+ private fun handleSetNicknameFocus(intent: SignUpContract.SignUpIntent.SetNicknameFocus) {
+ setState { copy(isNicknameFocused = intent.isFocused) }
+ }
- init {
- debounceNicknameValidation()
- }
-
- fun signInWithKakao(context: Context) {
- viewModelScope.launch {
- _signInState.value = SignInState(UiState.Loading)
- val tokenResult = runCatching { loginWithKakao(context) }
- tokenResult.onSuccess { token ->
- accessToken = token.accessToken
- fetchKakaoUserInfo(context)
- }.onFailure {
- _signInState.value = SignInState(UiState.Failure(it.localizedMessage ?: UNKNOWN_ERROR))
- }
- }
+ private fun setNicknameMaxLength() {
+ setState { copy(nicknameMaxLength = languageProvider.getNicknameMaxLength()) }
}
- fun proceedWithSignUp(context: Context) {
- viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _signUpState.value = SignUpState(UiState.Failure(FAILURE_NETWORK_MESSAGE))
- return@launch
- }
- _signUpState.value = SignUpState(UiState.Loading)
- val tokenResult = runCatching { loginWithKakao(context) }
- tokenResult.onSuccess { token ->
- accessToken = token.accessToken
- performSignUp(context)
- }.onFailure {
- _signUpState.value = SignUpState(UiState.Failure(it.localizedMessage ?: UNKNOWN_ERROR))
- }
- }
+ private fun handleProceedTerms() {
+ setState { copy(currentStep = SignUpContract.SignUpState.Step.NICKNAME) }
}
- private suspend fun loginWithKakao(context: Context): OAuthToken {
- return suspendCancellableCoroutine { continuation ->
- val callback: (OAuthToken?, Throwable?) -> Unit = { token, error ->
- if (error != null) {
- continuation.resumeWithException(error)
- } else if (token != null) {
- continuation.resume(token)
- }
- }
- if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) {
- UserApiClient.instance.loginWithKakaoTalk(context, callback = callback)
- } else {
- UserApiClient.instance.loginWithKakaoAccount(context, callback = callback)
- }
- }
+ private fun clearError() {
+ setState { copy(errorMessage = null) }
}
- private fun fetchKakaoUserInfo(context: Context) {
- UserApiClient.instance.me { user, error ->
- if (error != null) {
- _signInState.value = SignInState(UiState.Failure(error.localizedMessage))
- } else if (user != null) {
- val fcmToken = ClodyFirebaseMessagingService.getTokenFromPreferences(context) ?: ""
- val requestSignInDto = LoginRequestDto(platform = KAKAO_PLATFORM, fcmToken = fcmToken)
- validateUser("Bearer ${accessToken.orEmpty()}", requestSignInDto)
- }
+ private fun handleToggleAllChecked(intent: SignUpContract.SignUpIntent.ToggleAllChecked) {
+ setState {
+ copy(serviceChecked = intent.checked, privacyChecked = intent.checked)
}
}
- private fun validateUser(authorization: String, requestSignInDto: LoginRequestDto) {
- viewModelScope.launch {
- authRepository.signIn(authorization, requestSignInDto).fold(
- onSuccess = { response ->
- storeTokens(response.accessToken, response.refreshToken)
- _signInState.value = SignInState(UiState.Success(USER_EXISTS))
- },
- onFailure = {
- val message = it.localizedMessage ?: UNKNOWN_ERROR
- val uiState = if (message.contains("404")) {
- UiState.Failure(USER_NOT_FOUND_ERROR)
- } else {
- UiState.Failure(message)
- }
- _signInState.value = SignInState(uiState)
- },
- )
- }
+ private fun handleToggleServiceChecked(intent: SignUpContract.SignUpIntent.ToggleServiceChecked) {
+ setState { copy(serviceChecked = intent.checked) }
}
- private fun storeTokens(accessToken: String, refreshToken: String) {
- viewModelScope.launch {
- tokenRepository.setTokens(accessToken, refreshToken)
+ private fun handleTogglePrivacyChecked(intent: SignUpContract.SignUpIntent.TogglePrivacyChecked) {
+ setState { copy(privacyChecked = intent.checked) }
+ }
+
+ private fun setWebViewUrl() {
+ setState {
+ copy(
+ serviceUrl = languageProvider.getWebViewUrlFor(SettingOptionUrls.TERMS_OF_SERVICE_URL),
+ privacyUrl = languageProvider.getWebViewUrlFor(SettingOptionUrls.PRIVACY_POLICY_URL),
+ )
}
}
- fun setNickname(nickname: String) {
- _nickname.value = nickname
+ private suspend fun handleOpenWebView(url: String) {
+ _sideEffects.send(SignUpContract.SignUpSideEffect.NavigateToWebView(url))
}
- private fun performSignUp(context: Context) {
- val authorization = "Bearer ${accessToken.orEmpty()}"
- val fcmToken = ClodyFirebaseMessagingService.getTokenFromPreferences(context) ?: ""
- viewModelScope.launch {
- authRepository.signUp(
- authorization,
- SignUpRequestDto(platform = KAKAO_PLATFORM, name = nickname.value, fcmToken = fcmToken),
- ).fold(
- onSuccess = { response ->
- _signUpState.value = SignUpState(UiState.Success(SIGN_UP_SUCCESS))
- storeTokens(response.accessToken, response.refreshToken)
- },
- onFailure = { error ->
- val errorMessage = if (error.message?.contains("200") == false) {
- FAILURE_TEMPORARY_MESSAGE
- } else {
- error.localizedMessage ?: UNKNOWN_ERROR
- }
- _signUpState.value = SignUpState(UiState.Failure(errorMessage))
- },
+ private fun handleBackToTerms() {
+ setState {
+ copy(
+ currentStep = SignUpContract.SignUpState.Step.TERMS,
+ nickname = "",
+ isNicknameFocused = false,
+ isValidNickname = true,
+ nicknameMessage = NicknameMessage.DEFAULT,
)
}
}
- private fun debounceNicknameValidation() {
- viewModelScope.launch {
- _nickname
- .debounce(NICKNAME_VALIDATION_DELAY)
- .collectLatest { nickname ->
- validateNickname(nickname)
- }
+ private suspend fun signUp(context: Context) {
+ val state = withState(this@SignUpViewModel) { it }
+
+ if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) {
+ setState { copy(errorMessage = errorMessageProvider.getNetworkCheckError()) }
+ return
}
- }
- private fun validateNickname(nickname: String) {
- if (nickname.isNotEmpty()) {
- val isValid = nickname.matches(Regex(NICKNAME_PATTERN))
- _isValidNickname.value = isValid
- _nicknameMessage.value = if (isValid) DEFAULT_NICKNAME_MESSAGE else FAILURE_NICKNAME_MESSAGE
+ setState { copy(isLoading = true) }
+
+ val platform = oAuthDataStore.getPlatform()
+ val fcmToken = fcmTokenProvider.getToken().orEmpty()
+
+ if (platform == OAuthProvider.GOOGLE) {
+ val idToken = oAuthDataStore.getIdToken(platform = "google")
+ if (idToken.isNullOrBlank()) {
+ setState { copy(errorMessage = errorMessageProvider.getGoogleIdTokenMissingError(), isLoading = false) }
+ return
+ }
+ val request = SignUpRequestDto(
+ platform = OAuthProvider.GOOGLE.platform,
+ name = state.nickname,
+ fcmToken = fcmToken,
+ )
+
+ val result = authRepository.signUp("Bearer $idToken", request)
+ handleSignUpResult(result)
} else {
- _isValidNickname.value = true
- _nicknameMessage.value = DEFAULT_NICKNAME_MESSAGE
+ val idToken = oAuthDataStore.getIdToken(platform = "kakao")
+ if (idToken.isNullOrBlank()) {
+ setState { copy(errorMessage = errorMessageProvider.getLoginFailedError(), isLoading = false) }
+ return
+ }
+ val request = SignUpRequestDto(
+ platform = OAuthProvider.KAKAO.platform,
+ name = state.nickname,
+ fcmToken = fcmToken,
+ )
+
+ val result = authRepository.signUp("Bearer $idToken", request)
+ handleSignUpResult(result)
}
}
- fun resetSignUpState() {
- _signUpState.value = SignUpState()
+ private suspend fun handleSignUpResult(
+ result: Result,
+ ) {
+ result.fold(
+ onSuccess = {
+ tokenRepository.setTokens(it.accessToken, it.refreshToken)
+ oAuthDataStore.clear()
+ _sideEffects.send(SignUpContract.SignUpSideEffect.NavigateToTimeReminder)
+ },
+ onFailure = {
+ setState { copy(errorMessage = errorMessageProvider.getSignupFailedError()) }
+ },
+ )
+ setState { copy(isLoading = false) }
+ }
+ private fun validateNickname(nickname: String): Boolean {
+ val state = withState(this@SignUpViewModel) { it }
+ setState { copy(nicknameMaxLength = languageProvider.getNicknameMaxLength()) }
+ val regex = "^[a-zA-Z가-힣0-9ㄱ-ㅎㅏ-ㅣ가-힣]{2,${state.nicknameMaxLength}}$".toRegex()
+ return nickname.matches(regex)
}
- companion object {
- private const val USER_EXISTS = "유저가 이미 존재합니다"
- private const val SIGN_UP_SUCCESS = "회원가입 성공"
- private const val USER_NOT_FOUND_ERROR = "유저를 찾을 수 없습니다"
- private const val KAKAO_PLATFORM = "kakao"
-
- private const val NICKNAME_VALIDATION_DELAY = 300L
- private const val NICKNAME_PATTERN = "^[a-zA-Z가-힣0-9ㄱ-ㅎㅏ-ㅣ가-힣]{2,10}$"
- private const val DEFAULT_NICKNAME_MESSAGE = "특수문자, 띄어쓰기 없이 작성해주세요"
- private const val FAILURE_NICKNAME_MESSAGE = "사용할 수 없는 닉네임이에요"
+ @AssistedFactory
+ interface Factory : AssistedViewModelFactory {
+ override fun create(state: SignUpContract.SignUpState): SignUpViewModel
}
+
+ companion object :
+ MavericksViewModelFactory by hiltMavericksViewModelFactory()
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/navigation/SignUpNavigation.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/navigation/SignUpNavigation.kt
new file mode 100644
index 00000000..9c7a7de6
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/navigation/SignUpNavigation.kt
@@ -0,0 +1,25 @@
+package com.sopt.clody.presentation.ui.auth.signup.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptionsBuilder
+import androidx.navigation.compose.composable
+import com.sopt.clody.presentation.ui.auth.signup.SignUpRoute
+import com.sopt.clody.presentation.utils.navigation.Route
+
+fun NavGraphBuilder.signUpScreen(
+ navigateToHome: () -> Unit,
+ navigateToPrevious: () -> Unit,
+) {
+ composable {
+ SignUpRoute(
+ navigateToHome = navigateToHome,
+ navigateToPrevious = navigateToPrevious,
+ )
+ }
+}
+fun NavController.navigateToSignUp(
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) {
+ navigate(Route.SignUp, navOptions)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/page/NicknamePage.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/page/NicknamePage.kt
new file mode 100644
index 00000000..ec5e22ff
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/page/NicknamePage.kt
@@ -0,0 +1,162 @@
+package com.sopt.clody.presentation.ui.auth.signup.page
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import com.sopt.clody.R
+import com.sopt.clody.presentation.ui.auth.component.textfield.NickNameTextField
+import com.sopt.clody.presentation.ui.auth.signup.NicknameMessage
+import com.sopt.clody.presentation.ui.component.LoadingScreen
+import com.sopt.clody.presentation.ui.component.button.ClodyButton
+import com.sopt.clody.presentation.utils.base.BasePreview
+import com.sopt.clody.presentation.utils.base.ClodyPreview
+import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage
+import com.sopt.clody.ui.theme.ClodyTheme
+
+@Composable
+fun NickNamePage(
+ nickname: String,
+ isValidNickname: Boolean,
+ nicknameMaxLength: Int,
+ nicknameMessage: NicknameMessage,
+ isLoading: Boolean,
+ isFocused: Boolean,
+ onNicknameChange: (String) -> Unit,
+ onFocusChanged: (Boolean) -> Unit,
+ onCompleteClick: () -> Unit,
+ onBackClick: () -> Unit,
+) {
+ val focusManager = LocalFocusManager.current
+
+ val annotatedString = buildAnnotatedString {
+ withStyle(style = SpanStyle(color = ClodyTheme.colors.gray04)) {
+ append("${nickname.length}")
+ }
+ withStyle(style = SpanStyle(color = ClodyTheme.colors.gray06)) {
+ append(" / ")
+ }
+ withStyle(style = SpanStyle(color = ClodyTheme.colors.gray06)) {
+ append("$nicknameMaxLength")
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ IconButton(
+ onClick = onBackClick,
+ modifier = Modifier
+ .statusBarsPadding()
+ .padding(start = 8.dp),
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.ic_nickname_back),
+ contentDescription = null,
+ )
+ }
+ },
+ bottomBar = {
+ ClodyButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .imePadding()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 28.dp),
+ onClick = {
+ focusManager.clearFocus()
+ onCompleteClick()
+ },
+ text = stringResource(R.string.nickname_btn_next),
+ enabled = nickname.isNotEmpty() && isValidNickname,
+ )
+ },
+ content = { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(ClodyTheme.colors.white)
+ .padding(paddingValues)
+ .padding(horizontal = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.heightForScreenPercentage(0.056f))
+ Text(stringResource(R.string.nickname_title), style = ClodyTheme.typography.head1)
+ Spacer(modifier = Modifier.heightForScreenPercentage(0.06f))
+ NickNameTextField(
+ value = nickname,
+ onValueChange = onNicknameChange,
+ maxLength = nicknameMaxLength,
+ hint = stringResource(R.string.nickname_input_hint),
+ isFocused = isFocused,
+ isValid = isValidNickname,
+ onFocusChanged = onFocusChanged,
+ onRemove = { onNicknameChange("") },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = nicknameMessage.getMessage(),
+ color = when {
+ nickname.isEmpty() -> ClodyTheme.colors.gray04
+ isValidNickname -> ClodyTheme.colors.gray04
+ else -> ClodyTheme.colors.red
+ },
+ style = ClodyTheme.typography.detail1Regular,
+ )
+ Text(
+ text = annotatedString,
+ style = ClodyTheme.typography.detail1Medium,
+ )
+ }
+ }
+ },
+ )
+
+ if (isLoading) {
+ LoadingScreen()
+ }
+}
+
+@ClodyPreview
+@Composable
+private fun NicknamePagePreview() {
+ BasePreview {
+ NickNamePage(
+ nickname = "클로디",
+ isValidNickname = true,
+ nicknameMaxLength = 15,
+ nicknameMessage = NicknameMessage.DEFAULT,
+ isLoading = false,
+ isFocused = false,
+ onNicknameChange = {},
+ onFocusChanged = {},
+ onCompleteClick = {},
+ onBackClick = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/page/TermsOfServicePage.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/page/TermsOfServicePage.kt
new file mode 100644
index 00000000..6b2dd169
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/signup/page/TermsOfServicePage.kt
@@ -0,0 +1,178 @@
+package com.sopt.clody.presentation.ui.auth.signup.page
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+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.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.sopt.clody.R
+import com.sopt.clody.presentation.ui.auth.component.checkbox.CustomCheckbox
+import com.sopt.clody.presentation.ui.component.button.ClodyButton
+import com.sopt.clody.presentation.ui.home.calendar.component.HorizontalDivider
+import com.sopt.clody.presentation.utils.base.BasePreview
+import com.sopt.clody.presentation.utils.base.ClodyPreview
+import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage
+import com.sopt.clody.ui.theme.ClodyTheme
+
+@Composable
+fun TermsOfServicePage(
+ allChecked: Boolean,
+ serviceChecked: Boolean,
+ privacyChecked: Boolean,
+ serviceUrl: String,
+ privacyUrl: String,
+ onToggleAll: (Boolean) -> Unit,
+ onToggleService: (Boolean) -> Unit,
+ onTogglePrivacy: (Boolean) -> Unit,
+ onAgreeClick: () -> Unit,
+ navigateToPrevious: () -> Unit,
+ navigateToWebView: (String) -> Unit,
+) {
+ val isAgreeButtonEnabled = serviceChecked && privacyChecked
+
+ Scaffold(
+ topBar = {
+ IconButton(
+ onClick = navigateToPrevious,
+ modifier = Modifier
+ .statusBarsPadding()
+ .padding(start = 8.dp),
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.ic_nickname_back),
+ contentDescription = null,
+ )
+ }
+ },
+ bottomBar = {
+ ClodyButton(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 28.dp),
+ onClick = onAgreeClick,
+ text = stringResource(R.string.terms_btn_next),
+ enabled = isAgreeButtonEnabled,
+ )
+ },
+ content = { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color = ClodyTheme.colors.white)
+ .padding(paddingValues)
+ .padding(horizontal = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.heightForScreenPercentage(0.056f))
+ Text(
+ text = stringResource(R.string.terms_title),
+ color = ClodyTheme.colors.gray01,
+ style = ClodyTheme.typography.head1,
+ )
+ Spacer(modifier = Modifier.heightForScreenPercentage(0.06f))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.terms_agree_all),
+ modifier = Modifier.weight(1f),
+ color = ClodyTheme.colors.gray01,
+ style = ClodyTheme.typography.head3,
+ )
+ CustomCheckbox(
+ checked = allChecked,
+ onCheckedChange = onToggleAll,
+ size = 25.dp,
+ checkedImageRes = R.drawable.ic_terms_check_on_25,
+ uncheckedImageRes = R.drawable.ic_terms_check_off_25,
+ )
+ }
+ Spacer(modifier = Modifier.height(18.dp))
+ HorizontalDivider(color = ClodyTheme.colors.gray07, thickness = 1.dp)
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsCheckboxRow(
+ text = stringResource(R.string.terms_service_use),
+ checked = serviceChecked,
+ onCheckedChange = onToggleService,
+ onClickMore = { navigateToWebView(serviceUrl) },
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ TermsCheckboxRow(
+ text = stringResource(R.string.terms_service_privacy),
+ checked = privacyChecked,
+ onCheckedChange = onTogglePrivacy,
+ onClickMore = { navigateToWebView(privacyUrl) },
+ )
+ }
+ },
+ )
+}
+
+@Composable
+fun TermsCheckboxRow(
+ text: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ onClickMore: () -> Unit,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(text, style = ClodyTheme.typography.body1Medium)
+ Spacer(modifier = Modifier.width(8.dp))
+ Image(
+ painter = painterResource(id = R.drawable.ic_terms_next),
+ contentDescription = null,
+ modifier = Modifier
+ .clickable(onClick = onClickMore),
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ CustomCheckbox(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ size = 23.dp,
+ checkedImageRes = R.drawable.ic_terms_check_on_23,
+ uncheckedImageRes = R.drawable.ic_terms_check_off_23,
+ )
+ }
+}
+
+@ClodyPreview
+@Composable
+private fun TermsOfServicePagePreview() {
+ BasePreview {
+ TermsOfServicePage(
+ allChecked = false,
+ serviceChecked = false,
+ privacyChecked = false,
+ serviceUrl = "",
+ privacyUrl = "",
+ onToggleAll = {},
+ onToggleService = {},
+ onTogglePrivacy = {},
+ onAgreeClick = {},
+ navigateToPrevious = {},
+ navigateToWebView = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderNavigation.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderNavigation.kt
new file mode 100644
index 00000000..ac9bf056
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderNavigation.kt
@@ -0,0 +1,23 @@
+package com.sopt.clody.presentation.ui.auth.timereminder
+
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.NavOptionsBuilder
+import androidx.navigation.compose.composable
+import com.sopt.clody.presentation.utils.navigation.Route
+
+fun NavGraphBuilder.timeReminderScreen(
+ navigateToGuide: () -> Unit,
+) {
+ composable {
+ TimeReminderRoute(
+ navigateToGuide = navigateToGuide,
+ )
+ }
+}
+
+fun NavHostController.navigateToTimeReminder(
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) {
+ navigate(Route.TimeReminder, navOptions)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderScreen.kt
index 67b254da..a00ce254 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderScreen.kt
@@ -37,7 +37,6 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.sopt.clody.R
import com.sopt.clody.presentation.ui.auth.component.container.PickerBox
import com.sopt.clody.presentation.ui.auth.component.timepicker.BottomSheetTimePicker
-import com.sopt.clody.presentation.ui.auth.navigation.AuthNavigator
import com.sopt.clody.presentation.ui.component.LoadingScreen
import com.sopt.clody.presentation.ui.component.button.ClodyButton
import com.sopt.clody.presentation.ui.component.dialog.FailureDialog
@@ -45,11 +44,12 @@ import com.sopt.clody.presentation.ui.component.popup.ClodyPopupBottomSheet
import com.sopt.clody.presentation.ui.home.calendar.component.HorizontalDivider
import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
+import com.sopt.clody.presentation.utils.extension.TimePeriod
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun TimeReminderRoute(
- navigator: AuthNavigator,
+ navigateToGuide: () -> Unit,
viewModel: TimeReminderViewModel = hiltViewModel(),
) {
val timeReminderState by viewModel.timeReminderState.collectAsState()
@@ -81,15 +81,11 @@ fun TimeReminderRoute(
// 알림 권한 요청 결과에 따른 처리
LaunchedEffect(timeReminderState) {
when (val result = timeReminderState) {
- is TimeReminderState.Success -> {
- navigator.navigateGuide()
- }
-
+ is TimeReminderState.Success -> navigateToGuide()
is TimeReminderState.Failure -> {
showDialog = true
dialogMessage = result.error
}
-
else -> {}
}
}
@@ -106,11 +102,11 @@ fun TimeReminderRoute(
TimeReminderScreen(
onStartClick = {
- viewModel.setFixedTime("21", "30")
+ viewModel.setSelectedTime(TimePeriod.PM, "9", "30")
viewModel.sendNotification(context, isNotificationPermissionGranted.value)
},
- onTimeSelected = { amPm, hour, minute ->
- viewModel.setSelectedTime(amPm, hour, minute)
+ onTimeSelected = { period, hour, minute ->
+ viewModel.setSelectedTime(period, hour, minute)
},
onCompleteClick = {
AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.ONBOARDING_ALARM)
@@ -123,20 +119,20 @@ fun TimeReminderRoute(
@Composable
fun TimeReminderScreen(
onStartClick: () -> Unit,
- onTimeSelected: (String, String, String) -> Unit,
+ onTimeSelected: (TimePeriod, String, String) -> Unit,
onCompleteClick: () -> Unit,
isLoading: Boolean,
) {
var showBottomSheet by remember { mutableStateOf(false) }
- var selectedAmPm by remember { mutableStateOf("오후") }
+ var selectedTimePeriod by remember { mutableStateOf(TimePeriod.PM) }
var selectedHour by remember { mutableStateOf("9") }
var selectedMinute by remember { mutableStateOf("30") }
- val onRemindTimeSelected: (String, String, String) -> Unit = { amPm, hour, minute ->
- selectedAmPm = amPm
+ val onRemindTimeSelected: (TimePeriod, String, String) -> Unit = { period, hour, minute ->
+ selectedTimePeriod = period
selectedHour = hour
selectedMinute = minute
- onTimeSelected(amPm, hour, minute)
+ onTimeSelected(period, hour, minute)
}
Scaffold(
@@ -151,13 +147,13 @@ fun TimeReminderScreen(
) {
ClodyButton(
onClick = onCompleteClick,
- text = stringResource(id = R.string.time_reminder_complete_button),
+ text = stringResource(id = R.string.time_reminder_btn_complete),
enabled = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(LocalConfiguration.current.screenHeightDp.dp * 0.015f))
Text(
- text = stringResource(id = R.string.time_reminder_next_setting_button),
+ text = stringResource(id = R.string.time_reminder_btn_skip),
modifier = Modifier
.clickable(
onClick = onStartClick,
@@ -188,7 +184,12 @@ fun TimeReminderScreen(
)
Spacer(modifier = Modifier.height(LocalConfiguration.current.screenHeightDp.dp * 0.05f))
PickerBox(
- time = "$selectedAmPm ${selectedHour}시 ${selectedMinute}분",
+ time = stringResource(
+ id = R.string.time_reminder_time_format,
+ selectedTimePeriod.getLabel(),
+ selectedHour,
+ selectedMinute,
+ ),
modifier = Modifier.fillMaxWidth(),
onClick = { showBottomSheet = true },
)
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderViewModel.kt
index 91906f23..07fba0d3 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderViewModel.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/auth/timereminder/TimeReminderViewModel.kt
@@ -6,22 +6,25 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.sopt.clody.core.network.NetworkConnectivityObserver
+import com.sopt.clody.core.network.NetworkStatus
import com.sopt.clody.data.remote.dto.request.SendNotificationRequestDto
-import com.sopt.clody.data.remote.util.NetworkUtil
import com.sopt.clody.domain.repository.NotificationRepository
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_NETWORK_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.UNKNOWN_ERROR
+import com.sopt.clody.presentation.utils.extension.TimePeriod
+import com.sopt.clody.presentation.utils.extension.convertUTZtoKST
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TimeReminderViewModel @Inject constructor(
private val notificationRepository: NotificationRepository,
- private val networkUtil: NetworkUtil,
+ private val networkConnectivityObserver: NetworkConnectivityObserver,
+ private val errorMessageProvider: ErrorMessageProvider,
) : ViewModel() {
private val _timeReminderState = MutableStateFlow(TimeReminderState.Idle)
@@ -32,8 +35,8 @@ class TimeReminderViewModel @Inject constructor(
fun sendNotification(context: Context, isPermissionGranted: Boolean) {
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _timeReminderState.value = TimeReminderState.Failure(FAILURE_NETWORK_MESSAGE)
+ if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) {
+ _timeReminderState.value = TimeReminderState.Failure(errorMessageProvider.getNetworkError())
return@launch
}
@@ -45,6 +48,7 @@ class TimeReminderViewModel @Inject constructor(
val requestDto = SendNotificationRequestDto(
isDiaryAlarm = isPermissionGranted,
+ isDraftAlarm = false,
isReplyAlarm = isPermissionGranted,
time = selectedTime,
fcmToken = fcmToken,
@@ -57,9 +61,9 @@ class TimeReminderViewModel @Inject constructor(
},
onFailure = { error ->
val errorMessage = if (error.message?.contains("200") == false) {
- FAILURE_TEMPORARY_MESSAGE
+ errorMessageProvider.getTemporaryError()
} else {
- error.localizedMessage ?: UNKNOWN_ERROR
+ error.localizedMessage ?: errorMessageProvider.getUnknownError()
}
_timeReminderState.value = TimeReminderState.Failure(errorMessage)
},
@@ -76,22 +80,7 @@ class TimeReminderViewModel @Inject constructor(
return sharedPreferences.getString("fcm_token", null)
}
- fun setSelectedTime(amPm: String, hour: String, minute: String) {
- selectedTime = formatTime(amPm, hour, minute)
- }
-
- fun setFixedTime(hour: String, minute: String) {
- selectedTime = String.format("%02d:%02d", hour.toInt(), minute.toInt())
- }
-
- private fun formatTime(amPm: String, hour: String, minute: String): String {
- val hourInt = if (amPm == "오후" && hour.toInt() != 12) {
- hour.toInt() + 12
- } else if (amPm == "오전" && hour.toInt() == 12) {
- 0
- } else {
- hour.toInt()
- }
- return String.format("%02d:%02d", hourInt, minute.toInt())
+ fun setSelectedTime(period: TimePeriod, hour: String, minute: String) {
+ selectedTime = convertUTZtoKST(timePeriod = period, hour = hour, minute = minute)
}
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/FailureScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/FailureScreen.kt
index adfee414..feb23e6a 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/component/FailureScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/component/FailureScreen.kt
@@ -59,7 +59,7 @@ fun FailureScreen(
colors = ButtonDefaults.buttonColors(ClodyTheme.colors.mainYellow),
) {
Text(
- text = stringResource(R.string.failure_screen_refresh_btn),
+ text = stringResource(R.string.failure_screen_btn_refresh),
modifier = Modifier.padding(6.dp),
color = ClodyTheme.colors.gray01,
style = ClodyTheme.typography.body2SemiBold,
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/bottomsheet/DiaryDeleteSheet.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/bottomsheet/DiaryDeleteSheet.kt
index ba5f2b4e..4e3cb841 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/component/bottomsheet/DiaryDeleteSheet.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/component/bottomsheet/DiaryDeleteSheet.kt
@@ -18,6 +18,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sopt.clody.R
import com.sopt.clody.ui.theme.ClodyTheme
@@ -71,7 +72,7 @@ fun DiaryDeleteBottomSheetItem(
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "삭제하기",
+ text = stringResource(R.string.bottom_sheet_diary_delete),
style = ClodyTheme.typography.body4SemiBold,
color = ClodyTheme.colors.gray01,
)
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/button/ClodyButton.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/button/ClodyButton.kt
index accd545d..3fb7dea8 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/component/button/ClodyButton.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/component/button/ClodyButton.kt
@@ -7,6 +7,7 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sopt.clody.ui.theme.ClodyTheme
@@ -17,26 +18,26 @@ fun ClodyButton(
text: String,
enabled: Boolean,
modifier: Modifier = Modifier,
+ containerColor: Color = if (enabled) ClodyTheme.colors.mainYellow else ClodyTheme.colors.lightYellow,
+ contentColor: Color = if (enabled) ClodyTheme.colors.gray01 else ClodyTheme.colors.gray06,
+ disabledContainerColor: Color = ClodyTheme.colors.lightYellow,
+ disabledContentColor: Color = ClodyTheme.colors.gray06,
) {
- val backgroundColor = if (enabled) ClodyTheme.colors.mainYellow else ClodyTheme.colors.lightYellow
- val contentColor = if (enabled) ClodyTheme.colors.gray01 else ClodyTheme.colors.gray06
-
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(
- containerColor = backgroundColor,
+ containerColor = containerColor,
contentColor = contentColor,
- disabledContainerColor = ClodyTheme.colors.lightYellow,
- disabledContentColor = ClodyTheme.colors.gray06,
+ disabledContainerColor = disabledContainerColor,
+ disabledContentColor = disabledContentColor,
),
shape = RoundedCornerShape(10.dp),
enabled = enabled,
- modifier = modifier
- .height(50.dp),
+ modifier = modifier.height(50.dp),
) {
Text(
text = text,
- color = contentColor,
+ color = if (enabled) contentColor else disabledContentColor,
style = ClodyTheme.typography.body2SemiBold,
)
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/ClodyDialog.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/ClodyDialog.kt
index 6d45a195..cdd8265d 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/ClodyDialog.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/ClodyDialog.kt
@@ -43,6 +43,7 @@ fun ClodyDialog(
confirmButtonColor: Color,
confirmButtonTextColor: Color,
onDismiss: () -> Unit,
+ onDismissButtonClick: (() -> Unit)? = null,
) {
var isButtonClicked by remember { mutableStateOf(false) }
@@ -68,8 +69,7 @@ fun ClodyDialog(
.wrapContentHeight(),
) {
Column(
- modifier = Modifier
- .padding(28.dp),
+ modifier = Modifier.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@@ -77,6 +77,7 @@ fun ClodyDialog(
Text(
text = titleMassage,
+ textAlign = TextAlign.Center,
style = ClodyTheme.typography.body1SemiBold,
)
@@ -92,21 +93,21 @@ fun ClodyDialog(
Spacer(modifier = Modifier.height(32.dp))
Row(
- modifier = Modifier
- .fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth(),
) {
Button(
onClick = {
if (!isButtonClicked) {
isButtonClicked = true
- onDismiss()
+ // dismiss 버튼 클릭 시: 우선순위는 onDismissButtonClick
+ onDismissButtonClick?.invoke() ?: onDismiss()
}
},
modifier = Modifier
.weight(1f)
.background(
color = ClodyTheme.colors.gray07,
- shape = RoundedCornerShape(size = 8.dp),
+ shape = RoundedCornerShape(8.dp),
),
colors = ButtonDefaults.buttonColors(ClodyTheme.colors.gray07),
) {
@@ -130,7 +131,7 @@ fun ClodyDialog(
.weight(1f)
.background(
color = confirmButtonColor,
- shape = RoundedCornerShape(size = 8.dp),
+ shape = RoundedCornerShape(8.dp),
),
colors = ButtonDefaults.buttonColors(confirmButtonColor),
) {
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/FailureDialog.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/FailureDialog.kt
index 6ff6a936..6c13d329 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/FailureDialog.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/FailureDialog.kt
@@ -83,7 +83,7 @@ fun FailureDialog(
colors = ButtonDefaults.buttonColors(ClodyTheme.colors.mainYellow),
) {
Text(
- text = stringResource(R.string.failure_dialog_confirm_btn),
+ text = stringResource(R.string.failure_dialog_btn_confirm),
color = ClodyTheme.colors.gray02,
style = ClodyTheme.typography.body3SemiBold,
)
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/InspectionDialog.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/InspectionDialog.kt
new file mode 100644
index 00000000..fb885bf5
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/component/dialog/InspectionDialog.kt
@@ -0,0 +1,107 @@
+package com.sopt.clody.presentation.ui.component.dialog
+
+import androidx.compose.foundation.Image
+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.fillMaxSize
+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.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.sopt.clody.R
+import com.sopt.clody.presentation.utils.base.BasePreview
+import com.sopt.clody.presentation.utils.base.ClodyPreview
+import com.sopt.clody.ui.theme.ClodyTheme
+
+@Composable
+fun InspectionDialog(
+ inspectionTime: String,
+ onDismiss: () -> Unit,
+) {
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(
+ dismissOnClickOutside = false,
+ dismissOnBackPress = false,
+ usePlatformDefaultWidth = false,
+ ),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.5f))
+ .padding(horizontal = 24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Card(
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = ClodyTheme.colors.white),
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.img_inspection_dialog),
+ contentDescription = null,
+ )
+ Spacer(modifier = Modifier.height(20.dp))
+ Text(
+ text = "보다 안정적인 클로디 서비스를 위해\n시스템 점검 중이에요. 곧 다시 만나요!",
+ color = ClodyTheme.colors.gray03,
+ textAlign = TextAlign.Center,
+ style = ClodyTheme.typography.body3Medium,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "점검시간 : $inspectionTime",
+ color = ClodyTheme.colors.gray04,
+ textAlign = TextAlign.Center,
+ style = ClodyTheme.typography.body3Medium,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ onClick = onDismiss,
+ modifier = Modifier
+ .fillMaxWidth(),
+ shape = RoundedCornerShape(8.dp),
+ colors = ButtonDefaults.buttonColors(ClodyTheme.colors.mainYellow),
+ ) {
+ Text(
+ text = "확인",
+ color = ClodyTheme.colors.gray02,
+ style = ClodyTheme.typography.body3SemiBold,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@ClodyPreview
+@Composable
+private fun PreviewInspectionDialog() {
+ BasePreview {
+ InspectionDialog(
+ inspectionTime = "",
+ onDismiss = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/timepicker/YearMonthPicker.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/timepicker/YearMonthPicker.kt
index 9cd81c01..e91a665e 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/component/timepicker/YearMonthPicker.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/component/timepicker/YearMonthPicker.kt
@@ -2,6 +2,8 @@ package com.sopt.clody.presentation.ui.component.timepicker
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -14,19 +16,39 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.sopt.clody.R
import com.sopt.clody.presentation.ui.component.button.ClodyButton
+import com.sopt.clody.presentation.utils.extension.YearMonthLabelUtil
+import com.sopt.clody.presentation.utils.extension.toLocalizedMonthLabel
+import com.sopt.clody.presentation.utils.extension.toLocalizedYearLabel
import com.sopt.clody.ui.theme.ClodyTheme
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import java.util.Locale
@Composable
fun YearMonthPicker(
@@ -35,6 +57,18 @@ fun YearMonthPicker(
selectedMonth: Int,
onYearMonthSelected: (Int, Int) -> Unit,
) {
+ val yearItems = remember { (YearMonthLabelUtil.MIN_YEAR..YearMonthLabelUtil.MAX_YEAR).toList() }
+ val monthItems = remember { (1..12).toList() }
+
+ val yearLabelItems = yearItems.map { it.toLocalizedYearLabel() }
+ val monthLabelItems = monthItems.map { it.toLocalizedMonthLabel() }
+
+ val yearPickerState = rememberPickerState()
+ val monthPickerState = rememberPickerState()
+
+ val startYearIndex = (yearItems.indexOf(selectedYear) - 2).coerceAtLeast(0)
+ val startMonthIndex = (monthItems.indexOf(selectedMonth) - 2).coerceAtLeast(0)
+
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -49,7 +83,6 @@ fun YearMonthPicker(
.wrapContentSize()
.background(color = ClodyTheme.colors.white)
.padding(horizontal = 24.dp),
-
) {
Box(
modifier = Modifier
@@ -57,7 +90,7 @@ fun YearMonthPicker(
.padding(top = 16.dp, bottom = 30.dp),
) {
Text(
- text = "다른 날짜 보기",
+ text = stringResource(R.string.bottom_sheet_year_month_picker_title),
style = ClodyTheme.typography.body2SemiBold,
color = ClodyTheme.colors.gray01,
modifier = Modifier.align(Alignment.Center),
@@ -76,19 +109,7 @@ fun YearMonthPicker(
}
}
- val yearItems = remember { (2000..2030).map { "${it}년" } }
- val monthItems = remember { (1..12).map { "${it}월" } }
-
- val yearPickerState = rememberPickerState()
- val monthPickerState = rememberPickerState()
-
- val startYearIndex = (yearItems.indexOf("${selectedYear}년") - 2).coerceAtLeast(0)
- val startMonthIndex = (monthItems.indexOf("${selectedMonth}월") - 2).coerceAtLeast(0)
-
- Box(
- modifier = Modifier
- .fillMaxWidth(),
- ) {
+ Box(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -97,43 +118,63 @@ fun YearMonthPicker(
.background(ClodyTheme.colors.gray08, shape = RoundedCornerShape(8.dp)),
)
Row(
- modifier = Modifier
- .fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
- Spacer(modifier = Modifier.weight(1f))
- YearMonthPickerItem(
- state = yearPickerState,
- items = yearItems,
- startIndex = startYearIndex,
- visibleItemsCount = 5,
- infiniteScroll = false,
- modifier = Modifier
- .weight(1f),
- textModifier = Modifier.padding(8.dp),
- )
- Spacer(modifier = Modifier.width(20.dp))
- YearMonthPickerItem(
- state = monthPickerState,
- items = monthItems,
- startIndex = startMonthIndex,
- visibleItemsCount = 5,
- infiniteScroll = false,
- modifier = Modifier
- .weight(1f),
- textModifier = Modifier.padding(8.dp),
- )
- Spacer(modifier = Modifier.weight(1f))
+ if (LocalConfiguration.current.locales[0] == Locale.KOREA) {
+ Spacer(modifier = Modifier.width(20.dp))
+ YearMonthPickerItem(
+ state = yearPickerState,
+ items = yearLabelItems,
+ startIndex = startYearIndex,
+ visibleItemsCount = 5,
+ infiniteScroll = false,
+ modifier = Modifier.weight(1f),
+ textModifier = Modifier.padding(8.dp),
+ )
+ YearMonthPickerItem(
+ state = monthPickerState,
+ items = monthLabelItems,
+ startIndex = startMonthIndex,
+ visibleItemsCount = 5,
+ infiniteScroll = false,
+ modifier = Modifier.weight(1f),
+ textModifier = Modifier.padding(8.dp),
+ )
+ Spacer(modifier = Modifier.width(20.dp))
+ } else {
+ Spacer(modifier = Modifier.width(20.dp))
+ YearMonthPickerItem(
+ state = monthPickerState,
+ items = monthLabelItems,
+ startIndex = startMonthIndex,
+ visibleItemsCount = 5,
+ infiniteScroll = false,
+ modifier = Modifier.weight(1f),
+ textModifier = Modifier.padding(8.dp),
+ )
+ YearMonthPickerItem(
+ state = yearPickerState,
+ items = yearLabelItems,
+ startIndex = startYearIndex,
+ visibleItemsCount = 5,
+ infiniteScroll = false,
+ modifier = Modifier.weight(1f),
+ textModifier = Modifier.padding(8.dp),
+ )
+ Spacer(modifier = Modifier.width(20.dp))
+ }
}
}
+
ClodyButton(
onClick = {
- val selectedYear = yearPickerState.selectedItem.split("년")[0].toInt()
- val selectedMonth = monthPickerState.selectedItem.split("월")[0].toInt()
- onYearMonthSelected(selectedYear, selectedMonth)
+ val year = yearItems[yearLabelItems.indexOf(yearPickerState.selectedItem)]
+ val month = monthItems[monthLabelItems.indexOf(monthPickerState.selectedItem)]
+ onYearMonthSelected(year, month)
onDismissRequest()
},
- text = "완료",
+ text = stringResource(R.string.bottom_sheet_year_month_picker_btn_confirm),
enabled = true,
modifier = Modifier
.fillMaxWidth()
@@ -142,3 +183,87 @@ fun YearMonthPicker(
}
}
}
+
+@Composable
+fun YearMonthPickerItem(
+ modifier: Modifier = Modifier,
+ items: List,
+ state: PickerState = rememberPickerState(),
+ startIndex: Int = 0,
+ visibleItemsCount: Int,
+ textModifier: Modifier = Modifier,
+ infiniteScroll: Boolean = true,
+) {
+ val visibleItemsMiddle = visibleItemsCount / 2
+ val emptyItems = List(visibleItemsMiddle) { "" }
+ val paddedItems = emptyItems + items + emptyItems
+ val listScrollCount = if (infiniteScroll) Integer.MAX_VALUE else paddedItems.size
+ val listScrollMiddle = listScrollCount / 2
+ val listStartIndex = if (infiniteScroll) {
+ listScrollMiddle - listScrollMiddle % paddedItems.size - visibleItemsMiddle + startIndex
+ } else {
+ startIndex + visibleItemsMiddle
+ }
+
+ fun getItem(index: Int) = paddedItems.getOrNull(index).orEmpty()
+
+ val listState = rememberLazyListState(initialFirstVisibleItemIndex = listStartIndex)
+ val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState)
+
+ val itemHeightPixels = remember { mutableIntStateOf(0) }
+ val itemHeightDp = with(LocalDensity.current) { itemHeightPixels.intValue.toDp() }
+
+ val fadingEdgeGradient = remember {
+ Brush.verticalGradient(
+ 0f to Color.White.copy(alpha = 0.9f),
+ 0.1f to Color.White.copy(alpha = 0.8f),
+ 0.2f to Color.White.copy(alpha = 0.7f),
+ 0.3f to Color.White.copy(alpha = 0.6f),
+ 0.4f to Color.Transparent,
+ 0.5f to Color.Transparent,
+ 0.6f to Color.Transparent,
+ 0.7f to Color.White.copy(alpha = 0.7f),
+ 0.8f to Color.White.copy(alpha = 0.8f),
+ 0.9f to Color.White.copy(alpha = 0.9f),
+ )
+ }
+
+ LaunchedEffect(listState) {
+ snapshotFlow { listState.firstVisibleItemIndex }
+ .map { index -> getItem(index + visibleItemsMiddle) }
+ .distinctUntilChanged()
+ .collect { item -> state.selectedItem = item }
+ }
+
+ Box(modifier = modifier) {
+ LazyColumn(
+ state = listState,
+ flingBehavior = flingBehavior,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(itemHeightDp * visibleItemsCount)
+ .pointerInput(Unit) {
+ detectVerticalDragGestures { change, dragAmount ->
+ change.consume()
+ }
+ }
+ .drawWithContent {
+ drawContent()
+ drawRect(fadingEdgeGradient, size = size)
+ },
+ ) {
+ items(listScrollCount) { index ->
+ Text(
+ text = getItem(index),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = ClodyTheme.typography.head3Medium.copy(color = ClodyTheme.colors.gray01),
+ modifier = Modifier
+ .onSizeChanged { size -> itemHeightPixels.intValue = size.height }
+ .then(textModifier),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/timepicker/YearMonthPickerItem.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/timepicker/YearMonthPickerItem.kt
deleted file mode 100644
index 813f6ac7..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/component/timepicker/YearMonthPickerItem.kt
+++ /dev/null
@@ -1,123 +0,0 @@
-package com.sopt.clody.presentation.ui.component.timepicker
-
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.gestures.detectVerticalDragGestures
-import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.lazy.LazyColumn
-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.mutableIntStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.snapshotFlow
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.drawWithContent
-import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.tooling.preview.Preview
-import com.sopt.clody.ui.theme.ClodyTheme
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.map
-
-@OptIn(ExperimentalFoundationApi::class)
-@Composable
-fun YearMonthPickerItem(
- modifier: Modifier = Modifier,
- items: List,
- state: PickerState = rememberPickerState(),
- startIndex: Int = 0,
- visibleItemsCount: Int,
- textModifier: Modifier = Modifier,
- infiniteScroll: Boolean = true,
-) {
- val visibleItemsMiddle = visibleItemsCount / 2
- val emptyItems = List(visibleItemsMiddle) { "" }
- val paddedItems = emptyItems + items + emptyItems
- val listScrollCount = if (infiniteScroll) Integer.MAX_VALUE else paddedItems.size
- val listScrollMiddle = listScrollCount / 2
- val listStartIndex = if (infiniteScroll) {
- listScrollMiddle - listScrollMiddle % paddedItems.size - visibleItemsMiddle + startIndex
- } else {
- startIndex + visibleItemsMiddle
- }
-
- fun getItem(index: Int) = paddedItems.getOrNull(index).orEmpty()
-
- val listState = rememberLazyListState(initialFirstVisibleItemIndex = listStartIndex)
- val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState)
-
- val itemHeightPixels = remember { mutableIntStateOf(0) }
- val itemHeightDp = with(LocalDensity.current) { itemHeightPixels.intValue.toDp() }
-
- val fadingEdgeGradient = remember {
- Brush.verticalGradient(
- 0f to Color.White.copy(alpha = 0.9f),
- 0.1f to Color.White.copy(alpha = 0.8f),
- 0.2f to Color.White.copy(alpha = 0.7f),
- 0.3f to Color.White.copy(alpha = 0.6f),
- 0.4f to Color.Transparent,
- 0.5f to Color.Transparent,
- 0.6f to Color.Transparent,
- 0.7f to Color.White.copy(alpha = 0.7f),
- 0.8f to Color.White.copy(alpha = 0.8f),
- 0.9f to Color.White.copy(alpha = 0.9f),
- )
- }
-
- LaunchedEffect(listState) {
- snapshotFlow { listState.firstVisibleItemIndex }
- .map { index -> getItem(index + visibleItemsMiddle) }
- .distinctUntilChanged()
- .collect { item -> state.selectedItem = item }
- }
-
- Box(modifier = modifier) {
- LazyColumn(
- state = listState,
- flingBehavior = flingBehavior,
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier
- .fillMaxWidth()
- .height(itemHeightDp * visibleItemsCount)
- .pointerInput(Unit) {
- detectVerticalDragGestures { change, dragAmount ->
- change.consume()
- }
- }
- .drawWithContent {
- drawContent()
- drawRect(fadingEdgeGradient, size = size)
- },
- ) {
- items(listScrollCount) { index ->
- Text(
- text = getItem(index),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = ClodyTheme.typography.head3Medium.copy(color = ClodyTheme.colors.gray01),
- modifier = Modifier
- .onSizeChanged { size -> itemHeightPixels.intValue = size.height }
- .then(textModifier),
- )
- }
- }
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-fun PreviewPicker() {
- YearMonthPickerItem(
- items = (1..99).map { it.toString() },
- visibleItemsCount = 5,
- )
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/component/toast/ClodyToastMessage.kt b/app/src/main/java/com/sopt/clody/presentation/ui/component/toast/ClodyToastMessage.kt
index ff1fbf64..01a3f04c 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/component/toast/ClodyToastMessage.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/component/toast/ClodyToastMessage.kt
@@ -2,13 +2,14 @@ package com.sopt.clody.presentation.ui.component.toast
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.Row
import androidx.compose.foundation.layout.Spacer
+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.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -17,8 +18,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import com.sopt.clody.presentation.utils.base.BasePreview
+import com.sopt.clody.presentation.utils.base.ClodyPreview
import com.sopt.clody.ui.theme.ClodyTheme
import kotlinx.coroutines.delay
@@ -39,11 +41,15 @@ fun ClodyToastMessage(
Box(
modifier = modifier
- .wrapContentHeight()
- .background(color = backgroundColor, shape = RoundedCornerShape(28.dp))
- .padding(horizontal = 22.dp, vertical = 16.dp),
+ .height(42.dp)
+ .background(color = backgroundColor, shape = RoundedCornerShape(28.dp)),
+ contentAlignment = Alignment.Center,
) {
- Row(verticalAlignment = Alignment.CenterVertically) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(horizontal = 18.dp),
+ ) {
Image(
painter = painterResource(id = iconResId),
contentDescription = null,
@@ -59,16 +65,17 @@ fun ClodyToastMessage(
}
}
-@Preview
+@ClodyPreview
@Composable
-fun PreviewCustomToastMessage() {
- ClodyToastMessage(
- message = "토스트 메시지",
- iconResId = 0,
- backgroundColor = Color(0xFF000000),
- contentColor = Color(0xFFFFFFFF),
- durationMillis = 3000,
- onDismiss = {},
- modifier = Modifier,
- )
+private fun ClodyToastMessagePreview() {
+ BasePreview {
+ ClodyToastMessage(
+ message = "이메일 인증이 완료되었어요!",
+ iconResId = com.sopt.clody.R.drawable.ic_toast_error,
+ backgroundColor = ClodyTheme.colors.gray01,
+ contentColor = ClodyTheme.colors.white,
+ durationMillis = 3000L,
+ onDismiss = {},
+ )
+ }
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/DailyDiaryCard.kt b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/DailyDiaryCard.kt
index 63e10649..ed6029f9 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/DailyDiaryCard.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/DailyDiaryCard.kt
@@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sopt.clody.R
import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto
+import com.sopt.clody.domain.model.ReplyStatus
import com.sopt.clody.presentation.ui.diarylist.screen.DiaryListViewModel
import com.sopt.clody.ui.theme.ClodyTheme
@@ -41,11 +42,12 @@ fun DailyDiaryCard(
day: Int,
dayOfWeek: String,
showDiaryDeleteBottomSheet: () -> Unit,
- onClickReplyDiary: (Int, Int, Int, String) -> Unit,
+ onClickReplyDiary: (Int, Int, Int, ReplyStatus) -> Unit,
) {
val iconRes = when {
- dailyDiary.replyStatus == "READY_NOT_READ" && dailyDiary.diaryCount > 0 -> R.drawable.ic_home_ungiven_clover
- dailyDiary.replyStatus == "UNREADY" && dailyDiary.diaryCount > 0 -> R.drawable.ic_home_ungiven_clover
+ dailyDiary.replyStatus == ReplyStatus.READY_NOT_READ && dailyDiary.diaryCount > 0 -> R.drawable.ic_home_ungiven_clover
+ dailyDiary.replyStatus == ReplyStatus.UNREADY && dailyDiary.diaryCount > 0 -> R.drawable.ic_home_ungiven_clover
+ dailyDiary.replyStatus == ReplyStatus.INVALID_DRAFT -> R.drawable.ic_home_expired_written_clover
dailyDiary.diaryCount == 0 -> R.drawable.ic_home_ungiven_clover
dailyDiary.diaryCount in 1..2 -> R.drawable.ic_home_bottom_clover
dailyDiary.diaryCount in 3..4 -> R.drawable.ic_home_mid_clover
@@ -83,14 +85,14 @@ fun DailyDiaryCard(
verticalAlignment = Alignment.Bottom,
) {
Text(
- text = stringResource(R.string.diarylist_daily_diary_day, day),
+ text = stringResource(R.string.diary_list_daily_diary_day, day),
modifier = Modifier
.padding(end = 2.dp),
color = ClodyTheme.colors.gray01,
style = ClodyTheme.typography.body2SemiBold,
)
Text(
- text = stringResource(R.string.diarylist_daily_diary_day_of_week, dayOfWeek),
+ text = stringResource(R.string.diary_list_daily_diary_day_of_week, dayOfWeek),
color = ClodyTheme.colors.gray04,
style = ClodyTheme.typography.body4Medium,
)
@@ -120,8 +122,20 @@ fun ReplyDiaryButton(
year: Int,
month: Int,
day: Int,
- onClickReplyDiary: (Int, Int, Int, String) -> Unit,
+ onClickReplyDiary: (Int, Int, Int, ReplyStatus) -> Unit,
) {
+ val isDisabled = dailyDiary.isDeleted || dailyDiary.replyStatus == ReplyStatus.INVALID_DRAFT
+ val containerColor = if (dailyDiary.replyStatus == ReplyStatus.INVALID_DRAFT) {
+ ClodyTheme.colors.gray08
+ } else {
+ ClodyTheme.colors.lightBlue
+ }
+ val contentColor = if (dailyDiary.replyStatus == ReplyStatus.INVALID_DRAFT) {
+ ClodyTheme.colors.gray06
+ } else {
+ ClodyTheme.colors.blue
+ }
+
Box(
contentAlignment = Alignment.TopEnd,
) {
@@ -130,10 +144,10 @@ fun ReplyDiaryButton(
modifier = Modifier
.height(33.dp)
.padding(horizontal = 3.dp, vertical = 3.dp),
- enabled = !(dailyDiary.isDeleted),
+ enabled = !isDisabled,
colors = ButtonDefaults.buttonColors(
- containerColor = ClodyTheme.colors.lightBlue,
- contentColor = ClodyTheme.colors.blue,
+ containerColor = containerColor,
+ contentColor = contentColor,
disabledContainerColor = ClodyTheme.colors.gray08,
disabledContentColor = ClodyTheme.colors.gray06,
),
@@ -141,13 +155,13 @@ fun ReplyDiaryButton(
contentPadding = PaddingValues(0.dp),
) {
Text(
- text = stringResource(R.string.diarylist_check_reply),
+ text = stringResource(R.string.diary_list_btn_reply),
modifier = Modifier
.padding(horizontal = 10.dp, vertical = 2.dp),
style = ClodyTheme.typography.detail1SemiBold,
)
}
- if (dailyDiary.replyStatus == "READY_NOT_READ") {
+ if (dailyDiary.replyStatus == ReplyStatus.READY_NOT_READ) {
Image(
painter = painterResource(id = R.drawable.ic_reply_diary_new),
modifier = Modifier
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/DiaryListTopAppBar.kt b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/DiaryListTopAppBar.kt
index e6a80526..406866b5 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/DiaryListTopAppBar.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/DiaryListTopAppBar.kt
@@ -29,8 +29,8 @@ import com.sopt.clody.ui.theme.ClodyTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiaryListTopAppBar(
- selectedYear: Int,
- selectedMonth: Int,
+ selectedYear: String,
+ selectedMonth: String,
showYearMonthPicker: () -> Unit,
onClickCalendar: () -> Unit,
) {
@@ -50,7 +50,7 @@ fun DiaryListTopAppBar(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
- text = stringResource(R.string.diarylist_selected_year_month, selectedYear, selectedMonth),
+ text = stringResource(R.string.diary_list_selected_year_month, selectedYear, selectedMonth),
color = ClodyTheme.colors.gray01,
style = ClodyTheme.typography.head4,
)
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/MonthlyDiaryList.kt b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/MonthlyDiaryList.kt
index 49f8bdbc..401257a1 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/MonthlyDiaryList.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/component/MonthlyDiaryList.kt
@@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto
+import com.sopt.clody.domain.model.ReplyStatus
import com.sopt.clody.presentation.ui.diarylist.screen.DiaryListViewModel
import com.sopt.clody.presentation.utils.extension.getDayOfWeek
@@ -18,7 +19,7 @@ fun MonthlyDiaryList(
diaryListViewModel: DiaryListViewModel,
diaries: List,
showDiaryDeleteBottomSheet: () -> Unit,
- onClickReplyDiary: (Int, Int, Int, String) -> Unit,
+ onClickReplyDiary: (Int, Int, Int, ReplyStatus) -> Unit,
) {
LazyColumn(
modifier = Modifier
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/navigation/DiaryListNavGraph.kt b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/navigation/DiaryListNavGraph.kt
deleted file mode 100644
index 46ef810d..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/navigation/DiaryListNavGraph.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.sopt.clody.presentation.ui.diarylist.navigation
-
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavType
-import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
-import com.sopt.clody.presentation.ui.diarylist.screen.DiaryListRoute
-import java.time.LocalDate
-
-fun NavGraphBuilder.diaryListNavGraph(
- diaryListNavigator: DiaryListNavigator,
-) {
- composable(
- route = "diary_list/{selectedYearFromHome}/{selectedMonthFromHome}",
- arguments = listOf(
- navArgument("selectedYearFromHome") { type = NavType.IntType },
- navArgument("selectedMonthFromHome") { type = NavType.IntType },
- ),
- ) { backStackEntry ->
- val currentDate = LocalDate.now()
- val selectedYearFromHome = backStackEntry.arguments?.getInt("selectedYearFromHome") ?: currentDate.year
- val selectedMonthFromHome = backStackEntry.arguments?.getInt("selectedMonthFromHome") ?: currentDate.monthValue
- DiaryListRoute(
- navigator = diaryListNavigator,
- selectedYearFromHome = selectedYearFromHome,
- selectedMonthFromHome = selectedMonthFromHome,
- )
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/navigation/DiaryListNavigation.kt b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/navigation/DiaryListNavigation.kt
new file mode 100644
index 00000000..fa10a207
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/navigation/DiaryListNavigation.kt
@@ -0,0 +1,38 @@
+package com.sopt.clody.presentation.ui.diarylist.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import com.sopt.clody.domain.model.ReplyStatus
+import com.sopt.clody.presentation.ui.diarylist.screen.DiaryListRoute
+import com.sopt.clody.presentation.utils.navigation.Route
+
+fun NavGraphBuilder.diaryListScreen(
+ navigateToHome: (year: Int, month: Int) -> Unit,
+ navigateToReplyLoading: (
+ year: Int,
+ month: Int,
+ date: Int,
+ from: Route.ReplyLoading.ReplyLoadingFrom,
+ replyStatus: ReplyStatus,
+ ) -> Unit,
+) {
+ composable { backStackEntry ->
+ backStackEntry.toRoute().apply {
+ DiaryListRoute(
+ selectedYearFromHome = selectedYearFromHome,
+ selectedMonthFromHome = selectedMonthFromHome,
+ navigateToHome = navigateToHome,
+ navigateToReplyLoading = navigateToReplyLoading,
+ )
+ }
+ }
+}
+
+fun NavController.navigateToDiaryList(
+ selectedYearFromHome: Int,
+ selectedMonthFromHome: Int,
+) {
+ navigate(Route.DiaryList(selectedYearFromHome, selectedMonthFromHome))
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/navigation/DiaryListNavigator.kt b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/navigation/DiaryListNavigator.kt
deleted file mode 100644
index c3546037..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/navigation/DiaryListNavigator.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.sopt.clody.presentation.ui.diarylist.navigation
-
-import androidx.navigation.NavController
-
-class DiaryListNavigator(
- val navController: NavController,
-) {
- fun navigateHome(selectedYear: Int, selectedMonth: Int) {
- navController.navigate("home/$selectedYear/$selectedMonth")
- }
-
- fun navigateReplyLoading(year: Int, month: Int, day: Int, replyStatus: String) {
- navController.navigate("reply_loading/$year/$month/$day?from=diary_list&replyStatus=$replyStatus")
- }
-
- fun navigateBack() {
- navController.navigateUp()
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/screen/DiaryListScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/screen/DiaryListScreen.kt
index 086e56a0..ae5e732a 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/screen/DiaryListScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/screen/DiaryListScreen.kt
@@ -12,6 +12,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import com.sopt.clody.R
+import com.sopt.clody.domain.model.ReplyStatus
import com.sopt.clody.presentation.ui.component.FailureScreen
import com.sopt.clody.presentation.ui.component.LoadingScreen
import com.sopt.clody.presentation.ui.component.bottomsheet.DiaryDeleteSheet
@@ -22,17 +23,26 @@ import com.sopt.clody.presentation.ui.component.timepicker.YearMonthPicker
import com.sopt.clody.presentation.ui.diarylist.component.DiaryListTopAppBar
import com.sopt.clody.presentation.ui.diarylist.component.EmptyDiaryList
import com.sopt.clody.presentation.ui.diarylist.component.MonthlyDiaryList
-import com.sopt.clody.presentation.ui.diarylist.navigation.DiaryListNavigator
import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
+import com.sopt.clody.presentation.utils.extension.toLocalizedMonthLabel
+import com.sopt.clody.presentation.utils.extension.toLocalizedYearLabel
+import com.sopt.clody.presentation.utils.navigation.Route
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun DiaryListRoute(
- navigator: DiaryListNavigator,
- diaryListViewModel: DiaryListViewModel = hiltViewModel(),
selectedYearFromHome: Int,
selectedMonthFromHome: Int,
+ navigateToHome: (year: Int, month: Int) -> Unit,
+ navigateToReplyLoading: (
+ year: Int,
+ month: Int,
+ date: Int,
+ from: Route.ReplyLoading.ReplyLoadingFrom,
+ replyStatus: ReplyStatus,
+ ) -> Unit,
+ diaryListViewModel: DiaryListViewModel = hiltViewModel(),
) {
var selectedYearInDiaryList by remember { mutableIntStateOf(selectedYearFromHome) }
var selectedMonthInDiaryList by remember { mutableIntStateOf(selectedMonthFromHome) }
@@ -81,10 +91,17 @@ fun DiaryListRoute(
},
dismissDiaryDeleteDialog = { diaryDeleteDialogState = false },
onClickDiaryDelete = { year, month, day -> diaryListViewModel.deleteDailyDiary(year, month, day) },
- onClickCalendar = { navigator.navigateHome(selectedYearInDiaryList, selectedMonthInDiaryList) },
+ onClickCalendar = {
+ navigateToHome(selectedYearInDiaryList, selectedMonthInDiaryList)
+ },
onClickReplyDiary = { year, month, day, replyStatus ->
- navigator.navigateReplyLoading(year, month, day, replyStatus)
- AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.LIST_REPLY)
+ navigateToReplyLoading(
+ year,
+ month,
+ day,
+ Route.ReplyLoading.ReplyLoadingFrom.DIARY_LIST,
+ replyStatus,
+ )
},
)
}
@@ -110,13 +127,13 @@ fun DiaryListScreen(
dismissDiaryDeleteDialog: () -> Unit,
onClickDiaryDelete: (Int, Int, Int) -> Unit,
onClickCalendar: () -> Unit,
- onClickReplyDiary: (Int, Int, Int, String) -> Unit,
+ onClickReplyDiary: (Int, Int, Int, ReplyStatus) -> Unit,
) {
Scaffold(
topBar = {
DiaryListTopAppBar(
- selectedYear = selectedYearInDiaryList,
- selectedMonth = selectedMonthInDiaryList,
+ selectedYear = selectedYearInDiaryList.toLocalizedYearLabel(),
+ selectedMonth = selectedMonthInDiaryList.toLocalizedMonthLabel(),
showYearMonthPicker = showYearMonthPicker,
onClickCalendar = onClickCalendar,
)
@@ -182,10 +199,10 @@ fun DiaryListScreen(
if (diaryDeleteDialogState) {
ClodyDialog(
- titleMassage = stringResource(R.string.diary_delete_dialog_title),
- descriptionMassage = stringResource(R.string.diary_delete_dialog_description),
- confirmOption = stringResource(R.string.diary_delete_dialog_confirm_option),
- dismissOption = stringResource(R.string.diary_delete_dialog_dismiss_option),
+ titleMassage = stringResource(R.string.dialog_diary_delete_title),
+ descriptionMassage = stringResource(R.string.dialog_diary_delete_description),
+ confirmOption = stringResource(R.string.dialog_diary_delete_confirm),
+ dismissOption = stringResource(R.string.dialog_diary_delete_dismiss),
confirmAction = {
onClickDiaryDelete(selectedDiaryDate.year, selectedDiaryDate.month, selectedDiaryDate.day)
dismissDiaryDeleteDialog()
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/screen/DiaryListViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/screen/DiaryListViewModel.kt
index 5d5354b4..dff813a9 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/screen/DiaryListViewModel.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/diarylist/screen/DiaryListViewModel.kt
@@ -2,22 +2,25 @@ package com.sopt.clody.presentation.ui.diarylist.screen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.sopt.clody.data.remote.util.NetworkUtil
+import com.sopt.clody.core.network.NetworkConnectivityObserver
+import com.sopt.clody.core.network.NetworkStatus
import com.sopt.clody.domain.repository.DiaryRepository
-import com.sopt.clody.presentation.utils.extension.getDayOfWeek
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_NETWORK_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.UNKNOWN_ERROR
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
+import java.time.LocalDate
+import java.time.format.TextStyle
+import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class DiaryListViewModel @Inject constructor(
private val diaryRepository: DiaryRepository,
- private val networkUtil: NetworkUtil,
+ private val networkConnectivityObserver: NetworkConnectivityObserver,
+ private val errorMessageProvider: ErrorMessageProvider,
) : ViewModel() {
private val _diaryListState = MutableStateFlow(DiaryListState.Idle)
@@ -42,8 +45,8 @@ class DiaryListViewModel @Inject constructor(
if (retryCount >= maxRetryCount) return
_diaryListState.value = DiaryListState.Loading
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _diaryListState.value = DiaryListState.Failure(FAILURE_NETWORK_MESSAGE)
+ if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) {
+ _diaryListState.value = DiaryListState.Failure(errorMessageProvider.getNetworkError())
return@launch
}
val result = diaryRepository.getMonthlyDiary(year, month)
@@ -55,12 +58,12 @@ class DiaryListViewModel @Inject constructor(
onFailure = {
retryCount++
if (retryCount >= maxRetryCount) {
- DiaryListState.Failure(FAILURE_TEMPORARY_MESSAGE)
+ DiaryListState.Failure(errorMessageProvider.getTemporaryError())
} else {
val errorMessage = if (it.message?.contains("200") == false) {
- FAILURE_TEMPORARY_MESSAGE
+ errorMessageProvider.getTemporaryError()
} else {
- it.localizedMessage ?: UNKNOWN_ERROR
+ errorMessageProvider.getUnknownError()
}
DiaryListState.Failure(errorMessage)
}
@@ -74,15 +77,17 @@ class DiaryListViewModel @Inject constructor(
val year = diaryDate[0].toInt()
val month = diaryDate[1].toInt()
val day = diaryDate[2].toInt()
- val dayOfWeek = getDayOfWeek(year, month, day)
+
+ val date = LocalDate.of(year, month, day)
+ val dayOfWeek = date.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.getDefault())
_selectedDiaryDate.value = DiaryDate(year, month, day, dayOfWeek)
}
fun deleteDailyDiary(year: Int, month: Int, day: Int) {
_diaryDeleteState.value = DiaryDeleteState.Loading
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _failureDialogMessage.value = FAILURE_NETWORK_MESSAGE
+ if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) {
+ _failureDialogMessage.value = errorMessageProvider.getNetworkError()
DiaryDeleteState.Failure(_failureDialogMessage.value)
_showDiaryDeleteFailureDialog.value = true
return@launch
@@ -94,9 +99,9 @@ class DiaryListViewModel @Inject constructor(
},
onFailure = {
_failureDialogMessage.value = if (it.message?.contains("200") == false) {
- FAILURE_TEMPORARY_MESSAGE
+ errorMessageProvider.getTemporaryError()
} else {
- it.localizedMessage ?: UNKNOWN_ERROR
+ it.localizedMessage ?: errorMessageProvider.getUnknownError()
}
_showDiaryDeleteFailureDialog.value = true
DiaryDeleteState.Failure(_failureDialogMessage.value)
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/ClodyCalendar.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/ClodyCalendar.kt
index a47b4d40..3ce9cd36 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/ClodyCalendar.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/ClodyCalendar.kt
@@ -17,7 +17,7 @@ import com.sopt.clody.presentation.ui.component.LoadingScreen
import com.sopt.clody.presentation.ui.home.calendar.component.DailyDiaryListItem
import com.sopt.clody.presentation.ui.home.calendar.component.HorizontalDivider
import com.sopt.clody.presentation.ui.home.calendar.component.MonthlyItem
-import com.sopt.clody.presentation.ui.home.model.generateCalendarDates
+import com.sopt.clody.presentation.ui.home.calendar.model.generateCalendarDates
import com.sopt.clody.presentation.ui.home.screen.DailyDiariesState
import com.sopt.clody.presentation.ui.home.screen.HomeViewModel
import java.time.LocalDate
@@ -72,7 +72,7 @@ fun ClodyCalendar(
DailyDiaryListItem(
date = selectedDate,
dayOfWeek = initialDayOfWeek,
- dailyDiaries = state.data.diaries,
+ dailyDiary = state.data,
onShowDiaryDeleteStateChange = onShowDiaryDeleteStateChange,
)
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/DailyDiaryListItem.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/DailyDiaryListItem.kt
index cf428318..7268a3aa 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/DailyDiaryListItem.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/DailyDiaryListItem.kt
@@ -17,7 +17,9 @@ 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.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.sopt.clody.R
@@ -25,12 +27,13 @@ import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto
import com.sopt.clody.ui.theme.ClodyTheme
import kotlinx.datetime.DayOfWeek
import java.time.LocalDate
+import java.time.format.TextStyle
@Composable
fun DailyDiaryListItem(
date: LocalDate,
dayOfWeek: DayOfWeek,
- dailyDiaries: List,
+ dailyDiary: DailyDiariesResponseDto,
onShowDiaryDeleteStateChange: (Boolean) -> Unit,
) {
Column(
@@ -47,45 +50,68 @@ fun DailyDiaryListItem(
) {
Text(
text = "${date.month.value}.${date.dayOfMonth}",
- style = ClodyTheme.typography.body3Medium,
+ style = ClodyTheme.typography.body2Medium,
color = ClodyTheme.colors.gray04,
modifier = Modifier.padding(vertical = 3.dp),
)
Text(
- text = "${dayOfWeek.toKoreanShortLabel()}요일",
- style = ClodyTheme.typography.body2Medium,
+ text = dayOfWeek.getDisplayName(
+ TextStyle.FULL,
+ LocalConfiguration.current.locales.let { if (it.isEmpty) java.util.Locale.getDefault() else it[0] },
+ ),
+ style = ClodyTheme.typography.body2SemiBold,
color = ClodyTheme.colors.gray02,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
Spacer(modifier = Modifier.weight(1f))
- Image(
- painter = painterResource(id = R.drawable.ic_home_kebab),
- contentDescription = "go to delete",
- modifier = Modifier
- .clip(RoundedCornerShape(12.dp))
- .clickable(onClick = { onShowDiaryDeleteStateChange(true) }),
- )
- }
- if (dailyDiaries.isEmpty()) {
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier
- .fillMaxSize()
- .padding(vertical = 44.dp),
- ) {
- Text(
- text = "아직 감사 일기가 없어요!",
- style = ClodyTheme.typography.body3Regular,
- color = ClodyTheme.colors.gray05,
- textAlign = TextAlign.Center,
+ if (dailyDiary.diaries.isNotEmpty()) {
+ Image(
+ painter = painterResource(id = R.drawable.ic_home_kebab),
+ contentDescription = "go to delete",
+ modifier = Modifier
+ .clip(RoundedCornerShape(12.dp))
+ .clickable(onClick = { onShowDiaryDeleteStateChange(true) }),
)
}
- } else {
- dailyDiaries.forEachIndexed { index, diary ->
- DiaryItem(
- index = index + 1,
- text = diary.content,
- )
+ }
+
+ when {
+ dailyDiary.isDraft -> {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(vertical = 44.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.home_daily_diary_draft_message),
+ style = ClodyTheme.typography.body3Regular,
+ color = ClodyTheme.colors.gray05,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+
+ dailyDiary.diaries.isEmpty() -> {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(vertical = 44.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.home_daily_diary_empty_message),
+ style = ClodyTheme.typography.body3Regular,
+ color = ClodyTheme.colors.gray05,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+
+ else -> {
+ dailyDiary.diaries.forEachIndexed { index, diary ->
+ DiaryItem(index = index + 1, text = diary.content)
+ }
}
}
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/DayItem.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/DayItem.kt
index 30e3d17b..aa618e95 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/DayItem.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/DayItem.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.sopt.clody.R
import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
+import com.sopt.clody.domain.model.ReplyStatus
import com.sopt.clody.presentation.ui.type.DiaryCloverType
import com.sopt.clody.ui.theme.ClodyTheme
import kotlinx.datetime.DayOfWeek
@@ -56,7 +57,7 @@ fun DayItem(
painter = painterResource(id = iconRes),
contentDescription = "Diary clover icon",
)
- if (diaryData.replyStatus == "READY_NOT_READ" && diaryData.diaryCount > 0) {
+ if (diaryData.replyStatus == ReplyStatus.READY_NOT_READ && diaryData.diaryCount > 0) {
Image(
painter = painterResource(id = R.drawable.ic_home_unread_reply),
contentDescription = "Unread replies icon",
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/MonthlyItem.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/MonthlyItem.kt
index 90452006..55ae5155 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/MonthlyItem.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/MonthlyItem.kt
@@ -15,7 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
-import com.sopt.clody.presentation.ui.home.model.CalendarDate
+import com.sopt.clody.presentation.ui.home.calendar.model.CalendarDate
import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
import kotlinx.datetime.DayOfWeek
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/WeekHeader.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/WeekHeader.kt
index ba985f67..41e86052 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/WeekHeader.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/component/WeekHeader.kt
@@ -11,38 +11,38 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.sopt.clody.ui.theme.ClodyTheme
import kotlinx.datetime.DayOfWeek
+import java.time.format.TextStyle
@Composable
-fun WeekHeader(modifier: Modifier = Modifier, itemWidth: Dp) {
- val itemWidth = (LocalConfiguration.current.screenWidthDp.dp - 40.dp) / 7
+fun WeekHeader(
+ modifier: Modifier = Modifier,
+ itemWidth: Dp = (LocalConfiguration.current.screenWidthDp.dp - 40.dp) / 7,
+) {
+ val dayOfWeekArray = listOf(
+ DayOfWeek.SUNDAY,
+ DayOfWeek.MONDAY,
+ DayOfWeek.TUESDAY,
+ DayOfWeek.WEDNESDAY,
+ DayOfWeek.THURSDAY,
+ DayOfWeek.FRIDAY,
+ DayOfWeek.SATURDAY,
+ )
+
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier.fillMaxWidth(),
) {
- val weekLabelArray = listOf(
- DayOfWeek.SUNDAY,
- DayOfWeek.MONDAY,
- DayOfWeek.TUESDAY,
- DayOfWeek.WEDNESDAY,
- DayOfWeek.THURSDAY,
- DayOfWeek.FRIDAY,
- DayOfWeek.SATURDAY,
- )
-
- val koreanWeekLabels = weekLabelArray.map { it.toKoreanShortLabel() }
-
- koreanWeekLabels.forEach { week ->
+ dayOfWeekArray.forEach { week ->
Box(
modifier = Modifier.width(itemWidth),
contentAlignment = Alignment.Center,
) {
Text(
- text = week,
+ text = week.getDisplayName(TextStyle.NARROW, LocalConfiguration.current.locales[0]),
color = ClodyTheme.colors.gray05,
style = ClodyTheme.typography.detail1Medium,
textAlign = TextAlign.Center,
@@ -51,20 +51,3 @@ fun WeekHeader(modifier: Modifier = Modifier, itemWidth: Dp) {
}
}
}
-
-fun DayOfWeek.toKoreanShortLabel(): String {
- return when (this) {
- DayOfWeek.SUNDAY -> "일"
- DayOfWeek.MONDAY -> "월"
- DayOfWeek.TUESDAY -> "화"
- DayOfWeek.WEDNESDAY -> "수"
- DayOfWeek.THURSDAY -> "목"
- DayOfWeek.FRIDAY -> "금"
- DayOfWeek.SATURDAY -> "토"
- }
-}
-
-@Composable
-@Preview(showBackground = true)
-fun WeekHeaderPreview() {
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/model/CalendarDateData.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/model/CalendarDateData.kt
similarity index 88%
rename from app/src/main/java/com/sopt/clody/presentation/ui/home/model/CalendarDateData.kt
rename to app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/model/CalendarDateData.kt
index 6264ba52..fcbbc34f 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/model/CalendarDateData.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/model/CalendarDateData.kt
@@ -1,4 +1,4 @@
-package com.sopt.clody.presentation.ui.home.model
+package com.sopt.clody.presentation.ui.home.calendar.model
import java.time.YearMonth
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/model/DiaryDateData.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/model/DiaryDateData.kt
similarity index 71%
rename from app/src/main/java/com/sopt/clody/presentation/ui/home/model/DiaryDateData.kt
rename to app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/model/DiaryDateData.kt
index 55585e33..b8d5b3aa 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/model/DiaryDateData.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/calendar/model/DiaryDateData.kt
@@ -1,4 +1,4 @@
-package com.sopt.clody.presentation.ui.home.model
+package com.sopt.clody.presentation.ui.home.calendar.model
import java.time.LocalDate
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/component/CloverCount.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/component/CloverCount.kt
index d85e720c..f5f76ac9 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/component/CloverCount.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/component/CloverCount.kt
@@ -10,13 +10,15 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
+import com.sopt.clody.R
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun CloverCount(cloverCount: Int) {
- val text = "클로버 ${cloverCount}개"
+ val text = stringResource(R.string.home_total_clover, cloverCount)
Box(
modifier = Modifier
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/component/DiaryStateButton.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/component/DiaryStateButton.kt
index 754f7450..37431fde 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/component/DiaryStateButton.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/component/DiaryStateButton.kt
@@ -4,71 +4,74 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import com.sopt.clody.R
import com.sopt.clody.presentation.ui.component.button.ClodyButton
import com.sopt.clody.presentation.ui.component.button.ClodyReplyButton
-import java.time.LocalDate
+import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun DiaryStateButton(
- diaryCount: Int,
- isDeleted: Boolean,
+ hasDraft: Boolean,
+ canWrite: Boolean,
+ canReply: Boolean,
+ isInvalidDraft: Boolean,
year: Int,
month: Int,
day: Int,
onClickWriteDiary: (Int, Int, Int) -> Unit,
onClickReplyDiary: () -> Unit,
) {
- val today = LocalDate.now()
- val isAvailableDay = year == today.year && month == today.monthValue && (day == today.dayOfMonth || day == today.dayOfMonth - 1)
-
- val writeDiaryEnabled = diaryCount == 0 && isAvailableDay
- val writeDiaryDisabled = diaryCount == 0 && !isAvailableDay
- val checkReplyEnabled = diaryCount != 0 && !isDeleted
- val checkReplyDisabled = diaryCount != 0 && isDeleted
+ val modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
when {
- writeDiaryEnabled -> {
+ hasDraft -> {
ClodyButton(
onClick = { onClickWriteDiary(year, month, day) },
- text = "일기쓰기",
+ text = stringResource(R.string.home_btn_continue_draft),
enabled = true,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
+ modifier = modifier,
)
}
- writeDiaryDisabled -> {
+ isInvalidDraft -> {
ClodyButton(
- onClick = { onClickWriteDiary(year, month, day) },
- text = "일기쓰기",
+ onClick = { /* no-action */ },
+ text = stringResource(R.string.home_btn_check_reply),
enabled = false,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
+ modifier = modifier,
+ disabledContainerColor = ClodyTheme.colors.gray05,
+ disabledContentColor = ClodyTheme.colors.white,
)
}
- checkReplyEnabled -> {
+ canReply -> {
ClodyReplyButton(
onClick = onClickReplyDiary,
- text = "답장확인",
+ text = stringResource(R.string.home_btn_check_reply),
enabled = true,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
+ modifier = modifier,
)
}
- checkReplyDisabled -> {
- ClodyReplyButton(
- onClick = onClickReplyDiary,
- text = "답장확인",
+ canWrite -> {
+ ClodyButton(
+ onClick = { onClickWriteDiary(year, month, day) },
+ text = stringResource(R.string.home_btn_write_diary),
+ enabled = true,
+ modifier = modifier,
+ )
+ }
+
+ else -> {
+ ClodyButton(
+ onClick = { onClickWriteDiary(year, month, day) },
+ text = stringResource(R.string.home_btn_write_diary),
enabled = false,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
+ modifier = modifier,
)
}
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/component/HomeTopAppBar.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/component/HomeTopAppBar.kt
index 264207a8..6bac1a49 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/component/HomeTopAppBar.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/component/HomeTopAppBar.kt
@@ -1,16 +1,22 @@
package com.sopt.clody.presentation.ui.home.component
import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sopt.clody.R
import com.sopt.clody.ui.theme.ClodyTheme
@@ -21,21 +27,34 @@ fun HomeTopAppBar(
onClickDiaryList: () -> Unit,
onClickSetting: () -> Unit,
onShowYearMonthPickerStateChange: (Boolean) -> Unit,
- selectedYear: Int,
- selectedMonth: Int,
+ selectedYear: String,
+ selectedMonth: String,
) {
CenterAlignedTopAppBar(
title = {
Box(
- modifier = Modifier
- .padding(start = 16.dp),
+ modifier = Modifier.padding(start = 16.dp),
contentAlignment = Alignment.Center,
) {
- YearAndMonthTitle(
- onShowYearMonthPickerStateChange,
- selectedYear,
- selectedMonth,
- )
+ Row(
+ modifier = Modifier.clickable(
+ onClick = { onShowYearMonthPickerStateChange(true) },
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.home_year_month_format, selectedYear, selectedMonth),
+ style = ClodyTheme.typography.head4,
+ color = ClodyTheme.colors.gray01,
+ )
+ Image(
+ painter = painterResource(id = R.drawable.ic_home_under_arrow),
+ contentDescription = "choose month",
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
+ )
+ }
}
},
navigationIcon = {
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/component/YearAndMonthTitle.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/component/YearAndMonthTitle.kt
deleted file mode 100644
index 32d9e7bf..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/component/YearAndMonthTitle.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-package com.sopt.clody.presentation.ui.home.component
-
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.unit.dp
-import com.sopt.clody.R
-import com.sopt.clody.ui.theme.ClodyTheme
-
-@Composable
-fun YearAndMonthTitle(
- onShowYearMonthPickerStateChange: (Boolean) -> Unit,
- selectedYear: Int,
- selectedMonth: Int,
-) {
- val text = "${selectedYear}년 ${selectedMonth}월"
-
- Column {
- Row(
- modifier = Modifier.clickable(
- onClick = { onShowYearMonthPickerStateChange(true) },
- indication = null,
- interactionSource = remember { MutableInteractionSource() },
- ),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = text,
- style = ClodyTheme.typography.head4,
- color = ClodyTheme.colors.gray01,
- )
- Image(
- painter = painterResource(id = R.drawable.ic_home_under_arrow),
- contentDescription = "choose month",
- modifier = Modifier
- .padding(horizontal = 6.dp, vertical = 6.dp),
- )
- }
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/navigation/HomeNavGraph.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/navigation/HomeNavGraph.kt
deleted file mode 100644
index 3ef284b8..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/navigation/HomeNavGraph.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.sopt.clody.presentation.ui.home.navigation
-
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavType
-import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
-import com.sopt.clody.presentation.ui.home.screen.HomeRoute
-import java.time.LocalDate
-
-fun NavGraphBuilder.homeNavGraph(
- navigator: HomeNavigator,
-) {
- composable(
- route = "home/{selectedYear}/{selectedMonth}",
- arguments = listOf(
- navArgument("selectedYear") { type = NavType.IntType },
- navArgument("selectedMonth") { type = NavType.IntType },
- ),
- ) { backStackEntry ->
- val currentDate = LocalDate.now()
- val selectedYear = backStackEntry.arguments?.getInt("selectedYear") ?: currentDate.year
- val selectedMonth = backStackEntry.arguments?.getInt("selectedMonth") ?: currentDate.monthValue
- HomeRoute(
- navigator = navigator,
- selectedYear = selectedYear,
- selectedMonth = selectedMonth,
- )
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/navigation/HomeNavigation.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/navigation/HomeNavigation.kt
new file mode 100644
index 00000000..bc68535a
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/navigation/HomeNavigation.kt
@@ -0,0 +1,46 @@
+package com.sopt.clody.presentation.ui.home.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptionsBuilder
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import com.sopt.clody.domain.model.ReplyStatus
+import com.sopt.clody.presentation.ui.home.screen.HomeRoute
+import com.sopt.clody.presentation.utils.navigation.Route
+import java.time.LocalDate
+
+fun NavGraphBuilder.homeScreen(
+ navigateToDiaryList: (year: Int, month: Int) -> Unit,
+ navigateToSetting: () -> Unit,
+ navigateToWriteDiary: (year: Int, month: Int, date: Int) -> Unit,
+ navigateToReplyLoading: (
+ year: Int,
+ month: Int,
+ date: Int,
+ from: Route.ReplyLoading.ReplyLoadingFrom,
+ replyStatus: ReplyStatus,
+ ) -> Unit,
+) {
+ composable { backStackEntry ->
+ backStackEntry.toRoute().apply {
+ HomeRoute(
+ isFromReplyDiary = isFromReplyDiary,
+ navigateToDiaryList = navigateToDiaryList,
+ navigateToSetting = navigateToSetting,
+ navigateToWriteDiary = navigateToWriteDiary,
+ navigateToReplyLoading = navigateToReplyLoading,
+ )
+ }
+ }
+}
+
+fun NavController.navigateToHome(
+ selectedYear: Int = LocalDate.now().year,
+ selectedMonth: Int = LocalDate.now().monthValue,
+ selectedDay: Int? = LocalDate.now().dayOfMonth,
+ isFromReplyDiary: Boolean = false,
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) {
+ navigate(Route.Home(selectedYear, selectedMonth, selectedDay, isFromReplyDiary), navOptions)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/navigation/HomeNavigator.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/navigation/HomeNavigator.kt
deleted file mode 100644
index 80428b26..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/navigation/HomeNavigator.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.sopt.clody.presentation.ui.home.navigation
-
-import androidx.navigation.NavController
-
-class HomeNavigator(
- val navController: NavController,
-) {
- fun navigateDiaryList(selectedYear: Int, selectedMonth: Int) {
- navController.navigate("diary_list/$selectedYear/$selectedMonth")
- }
-
- fun navigateSetting() {
- navController.navigate("setting")
- }
-
- fun navigateWriteDiary(year: Int, month: Int, day: Int) {
- navController.navigate("write_diary/$year/$month/$day")
- }
-
- fun navigateReplyLoading(year: Int, month: Int, day: Int, replyStatus: String) {
- navController.navigate("reply_loading/$year/$month/$day?from=home&replyStatus=$replyStatus")
- }
-
- fun navigateBack() {
- navController.navigateUp()
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeScreen.kt
index 1e9ef2c9..a5f9d9a0 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeScreen.kt
@@ -3,131 +3,328 @@ package com.sopt.clody.presentation.ui.home.screen
import android.app.Activity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
+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.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.sopt.clody.R
+import com.sopt.clody.core.review.InAppReviewManager
+import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
+import com.sopt.clody.domain.model.ReplyStatus
import com.sopt.clody.presentation.ui.component.FailureScreen
import com.sopt.clody.presentation.ui.component.LoadingScreen
import com.sopt.clody.presentation.ui.component.bottomsheet.DiaryDeleteSheet
+import com.sopt.clody.presentation.ui.component.button.ClodyButton
import com.sopt.clody.presentation.ui.component.dialog.ClodyDialog
import com.sopt.clody.presentation.ui.component.popup.ClodyPopupBottomSheet
import com.sopt.clody.presentation.ui.component.timepicker.YearMonthPicker
+import com.sopt.clody.presentation.ui.component.toast.ClodyToastMessage
+import com.sopt.clody.presentation.ui.home.calendar.model.DiaryDateData
import com.sopt.clody.presentation.ui.home.component.DiaryStateButton
import com.sopt.clody.presentation.ui.home.component.HomeTopAppBar
-import com.sopt.clody.presentation.ui.home.model.DiaryDateData
-import com.sopt.clody.presentation.ui.home.navigation.HomeNavigator
import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
+import com.sopt.clody.presentation.utils.extension.toLocalizedMonthLabel
+import com.sopt.clody.presentation.utils.extension.toLocalizedYearLabel
+import com.sopt.clody.presentation.utils.navigation.Route
import com.sopt.clody.ui.theme.ClodyTheme
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import java.time.LocalDate
@Composable
fun HomeRoute(
- navigator: HomeNavigator,
+ isFromReplyDiary: Boolean,
+ navigateToDiaryList: (year: Int, month: Int) -> Unit,
+ navigateToSetting: () -> Unit,
+ navigateToWriteDiary: (year: Int, month: Int, date: Int) -> Unit,
+ navigateToReplyLoading: (
+ year: Int,
+ month: Int,
+ date: Int,
+ from: Route.ReplyLoading.ReplyLoadingFrom,
+ replyStatus: ReplyStatus,
+ ) -> Unit,
homeViewModel: HomeViewModel = hiltViewModel(),
- selectedYear: Int,
- selectedMonth: Int,
) {
val calendarState by homeViewModel.calendarState.collectAsStateWithLifecycle()
- val dailyDiariesState by homeViewModel.dailyDiariesState.collectAsStateWithLifecycle()
-
- val isError = calendarState is CalendarState.Error || dailyDiariesState is DailyDiariesState.Error
- val errorMessage = when {
- calendarState is CalendarState.Error -> (calendarState as CalendarState.Error).message
- dailyDiariesState is DailyDiariesState.Error -> (dailyDiariesState as DailyDiariesState.Error).message
- else -> ""
- }
+ val replyStatus by homeViewModel.replyStatus.collectAsStateWithLifecycle()
+ val showFirstDraftPopup by homeViewModel.showFirstDraftPopup.collectAsStateWithLifecycle()
+ val draftAlarmEnableToast by homeViewModel.draftAlarmEnableToast.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val showInAppReviewPopup by homeViewModel.showInAppReviewPopup.collectAsStateWithLifecycle()
+ val showContinueDraftDialog by homeViewModel.showContinueDraftDialog.collectAsStateWithLifecycle()
+ val showDiaryDeleteState by homeViewModel.showDiaryDeleteState.collectAsStateWithLifecycle()
+ val showDiaryDeleteDialog by homeViewModel.showDiaryDeleteDialog.collectAsStateWithLifecycle()
+ val selectedDiaryDate by homeViewModel.selectedDiaryDate.collectAsStateWithLifecycle()
+ val selectedDate by homeViewModel.selectedDate.collectAsStateWithLifecycle()
+ val deleteDiaryState by homeViewModel.deleteDiaryState.collectAsStateWithLifecycle()
+ val (isError, errorMessage) = homeViewModel.errorState.collectAsStateWithLifecycle().value
+ val showYearMonthPickerState by homeViewModel.showYearMonthPickerState.collectAsStateWithLifecycle()
+ val hasDraft by homeViewModel.hasDraft.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.HOME)
- }
- LaunchedEffect(selectedYear, selectedMonth) {
- homeViewModel.refreshCalendarDataCalendarData(selectedYear, selectedMonth)
- val selectedDate = homeViewModel.selectedDate.value
- homeViewModel.loadDailyDiariesData(selectedYear, selectedMonth, selectedDate.dayOfMonth)
+ if (showInAppReviewPopup && isFromReplyDiary) {
+ InAppReviewManager.showPopup(context as Activity)
+ homeViewModel.updateShowInAppReviewPopup(false)
+ }
}
+ LaunchedEffect(Unit) {
+ val year = selectedDiaryDate.year
+ val month = selectedDiaryDate.month
+ val day = selectedDate.dayOfMonth
+
+ try {
+ coroutineScope {
+ val calendarDeferred = async { homeViewModel.loadCalendarData(year, month) }
+ val dailyDeferred = async { homeViewModel.loadDailyDiariesData(year, month, day) }
+
+ calendarDeferred.await()
+ dailyDeferred.await()
+ }
+ } catch (e: Exception) {
+ homeViewModel.setErrorState(true, "데이터를 불러오는데 실패했습니다.")
+ }
+ }
if (isError) {
FailureScreen(
message = errorMessage,
confirmAction = {
- val selectedDate = homeViewModel.selectedDate.value
- homeViewModel.refreshCalendarDataCalendarData(selectedYear, selectedMonth)
- homeViewModel.loadDailyDiariesData(selectedYear, selectedMonth, selectedDate.dayOfMonth)
+ homeViewModel.refreshCalendarDataCalendarData(
+ selectedDiaryDate.year,
+ selectedDiaryDate.month,
+ )
+ homeViewModel.loadDailyDiariesData(
+ selectedDiaryDate.year,
+ selectedDiaryDate.month,
+ selectedDate.dayOfMonth,
+ )
},
)
} else {
HomeScreen(
homeViewModel = homeViewModel,
- onClickDiaryList = { selectedYearFromHome, selectedMonthFromHome ->
- navigator.navigateDiaryList(
- selectedYearFromHome,
- selectedMonthFromHome,
- )
- },
- onClickSetting = { navigator.navigateSetting() },
+ calendarState = calendarState,
+ deleteDiaryState = deleteDiaryState,
+ showYearMonthPickerState = showYearMonthPickerState,
+ onClickDiaryList = navigateToDiaryList,
+ onClickSetting = navigateToSetting,
onClickWriteDiary = { year, month, day ->
AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.HOME_WRITING_DIARY)
- navigator.navigateWriteDiary(year, month, day)
+ if (hasDraft && !homeViewModel.isValidDraftDate()) {
+ homeViewModel.setShowContinueDraftDialog(true)
+ } else {
+ navigateToWriteDiary(year, month, day)
+ }
},
- onClickReplyDiary = { year, month, day, replyStatus ->
+ onClickReplyDiary = { year, month, day, _ ->
AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.HOME_REPLY)
- navigator.navigateReplyLoading(year, month, day, replyStatus)
+ navigateToReplyLoading(
+ year,
+ month,
+ day,
+ Route.ReplyLoading.ReplyLoadingFrom.HOME,
+ replyStatus,
+ )
},
- selectedYear = selectedYear,
- selectedMonth = selectedMonth,
+ isError = isError,
+ errorMessage = errorMessage,
+ selectedYear = selectedDiaryDate.year,
+ selectedMonth = selectedDiaryDate.month,
+ selectedDate = selectedDate,
+ hasDraft = hasDraft,
+ canWrite = homeViewModel.canWriteDiary(),
+ canReply = homeViewModel.canReplyDiary(),
+ isInvalidDraft = replyStatus == ReplyStatus.INVALID_DRAFT,
)
+
+ if (showFirstDraftPopup) {
+ ClodyPopupBottomSheet(
+ onDismissRequest = { homeViewModel.updateFirstDraftUse(false) },
+ content = {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 20.dp)
+ .padding(horizontal = 16.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.bottom_sheet_home_initial_draft_title),
+ color = ClodyTheme.colors.gray01,
+ textAlign = TextAlign.Center,
+ style = ClodyTheme.typography.head3,
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Text(
+ text = stringResource(R.string.bottom_sheet_home_initial_draft_description),
+ color = ClodyTheme.colors.gray04,
+ textAlign = TextAlign.Center,
+ style = ClodyTheme.typography.body3Regular,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(R.string.bottom_sheet_home_initial_draft_guide),
+ color = ClodyTheme.colors.gray04,
+ textAlign = TextAlign.Center,
+ style = ClodyTheme.typography.body3Regular,
+ )
+ Spacer(modifier = Modifier.height(28.dp))
+ ClodyButton(
+ text = stringResource(R.string.bottom_sheet_home_initial_draft_accept),
+ onClick = {
+ homeViewModel.enableDraftAlarm()
+ homeViewModel.updateFirstDraftUse(false)
+ },
+ enabled = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Text(
+ text = stringResource(R.string.bottom_sheet_home_initial_draft_skip),
+ modifier = Modifier
+ .clickable(onClick = { homeViewModel.updateFirstDraftUse(false) })
+ .padding(12.dp),
+ color = ClodyTheme.colors.gray05,
+ style = ClodyTheme.typography.body4Medium,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+ },
+ )
+ }
+
+ if (draftAlarmEnableToast) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.BottomCenter,
+ content = {
+ ClodyToastMessage(
+ message = stringResource(R.string.toast_home_draft_alarm_enabled),
+ iconResId = R.drawable.ic_toast_check_on_18,
+ backgroundColor = ClodyTheme.colors.gray04,
+ contentColor = ClodyTheme.colors.white,
+ durationMillis = 3000,
+ onDismiss = { homeViewModel.resetDraftAlarmEnableToast() },
+ modifier = Modifier
+ .navigationBarsPadding()
+ .padding(40.dp),
+ )
+ },
+ )
+ }
+
+ if (showContinueDraftDialog) {
+ ClodyDialog(
+ titleMassage = stringResource(R.string.dialog_home_continue_draft_title),
+ descriptionMassage = stringResource(R.string.dialog_home_continue_draft_description),
+ confirmOption = stringResource(R.string.dialog_home_continue_draft_confirm),
+ dismissOption = stringResource(R.string.dialog_home_continue_draft_dismiss),
+ confirmAction = {
+ homeViewModel.setShowContinueDraftDialog(false)
+ val date = homeViewModel.selectedDate.value
+ navigateToWriteDiary(date.year, date.monthValue, date.dayOfMonth)
+ },
+ onDismiss = {
+ homeViewModel.setShowContinueDraftDialog(false)
+ },
+ confirmButtonColor = ClodyTheme.colors.mainYellow,
+ confirmButtonTextColor = ClodyTheme.colors.gray01,
+ )
+ }
+
+ if (showDiaryDeleteState) {
+ DiaryDeleteSheet(
+ onDismiss = { homeViewModel.setShowDiaryDeleteState(false) },
+ showDiaryDeleteDialog = {
+ AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.HOME_DELETE_DIARY)
+ homeViewModel.setShowDiaryDeleteDialog(true)
+ },
+ )
+ }
+
+ if (showDiaryDeleteDialog) {
+ ClodyDialog(
+ titleMassage = stringResource(R.string.dialog_diary_delete_title),
+ descriptionMassage = stringResource(R.string.dialog_diary_delete_description),
+ confirmOption = stringResource(R.string.dialog_diary_delete_confirm),
+ dismissOption = stringResource(R.string.dialog_diary_delete_dismiss),
+ confirmAction = {
+ homeViewModel.deleteDailyDiary(
+ selectedDiaryDate.year,
+ selectedDiaryDate.month,
+ selectedDate.dayOfMonth,
+ )
+ homeViewModel.setShowDiaryDeleteDialog(false)
+ },
+ onDismiss = {
+ AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.HOME_NO_DELETE_DIARY)
+ homeViewModel.setShowDiaryDeleteDialog(false)
+ },
+ confirmButtonColor = ClodyTheme.colors.red,
+ confirmButtonTextColor = ClodyTheme.colors.white,
+ )
+ }
}
}
@Composable
fun HomeScreen(
homeViewModel: HomeViewModel,
+ calendarState: CalendarState,
+ deleteDiaryState: DeleteDiaryState,
+ showYearMonthPickerState: Boolean,
onClickDiaryList: (Int, Int) -> Unit,
onClickSetting: () -> Unit,
onClickWriteDiary: (Int, Int, Int) -> Unit,
- onClickReplyDiary: (Int, Int, Int, String) -> Unit,
+ onClickReplyDiary: (
+ year: Int,
+ month: Int,
+ date: Int,
+ replyStatus: Route.ReplyLoading.ReplyLoadingFrom,
+ ) -> Unit,
+ isError: Boolean,
+ errorMessage: String,
selectedYear: Int,
selectedMonth: Int,
+ selectedDate: LocalDate,
+ hasDraft: Boolean,
+ canWrite: Boolean,
+ canReply: Boolean,
+ isInvalidDraft: Boolean,
) {
- val (isError, errorMessage) = homeViewModel.errorState.collectAsStateWithLifecycle().value
-
if (isError) {
FailureScreen(
message = errorMessage,
confirmAction = {
homeViewModel.refreshCalendarDataCalendarData(selectedYear, selectedMonth)
- val selectedDate = homeViewModel.selectedDate.value
homeViewModel.loadDailyDiariesData(selectedYear, selectedMonth, selectedDate.dayOfMonth)
},
)
} else {
- val selectedDiaryDate by homeViewModel.selectedDiaryDate.collectAsStateWithLifecycle()
- val selectedDate by homeViewModel.selectedDate.collectAsStateWithLifecycle()
- val diaryCount by homeViewModel.diaryCount.collectAsStateWithLifecycle()
- val replyStatus by homeViewModel.replyStatus.collectAsStateWithLifecycle()
- val isToday by homeViewModel.isToday.collectAsStateWithLifecycle()
- val isDeleted by homeViewModel.isDeleted.collectAsStateWithLifecycle()
- val calendarState by homeViewModel.calendarState.collectAsStateWithLifecycle()
- val deleteDiaryState by homeViewModel.deleteDiaryState.collectAsStateWithLifecycle()
- val showYearMonthPickerState by homeViewModel.showYearMonthPickerState.collectAsStateWithLifecycle()
- val showDiaryDeleteState by homeViewModel.showDiaryDeleteState.collectAsStateWithLifecycle()
- val showDiaryDeleteDialog by homeViewModel.showDiaryDeleteDialog.collectAsStateWithLifecycle()
-
var backPressedTime by remember { mutableStateOf(0L) }
val backPressThreshold = 2000
val context = LocalContext.current
@@ -146,18 +343,18 @@ fun HomeScreen(
HomeTopAppBar(
onClickDiaryList = {
AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.HOME_LIST_DIARY)
- onClickDiaryList(selectedDiaryDate.year, selectedDiaryDate.month)
+ onClickDiaryList(selectedYear, selectedMonth)
},
onClickSetting = onClickSetting,
onShowYearMonthPickerStateChange = { newState -> homeViewModel.setShowYearMonthPickerState(newState) },
- selectedYear = selectedDiaryDate.year,
- selectedMonth = selectedDiaryDate.month,
+ selectedYear = selectedYear.toLocalizedYearLabel(),
+ selectedMonth = selectedMonth.toLocalizedMonthLabel(),
)
},
containerColor = ClodyTheme.colors.white,
content = { innerPadding ->
- when (val state = calendarState) {
- is CalendarState.Idle -> { }
+ when (calendarState) {
+ is CalendarState.Idle -> {}
is CalendarState.Loading -> {
LoadingScreen()
@@ -165,27 +362,27 @@ fun HomeScreen(
is CalendarState.Success -> {
ScrollableCalendar(
- selectedYear = selectedDiaryDate.year,
- selectedMonth = selectedDiaryDate.month,
- cloverCount = state.data.totalCloverCount,
- diaries = state.data.diaries,
+ selectedYear = selectedYear,
+ selectedMonth = selectedMonth,
+ cloverCount = calendarState.data.totalCloverCount,
+ diaries = calendarState.data.diaries,
homeViewModel = homeViewModel,
onShowDiaryDeleteStateChange = { newState -> homeViewModel.setShowDiaryDeleteState(newState) },
selectedDate = selectedDate,
- onDiaryDataUpdated = { diaryCount, replyStatus ->
- homeViewModel.updateDiaryState(state.data.diaries)
+ onDiaryDataUpdated = { _, _ ->
+ homeViewModel.updateDiaryState(calendarState.data.diaries)
},
modifier = Modifier.padding(innerPadding),
)
}
is CalendarState.Error -> {
- homeViewModel.setErrorState(true, state.message ?: "알 수 없는 오류가 발생했습니다.")
+ homeViewModel.setErrorState(true, calendarState.message)
}
}
when (deleteDiaryState) {
- is DeleteDiaryState.Idle -> { }
+ is DeleteDiaryState.Idle -> {}
is DeleteDiaryState.Loading -> {
LoadingScreen()
@@ -195,7 +392,7 @@ fun HomeScreen(
}
is DeleteDiaryState.Failure -> {
- homeViewModel.setErrorState(true, "일기 삭제 중 오류가 발생했습니다.")
+ homeViewModel.setErrorState(true, stringResource(R.string.home_error_delete_diary))
}
}
},
@@ -207,18 +404,20 @@ fun HomeScreen(
) {
Spacer(modifier = Modifier.height(14.dp))
DiaryStateButton(
- diaryCount = diaryCount,
- isDeleted = isDeleted,
- year = selectedDate.year,
- month = selectedDate.monthValue,
+ hasDraft = hasDraft,
+ canWrite = canWrite,
+ canReply = canReply,
+ isInvalidDraft = isInvalidDraft,
+ year = selectedYear,
+ month = selectedMonth,
day = selectedDate.dayOfMonth,
onClickWriteDiary = onClickWriteDiary,
onClickReplyDiary = {
onClickReplyDiary(
- selectedDate.year,
- selectedDate.monthValue,
+ selectedYear,
+ selectedMonth,
selectedDate.dayOfMonth,
- replyStatus,
+ Route.ReplyLoading.ReplyLoadingFrom.HOME,
)
},
)
@@ -231,8 +430,8 @@ fun HomeScreen(
ClodyPopupBottomSheet(onDismissRequest = { homeViewModel.setShowYearMonthPickerState(false) }) {
YearMonthPicker(
onDismissRequest = { homeViewModel.setShowYearMonthPickerState(false) },
- selectedYear = selectedDiaryDate.year,
- selectedMonth = selectedDiaryDate.month,
+ selectedYear = selectedYear,
+ selectedMonth = selectedMonth,
onYearMonthSelected = { year, month ->
homeViewModel.updateSelectedDiaryDate(DiaryDateData(year, month))
homeViewModel.loadCalendarData(year, month)
@@ -240,38 +439,5 @@ fun HomeScreen(
)
}
}
-
- if (showDiaryDeleteState) {
- DiaryDeleteSheet(
- onDismiss = { homeViewModel.setShowDiaryDeleteState(false) },
- showDiaryDeleteDialog = {
- AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.HOME_DELETE_DIARY)
- homeViewModel.setShowDiaryDeleteDialog(true)
- },
- )
- }
-
- if (showDiaryDeleteDialog) {
- ClodyDialog(
- titleMassage = "정말 일기를 삭제할까요?",
- descriptionMassage = "아직 답장이 오지 않았거나 삭제하고\n다시 작성한 일기는 답장을 받을 수 없어요.",
- confirmOption = "삭제할래요",
- dismissOption = "아니요",
- confirmAction = {
- homeViewModel.deleteDailyDiary(
- selectedDiaryDate.year,
- selectedDiaryDate.month,
- selectedDate.dayOfMonth,
- )
- homeViewModel.setShowDiaryDeleteDialog(false)
- },
- onDismiss = {
- AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.HOME_NO_DELETE_DIARY)
- homeViewModel.setShowDiaryDeleteDialog(false)
- },
- confirmButtonColor = ClodyTheme.colors.red,
- confirmButtonTextColor = ClodyTheme.colors.white,
- )
- }
}
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeViewModel.kt
index eb99e84d..fc5121fb 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeViewModel.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/HomeViewModel.kt
@@ -2,23 +2,39 @@ package com.sopt.clody.presentation.ui.home.screen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.sopt.clody.core.fcm.FcmTokenProvider
+import com.sopt.clody.core.network.NetworkConnectivityObserver
+import com.sopt.clody.core.network.NetworkStatus
+import com.sopt.clody.data.remote.dto.request.SendNotificationRequestDto
import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto
import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
-import com.sopt.clody.data.remote.util.NetworkUtil
+import com.sopt.clody.data.remote.dto.response.NotificationInfoResponseDto
+import com.sopt.clody.domain.model.ReplyStatus
import com.sopt.clody.domain.repository.DiaryRepository
-import com.sopt.clody.presentation.ui.home.model.DiaryDateData
-import com.sopt.clody.presentation.utils.network.ErrorMessages
+import com.sopt.clody.domain.repository.DraftRepository
+import com.sopt.clody.domain.repository.NotificationRepository
+import com.sopt.clody.domain.repository.ReviewRepository
+import com.sopt.clody.presentation.ui.home.calendar.model.DiaryDateData
+import com.sopt.clody.presentation.ui.setting.notificationsetting.screen.NotificationChangeState
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.time.LocalDate
+import java.time.ZoneId
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
private val diaryRepository: DiaryRepository,
- private val networkUtil: NetworkUtil,
+ private val notificationRepository: NotificationRepository,
+ private val draftRepository: DraftRepository,
+ private val fcmTokenProvider: FcmTokenProvider,
+ private val reviewRepository: ReviewRepository,
+ private val errorMessageProvider: ErrorMessageProvider,
+ private val networkConnectivityObserver: NetworkConnectivityObserver,
) : ViewModel() {
private val _calendarState = MutableStateFlow>(CalendarState.Idle)
@@ -44,8 +60,8 @@ class HomeViewModel @Inject constructor(
private val _diaryCount = MutableStateFlow(0)
val diaryCount: StateFlow get() = _diaryCount
- private val _replyStatus = MutableStateFlow("UNREADY")
- val replyStatus: StateFlow get() = _replyStatus
+ private val _replyStatus = MutableStateFlow(ReplyStatus.UNREADY)
+ val replyStatus: StateFlow get() = _replyStatus
private val _isToday = MutableStateFlow(false)
val isToday: StateFlow get() = _isToday
@@ -62,9 +78,27 @@ class HomeViewModel @Inject constructor(
private val _showDiaryDeleteDialog = MutableStateFlow(false)
val showDiaryDeleteDialog: StateFlow get() = _showDiaryDeleteDialog
+ private val _showContinueDraftDialog = MutableStateFlow(false)
+ val showContinueDraftDialog: StateFlow get() = _showContinueDraftDialog
+
+ private val _showFirstDraftPopup = MutableStateFlow(draftRepository.getIsFirstUse())
+ val showFirstDraftPopup: StateFlow = _showFirstDraftPopup
+
+ private val _draftAlarmChangeState = MutableStateFlow(NotificationChangeState.Idle)
+ val draftAlarmChangeState: StateFlow = _draftAlarmChangeState
+
+ private val _draftAlarmEnableToast = MutableStateFlow(false)
+ val draftAlarmEnableToast: StateFlow = _draftAlarmEnableToast
+
+ private val _showInAppReviewPopup = MutableStateFlow(reviewRepository.getShouldShowPopup())
+ val showInAppReviewPopup: StateFlow get() = _showInAppReviewPopup
+
private val _errorState = MutableStateFlow>(false to "")
val errorState: StateFlow> = _errorState
+ private val _hasDraft = MutableStateFlow(false)
+ val hasDraft: StateFlow get() = _hasDraft
+
private var isInitialized = false
init {
@@ -79,14 +113,14 @@ class HomeViewModel @Inject constructor(
}
}
- fun setErrorState(isError: Boolean, message: String = ErrorMessages.FAILURE_TEMPORARY_MESSAGE) {
+ fun setErrorState(isError: Boolean, message: String = errorMessageProvider.getTemporaryError()) {
_errorState.value = isError to message
}
fun loadCalendarData(year: Int, month: Int) {
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- setErrorState(true, ErrorMessages.FAILURE_NETWORK_MESSAGE)
+ if (!isNetworkAvailable()) {
+ setErrorState(true, errorMessageProvider.getNetworkError())
return@launch
}
@@ -98,8 +132,8 @@ class HomeViewModel @Inject constructor(
CalendarState.Success(it)
},
onFailure = { exception ->
- setErrorState(true, exception.message ?: ErrorMessages.UNKNOWN_ERROR)
- CalendarState.Error(exception.message ?: ErrorMessages.UNKNOWN_ERROR)
+ setErrorState(true, errorMessageProvider.getTemporaryError())
+ CalendarState.Error(errorMessageProvider.getTemporaryError())
},
)
}
@@ -107,21 +141,25 @@ class HomeViewModel @Inject constructor(
fun loadDailyDiariesData(year: Int, month: Int, date: Int) {
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- setErrorState(true, ErrorMessages.FAILURE_NETWORK_MESSAGE)
+ if (!isNetworkAvailable()) {
+ setErrorState(true, errorMessageProvider.getNetworkError())
return@launch
}
_dailyDiariesState.value = DailyDiariesState.Loading
val result = diaryRepository.getDailyDiariesData(year, month, date)
_dailyDiariesState.value = result.fold(
- onSuccess = {
+ onSuccess = { dailyResponse ->
+ _hasDraft.value = dailyResponse.isDraft
+ _diaryCount.value = dailyResponse.diaries.size
+ _isDeleted.value = dailyResponse.isDeleted
+
setErrorState(false)
- DailyDiariesState.Success(it)
+ DailyDiariesState.Success(dailyResponse)
},
onFailure = { exception ->
- setErrorState(true, exception.message ?: ErrorMessages.UNKNOWN_ERROR)
- DailyDiariesState.Error(exception.message ?: ErrorMessages.UNKNOWN_ERROR)
+ setErrorState(true, errorMessageProvider.getTemporaryError())
+ DailyDiariesState.Error(errorMessageProvider.getTemporaryError())
},
)
}
@@ -135,17 +173,24 @@ class HomeViewModel @Inject constructor(
onSuccess = {
loadCalendarData(year, month)
loadDailyDiariesData(year, month, day)
+ _diaryCount.value = 0
+ _isDeleted.value = false
+ _replyStatus.value = ReplyStatus.UNREADY
+
DeleteDiaryState.Success
},
onFailure = {
- DeleteDiaryState.Failure(it.message ?: "Unknown error")
+ DeleteDiaryState.Failure(it.message ?: errorMessageProvider.getTemporaryError())
},
)
}
}
fun refreshCalendarDataCalendarData(year: Int, month: Int) {
- if (calendarState.value is CalendarState.Success && _selectedDiaryDate.value.year == year && _selectedDiaryDate.value.month == month) {
+ if (calendarState.value is CalendarState.Success &&
+ _selectedDiaryDate.value.year == year &&
+ _selectedDiaryDate.value.month == month
+ ) {
return
}
_selectedDiaryDate.value = DiaryDateData(year, month)
@@ -164,7 +209,7 @@ class HomeViewModel @Inject constructor(
fun updateDiaryState(diaries: List) {
val selectedDiary = diaries.getOrNull(_selectedDate.value.dayOfMonth - 1)
_diaryCount.value = selectedDiary?.diaryCount ?: 0
- _replyStatus.value = selectedDiary?.replyStatus ?: "UNREADY"
+ _replyStatus.value = selectedDiary?.replyStatus ?: ReplyStatus.UNREADY
_isDeleted.value = selectedDiary?.isDeleted ?: false
}
@@ -179,4 +224,92 @@ class HomeViewModel @Inject constructor(
fun setShowDiaryDeleteDialog(state: Boolean) {
_showDiaryDeleteDialog.value = state
}
+
+ fun setShowContinueDraftDialog(state: Boolean) {
+ _showContinueDraftDialog.value = state
+ }
+
+ fun updateFirstDraftUse(newState: Boolean) {
+ draftRepository.setIsFirstUse(false)
+ _showFirstDraftPopup.value = newState
+ }
+
+ fun canWriteDiary(): Boolean {
+ val userTimeZone = ZoneId.systemDefault().id
+ val today = LocalDate.now()
+ val selected = _selectedDate.value
+ val isAvailableDay = if (userTimeZone == "Asia/Seoul") {
+ selected == today || selected == today.minusDays(1)
+ } else {
+ selected == today
+ }
+ return _diaryCount.value == 0 && isAvailableDay
+ }
+
+ fun canReplyDiary(): Boolean {
+ return _diaryCount.value > 0 && !_isDeleted.value
+ }
+
+ fun isValidDraftDate(): Boolean {
+ val today = LocalDate.now()
+ val selected = _selectedDate.value
+ return selected == today || selected == today.minusDays(1)
+ }
+
+ fun enableDraftAlarm() {
+ viewModelScope.launch {
+ if (!isNetworkAvailable()) {
+ setErrorState(true, errorMessageProvider.getNetworkError())
+ return@launch
+ }
+
+ val fcmToken = fcmTokenProvider.getToken().orEmpty()
+ val notificationInfo = getNotificationInfo() ?: return@launch
+ val request = buildDraftAlarmRequest(notificationInfo, fcmToken)
+ sendDraftAlarmRequest(request)
+ }
+ }
+
+ private suspend fun isNetworkAvailable(): Boolean {
+ return networkConnectivityObserver.networkStatus.first() == NetworkStatus.Available
+ }
+
+ private suspend fun getNotificationInfo(): NotificationInfoResponseDto? {
+ return notificationRepository.getNotificationInfo().getOrElse {
+ _draftAlarmChangeState.value = NotificationChangeState.Failure(errorMessageProvider.getTemporaryError())
+ null
+ }
+ }
+
+ private fun buildDraftAlarmRequest(
+ info: NotificationInfoResponseDto,
+ fcmToken: String,
+ ): SendNotificationRequestDto = SendNotificationRequestDto(
+ isDiaryAlarm = info.isDiaryAlarm,
+ isDraftAlarm = true,
+ isReplyAlarm = info.isReplyAlarm,
+ time = info.time,
+ fcmToken = fcmToken,
+ )
+
+ private suspend fun sendDraftAlarmRequest(request: SendNotificationRequestDto) {
+ notificationRepository.sendNotification(request).fold(
+ onSuccess = {
+ _draftAlarmEnableToast.value = true
+ _draftAlarmChangeState.value = NotificationChangeState.Success(it)
+ },
+ onFailure = {
+ _draftAlarmChangeState.value = NotificationChangeState.Failure(errorMessageProvider.getTemporaryError())
+ },
+ )
+ }
+
+ fun resetDraftAlarmEnableToast() {
+ _draftAlarmEnableToast.value = false
+ }
+
+ fun updateShowInAppReviewPopup(state: Boolean) {
+ reviewRepository.setShouldShowPopup(state)
+ _showInAppReviewPopup.value = state
+ }
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/ScrollableCalendar.kt b/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/ScrollableCalendar.kt
index 1c8ff47b..920c1616 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/ScrollableCalendar.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/home/screen/ScrollableCalendar.kt
@@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
+import com.sopt.clody.domain.model.ReplyStatus
import com.sopt.clody.presentation.ui.home.calendar.ClodyCalendar
import com.sopt.clody.presentation.ui.home.component.CloverCount
import com.sopt.clody.ui.theme.ClodyTheme
@@ -26,13 +27,16 @@ fun ScrollableCalendar(
diaries: List,
onShowDiaryDeleteStateChange: (Boolean) -> Unit,
selectedDate: LocalDate,
- onDiaryDataUpdated: (Int, String) -> Unit,
+ onDiaryDataUpdated: (Int, ReplyStatus) -> Unit,
modifier: Modifier = Modifier,
) {
LaunchedEffect(selectedDate, diaries) {
if (selectedDate.year == selectedYear && selectedDate.monthValue == selectedMonth) {
homeViewModel.updateDiaryState(diaries)
- onDiaryDataUpdated(homeViewModel.diaryCount.value, homeViewModel.replyStatus.value)
+ onDiaryDataUpdated(
+ homeViewModel.diaryCount.value,
+ homeViewModel.replyStatus.value,
+ )
}
}
val scrollState = rememberScrollState()
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/login/GoogleSignInHelper.kt b/app/src/main/java/com/sopt/clody/presentation/ui/login/GoogleSignInHelper.kt
new file mode 100644
index 00000000..a9644701
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/login/GoogleSignInHelper.kt
@@ -0,0 +1,50 @@
+package com.sopt.clody.presentation.ui.login
+
+import android.content.Context
+import android.content.Intent
+import androidx.activity.result.IntentSenderRequest
+import com.google.android.gms.auth.api.identity.BeginSignInRequest
+import com.google.android.gms.auth.api.identity.Identity
+import com.google.android.gms.auth.api.identity.SignInClient
+import com.sopt.clody.BuildConfig
+
+class GoogleSignInHelper(context: Context) {
+
+ private val signInClient: SignInClient = Identity.getSignInClient(context.applicationContext)
+
+ fun requestSignIn(
+ onSuccess: (IntentSenderRequest) -> Unit,
+ onFailure: (Exception) -> Unit,
+ ) {
+ val request = buildSignInRequest()
+ signInClient.beginSignIn(request)
+ .addOnSuccessListener { result ->
+ val intentSenderRequest =
+ IntentSenderRequest.Builder(result.pendingIntent.intentSender).build()
+ onSuccess(intentSenderRequest)
+ }
+ .addOnFailureListener { e ->
+ onFailure(e)
+ }
+ }
+
+ private fun buildSignInRequest(): BeginSignInRequest {
+ return BeginSignInRequest.Builder()
+ .setGoogleIdTokenRequestOptions(
+ BeginSignInRequest.GoogleIdTokenRequestOptions.builder()
+ .setSupported(true)
+ .setServerClientId(BuildConfig.GOOGLE_AUTH_WEB_CLIENT_ID)
+ .setFilterByAuthorizedAccounts(false)
+ .build(),
+ )
+ .setAutoSelectEnabled(false)
+ .build()
+ }
+
+ fun extractIdToken(data: Intent?): String? {
+ return runCatching {
+ val credential = signInClient.getSignInCredentialFromIntent(data)
+ credential.googleIdToken
+ }.getOrNull()
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginContract.kt b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginContract.kt
new file mode 100644
index 00000000..9daaa695
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginContract.kt
@@ -0,0 +1,25 @@
+package com.sopt.clody.presentation.ui.login
+
+import android.content.Context
+import com.airbnb.mvrx.MavericksState
+import com.sopt.clody.data.datastore.OAuthProvider
+
+class LoginContract {
+
+ data class LoginState(
+ val loginType: OAuthProvider? = null,
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+ ) : MavericksState
+
+ sealed class LoginIntent {
+ data class LoginOAuth(val provider: OAuthProvider, val context: Context? = null, val idToken: String? = null) : LoginIntent()
+ data object ClearError : LoginIntent()
+ }
+
+ sealed interface LoginSideEffect {
+ data object NavigateToHome : LoginSideEffect
+ data object NavigateToSignUp : LoginSideEffect
+ data class ShowError(val message: String) : LoginSideEffect
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginScreen.kt
new file mode 100644
index 00000000..783962b2
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginScreen.kt
@@ -0,0 +1,171 @@
+package com.sopt.clody.presentation.ui.login
+
+import android.app.Activity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.airbnb.mvrx.compose.collectAsState
+import com.airbnb.mvrx.compose.mavericksViewModel
+import com.google.accompanist.systemuicontroller.rememberSystemUiController
+import com.sopt.clody.R
+import com.sopt.clody.data.datastore.OAuthProvider
+import com.sopt.clody.presentation.ui.auth.component.button.GoogleButton
+import com.sopt.clody.presentation.ui.auth.component.button.KaKaoButton
+import com.sopt.clody.presentation.ui.component.dialog.FailureDialog
+import com.sopt.clody.presentation.ui.login.LoginContract.LoginIntent
+import com.sopt.clody.presentation.utils.base.BasePreview
+import com.sopt.clody.presentation.utils.base.ClodyPreview
+import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage
+import com.sopt.clody.presentation.utils.extension.repeatOnStarted
+import com.sopt.clody.ui.theme.ClodyTheme
+
+@Composable
+fun LoginRoute(
+ navigateToSignUp: () -> Unit,
+ navigateToHome: () -> Unit,
+ viewModel: LoginViewModel = mavericksViewModel(),
+) {
+ val state by viewModel.collectAsState()
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ val googleSignInHelper = remember { GoogleSignInHelper(context) }
+
+ val googleSignInLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartIntentSenderForResult(),
+ ) { result ->
+
+ if (result.resultCode == Activity.RESULT_OK) {
+ val idToken = googleSignInHelper.extractIdToken(result.data)
+ viewModel.postIntent(LoginIntent.LoginOAuth(OAuthProvider.GOOGLE, idToken = idToken))
+ }
+ }
+
+ LaunchedEffect(viewModel) {
+ lifecycleOwner.repeatOnStarted {
+ viewModel.sideEffects.collect { sideEffect ->
+ when (sideEffect) {
+ is LoginContract.LoginSideEffect.NavigateToHome -> navigateToHome()
+ is LoginContract.LoginSideEffect.NavigateToSignUp -> navigateToSignUp()
+ is LoginContract.LoginSideEffect.ShowError -> {
+ // 삐용삐용 여기 뭘로 처리할까?
+ }
+ }
+ }
+ }
+ }
+
+ LoginScreen(
+ state = state,
+ onKaKaoLoginClick = {
+ viewModel.postIntent(LoginIntent.LoginOAuth(OAuthProvider.KAKAO, context = context))
+ },
+ onGoogleLoginClick = {
+ googleSignInHelper.requestSignIn(
+ onSuccess = { intentSenderRequest -> googleSignInLauncher.launch(intentSenderRequest) },
+ onFailure = { viewModel.postIntent(LoginIntent.ClearError) },
+ )
+ },
+ )
+
+ // 에러 메시지 추가로 다이얼로그로 처리하고 싶다면?
+ state.errorMessage?.let { message ->
+ FailureDialog(message = message) {
+ viewModel.postIntent(LoginContract.LoginIntent.ClearError)
+ }
+ }
+}
+
+@Composable
+fun LoginScreen(
+ state: LoginContract.LoginState,
+ onKaKaoLoginClick: () -> Unit,
+ onGoogleLoginClick: () -> Unit,
+) {
+ val systemUiController = rememberSystemUiController()
+ val backgroundColor = ClodyTheme.colors.white
+
+ LaunchedEffect(Unit) {
+ systemUiController.setStatusBarColor(
+ color = backgroundColor,
+ darkIcons = true,
+ )
+ }
+
+ Scaffold(
+ bottomBar = {
+ if (state.loginType == OAuthProvider.KAKAO) {
+ KaKaoButton(
+ text = stringResource(id = R.string.signup_btn_kakao),
+ onClick = onKaKaoLoginClick,
+ modifier = Modifier
+ .navigationBarsPadding()
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 40.dp),
+ )
+ } else {
+ GoogleButton(
+ text = stringResource(id = R.string.signup_btn_google),
+ onClick = onGoogleLoginClick,
+ modifier = Modifier
+ .navigationBarsPadding()
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 40.dp),
+ )
+ }
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color = backgroundColor)
+ .padding(innerPadding),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Spacer(modifier = Modifier.heightForScreenPercentage(0.36f))
+ Image(
+ painter = painterResource(id = R.drawable.img_splash_logo),
+ contentDescription = "App Logo",
+ modifier = Modifier.size(160.dp),
+ )
+ }
+ }
+}
+
+@ClodyPreview
+@Composable
+fun LoginScreenPreview() {
+ BasePreview {
+ LoginScreen(
+ state = LoginContract.LoginState(
+ isLoading = false,
+ loginType = OAuthProvider.KAKAO,
+ errorMessage = null,
+ ),
+ onKaKaoLoginClick = {},
+ onGoogleLoginClick = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginType.kt b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginType.kt
new file mode 100644
index 00000000..ff6997ab
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginType.kt
@@ -0,0 +1,5 @@
+package com.sopt.clody.presentation.ui.login
+
+enum class LoginType {
+ KAKAO, GOOGLE
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginViewModel.kt
new file mode 100644
index 00000000..8c866da6
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/login/LoginViewModel.kt
@@ -0,0 +1,146 @@
+package com.sopt.clody.presentation.ui.login
+
+import com.airbnb.mvrx.MavericksViewModel
+import com.airbnb.mvrx.MavericksViewModelFactory
+import com.airbnb.mvrx.hilt.AssistedViewModelFactory
+import com.airbnb.mvrx.hilt.hiltMavericksViewModelFactory
+import com.sopt.clody.core.fcm.FcmTokenProvider
+import com.sopt.clody.core.login.LoginSdk
+import com.sopt.clody.data.datastore.OAuthDataStore
+import com.sopt.clody.data.datastore.OAuthProvider
+import com.sopt.clody.data.remote.dto.request.GoogleSignUpRequestDto
+import com.sopt.clody.data.remote.dto.request.LoginRequestDto
+import com.sopt.clody.domain.repository.AuthRepository
+import com.sopt.clody.domain.repository.TokenRepository
+import com.sopt.clody.presentation.ui.login.LoginContract.LoginIntent
+import com.sopt.clody.presentation.utils.language.LanguageProvider
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.BUFFERED
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+
+class LoginViewModel @AssistedInject constructor(
+ @Assisted initialState: LoginContract.LoginState,
+ private val loginSdk: LoginSdk,
+ private val authRepository: AuthRepository,
+ private val tokenRepository: TokenRepository,
+ private val fcmTokenProvider: FcmTokenProvider,
+ private val oauthDataStore: OAuthDataStore,
+ private val languageProvider: LanguageProvider,
+ private val errorMessageProvider: ErrorMessageProvider,
+) : MavericksViewModel(initialState) {
+
+ private val _intents = Channel(BUFFERED)
+ private val _sideEffects = Channel(BUFFERED)
+ val sideEffects = _sideEffects.receiveAsFlow()
+
+ init {
+ setState { copy(loginType = languageProvider.getLoginType()) }
+ _intents
+ .receiveAsFlow()
+ .onEach(::handleIntent)
+ .launchIn(viewModelScope)
+ }
+
+ fun postIntent(intent: LoginIntent) {
+ viewModelScope.launch { _intents.send(intent) }
+ }
+
+ private suspend fun handleIntent(intent: LoginIntent) {
+ when (intent) {
+ is LoginIntent.ClearError -> setState { copy(errorMessage = null) }
+ is LoginIntent.LoginOAuth -> handleLoginOAuth(intent)
+ }
+ }
+
+ private suspend fun handleLoginOAuth(intent: LoginIntent.LoginOAuth) {
+ setState { copy(isLoading = true, errorMessage = null) }
+
+ when (intent.provider) {
+ OAuthProvider.KAKAO -> {
+ loginSdk.login(intent.context!!).fold(
+ onSuccess = { token ->
+ validateKakaoUser(token.value)
+ },
+ onFailure = { error ->
+ setState { copy(isLoading = false) }
+ _sideEffects.send(LoginContract.LoginSideEffect.ShowError(errorMessageProvider.getLoginFailedError()))
+ },
+ )
+ }
+
+ OAuthProvider.GOOGLE -> {
+ val idToken = intent.idToken
+ if (idToken.isNullOrBlank()) {
+ _sideEffects.send(LoginContract.LoginSideEffect.ShowError(errorMessageProvider.getLoginFailedError()))
+ return
+ }
+
+ validateGoogleUser(idToken)
+ }
+ }
+ }
+
+ private suspend fun validateKakaoUser(kakaoToken: String) {
+ val fcmToken = fcmTokenProvider.getToken().orEmpty()
+ val request = LoginRequestDto(platform = OAuthProvider.KAKAO.platform, fcmToken = fcmToken)
+
+ authRepository.signIn("Bearer $kakaoToken", request).fold(
+ onSuccess = {
+ tokenRepository.setTokens(it.accessToken, it.refreshToken)
+ setState { copy(isLoading = false) }
+ _sideEffects.send(LoginContract.LoginSideEffect.NavigateToHome)
+ },
+ onFailure = { error ->
+ setState { copy(isLoading = false) }
+ val msg = error.message.orEmpty()
+ if (msg.contains("404")) {
+ oauthDataStore.saveIdToken(platform = "kakao", token = kakaoToken)
+ oauthDataStore.savePlatform(OAuthProvider.KAKAO)
+ _sideEffects.send(LoginContract.LoginSideEffect.NavigateToSignUp)
+ } else {
+ _sideEffects.send(LoginContract.LoginSideEffect.ShowError(msg))
+ }
+ },
+ )
+ }
+
+ private suspend fun validateGoogleUser(idToken: String) {
+ val fcmToken = fcmTokenProvider.getToken().orEmpty()
+ val request = GoogleSignUpRequestDto(idToken = idToken, fcmToken = fcmToken)
+
+ authRepository.signUpWithGoogle(request).fold(
+ onSuccess = {
+ tokenRepository.setTokens(it.accessToken, it.refreshToken)
+ setState { copy(isLoading = false) }
+ _sideEffects.send(LoginContract.LoginSideEffect.NavigateToHome)
+ },
+ onFailure = { error ->
+ setState { copy(isLoading = false) }
+
+ val msg = error.message.orEmpty()
+ if (msg.contains("404")) {
+ oauthDataStore.saveIdToken(platform = "google", token = idToken)
+ oauthDataStore.savePlatform(OAuthProvider.GOOGLE)
+ _sideEffects.send(LoginContract.LoginSideEffect.NavigateToSignUp)
+ } else {
+ _sideEffects.send(LoginContract.LoginSideEffect.ShowError(msg))
+ }
+ },
+ )
+ }
+
+ @AssistedFactory
+ interface Factory : AssistedViewModelFactory {
+ override fun create(state: LoginContract.LoginState): LoginViewModel
+ }
+
+ companion object :
+ MavericksViewModelFactory by hiltMavericksViewModelFactory()
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/login/navigation/LoginNavigation.kt b/app/src/main/java/com/sopt/clody/presentation/ui/login/navigation/LoginNavigation.kt
new file mode 100644
index 00000000..a8d4a1a7
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/login/navigation/LoginNavigation.kt
@@ -0,0 +1,26 @@
+package com.sopt.clody.presentation.ui.login.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptionsBuilder
+import androidx.navigation.compose.composable
+import com.sopt.clody.presentation.ui.login.LoginRoute
+import com.sopt.clody.presentation.utils.navigation.Route
+
+fun NavGraphBuilder.loginScreen(
+ navigateToSignUp: () -> Unit,
+ navigateToHome: () -> Unit,
+) {
+ composable {
+ LoginRoute(
+ navigateToSignUp = navigateToSignUp,
+ navigateToHome = navigateToHome,
+ )
+ }
+}
+
+fun NavController.navigateToLogin(
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) {
+ navigate(Route.Login, navOptions)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/main/ClodyApp.kt b/app/src/main/java/com/sopt/clody/presentation/ui/main/ClodyApp.kt
new file mode 100644
index 00000000..6a23e9ba
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/main/ClodyApp.kt
@@ -0,0 +1,34 @@
+package com.sopt.clody.presentation.ui.main
+
+import android.content.Intent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
+import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
+
+/**
+ * 루트 Composable 함수.
+ * 앱 전반의 테마와 시스템 UI 설정, Scaffold 레이아웃을 관리하고
+ * [ClodyNavHost]를 통해 실제 네비게이션 경로를 구성.
+ */
+@Composable
+fun ClodyApp(
+ appState: ClodyAppState,
+ startIntent: Intent,
+) {
+ LaunchedEffect(startIntent) {
+ if (startIntent.hasExtra("google.message_id")) {
+ AmplitudeUtils.trackEvent(AmplitudeConstraints.ALARM)
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ ClodyNavHost(
+ appState = appState,
+ startIntent = startIntent,
+ )
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/main/ClodyAppState.kt b/app/src/main/java/com/sopt/clody/presentation/ui/main/ClodyAppState.kt
new file mode 100644
index 00000000..ecbb4abf
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/main/ClodyAppState.kt
@@ -0,0 +1,29 @@
+package com.sopt.clody.presentation.ui.main
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.rememberNavController
+import com.sopt.clody.presentation.utils.navigation.Route
+
+/**
+ * 앱의 전역 상태를 관리하는 클래스.
+ * 현재는 네비게이션 컨트롤러만 포함되어 있으며,
+ * 추후 다른 상태(예: 사용자 인증 상태, 테마 설정 등) 추가 가능.
+ */
+@Stable
+class ClodyAppState(
+ val navController: NavHostController,
+) {
+ val startDestination = Route.Splash
+}
+
+/**
+ * [ClodyAppState]를 기억하고 유지하는 Composable 함수.
+ * [rememberNavController]를 활용해 NavController를 생성.
+ */
+@Composable
+fun rememberClodyAppState(
+ navController: NavHostController = rememberNavController(),
+): ClodyAppState = remember { ClodyAppState(navController) }
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/main/ClodyNavHost.kt b/app/src/main/java/com/sopt/clody/presentation/ui/main/ClodyNavHost.kt
new file mode 100644
index 00000000..8408e01d
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/main/ClodyNavHost.kt
@@ -0,0 +1,118 @@
+package com.sopt.clody.presentation.ui.main
+
+import android.content.Intent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import com.sopt.clody.presentation.ui.auth.guide.navigation.guideScreen
+import com.sopt.clody.presentation.ui.auth.guide.navigation.navigateToGuide
+import com.sopt.clody.presentation.ui.auth.signup.navigation.navigateToSignUp
+import com.sopt.clody.presentation.ui.auth.signup.navigation.signUpScreen
+import com.sopt.clody.presentation.ui.auth.timereminder.navigateToTimeReminder
+import com.sopt.clody.presentation.ui.auth.timereminder.timeReminderScreen
+import com.sopt.clody.presentation.ui.diarylist.navigation.diaryListScreen
+import com.sopt.clody.presentation.ui.diarylist.navigation.navigateToDiaryList
+import com.sopt.clody.presentation.ui.home.navigation.homeScreen
+import com.sopt.clody.presentation.ui.home.navigation.navigateToHome
+import com.sopt.clody.presentation.ui.login.navigation.loginScreen
+import com.sopt.clody.presentation.ui.login.navigation.navigateToLogin
+import com.sopt.clody.presentation.ui.replydiary.navigation.navigateToReplyDiary
+import com.sopt.clody.presentation.ui.replydiary.navigation.replyDiaryScreen
+import com.sopt.clody.presentation.ui.replyloading.navigation.navigateToReplyLoading
+import com.sopt.clody.presentation.ui.replyloading.navigation.replyLoadingScreen
+import com.sopt.clody.presentation.ui.setting.navigation.accountManagementScreen
+import com.sopt.clody.presentation.ui.setting.navigation.navigateToAccountManagement
+import com.sopt.clody.presentation.ui.setting.navigation.navigateToNotificationSetting
+import com.sopt.clody.presentation.ui.setting.navigation.navigateToSetting
+import com.sopt.clody.presentation.ui.setting.navigation.notificationSettingScreen
+import com.sopt.clody.presentation.ui.setting.navigation.settingScreen
+import com.sopt.clody.presentation.ui.splash.navigation.splashScreen
+import com.sopt.clody.presentation.ui.writediary.navigation.navigateToWriteDiary
+import com.sopt.clody.presentation.ui.writediary.navigation.writeDiaryScreen
+import com.sopt.clody.presentation.utils.navigation.safePopBackStack
+
+/**
+ * 앱 전역의 Navigation Host.
+ *
+ * 각 기능? 화면?의 navigation graph를 이곳에 연결.
+ * [NavHost]를 사용하여 화면 전환을 구성하고,
+ * startIntent 등을 통해 초기 경로 조건 처리도 할 수 있음.
+ *
+ * @param appState 앱 상태를 포함한 네비게이션 컨트롤러
+ * @param modifier Compose UI Modifier
+ * @param startIntent 외부에서 전달된 인텐트 (푸시 처리 등)
+ */
+@Composable
+fun ClodyNavHost(
+ appState: ClodyAppState,
+ modifier: Modifier = Modifier,
+ startIntent: Intent,
+) {
+ val navController: NavHostController = appState.navController
+
+ Box(modifier = modifier.fillMaxSize()) {
+ NavHost(
+ navController = navController,
+ startDestination = appState.startDestination,
+ ) {
+ splashScreen(
+ startIntent = startIntent,
+ navigateToLogin = navController::navigateToLogin,
+ navigateToHome = navController::navigateToHome,
+ )
+
+ loginScreen(
+ navigateToSignUp = navController::navigateToSignUp,
+ navigateToHome = navController::navigateToHome,
+ )
+ signUpScreen(
+ navigateToHome = navController::navigateToTimeReminder,
+ navigateToPrevious = navController::safePopBackStack,
+ )
+ timeReminderScreen(
+ navigateToGuide = navController::navigateToGuide,
+ )
+ guideScreen(
+ navigateToHome = navController::navigateToHome,
+ )
+ homeScreen(
+ navigateToDiaryList = navController::navigateToDiaryList,
+ navigateToSetting = navController::navigateToSetting,
+ navigateToWriteDiary = navController::navigateToWriteDiary,
+ navigateToReplyLoading = navController::navigateToReplyLoading,
+ )
+ diaryListScreen(
+ navigateToHome = navController::navigateToHome,
+ navigateToReplyLoading = navController::navigateToReplyLoading,
+ )
+ writeDiaryScreen(
+ navigateToReplyLoading = navController::navigateToReplyLoading,
+ navigateToHome = navController::navigateToHome,
+ navigateToPrevious = navController::safePopBackStack,
+ )
+ replyLoadingScreen(
+ navigateToReplyDiary = navController::navigateToReplyDiary,
+ navigateToHome = navController::navigateToHome,
+ navigateToDiaryList = navController::navigateToDiaryList,
+ )
+ replyDiaryScreen(
+ navigateToHome = navController::navigateToHome,
+ )
+ settingScreen(
+ navigateToAccountManagement = navController::navigateToAccountManagement,
+ navigateToNotification = navController::navigateToNotificationSetting,
+ navigateToPrevious = navController::safePopBackStack,
+ )
+ accountManagementScreen(
+ navigateToPrevious = navController::safePopBackStack,
+ navigateToLogin = navController::navigateToLogin,
+ )
+ notificationSettingScreen(
+ navigateToPrevious = navController::safePopBackStack,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/main/MainActivity.kt b/app/src/main/java/com/sopt/clody/presentation/ui/main/MainActivity.kt
index bc1e827f..a9e60661 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/main/MainActivity.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/main/MainActivity.kt
@@ -1,32 +1,13 @@
package com.sopt.clody.presentation.ui.main
import android.annotation.SuppressLint
-import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.SideEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.runtime.setValue
-import androidx.navigation.compose.rememberNavController
-import com.sopt.clody.presentation.ui.auth.navigation.AuthNavigator
-import com.sopt.clody.presentation.ui.diarylist.navigation.DiaryListNavigator
-import com.sopt.clody.presentation.ui.home.navigation.HomeNavigator
-import com.sopt.clody.presentation.ui.navigatior.MainNavHost
-import com.sopt.clody.presentation.ui.replydiary.navigation.ReplyDiaryNavigator
-import com.sopt.clody.presentation.ui.replyloading.navigation.ReplyLoadingNavigator
-import com.sopt.clody.presentation.ui.setting.navigation.SettingNavigator
-import com.sopt.clody.presentation.ui.writediary.navigation.WriteDiaryNavigator
-import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
-import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
-import com.sopt.clody.ui.theme.CLODYTheme
+import com.sopt.clody.ui.theme.ClodyTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -36,6 +17,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(
android.graphics.Color.TRANSPARENT,
@@ -46,63 +28,12 @@ class MainActivity : ComponentActivity() {
android.graphics.Color.WHITE,
),
)
- setContent {
- CLODYTheme {
- val navController = rememberNavController()
- val currentIntent by rememberUpdatedState(newValue = intent)
- var processedIntent by remember { mutableStateOf(null) }
-
- LaunchedEffect(key1 = currentIntent) {
- if (processedIntent != currentIntent) {
- handleIntent(currentIntent)
- processedIntent = currentIntent
- }
- }
-
- SideEffect {
- if (processedIntent != intent) {
- handleIntent(intent)
- processedIntent = intent
- }
- }
-
- LaunchedEffect(key1 = intent.getBooleanExtra("NAVIGATE_TO_LOGIN", false)) {
- if (intent.getBooleanExtra("NAVIGATE_TO_LOGIN", false)) {
- navController.navigate("register_graph") {
- popUpTo(0) { inclusive = true }
- }
- }
- }
-
- val authNavigator = remember(navController) { AuthNavigator(navController) }
- val homeNavigator = remember(navController) { HomeNavigator(navController) }
- val diaryListNavigator = remember(navController) { DiaryListNavigator(navController) }
- val writeDiaryNavigator = remember(navController) { WriteDiaryNavigator(navController) }
- val settingNavigator = remember(navController) { SettingNavigator(navController) }
- val replyLoadingNavigator = remember(navController) { ReplyLoadingNavigator(navController) }
- val replyDiaryNavigator = remember(navController) { ReplyDiaryNavigator(navController) }
- MainNavHost(
- navController = navController,
- authNavigator = authNavigator,
- homeNavigator = homeNavigator,
- diaryListNavigator = diaryListNavigator,
- writeDiaryNavigator = writeDiaryNavigator,
- settingNavigator = settingNavigator,
- replyLoadingNavigator = replyLoadingNavigator,
- replyDiaryNavigator = replyDiaryNavigator,
- )
+ setContent {
+ ClodyTheme {
+ val appState = rememberClodyAppState()
+ ClodyApp(appState = appState, startIntent = intent)
}
}
}
-
- private fun handleIntent(intent: Intent?) {
- if (intent?.extras?.containsKey("google.message_id") == true) {
- logPushClickEvent()
- }
- }
-
- private fun logPushClickEvent() {
- AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.ALARM)
- }
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/navigatior/MainNavHost.kt b/app/src/main/java/com/sopt/clody/presentation/ui/navigatior/MainNavHost.kt
deleted file mode 100644
index 385666f8..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/navigatior/MainNavHost.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package com.sopt.clody.presentation.ui.navigatior
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.navigation.NavHostController
-import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
-import com.sopt.clody.presentation.ui.auth.navigation.AuthNavigator
-import com.sopt.clody.presentation.ui.auth.navigation.guidNavGraph
-import com.sopt.clody.presentation.ui.auth.navigation.nicknameNavGraph
-import com.sopt.clody.presentation.ui.auth.navigation.registerNavGraph
-import com.sopt.clody.presentation.ui.auth.navigation.termsOfServiceNavGraph
-import com.sopt.clody.presentation.ui.auth.navigation.timeReminderNavGraph
-import com.sopt.clody.presentation.ui.diarylist.navigation.DiaryListNavigator
-import com.sopt.clody.presentation.ui.diarylist.navigation.diaryListNavGraph
-import com.sopt.clody.presentation.ui.home.navigation.HomeNavigator
-import com.sopt.clody.presentation.ui.home.navigation.homeNavGraph
-import com.sopt.clody.presentation.ui.replydiary.navigation.ReplyDiaryNavigator
-import com.sopt.clody.presentation.ui.replyloading.navigation.ReplyLoadingNavigator
-import com.sopt.clody.presentation.ui.replyloading.navigation.replyLoadingNavGraph
-import com.sopt.clody.presentation.ui.setting.navigation.SettingNavigator
-import com.sopt.clody.presentation.ui.setting.navigation.accountManagementNavGraph
-import com.sopt.clody.presentation.ui.setting.navigation.notificationSettingNavGraph
-import com.sopt.clody.presentation.ui.setting.navigation.settingNavGraph
-import com.sopt.clody.presentation.ui.setting.navigation.webViewNavGraph
-import com.sopt.clody.presentation.ui.splash.SplashRoute
-import com.sopt.clody.presentation.ui.writediary.navigation.WriteDiaryNavigator
-import com.sopt.clody.presentation.ui.writediary.navigation.writeDiaryNavGraph
-
-@Composable
-fun MainNavHost(
- navController: NavHostController,
- modifier: Modifier = Modifier,
- authNavigator: AuthNavigator,
- homeNavigator: HomeNavigator,
- diaryListNavigator: DiaryListNavigator,
- writeDiaryNavigator: WriteDiaryNavigator,
- settingNavigator: SettingNavigator,
- replyLoadingNavigator: ReplyLoadingNavigator,
- replyDiaryNavigator: ReplyDiaryNavigator,
-) {
- Box(
- modifier = modifier,
- ) {
- NavHost(
- navController = navController,
- startDestination = "splash",
- ) {
- composable("splash") { SplashRoute(navigator = authNavigator) }
- registerNavGraph(authNavigator)
- termsOfServiceNavGraph(authNavigator)
- nicknameNavGraph(authNavigator)
- guidNavGraph(authNavigator)
- timeReminderNavGraph(authNavigator)
- homeNavGraph(homeNavigator)
- diaryListNavGraph(diaryListNavigator)
- writeDiaryNavGraph(writeDiaryNavigator)
- settingNavGraph(settingNavigator)
- accountManagementNavGraph(settingNavigator)
- notificationSettingNavGraph(settingNavigator)
- webViewNavGraph(settingNavigator)
- replyLoadingNavGraph(replyLoadingNavigator, replyDiaryNavigator)
- }
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryScreen.kt
index f68ddafb..c391ff08 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryScreen.kt
@@ -35,21 +35,22 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.sopt.clody.R
+import com.sopt.clody.domain.model.ReplyStatus
import com.sopt.clody.presentation.ui.component.FailureScreen
import com.sopt.clody.presentation.ui.component.LoadingScreen
-import com.sopt.clody.presentation.ui.replydiary.navigation.ReplyDiaryNavigator
import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage
+import com.sopt.clody.presentation.utils.extension.toLocalizedMonthLabel
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun ReplyDiaryRoute(
- navigator: ReplyDiaryNavigator,
year: Int,
month: Int,
date: Int,
- replyStatus: String,
+ replyStatus: ReplyStatus,
+ navigateToHome: (year: Int, month: Int, date: Int, isFromReplyDiary: Boolean) -> Unit,
viewModel: ReplyDiaryViewModel = hiltViewModel(),
) {
val replyDiaryState by viewModel.replyDiaryState.collectAsState()
@@ -65,7 +66,7 @@ fun ReplyDiaryRoute(
BackHandler {
val currentTime = System.currentTimeMillis()
if (currentTime - backPressedTime <= backPressThreshold) {
- navigator.navigateHome(year, month)
+ navigateToHome(year, month, date, true)
} else {
backPressedTime = currentTime
}
@@ -79,7 +80,7 @@ fun ReplyDiaryRoute(
is ReplyDiaryState.Success -> {
val successState = replyDiaryState as ReplyDiaryState.Success
ReplyDiaryScreen(
- onClickBack = { navigator.navigateHome(year, month) },
+ navigateToHome = { navigateToHome(year, month, date, true) },
replyStatus = replyStatus,
replyDiaryState = successState,
)
@@ -99,18 +100,17 @@ fun ReplyDiaryRoute(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplyDiaryScreen(
- onClickBack: () -> Unit,
- replyStatus: String,
+ navigateToHome: () -> Unit,
+ replyStatus: ReplyStatus,
replyDiaryState: ReplyDiaryState.Success,
) {
var showDialog by remember { mutableStateOf(false) }
LaunchedEffect(replyDiaryState) {
- if (replyStatus == "READY_NOT_READ") {
+ if (replyStatus == ReplyStatus.READY_NOT_READ || replyStatus == ReplyStatus.UNREADY) {
showDialog = true
}
}
-
Scaffold(
topBar = {
val month = replyDiaryState.month
@@ -119,13 +119,13 @@ fun ReplyDiaryScreen(
modifier = Modifier.statusBarsPadding(),
title = {
Text(
- text = stringResource(R.string.reply_month_and_date, month, date),
+ text = stringResource(R.string.reply_month_and_date, month.toLocalizedMonthLabel(), date.toString()),
style = ClodyTheme.typography.head4,
color = ClodyTheme.colors.gray01,
)
},
navigationIcon = {
- IconButton(onClick = onClickBack) {
+ IconButton(onClick = navigateToHome) {
Image(
painterResource(id = R.drawable.ic_nickname_back),
contentDescription = "back",
@@ -138,13 +138,12 @@ fun ReplyDiaryScreen(
content = { innerPadding ->
Box(
modifier = Modifier
- .fillMaxSize()
.background(ClodyTheme.colors.white)
.padding(innerPadding),
) {
Column(
modifier = Modifier
- .fillMaxWidth()
+ .fillMaxSize()
.padding(horizontal = 24.dp)
.padding(bottom = 28.dp)
.clip(RoundedCornerShape(16.dp))
@@ -153,7 +152,7 @@ fun ReplyDiaryScreen(
) {
val content = replyDiaryState.content
val nickname = replyDiaryState.nickname
- val replyMessage = stringResource(R.string.reply_message, nickname)
+ val replyMessage = stringResource(R.string.reply_title, nickname)
Spacer(modifier = Modifier.heightForScreenPercentage(0.02f))
Image(
@@ -188,9 +187,9 @@ fun ReplyDiaryScreen(
if (showDialog) {
CloverDialog(
onDismiss = { showDialog = false },
- titleMassage = stringResource(R.string.clover_dialog_title, replyDiaryState.nickname),
- descriptionMassage = stringResource(R.string.clover_dialog_description),
- confirmOption = stringResource(R.string.clover_dialog_confirm_option),
+ titleMassage = stringResource(R.string.dialog_reply_clover_title, replyDiaryState.nickname),
+ descriptionMassage = stringResource(R.string.dialog_reply_clover_description),
+ confirmOption = stringResource(R.string.dialog_reply_clover_confirm),
confirmAction = { showDialog = false },
confirmButtonColor = ClodyTheme.colors.mainYellow,
)
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryViewModel.kt
index 9dc9db8c..4a93338f 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryViewModel.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/ReplyDiaryViewModel.kt
@@ -2,17 +2,18 @@ package com.sopt.clody.presentation.ui.replydiary
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.sopt.clody.core.network.NetworkConnectivityObserver
+import com.sopt.clody.core.network.NetworkStatus
import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto
-import com.sopt.clody.data.remote.util.NetworkUtil
+import com.sopt.clody.data.remote.util.ApiError
import com.sopt.clody.domain.repository.DiaryRepository
import com.sopt.clody.presentation.utils.extension.throttleFirst
-import com.sopt.clody.presentation.utils.network.ErrorMessages
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_NETWORK_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.UNKNOWN_ERROR
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -22,7 +23,8 @@ import javax.inject.Inject
@HiltViewModel
class ReplyDiaryViewModel @Inject constructor(
private val diaryRepository: DiaryRepository,
- private val networkUtil: NetworkUtil,
+ private val errorMessageProvider: ErrorMessageProvider,
+ private val networkConnectivityObserver: NetworkConnectivityObserver,
) : ViewModel() {
private val _replyDiaryState = MutableStateFlow(ReplyDiaryState.Idle)
@@ -32,7 +34,7 @@ class ReplyDiaryViewModel @Inject constructor(
private var lastMonth: Int = 0
private var lastDate: Int = 0
- private val _retryFlow = MutableSharedFlow() // 연속 클릭을 제어하기 위해 선언.
+ private val _retryFlow = MutableSharedFlow()
init {
setupRetryFlow()
@@ -40,11 +42,11 @@ class ReplyDiaryViewModel @Inject constructor(
private fun setupRetryFlow() {
_retryFlow
- .throttleFirst(2000L) // 2초 동안 첫 번째 이벤트만 발행.
- .onEach { // Flow에서 발생한 이벤트를 받아서 getReplyDiaryInternal 호출.
+ .throttleFirst(2000L)
+ .onEach {
getReplyDiaryInternal(lastYear, lastMonth, lastDate)
}
- .launchIn(viewModelScope) // Flow를 viewModelScope에서 실행하고 구독을 유지, 즉 viewmodel이 살아있는 동안 flow가 실행됨
+ .launchIn(viewModelScope)
}
fun getReplyDiary(year: Int, month: Int, date: Int) {
@@ -56,8 +58,9 @@ class ReplyDiaryViewModel @Inject constructor(
private fun getReplyDiaryInternal(year: Int, month: Int, date: Int) {
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- updateState(ReplyDiaryState.Failure(FAILURE_NETWORK_MESSAGE))
+ val isConnected = networkConnectivityObserver.networkStatus.first() == NetworkStatus.Available
+ if (!isConnected) {
+ updateState(ReplyDiaryState.Failure(errorMessageProvider.getNetworkError()))
return@launch
}
@@ -73,7 +76,7 @@ class ReplyDiaryViewModel @Inject constructor(
onSuccess = { data ->
updateState(
ReplyDiaryState.Success(
- content = data.content ?: "",
+ content = data.content.orEmpty(),
nickname = data.nickname,
month = data.month,
date = data.date,
@@ -81,9 +84,12 @@ class ReplyDiaryViewModel @Inject constructor(
)
},
onFailure = { throwable ->
- updateState(ReplyDiaryState.Failure(ErrorMessages.FAILURE_TEMPORARY_MESSAGE))
- val errorMessage = throwable.localizedMessage ?: UNKNOWN_ERROR
- Timber.tag("ReplyDiaryViewModel").e("API 요청 실패: %s", errorMessage)
+ val message = when (throwable) {
+ is ApiError -> errorMessageProvider.getApiError(throwable)
+ else -> errorMessageProvider.getTemporaryError()
+ }
+ updateState(ReplyDiaryState.Failure(message))
+ Timber.tag("ReplyDiaryViewModel").e(throwable, "API 요청 실패")
},
)
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/navigation/ReplyDiaryNavigation.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/navigation/ReplyDiaryNavigation.kt
new file mode 100644
index 00000000..492ea5f2
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/navigation/ReplyDiaryNavigation.kt
@@ -0,0 +1,36 @@
+package com.sopt.clody.presentation.ui.replydiary.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptionsBuilder
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import com.sopt.clody.domain.model.ReplyStatus
+import com.sopt.clody.presentation.ui.replydiary.ReplyDiaryRoute
+import com.sopt.clody.presentation.utils.navigation.Route
+
+fun NavGraphBuilder.replyDiaryScreen(
+ navigateToHome: (year: Int, month: Int, date: Int, isFromReplyDiary: Boolean) -> Unit,
+) {
+ composable { backStackEntry ->
+ backStackEntry.toRoute().apply {
+ ReplyDiaryRoute(
+ year = year,
+ month = month,
+ date = date,
+ replyStatus = replyStatus,
+ navigateToHome = navigateToHome,
+ )
+ }
+ }
+}
+
+fun NavController.navigateToReplyDiary(
+ year: Int,
+ month: Int,
+ day: Int,
+ replyStatus: ReplyStatus = ReplyStatus.UNREADY,
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) {
+ navigate(Route.ReplyDiary(year, month, day, replyStatus), navOptions)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/navigation/ReplyDiaryNavigator.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/navigation/ReplyDiaryNavigator.kt
deleted file mode 100644
index 419dd1af..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/replydiary/navigation/ReplyDiaryNavigator.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.sopt.clody.presentation.ui.replydiary.navigation
-
-import androidx.navigation.NavHostController
-
-class ReplyDiaryNavigator(
- val navController: NavHostController,
-) {
- fun navigateHome(selectedYear: Int, selectedMonth: Int) {
- navController.navigate("home/$selectedYear/$selectedMonth") {
- popUpTo(navController.graph.startDestinationId) {
- inclusive = true
- }
- }
- }
-
- fun navigateBack() {
- navController.navigateUp()
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/component/QuickReplyAdButton.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/component/QuickReplyAdButton.kt
index 3507cd0e..4db6005c 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/component/QuickReplyAdButton.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/component/QuickReplyAdButton.kt
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sopt.clody.R
@@ -40,7 +41,7 @@ fun QuickReplyAdButton(
tint = Color.Unspecified,
)
Text(
- text = "광고 보고 바로 답장 받기",
+ text = stringResource(R.string.reply_loading_btn_ad_request),
style = ClodyTheme.typography.body4Medium,
color = ClodyTheme.colors.blue,
modifier = Modifier.padding(start = 5.dp),
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/navigation/ReplyLoadingNavGraph.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/navigation/ReplyLoadingNavGraph.kt
deleted file mode 100644
index 243d1d6e..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/navigation/ReplyLoadingNavGraph.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-package com.sopt.clody.presentation.ui.replyloading.navigation
-
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavType
-import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
-import com.sopt.clody.presentation.ui.replydiary.ReplyDiaryRoute
-import com.sopt.clody.presentation.ui.replydiary.navigation.ReplyDiaryNavigator
-import com.sopt.clody.presentation.ui.replyloading.screen.ReplyLoadingRoute
-import java.time.LocalDate
-
-fun NavGraphBuilder.replyLoadingNavGraph(
- replyLoadingNavigator: ReplyLoadingNavigator,
- replyDiaryNavigator: ReplyDiaryNavigator,
-) {
- val currentDate = LocalDate.now()
- composable(
- "reply_loading/{year}/{month}/{day}?from={from}&replyStatus={replyStatus}",
- arguments = listOf(
- navArgument("year") { type = NavType.IntType },
- navArgument("month") { type = NavType.IntType },
- navArgument("day") { type = NavType.IntType },
- navArgument("from") { defaultValue = "home" },
- navArgument("replyStatus") { defaultValue = "UNREADY" },
- ),
- ) { backStackEntry ->
- val year = backStackEntry.arguments?.getInt("year") ?: currentDate.year
- val month = backStackEntry.arguments?.getInt("month") ?: currentDate.monthValue
- val day = backStackEntry.arguments?.getInt("day") ?: currentDate.dayOfMonth
- val from = backStackEntry.arguments?.getString("from") ?: "home"
- val replyStatus = backStackEntry.arguments?.getString("replyStatus") ?: "UNREADY"
- ReplyLoadingRoute(replyLoadingNavigator, year, month, day, from, replyStatus)
- }
- composable(
- "reply_diary/{year}/{month}/{day}?replyStatus={replyStatus}",
- arguments = listOf(
- navArgument("year") { type = NavType.IntType },
- navArgument("month") { type = NavType.IntType },
- navArgument("day") { type = NavType.IntType },
- navArgument("replyStatus") { defaultValue = "UNREADY" },
- ),
- ) { backStackEntry ->
- val year = backStackEntry.arguments?.getInt("year") ?: currentDate.year
- val month = backStackEntry.arguments?.getInt("month") ?: currentDate.monthValue
- val day = backStackEntry.arguments?.getInt("day") ?: currentDate.dayOfMonth
- val replyStatus = backStackEntry.arguments?.getString("replyStatus") ?: "UNREADY"
- ReplyDiaryRoute(replyDiaryNavigator, year, month, day, replyStatus)
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/navigation/ReplyLoadingNavigation.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/navigation/ReplyLoadingNavigation.kt
new file mode 100644
index 00000000..be1af5f9
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/navigation/ReplyLoadingNavigation.kt
@@ -0,0 +1,45 @@
+package com.sopt.clody.presentation.ui.replyloading.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptionsBuilder
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import com.sopt.clody.domain.model.ReplyStatus
+import com.sopt.clody.presentation.ui.replyloading.screen.ReplyLoadingRoute
+import com.sopt.clody.presentation.utils.navigation.Route
+
+fun NavGraphBuilder.replyLoadingScreen(
+ navigateToReplyDiary: (year: Int, month: Int, day: Int, status: ReplyStatus) -> Unit,
+ navigateToHome: (year: Int, month: Int, day: Int) -> Unit,
+ navigateToDiaryList: (year: Int, month: Int) -> Unit,
+) {
+ composable { backStackEntry ->
+ backStackEntry.toRoute().apply {
+ ReplyLoadingRoute(
+ year = year,
+ month = month,
+ date = date,
+ from = from,
+ replyStatus = replyStatus,
+ navigateToReplyDiary = navigateToReplyDiary,
+ navigateToHome = navigateToHome,
+ navigateToDiaryList = navigateToDiaryList,
+ )
+ }
+ }
+}
+
+fun NavController.navigateToReplyLoading(
+ year: Int,
+ month: Int,
+ day: Int,
+ from: Route.ReplyLoading.ReplyLoadingFrom = Route.ReplyLoading.ReplyLoadingFrom.HOME,
+ replyStatus: ReplyStatus = ReplyStatus.UNREADY,
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) {
+ navigate(
+ Route.ReplyLoading(year, month, day, from, replyStatus),
+ navOptions,
+ )
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/navigation/ReplyLoadingNavigator.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/navigation/ReplyLoadingNavigator.kt
deleted file mode 100644
index 1653ca08..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/navigation/ReplyLoadingNavigator.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.sopt.clody.presentation.ui.replyloading.navigation
-
-import androidx.navigation.NavHostController
-
-class ReplyLoadingNavigator(
- private val navController: NavHostController,
-) {
- fun navigateHome(selectedYear: Int, selectedMonth: Int) {
- navController.navigate("home/$selectedYear/$selectedMonth") {
- popUpTo(navController.graph.startDestinationId) {
- inclusive = true
- }
- }
- }
-
- fun navigateReplyDiary(year: Int, month: Int, day: Int, replyStatus: String) {
- navController.navigate("reply_diary/$year/$month/$day?replyStatus=$replyStatus")
- }
-
- private fun navigateWithPopUp(route: String, inclusive: Boolean = false) {
- navController.navigate(route) {
- popUpTo(navController.graph.startDestinationId) {
- this.inclusive = inclusive
- }
- }
- }
-
- fun navigateBack(selectedYear: Int, selectedMonth: Int, from: String) {
- when (from) {
- "diary_list" -> {
- navigateWithPopUp("diary_list/$selectedYear/$selectedMonth")
- }
-
- "home" -> {
- navigateWithPopUp("home/$selectedYear/$selectedMonth")
- }
-
- "write_diary" -> {
- navigateWithPopUp("home/$selectedYear/$selectedMonth") // 예외적으로 홈으로
- }
-
- else -> {
- navController.navigateUp()
- }
- }
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingScreen.kt
index 400dfb9c..5b931081 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingScreen.kt
@@ -37,28 +37,31 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.airbnb.lottie.compose.LottieConstants
import com.sopt.clody.R
+import com.sopt.clody.domain.model.ReplyStatus
import com.sopt.clody.presentation.ui.component.FailureScreen
import com.sopt.clody.presentation.ui.component.LoadingScreen
import com.sopt.clody.presentation.ui.component.button.ClodyButton
import com.sopt.clody.presentation.ui.component.toast.ClodyToastMessage
import com.sopt.clody.presentation.ui.replyloading.component.LottieAnimation
import com.sopt.clody.presentation.ui.replyloading.component.QuickReplyAdButton
-import com.sopt.clody.presentation.ui.replyloading.navigation.ReplyLoadingNavigator
import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage
+import com.sopt.clody.presentation.utils.navigation.Route
import com.sopt.clody.ui.theme.ClodyTheme
import kotlinx.coroutines.delay
import java.time.LocalDateTime
@Composable
fun ReplyLoadingRoute(
- navigator: ReplyLoadingNavigator,
year: Int,
month: Int,
- day: Int,
- from: String,
- replyStatus: String,
+ date: Int,
+ from: Route.ReplyLoading.ReplyLoadingFrom,
+ replyStatus: ReplyStatus,
+ navigateToReplyDiary: (year: Int, month: Int, day: Int, status: ReplyStatus) -> Unit,
+ navigateToHome: (year: Int, month: Int, day: Int) -> Unit,
+ navigateToDiaryList: (year: Int, month: Int) -> Unit,
viewModel: ReplyLoadingViewModel = hiltViewModel(),
) {
val replyLoadingState by viewModel.replyLoadingState.collectAsState()
@@ -71,16 +74,24 @@ fun ReplyLoadingRoute(
LaunchedEffect(Unit) {
AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.WAITING_DIARY)
- viewModel.getDiaryTime(year, month, day)
+ viewModel.getDiaryTime(year, month, date)
}
var backPressedTime by remember { mutableStateOf(0L) }
val backPressThreshold = 2000
+ val handleBackNavigation = {
+ when (from) {
+ Route.ReplyLoading.ReplyLoadingFrom.HOME -> navigateToHome(year, month, date)
+
+ Route.ReplyLoading.ReplyLoadingFrom.DIARY_LIST -> navigateToDiaryList(year, month)
+ }
+ }
+
BackHandler {
val currentTime = System.currentTimeMillis()
if (currentTime - backPressedTime <= backPressThreshold) {
- navigator.navigateHome(year, month)
+ handleBackNavigation()
} else {
backPressedTime = currentTime
}
@@ -94,8 +105,10 @@ fun ReplyLoadingRoute(
is ReplyLoadingState.Success -> {
val successState = replyLoadingState as ReplyLoadingState.Success
ReplyLoadingScreen(
- onCompleteClick = { navigator.navigateReplyDiary(year, month, day, replyStatus) },
- onBackClick = { navigator.navigateBack(year, month, from) },
+ onCompleteClick = {
+ navigateToReplyDiary(year, month, date, replyStatus)
+ },
+ onBackClick = { handleBackNavigation() },
replyLoadingState = successState,
onShowAdClick = {
viewModel.loadAndShowRewardedAd(activity)
@@ -166,9 +179,9 @@ fun ReplyLoadingScreen(
val minutes = ((remainingTime % 3600) / 60).toInt()
val seconds = (remainingTime % 60).toInt()
- val loadingMessage = stringResource(id = R.string.loading_message)
- val nearlyDoneMessage = stringResource(id = R.string.loading_nearly_done_message)
- val completeMessage = stringResource(id = R.string.loading_complete_message)
+ val loadingMessage = stringResource(id = R.string.reply_loading_initial_description)
+ val nearlyDoneMessage = stringResource(id = R.string.reply_loading_after_ad_description)
+ val completeMessage = stringResource(id = R.string.reply_loading_complete_description)
// 메시지 분기
val textToShow = when {
@@ -204,7 +217,7 @@ fun ReplyLoadingScreen(
if (isComplete) AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.WAITING_DIARY_REPLY)
onCompleteClick()
},
- text = stringResource(R.string.loading_button_open),
+ text = stringResource(R.string.reply_loading_btn_open),
enabled = isComplete,
)
},
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingViewModel.kt
index e885e820..b66d127e 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingViewModel.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/replyloading/screen/ReplyLoadingViewModel.kt
@@ -3,19 +3,20 @@ package com.sopt.clody.presentation.ui.replyloading.screen
import android.app.Activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.sopt.clody.core.RewardAdShower
+import com.sopt.clody.core.ad.RewardAdShower
+import com.sopt.clody.core.network.NetworkConnectivityObserver
+import com.sopt.clody.core.network.NetworkStatus
import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto
-import com.sopt.clody.data.remote.util.NetworkUtil
+import com.sopt.clody.data.remote.util.ApiError
import com.sopt.clody.domain.repository.AdRepository
import com.sopt.clody.domain.repository.DiaryRepository
import com.sopt.clody.presentation.utils.extension.throttleFirst
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_NETWORK_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.UNKNOWN_ERROR
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -27,8 +28,9 @@ import javax.inject.Inject
class ReplyLoadingViewModel @Inject constructor(
private val diaryRepository: DiaryRepository,
private val adRepository: AdRepository,
- private val networkUtil: NetworkUtil,
private val rewardAdShower: RewardAdShower,
+ private val errorMessageProvider: ErrorMessageProvider,
+ private val networkConnectivityObserver: NetworkConnectivityObserver,
) : ViewModel() {
private val _replyLoadingState = MutableStateFlow(ReplyLoadingState.Idle)
@@ -65,9 +67,7 @@ class ReplyLoadingViewModel @Inject constructor(
private fun setupRetryFlow() {
_retryFlow
.throttleFirst(2000L)
- .onEach {
- getDiaryTimeInternal(lastYear, lastMonth, lastDate)
- }
+ .onEach { getDiaryTimeInternal(lastYear, lastMonth, lastDate) }
.launchIn(viewModelScope)
}
@@ -82,8 +82,9 @@ class ReplyLoadingViewModel @Inject constructor(
_replyLoadingState.value = ReplyLoadingState.Loading
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _replyLoadingState.value = ReplyLoadingState.Failure(FAILURE_NETWORK_MESSAGE)
+ val isConnected = networkConnectivityObserver.networkStatus.first() == NetworkStatus.Available
+ if (!isConnected) {
+ _replyLoadingState.value = ReplyLoadingState.Failure(errorMessageProvider.getNetworkError())
return@launch
}
@@ -95,15 +96,9 @@ class ReplyLoadingViewModel @Inject constructor(
private fun handleResult(result: Result) {
result.fold(
onSuccess = { data ->
- val diaryWrittenDay = data.date.split("-")
- var targetDateTime = LocalDateTime.of(
- diaryWrittenDay[0].toInt(),
- diaryWrittenDay[1].toInt(),
- diaryWrittenDay[2].toInt(),
- data.HH,
- data.mm,
- data.ss,
- ).plusMinutes(if (data.isFirst) INITIAL_REMINDER_MINUTES else REGULAR_REMINDER_HOURS * 60)
+ val (y, m, d) = data.date.split("-").map { it.toInt() }
+ var targetDateTime = LocalDateTime.of(y, m, d, data.HH, data.mm, data.ss)
+ .plusMinutes(if (data.isFirst) INITIAL_REMINDER_MINUTES else REGULAR_REMINDER_HOURS * 60)
if (_isAdCompleted.value || data.isFromAd) {
targetDateTime = LocalDateTime.now()
@@ -114,9 +109,13 @@ class ReplyLoadingViewModel @Inject constructor(
_isWaitingForPatchResponse.value = false
},
onFailure = { throwable ->
- _replyLoadingState.value = ReplyLoadingState.Failure(FAILURE_TEMPORARY_MESSAGE)
- val errorMessage = throwable.localizedMessage ?: UNKNOWN_ERROR
- Timber.tag("ReplyLoadingViewModel").e("API 요청 실패: %s", errorMessage)
+ val message = if (throwable is ApiError) {
+ errorMessageProvider.getApiError(throwable)
+ } else {
+ errorMessageProvider.getTemporaryError()
+ }
+ _replyLoadingState.value = ReplyLoadingState.Failure(message)
+ Timber.tag("ReplyLoadingViewModel").e(throwable, "DiaryTime API 요청 실패")
},
)
}
@@ -138,7 +137,7 @@ class ReplyLoadingViewModel @Inject constructor(
val startAdResult = adRepository.startAd(lastYear, lastMonth, lastDate)
if (startAdResult.isFailure) {
_isAdLoading.value = false
- _adErrorMessage.value = "잠시 후 다시 시도해주세요!"
+ _adErrorMessage.value = errorMessageProvider.getTemporaryError()
return@launch
}
@@ -152,7 +151,7 @@ class ReplyLoadingViewModel @Inject constructor(
_isAdPreloaded = true
showRewardedAd(activity)
} else {
- _adErrorMessage.value = "잠시 후 다시 시도해주세요!"
+ _adErrorMessage.value = errorMessageProvider.getTemporaryError()
}
}
}
@@ -164,7 +163,6 @@ class ReplyLoadingViewModel @Inject constructor(
onAdRewarded = {
viewModelScope.launch {
_isWaitingForPatchResponse.value = true
-
adRepository.endAd(lastYear, lastMonth, lastDate).onSuccess {
_replyLoadingState.value = ReplyLoadingState.Success(LocalDateTime.now())
_isWaitingForPatchResponse.value = false
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementLogoutOption.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementLogoutOption.kt
index 61281c6e..f171c547 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementLogoutOption.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementLogoutOption.kt
@@ -21,16 +21,21 @@ import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun AccountManagementLogoutOption(
userEmail: String,
+ platform: String,
updateLogoutDialog: (Boolean) -> Unit,
) {
+ val platformIconRes = when (platform) {
+ "kakao" -> R.drawable.img_account_management_kakao
+ "google" -> R.drawable.img_google_button_logo
+ else -> R.drawable.img_google_button_logo // 서버에서 google을 어떻게 내려줄까요?
+ }
+
Row(
- modifier = Modifier
- .padding(top = 12.dp, bottom = 24.dp, start = 22.dp, end = 24.dp),
+ modifier = Modifier.padding(top = 12.dp, bottom = 24.dp, start = 22.dp, end = 24.dp),
) {
Image(
- painter = painterResource(id = R.drawable.img_account_management_kakao),
- modifier = Modifier
- .size(24.dp),
+ painter = painterResource(id = platformIconRes),
+ modifier = Modifier.size(24.dp),
contentDescription = null,
)
Spacer(modifier = Modifier.width(10.dp))
@@ -41,7 +46,7 @@ fun AccountManagementLogoutOption(
)
Spacer(modifier = Modifier.weight(1f))
Text(
- text = stringResource(R.string.account_management_logout_button),
+ text = stringResource(R.string.account_management_btn_logout),
modifier = Modifier.clickable(
onClick = { updateLogoutDialog(true) },
indication = null,
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementNicknameOption.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementNicknameOption.kt
index c1bd0e37..cbc43285 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementNicknameOption.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementNicknameOption.kt
@@ -43,7 +43,7 @@ fun AccountManagementNicknameOption(
)
Spacer(modifier = Modifier.weight(1f))
Text(
- text = stringResource(R.string.account_management_nickname_change_button),
+ text = stringResource(R.string.account_management_btn_change_nickname),
modifier = Modifier.clickable(
onClick = { updateNicknameChangeBottomSheet(true) },
indication = null,
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementRevokeOption.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementRevokeOption.kt
index 95bad3d2..4f3a8e98 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementRevokeOption.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/AccountManagementRevokeOption.kt
@@ -28,7 +28,7 @@ fun AccountManagementRevokeOption(
)
Spacer(modifier = Modifier.weight(1f))
Text(
- text = stringResource(R.string.account_management_revoke_button),
+ text = stringResource(R.string.account_management_btn_revoke),
modifier = Modifier.clickable(
onClick = { updateRevokeDialog(true) },
indication = null,
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/NicknameChangeBottomSheet.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/NicknameChangeBottomSheet.kt
index 1caf228f..8488904a 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/NicknameChangeBottomSheet.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/NicknameChangeBottomSheet.kt
@@ -15,7 +15,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -28,6 +27,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.sopt.clody.R
import com.sopt.clody.data.remote.dto.request.ModifyNicknameRequestDto
+import com.sopt.clody.presentation.ui.auth.signup.NicknameMessage
import com.sopt.clody.presentation.ui.component.bottomsheet.ClodyBottomSheet
import com.sopt.clody.presentation.ui.component.button.ClodyButton
import com.sopt.clody.presentation.ui.setting.screen.AccountManagementViewModel
@@ -38,7 +38,8 @@ fun NicknameChangeBottomSheet(
accountManagementViewModel: AccountManagementViewModel,
userName: String,
isValidNickname: Boolean,
- nicknameMessage: String,
+ nicknameMaxLength: Int,
+ nicknameMessage: NicknameMessage,
onDismiss: () -> Unit,
) {
ClodyBottomSheet(
@@ -47,6 +48,7 @@ fun NicknameChangeBottomSheet(
accountManagementViewModel = accountManagementViewModel,
userName = userName,
isValidNickname = isValidNickname,
+ nicknameMaxLength = nicknameMaxLength,
nicknameMessage = nicknameMessage,
onDismiss = onDismiss,
)
@@ -60,14 +62,13 @@ fun NicknameChangeBottomSheetItem(
accountManagementViewModel: AccountManagementViewModel,
userName: String,
isValidNickname: Boolean,
- nicknameMessage: String,
+ nicknameMaxLength: Int,
+ nicknameMessage: NicknameMessage,
onDismiss: () -> Unit,
) {
var nickname by remember { mutableStateOf(TextFieldValue("")) }
var nicknameChangeState by remember { mutableStateOf(false) }
var isFocusedState by remember { mutableStateOf(false) }
- val userNicknameState by accountManagementViewModel.userNicknameState.collectAsState()
- val nicknameMaxLength = 10
Surface {
Column(
@@ -84,7 +85,7 @@ fun NicknameChangeBottomSheetItem(
.padding(top = 8.dp),
) {
Text(
- text = stringResource(R.string.account_management_nickname_change_title),
+ text = stringResource(R.string.bottom_sheet_nickname_change_title),
modifier = Modifier.align(Alignment.Center),
color = ClodyTheme.colors.gray01,
style = ClodyTheme.typography.body2SemiBold,
@@ -112,6 +113,7 @@ fun NicknameChangeBottomSheetItem(
accountManagementViewModel.validateNickname(nickname.text)
nicknameChangeState = it.text.isNotEmpty()
},
+ nicknameMaxLength = nicknameMaxLength,
isFocused = isFocusedState,
isValid = isValidNickname,
onRemove = {
@@ -133,7 +135,7 @@ fun NicknameChangeBottomSheetItem(
.padding(horizontal = 24.dp),
) {
Text(
- text = nicknameMessage,
+ text = nicknameMessage.getMessage(),
color = when {
nickname.text.isEmpty() -> ClodyTheme.colors.gray04
isValidNickname -> ClodyTheme.colors.gray04
@@ -163,7 +165,7 @@ fun NicknameChangeBottomSheetItem(
accountManagementViewModel.changeNickname(ModifyNicknameRequestDto(name = nickname.text))
onDismiss()
},
- text = stringResource(R.string.account_management_nickname_change_confirm),
+ text = stringResource(R.string.bottom_sheet_nickname_change_confirm),
enabled = nicknameChangeState && isValidNickname,
modifier = Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/NicknameChangeTextField.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/NicknameChangeTextField.kt
index 9e36c7a0..177041c4 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/NicknameChangeTextField.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/NicknameChangeTextField.kt
@@ -31,6 +31,7 @@ import com.sopt.clody.ui.theme.ClodyTheme
fun NickNameChangeTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
+ nicknameMaxLength: Int,
isFocused: Boolean,
isValid: Boolean,
onRemove: () -> Unit,
@@ -38,8 +39,6 @@ fun NickNameChangeTextField(
modifier: Modifier = Modifier,
hint: String = "",
) {
- val nicknameMaxLength = 10
-
Box(modifier = modifier) {
BasicTextField(
value = value,
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/SettingVersionInfo.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/SettingVersionInfo.kt
index fcb068ea..84649dd9 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/SettingVersionInfo.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/component/SettingVersionInfo.kt
@@ -10,7 +10,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import com.sopt.clody.R
import com.sopt.clody.ui.theme.ClodyTheme
@@ -34,9 +33,7 @@ fun SettingVersionInfo(
Text(
text = versionInfo,
color = ClodyTheme.colors.gray05,
- style = ClodyTheme.typography.body4Medium.copy(
- letterSpacing = 2.sp,
- ),
+ style = ClodyTheme.typography.body4Medium,
)
}
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/navigation/SettingNavGraph.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/navigation/SettingNavGraph.kt
deleted file mode 100644
index 471df2f5..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/navigation/SettingNavGraph.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.sopt.clody.presentation.ui.setting.navigation
-
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavType
-import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
-import com.sopt.clody.presentation.ui.setting.notificationsetting.screen.NotificationSettingRoute
-import com.sopt.clody.presentation.ui.setting.screen.AccountManagementRoute
-import com.sopt.clody.presentation.ui.setting.screen.SettingRoute
-import com.sopt.clody.presentation.ui.setting.screen.WebViewRoute
-
-fun NavGraphBuilder.settingNavGraph(
- navigator: SettingNavigator,
-) {
- composable("setting") {
- SettingRoute(navigator)
- }
-}
-
-fun NavGraphBuilder.accountManagementNavGraph(
- navigator: SettingNavigator,
-) {
- composable("account_management") {
- AccountManagementRoute(navigator)
- }
-}
-
-fun NavGraphBuilder.notificationSettingNavGraph(
- navigator: SettingNavigator,
-) {
- composable("notification_setting") {
- NotificationSettingRoute(navigator)
- }
-}
-
-fun NavGraphBuilder.webViewNavGraph(
- navigator: SettingNavigator,
-) {
- composable(
- route = "web_view/{encodedUrl}",
- arguments = listOf(navArgument("encodedUrl") { type = NavType.StringType }),
- ) { backStackEntry ->
- val encodedUrl = backStackEntry.arguments?.getString("encodedUrl")
- encodedUrl?.let {
- WebViewRoute(navigator, it)
- }
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/navigation/SettingNavigation.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/navigation/SettingNavigation.kt
new file mode 100644
index 00000000..b2fc48f4
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/navigation/SettingNavigation.kt
@@ -0,0 +1,62 @@
+package com.sopt.clody.presentation.ui.setting.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptionsBuilder
+import androidx.navigation.compose.composable
+import com.sopt.clody.presentation.ui.setting.notificationsetting.screen.NotificationSettingRoute
+import com.sopt.clody.presentation.ui.setting.screen.AccountManagementRoute
+import com.sopt.clody.presentation.ui.setting.screen.SettingRoute
+import com.sopt.clody.presentation.utils.navigation.Route
+
+fun NavGraphBuilder.settingScreen(
+ navigateToAccountManagement: () -> Unit,
+ navigateToNotification: () -> Unit,
+ navigateToPrevious: () -> Unit,
+) {
+ composable {
+ SettingRoute(
+ navigateToAccountManagement = navigateToAccountManagement,
+ navigateToNotification = navigateToNotification,
+ navigateToPrevious = navigateToPrevious,
+ )
+ }
+}
+
+fun NavGraphBuilder.accountManagementScreen(
+ navigateToPrevious: () -> Unit,
+ navigateToLogin: () -> Unit,
+) {
+ composable {
+ AccountManagementRoute(
+ navigateToPrevious = navigateToPrevious,
+ navigateToLogin = navigateToLogin,
+ )
+ }
+}
+
+fun NavGraphBuilder.notificationSettingScreen(
+ navigateToPrevious: () -> Unit,
+) {
+ composable {
+ NotificationSettingRoute(navigateToPrevious = navigateToPrevious)
+ }
+}
+
+fun NavController.navigateToSetting(
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) {
+ navigate(Route.Setting, navOptions)
+}
+
+fun NavController.navigateToAccountManagement(
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) {
+ navigate(Route.AccountManagement, navOptions)
+}
+
+fun NavController.navigateToNotificationSetting(
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) {
+ navigate(Route.NotificationSetting, navOptions)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/navigation/SettingNavigator.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/navigation/SettingNavigator.kt
deleted file mode 100644
index 4a317454..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/navigation/SettingNavigator.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.sopt.clody.presentation.ui.setting.navigation
-
-import androidx.navigation.NavHostController
-import java.net.URLEncoder
-
-class SettingNavigator(
- val navController: NavHostController,
-) {
- fun navigateAccountManagement() {
- navController.navigate("account_management")
- }
-
- fun navigateNotificationSetting() {
- navController.navigate("notification_setting")
- }
-
- fun navigateWebView(url: String) {
- val encodedUrl = URLEncoder.encode(url, "UTF-8")
- navController.navigate("web_view/$encodedUrl")
- }
-
- fun navigateBack() {
- if (navController.currentBackStackEntry?.lifecycle?.currentState == androidx.lifecycle.Lifecycle.State.RESUMED) {
- navController.popBackStack()
- }
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/DiaryAlarmSwitch.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/DiaryAlarmSwitch.kt
deleted file mode 100644
index 3c6705b5..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/DiaryAlarmSwitch.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package com.sopt.clody.presentation.ui.setting.notificationsetting.component
-
-import android.content.Context
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Switch
-import androidx.compose.material3.SwitchDefaults
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.scale
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.unit.dp
-import com.sopt.clody.R
-import com.sopt.clody.data.remote.dto.response.NotificationInfoResponseDto
-import com.sopt.clody.presentation.ui.setting.notificationsetting.screen.NotificationSettingViewModel
-import com.sopt.clody.ui.theme.ClodyTheme
-
-@Composable
-fun DiaryAlarmSwitch(
- notificationSettingViewModel: NotificationSettingViewModel,
- context: Context,
- title: String,
- notificationInfo: NotificationInfoResponseDto,
- checkedState: MutableState,
-) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- text = title,
- style = ClodyTheme.typography.body1Medium,
- color = ClodyTheme.colors.gray03,
- )
- Spacer(modifier = Modifier.weight(1f))
- Switch(
- checked = checkedState.value,
- onCheckedChange = {
- checkedState.value = it
- notificationSettingViewModel.changeDiaryAlarm(context, notificationInfo, checkedState.value)
- },
- modifier = Modifier.scale(0.8f),
- thumbContent = {
- Image(
- painter = painterResource(id = R.drawable.ic_notification_setting_switch_thumb),
- contentDescription = null,
- )
- },
- colors = SwitchDefaults.colors(
- checkedThumbColor = ClodyTheme.colors.white,
- checkedTrackColor = ClodyTheme.colors.mainYellow,
- uncheckedThumbColor = ClodyTheme.colors.white,
- uncheckedTrackColor = ClodyTheme.colors.gray06,
- uncheckedBorderColor = ClodyTheme.colors.gray06,
- ),
- )
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSettingTimePicker.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSettingTimePicker.kt
index f168b727..23c3ecde 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSettingTimePicker.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSettingTimePicker.kt
@@ -28,16 +28,16 @@ import com.sopt.clody.R
import com.sopt.clody.presentation.ui.component.button.ClodyButton
import com.sopt.clody.presentation.ui.component.timepicker.ClodyPicker
import com.sopt.clody.presentation.ui.component.timepicker.rememberPickerState
-import com.sopt.clody.presentation.ui.setting.notificationsetting.screen.NotificationSettingViewModel
+import com.sopt.clody.presentation.utils.extension.TimePeriod
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun NotificationSettingTimePicker(
- notificationSettingViewModel: NotificationSettingViewModel,
- onTimeSelected: (String) -> Unit,
onDismissRequest: () -> Unit,
+ onConfirm: (TimePeriod, String, String) -> Unit,
) {
- val amPmItems = remember { listOf("오전", "오후") }
+ val amPmItemsLabel = TimePeriod.entries.map { it.getLabel() }
+ val amPmItems = remember { amPmItemsLabel }
val hourItems = remember { (1..12).map { it.toString() } }
val minuteItems = remember { listOf("00", "10", "20", "30", "40", "50") }
@@ -67,7 +67,7 @@ fun NotificationSettingTimePicker(
.padding(top = 16.dp, bottom = 30.dp),
) {
Text(
- stringResource(id = R.string.time_picker_title),
+ text = stringResource(R.string.bottom_sheet_notification_time_change_title),
style = ClodyTheme.typography.head4,
color = ClodyTheme.colors.gray01,
modifier = Modifier.align(Alignment.Center),
@@ -138,14 +138,10 @@ fun NotificationSettingTimePicker(
}
ClodyButton(
onClick = {
- val selectedTime = notificationSettingViewModel.convertTo24HourFormat(
- amPmPickerState.selectedItem,
- hourPickerState.selectedItem,
- minutePickerState.selectedItem,
- )
- onTimeSelected(selectedTime)
+ val selectedPeriod = if (amPmPickerState.selectedItem == "오전") TimePeriod.AM else TimePeriod.PM
+ onConfirm(selectedPeriod, hourPickerState.selectedItem, minutePickerState.selectedItem)
},
- text = stringResource(R.string.notification_setting_timepicker_confirm),
+ text = stringResource(R.string.bottom_sheet_notification_time_change_confirm),
enabled = true,
modifier = Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/ReplyAlarmSwitch.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSwitch.kt
similarity index 61%
rename from app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/ReplyAlarmSwitch.kt
rename to app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSwitch.kt
index c8f18fd1..3464b406 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/ReplyAlarmSwitch.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSwitch.kt
@@ -1,53 +1,43 @@
package com.sopt.clody.presentation.ui.setting.notificationsetting.component
-import android.content.Context
+import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.unit.dp
+import androidx.compose.ui.res.stringResource
import com.sopt.clody.R
-import com.sopt.clody.data.remote.dto.response.NotificationInfoResponseDto
-import com.sopt.clody.presentation.ui.setting.notificationsetting.screen.NotificationSettingViewModel
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
-fun ReplyAlarmSwitch(
- notificationSettingViewModel: NotificationSettingViewModel,
- context: Context,
- title: String,
- notificationInfo: NotificationInfoResponseDto,
- checkedState: MutableState,
+fun NotificationSwitch(
+ @StringRes title: Int,
+ checkedState: Boolean,
+ onCheckedChanged: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
) {
Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp),
+ modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
- text = title,
+ text = stringResource(title),
style = ClodyTheme.typography.body1Medium,
color = ClodyTheme.colors.gray03,
)
Spacer(modifier = Modifier.weight(1f))
Switch(
- checked = checkedState.value,
- onCheckedChange = {
- checkedState.value = it
- notificationSettingViewModel.changeReplyAlarm(context, notificationInfo, checkedState.value)
- },
- modifier = Modifier.scale(0.8f),
+ checked = checkedState,
+ onCheckedChange = onCheckedChanged,
+ modifier = Modifier.scale(0.7f),
thumbContent = {
Image(
painter = painterResource(id = R.drawable.ic_notification_setting_switch_thumb),
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSettingTime.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationTimeSelector.kt
similarity index 77%
rename from app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSettingTime.kt
rename to app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationTimeSelector.kt
index bab0901b..4bf314b4 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationSettingTime.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/component/NotificationTimeSelector.kt
@@ -6,8 +6,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -15,20 +13,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
import com.sopt.clody.R
+import com.sopt.clody.presentation.utils.extension.convertKSTtoUTZ
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
-fun NotificationSettingTime(
- selectedTime: String,
- updateNotificationTimePicker: (Boolean) -> Unit,
+fun NotificationTimeSelector(
+ time: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
) {
+ val timeUST = convertKSTtoUTZ(time)
+
Row(
- modifier = Modifier
- .fillMaxWidth()
- .wrapContentHeight()
- .padding(horizontal = 24.dp),
+ modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
@@ -39,14 +37,14 @@ fun NotificationSettingTime(
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier.clickable(
- onClick = { updateNotificationTimePicker(true) },
+ onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() },
),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
- text = selectedTime,
+ text = stringResource(R.string.notification_setting_selected_time, timeUST.first.getLabel(), timeUST.second, timeUST.third),
style = ClodyTheme.typography.body3Medium,
color = ClodyTheme.colors.gray05,
)
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/DiaryAlarmChangeState.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/DiaryAlarmChangeState.kt
deleted file mode 100644
index 7bb52cd4..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/DiaryAlarmChangeState.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.sopt.clody.presentation.ui.setting.notificationsetting.screen
-
-import com.sopt.clody.data.remote.dto.response.SendNotificationResponseDto
-
-sealed class DiaryAlarmChangeState {
- data object Idle : DiaryAlarmChangeState()
- data object Loading : DiaryAlarmChangeState()
- data class Success(val data: SendNotificationResponseDto) : DiaryAlarmChangeState()
- data class Failure(val errorMessage: String) : DiaryAlarmChangeState()
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/NotificationChangeState.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/NotificationChangeState.kt
new file mode 100644
index 00000000..ab19b075
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/NotificationChangeState.kt
@@ -0,0 +1,10 @@
+package com.sopt.clody.presentation.ui.setting.notificationsetting.screen
+
+import com.sopt.clody.data.remote.dto.response.SendNotificationResponseDto
+
+sealed class NotificationChangeState {
+ data object Idle : NotificationChangeState()
+ data object Loading : NotificationChangeState()
+ data class Success(val data: SendNotificationResponseDto) : NotificationChangeState()
+ data class Failure(val errorMessage: String) : NotificationChangeState()
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/NotificationSettingScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/NotificationSettingScreen.kt
index bf70651b..ce2542af 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/NotificationSettingScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/NotificationSettingScreen.kt
@@ -1,15 +1,14 @@
package com.sopt.clody.presentation.ui.setting.notificationsetting.screen
-import android.content.Context
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -23,145 +22,59 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.sopt.clody.R
import com.sopt.clody.data.remote.dto.response.NotificationInfoResponseDto
+import com.sopt.clody.domain.Notification
import com.sopt.clody.presentation.ui.component.FailureScreen
import com.sopt.clody.presentation.ui.component.LoadingScreen
import com.sopt.clody.presentation.ui.component.dialog.FailureDialog
import com.sopt.clody.presentation.ui.component.popup.ClodyPopupBottomSheet
import com.sopt.clody.presentation.ui.component.toast.ClodyToastMessage
import com.sopt.clody.presentation.ui.setting.component.SettingTopAppBar
-import com.sopt.clody.presentation.ui.setting.navigation.SettingNavigator
-import com.sopt.clody.presentation.ui.setting.notificationsetting.component.DiaryAlarmSwitch
-import com.sopt.clody.presentation.ui.setting.notificationsetting.component.NotificationSettingTime
import com.sopt.clody.presentation.ui.setting.notificationsetting.component.NotificationSettingTimePicker
-import com.sopt.clody.presentation.ui.setting.notificationsetting.component.ReplyAlarmSwitch
+import com.sopt.clody.presentation.ui.setting.notificationsetting.component.NotificationSwitch
+import com.sopt.clody.presentation.ui.setting.notificationsetting.component.NotificationTimeSelector
+import com.sopt.clody.presentation.utils.base.ClodyPreview
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun NotificationSettingRoute(
- navigator: SettingNavigator,
notificationSettingViewModel: NotificationSettingViewModel = hiltViewModel(),
+ navigateToPrevious: () -> Unit,
) {
val context = LocalContext.current
val notificationInfoState by notificationSettingViewModel.notificationInfoState.collectAsState()
- val diaryAlarmChangeState by notificationSettingViewModel.diaryAlarmChangeState.collectAsState()
+ val diaryAlarm by notificationSettingViewModel.diaryAlarm.collectAsState()
+ val draftAlarm by notificationSettingViewModel.draftAlarm.collectAsState()
+ val replyAlarm by notificationSettingViewModel.replyAlarm.collectAsState()
+ val notificationTime by notificationSettingViewModel.notificationTime.collectAsState()
val notificationTimeChangeState by notificationSettingViewModel.notificationTimeChangeState.collectAsState()
- val replyAlarmChangeState by notificationSettingViewModel.replyAlarmChangeState.collectAsState()
val showFailureDialog by notificationSettingViewModel.showFailureDialog.collectAsState()
val failureDialogMessage by notificationSettingViewModel.failureDialogMessage.collectAsState()
var showNotificationTimePicker by remember { mutableStateOf(false) }
- var notificationInfo by remember { mutableStateOf(null) }
-
- LaunchedEffect(Unit) {
- notificationSettingViewModel.getNotificationInfo()
- }
-
- LaunchedEffect(diaryAlarmChangeState, notificationTimeChangeState, replyAlarmChangeState) {
- val isSuccess = diaryAlarmChangeState is DiaryAlarmChangeState.Success ||
- notificationTimeChangeState is NotificationTimeChangeState.Success ||
- replyAlarmChangeState is ReplyAlarmChangeState.Success
-
- if (isSuccess) {
- notificationSettingViewModel.getNotificationInfo()
- }
- }
NotificationSettingScreen(
- notificationSettingViewModel = notificationSettingViewModel,
- context = context,
notificationInfoState = notificationInfoState,
- notificationInfo = notificationInfo,
- notificationTimeChangeState = notificationTimeChangeState,
- showNotificationTimePicker = showNotificationTimePicker,
- updateNotificationTimePicker = { state -> showNotificationTimePicker = state },
- showFailureDialog = showFailureDialog,
- failureDialogMessage = failureDialogMessage,
- onClickBack = { navigator.navigateBack() },
- onNotificationInfoAvailable = { notificationInfo = it },
+ diaryAlarm = diaryAlarm,
+ draftAlarm = draftAlarm,
+ replyAlarm = replyAlarm,
+ notificationTime = notificationTime,
+ onClickBack = navigateToPrevious,
+ onClickDiarySwitch = { notificationSettingViewModel.changeAlarm(context, Notification.DIARY) },
+ onClickDraftSwitch = { notificationSettingViewModel.changeAlarm(context, Notification.DRAFT) },
+ onClickNotificationTime = { showNotificationTimePicker = true },
+ onClickReplySwitch = { notificationSettingViewModel.changeAlarm(context, Notification.REPLY) },
+ onClickRetry = { notificationSettingViewModel.getNotificationInfo() },
)
-}
-
-@Composable
-fun NotificationSettingScreen(
- notificationSettingViewModel: NotificationSettingViewModel,
- context: Context,
- notificationInfoState: NotificationInfoState,
- notificationInfo: NotificationInfoResponseDto?,
- notificationTimeChangeState: NotificationTimeChangeState,
- showNotificationTimePicker: Boolean,
- updateNotificationTimePicker: (Boolean) -> Unit,
- showFailureDialog: Boolean,
- failureDialogMessage: String,
- onClickBack: () -> Unit,
- onNotificationInfoAvailable: (NotificationInfoResponseDto) -> Unit,
-) {
- Scaffold(
- topBar = {
- SettingTopAppBar(
- title = stringResource(R.string.notification_setting_title),
- onClickBack = onClickBack,
- )
- },
- containerColor = ClodyTheme.colors.white,
- ) { innerPadding ->
- when (notificationInfoState) {
- is NotificationInfoState.Idle -> {}
-
- is NotificationInfoState.Loading -> {
- LoadingScreen()
- }
-
- is NotificationInfoState.Success -> {
- val notificationInfo = notificationInfoState.data
- onNotificationInfoAvailable(notificationInfo)
- val notificationTime = notificationSettingViewModel.convertTo12HourFormat(notificationInfo.time)
- Column(
- modifier = Modifier
- .padding(innerPadding),
- ) {
- Spacer(modifier = Modifier.height(20.dp))
- DiaryAlarmSwitch(
- notificationSettingViewModel = notificationSettingViewModel,
- context = context,
- title = stringResource(R.string.notification_setting_write_diary),
- notificationInfo = notificationInfo,
- checkedState = remember { mutableStateOf(notificationInfo.isDiaryAlarm) },
- )
- Spacer(modifier = Modifier.height(32.dp))
- NotificationSettingTime(
- selectedTime = notificationTime,
- updateNotificationTimePicker = updateNotificationTimePicker,
- )
- Spacer(modifier = Modifier.height(32.dp))
- ReplyAlarmSwitch(
- notificationSettingViewModel = notificationSettingViewModel,
- context = context,
- title = stringResource(R.string.notification_setting_reply_diary),
- notificationInfo = notificationInfo,
- checkedState = remember { mutableStateOf(notificationInfo.isReplyAlarm) },
- )
- }
- }
-
- is NotificationInfoState.Failure -> {
- FailureScreen(
- message = notificationInfoState.errorMessage,
- confirmAction = { notificationSettingViewModel.getNotificationInfo() },
- )
- }
- }
- }
if (showNotificationTimePicker) {
- ClodyPopupBottomSheet(onDismissRequest = { updateNotificationTimePicker(false) }) {
+ ClodyPopupBottomSheet(
+ onDismissRequest = { showNotificationTimePicker = false },
+ ) {
NotificationSettingTimePicker(
- notificationSettingViewModel = notificationSettingViewModel,
- onTimeSelected = { newNotificationTime ->
- notificationInfo?.let {
- notificationSettingViewModel.changeNotificationTime(context, it, newNotificationTime)
- }
- updateNotificationTimePicker(false)
+ onDismissRequest = { showNotificationTimePicker = false },
+ onConfirm = { timePeriod, hour, minute ->
+ notificationSettingViewModel.changeNotificationTime(context, timePeriod, hour, minute)
+ showNotificationTimePicker = false
},
- onDismissRequest = { updateNotificationTimePicker(false) },
)
}
}
@@ -174,12 +87,13 @@ fun NotificationSettingScreen(
contentAlignment = Alignment.BottomCenter,
) {
ClodyToastMessage(
- message = stringResource(R.string.notification_setting_change_success_toast),
+ message = stringResource(R.string.toast_notification_setting_time_change),
iconResId = R.drawable.ic_toast_check_on_18,
backgroundColor = ClodyTheme.colors.gray04,
contentColor = ClodyTheme.colors.white,
durationMillis = 3000,
- onDismiss = { notificationSettingViewModel.resetNotificationChangeState() },
+ onDismiss = { notificationSettingViewModel.resetNotificationTimeChangeState() },
+ modifier = Modifier.navigationBarsPadding(),
)
}
}
@@ -191,3 +105,103 @@ fun NotificationSettingScreen(
)
}
}
+
+@Composable
+fun NotificationSettingScreen(
+ notificationInfoState: NotificationInfoState,
+ diaryAlarm: Boolean,
+ draftAlarm: Boolean,
+ replyAlarm: Boolean,
+ notificationTime: String,
+ onClickBack: () -> Unit,
+ onClickDiarySwitch: (Boolean) -> Unit,
+ onClickDraftSwitch: (Boolean) -> Unit,
+ onClickNotificationTime: () -> Unit,
+ onClickReplySwitch: (Boolean) -> Unit,
+ onClickRetry: () -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ SettingTopAppBar(
+ title = stringResource(R.string.notification_setting_title),
+ onClickBack = onClickBack,
+ )
+ },
+ containerColor = ClodyTheme.colors.white,
+ content = { innerPadding ->
+ when (notificationInfoState) {
+ is NotificationInfoState.Idle -> {}
+
+ is NotificationInfoState.Loading -> {
+ LoadingScreen()
+ }
+
+ is NotificationInfoState.Success -> {
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .padding(horizontal = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.height(24.dp))
+ NotificationSwitch(
+ title = R.string.notification_setting_write_diary,
+ checkedState = diaryAlarm,
+ onCheckedChanged = onClickDiarySwitch,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ NotificationSwitch(
+ title = R.string.notification_setting_draft_diary,
+ checkedState = draftAlarm,
+ onCheckedChanged = onClickDraftSwitch,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ NotificationTimeSelector(
+ time = notificationTime,
+ onClick = onClickNotificationTime,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ NotificationSwitch(
+ title = R.string.notification_setting_reply_diary,
+ checkedState = replyAlarm,
+ onCheckedChanged = onClickReplySwitch,
+ )
+ }
+ }
+
+ is NotificationInfoState.Failure -> {
+ FailureScreen(
+ message = notificationInfoState.errorMessage,
+ confirmAction = onClickRetry,
+ )
+ }
+ }
+ },
+ )
+}
+
+@ClodyPreview
+@Composable
+private fun PreviewNotificationSettingScreen() {
+ ClodyTheme {
+ NotificationSettingScreen(
+ notificationInfoState = NotificationInfoState.Success(
+ NotificationInfoResponseDto(
+ isDiaryAlarm = true,
+ isDraftAlarm = false,
+ isReplyAlarm = true,
+ time = "21:30",
+ ),
+ ),
+ diaryAlarm = true,
+ draftAlarm = false,
+ replyAlarm = true,
+ notificationTime = "21:30",
+ onClickBack = {},
+ onClickDiarySwitch = {},
+ onClickDraftSwitch = {},
+ onClickNotificationTime = {},
+ onClickReplySwitch = {},
+ onClickRetry = {},
+ )
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/NotificationSettingViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/NotificationSettingViewModel.kt
index d1f27dd6..dfdc1d8b 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/NotificationSettingViewModel.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/NotificationSettingViewModel.kt
@@ -3,39 +3,49 @@ package com.sopt.clody.presentation.ui.setting.notificationsetting.screen
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.sopt.clody.core.network.NetworkConnectivityObserver
+import com.sopt.clody.core.network.NetworkStatus
import com.sopt.clody.data.remote.dto.request.SendNotificationRequestDto
-import com.sopt.clody.data.remote.dto.response.NotificationInfoResponseDto
-import com.sopt.clody.data.remote.util.NetworkUtil
+import com.sopt.clody.domain.Notification
import com.sopt.clody.domain.repository.NotificationRepository
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_NETWORK_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.UNKNOWN_ERROR
+import com.sopt.clody.presentation.utils.extension.TimePeriod
+import com.sopt.clody.presentation.utils.extension.convertUTZtoKST
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class NotificationSettingViewModel @Inject constructor(
private val notificationRepository: NotificationRepository,
- private val networkUtil: NetworkUtil,
+ private val networkConnectivityObserver: NetworkConnectivityObserver,
+ private val errorMessageProvider: ErrorMessageProvider,
) : ViewModel() {
+ private val _diaryAlarm = MutableStateFlow(false)
+ val diaryAlarm: StateFlow = _diaryAlarm
+
+ private val _draftAlarm = MutableStateFlow(false)
+ val draftAlarm: StateFlow = _draftAlarm
+
+ private val _replyAlarm = MutableStateFlow(false)
+ val replyAlarm: StateFlow = _replyAlarm
+
+ private val _notificationTime = MutableStateFlow("")
+ val notificationTime: StateFlow = _notificationTime
+
private val _notificationInfoState = MutableStateFlow(NotificationInfoState.Idle)
val notificationInfoState: StateFlow = _notificationInfoState
- private val _diaryAlarmChangeState = MutableStateFlow(DiaryAlarmChangeState.Idle)
- val diaryAlarmChangeState: StateFlow = _diaryAlarmChangeState
+ private val _notificationChangeState = MutableStateFlow(NotificationChangeState.Idle)
+ val notificationChangeState: StateFlow = _notificationChangeState
- private val _notificationTimeChangeState = MutableStateFlow(
- NotificationTimeChangeState.Idle,
- )
+ private val _notificationTimeChangeState = MutableStateFlow(NotificationTimeChangeState.Idle)
val notificationTimeChangeState: StateFlow = _notificationTimeChangeState
- private val _replyAlarmChangeState = MutableStateFlow(ReplyAlarmChangeState.Idle)
- val replyAlarmChangeState: StateFlow = _replyAlarmChangeState
-
private val _showFailureDialog = MutableStateFlow(false)
val showFailureDialog: StateFlow = _showFailureDialog
@@ -45,29 +55,37 @@ class NotificationSettingViewModel @Inject constructor(
private val maxRetryCount = 3
private var retryCount = 0
+ init {
+ getNotificationInfo()
+ }
+
fun getNotificationInfo() {
if (retryCount >= maxRetryCount) return
_notificationInfoState.value = NotificationInfoState.Loading
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _notificationInfoState.value = NotificationInfoState.Failure(FAILURE_NETWORK_MESSAGE)
+ if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) {
+ _notificationInfoState.value = NotificationInfoState.Failure(errorMessageProvider.getNetworkError())
return@launch
}
val result = notificationRepository.getNotificationInfo()
_notificationInfoState.value = result.fold(
onSuccess = {
retryCount = 0
+ _diaryAlarm.value = it.isDiaryAlarm
+ _draftAlarm.value = it.isDraftAlarm
+ _replyAlarm.value = it.isReplyAlarm
+ _notificationTime.value = it.time
NotificationInfoState.Success(it)
},
onFailure = {
retryCount++
if (retryCount >= maxRetryCount) {
- NotificationInfoState.Failure(FAILURE_TEMPORARY_MESSAGE)
+ NotificationInfoState.Failure(errorMessageProvider.getTemporaryError())
} else {
if (it.message?.contains("200") == false) {
- NotificationInfoState.Failure(FAILURE_TEMPORARY_MESSAGE)
+ NotificationInfoState.Failure(errorMessageProvider.getTemporaryError())
} else {
- NotificationInfoState.Failure(UNKNOWN_ERROR)
+ NotificationInfoState.Failure(errorMessageProvider.getUnknownError())
}
}
},
@@ -75,45 +93,61 @@ class NotificationSettingViewModel @Inject constructor(
}
}
- fun changeDiaryAlarm(context: Context, notificationInfo: NotificationInfoResponseDto, diaryAlarm: Boolean) {
- _diaryAlarmChangeState.value = DiaryAlarmChangeState.Loading
+ fun changeAlarm(context: Context, notificationType: Notification) {
+ _notificationChangeState.value = NotificationChangeState.Loading
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _failureDialogMessage.value = FAILURE_NETWORK_MESSAGE
+ if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) {
+ _failureDialogMessage.value = errorMessageProvider.getNetworkError()
_showFailureDialog.value = true
return@launch
}
+
val fcmToken = getTokenFromPreferences(context)
if (fcmToken.isNullOrBlank()) {
- _diaryAlarmChangeState.value = DiaryAlarmChangeState.Failure("FCM Token을 가져오는데 실패했습니다.")
+ _notificationChangeState.value = NotificationChangeState.Failure("FCM Token을 가져오는데 실패했습니다.")
return@launch
}
+
val requestDto = SendNotificationRequestDto(
- isDiaryAlarm = diaryAlarm,
- isReplyAlarm = notificationInfo.isReplyAlarm,
- time = notificationInfo.time,
+ isDiaryAlarm = when (notificationType) {
+ Notification.DIARY -> !_diaryAlarm.value
+ else -> _diaryAlarm.value
+ },
+ isDraftAlarm = when (notificationType) {
+ Notification.DRAFT -> !_draftAlarm.value
+ else -> _draftAlarm.value
+ },
+ isReplyAlarm = when (notificationType) {
+ Notification.REPLY -> !_replyAlarm.value
+ else -> _replyAlarm.value
+ },
+ time = _notificationTime.value,
fcmToken = fcmToken,
)
+
notificationRepository.sendNotification(requestDto).fold(
- onSuccess = { _diaryAlarmChangeState.value = DiaryAlarmChangeState.Success(it) },
+ onSuccess = {
+ _notificationChangeState.value = NotificationChangeState.Success(it)
+ getNotificationInfo()
+ },
onFailure = {
_failureDialogMessage.value = if (it.message?.contains("200") == false) {
- FAILURE_TEMPORARY_MESSAGE
+ errorMessageProvider.getTemporaryError()
} else {
- UNKNOWN_ERROR
+ errorMessageProvider.getUnknownError()
}
_showFailureDialog.value = true
- DiaryAlarmChangeState.Failure(_failureDialogMessage.value)
+ _notificationChangeState.value = NotificationChangeState.Failure(_failureDialogMessage.value)
},
)
}
}
- fun changeNotificationTime(context: Context, notificationInfo: NotificationInfoResponseDto, time: String) {
+ fun changeNotificationTime(context: Context, timePeriod: TimePeriod, hour: String, minute: String) {
_notificationTimeChangeState.value = NotificationTimeChangeState.Loading
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _failureDialogMessage.value = FAILURE_NETWORK_MESSAGE
+ if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) {
+ _failureDialogMessage.value = errorMessageProvider.getNetworkError()
_showFailureDialog.value = true
return@launch
}
@@ -123,61 +157,31 @@ class NotificationSettingViewModel @Inject constructor(
return@launch
}
val requestDto = SendNotificationRequestDto(
- isDiaryAlarm = notificationInfo.isDiaryAlarm,
- isReplyAlarm = notificationInfo.isReplyAlarm,
- time = time,
+ isDiaryAlarm = _diaryAlarm.value,
+ isDraftAlarm = _draftAlarm.value,
+ isReplyAlarm = _replyAlarm.value,
+ time = convertUTZtoKST(timePeriod, hour, minute),
fcmToken = fcmToken,
)
notificationRepository.sendNotification(requestDto).fold(
- onSuccess = { _notificationTimeChangeState.value = NotificationTimeChangeState.Success(it) },
- onFailure = {
- _failureDialogMessage.value = if (it.message?.contains("200") == false) {
- FAILURE_TEMPORARY_MESSAGE
- } else {
- UNKNOWN_ERROR
- }
- _showFailureDialog.value = true
- NotificationInfoState.Failure(_failureDialogMessage.value)
+ onSuccess = {
+ _notificationTimeChangeState.value = NotificationTimeChangeState.Success(it)
+ getNotificationInfo()
},
- )
- }
- }
-
- fun changeReplyAlarm(context: Context, notificationInfo: NotificationInfoResponseDto, replyAlarm: Boolean) {
- _replyAlarmChangeState.value = ReplyAlarmChangeState.Loading
- viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _failureDialogMessage.value = FAILURE_NETWORK_MESSAGE
- _showFailureDialog.value = true
- return@launch
- }
- val fcmToken = getTokenFromPreferences(context)
- if (fcmToken.isNullOrBlank()) {
- _replyAlarmChangeState.value = ReplyAlarmChangeState.Failure("FCM Token을 가져오는데 실패했습니다.")
- return@launch
- }
- val requestDto = SendNotificationRequestDto(
- isDiaryAlarm = notificationInfo.isDiaryAlarm,
- isReplyAlarm = replyAlarm,
- time = notificationInfo.time,
- fcmToken = fcmToken,
- )
- notificationRepository.sendNotification(requestDto).fold(
- onSuccess = { _replyAlarmChangeState.value = ReplyAlarmChangeState.Success(it) },
onFailure = {
_failureDialogMessage.value = if (it.message?.contains("200") == false) {
- FAILURE_TEMPORARY_MESSAGE
+ errorMessageProvider.getTemporaryError()
} else {
- UNKNOWN_ERROR
+ errorMessageProvider.getUnknownError()
}
_showFailureDialog.value = true
- ReplyAlarmChangeState.Failure(_failureDialogMessage.value)
+ _notificationTimeChangeState.value = NotificationTimeChangeState.Failure(_failureDialogMessage.value)
},
)
}
}
- fun resetNotificationChangeState() {
+ fun resetNotificationTimeChangeState() {
_notificationTimeChangeState.value = NotificationTimeChangeState.Idle
}
@@ -190,31 +194,4 @@ class NotificationSettingViewModel @Inject constructor(
val sharedPreferences = context.getSharedPreferences("fcm_prefs", Context.MODE_PRIVATE)
return sharedPreferences.getString("fcm_token", null)
}
-
- fun convertTo12HourFormat(time: String): String {
- val (hourBefore, minuteBefore) = time.split(":").map { it.toInt() }
-
- val amPm = if (hourBefore < 12) "오전" else "오후"
-
- val hourAfter = when {
- hourBefore == 0 -> 12
- hourBefore > 12 -> hourBefore - 12
- else -> hourBefore
- }
-
- val minuteAfter = if (minuteBefore == 0) "00" else minuteBefore
-
- return String.format("$amPm ${hourAfter}시 ${minuteAfter}분")
- }
-
- fun convertTo24HourFormat(amPm: String, hour: String, minute: String): String {
- val hourInt = if (amPm == "오후" && hour.toInt() != 12) {
- hour.toInt() + 12
- } else if (amPm == "오전" && hour.toInt() == 12) {
- 0
- } else {
- hour.toInt()
- }
- return String.format("%02d:%02d", hourInt, minute.toInt())
- }
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/ReplyAlarmChangeState.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/ReplyAlarmChangeState.kt
deleted file mode 100644
index f11f7372..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/notificationsetting/screen/ReplyAlarmChangeState.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.sopt.clody.presentation.ui.setting.notificationsetting.screen
-
-import com.sopt.clody.data.remote.dto.response.SendNotificationResponseDto
-
-sealed class ReplyAlarmChangeState {
- data object Idle : ReplyAlarmChangeState()
- data object Loading : ReplyAlarmChangeState()
- data class Success(val data: SendNotificationResponseDto) : ReplyAlarmChangeState()
- data class Failure(val errorMessage: String) : ReplyAlarmChangeState()
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementScreen.kt
index 5f4a6adf..0f847204 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementScreen.kt
@@ -18,6 +18,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.sopt.clody.R
+import com.sopt.clody.presentation.ui.auth.signup.NicknameMessage
import com.sopt.clody.presentation.ui.component.FailureScreen
import com.sopt.clody.presentation.ui.component.LoadingScreen
import com.sopt.clody.presentation.ui.component.dialog.ClodyDialog
@@ -30,14 +31,14 @@ import com.sopt.clody.presentation.ui.setting.component.LogoutDialog
import com.sopt.clody.presentation.ui.setting.component.NicknameChangeBottomSheet
import com.sopt.clody.presentation.ui.setting.component.SettingSeparateLine
import com.sopt.clody.presentation.ui.setting.component.SettingTopAppBar
-import com.sopt.clody.presentation.ui.setting.navigation.SettingNavigator
import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun AccountManagementRoute(
- navigator: SettingNavigator,
+ navigateToPrevious: () -> Unit,
+ navigateToLogin: () -> Unit,
accountManagementViewModel: AccountManagementViewModel = hiltViewModel(),
) {
val userInfoState by accountManagementViewModel.userInfoState.collectAsState()
@@ -51,6 +52,7 @@ fun AccountManagementRoute(
var showRevokeDialog by remember { mutableStateOf(false) }
val showFailureDialog by accountManagementViewModel.showFailureDialog.collectAsState()
val failureDialogMessage by accountManagementViewModel.failureDialogMessage.collectAsState()
+ val nicknameMaxLength by accountManagementViewModel.nicknameMaxLength.collectAsState()
LaunchedEffect(Unit) {
accountManagementViewModel.fetchUserInfo()
@@ -64,17 +66,13 @@ fun AccountManagementRoute(
LaunchedEffect(revokeAccountState) {
if (revokeAccountState is RevokeAccountState.Success) {
- navigator.navController.navigate("register_graph") {
- popUpTo("home") { inclusive = true }
- }
+ navigateToLogin()
}
}
LaunchedEffect(logOutState) {
if (logOutState is LogOutState.Success) {
- navigator.navController.navigate("register_graph") {
- popUpTo("home") { inclusive = true }
- }
+ navigateToLogin()
}
}
@@ -85,6 +83,7 @@ fun AccountManagementRoute(
showNicknameChangeBottomSheet = showNicknameChangeBottomSheet,
updateNicknameChangeBottomSheet = { state -> showNicknameChangeBottomSheet = state },
isValidNickname = isValidNickname,
+ nicknameMaxLength = nicknameMaxLength,
nicknameMessage = nicknameMessage,
showLogoutDialog = showLogoutDialog,
updateLogoutDialog = { state -> showLogoutDialog = state },
@@ -92,7 +91,7 @@ fun AccountManagementRoute(
updateRevokeDialog = { state -> showRevokeDialog = state },
showFailureDialog = showFailureDialog,
failureDialogMessage = failureDialogMessage,
- onBackClick = { navigator.navigateBack() },
+ onBackClick = navigateToPrevious,
)
}
@@ -104,7 +103,8 @@ fun AccountManagementScreen(
showNicknameChangeBottomSheet: Boolean,
updateNicknameChangeBottomSheet: (Boolean) -> Unit,
isValidNickname: Boolean,
- nicknameMessage: String,
+ nicknameMaxLength: Int,
+ nicknameMessage: NicknameMessage,
showLogoutDialog: Boolean,
updateLogoutDialog: (Boolean) -> Unit,
showRevokeDialog: Boolean,
@@ -136,12 +136,11 @@ fun AccountManagementScreen(
updateNicknameChangeBottomSheet = updateNicknameChangeBottomSheet,
)
- if (userInfo.platform == "kakao") {
- AccountManagementLogoutOption(
- userEmail = userInfo.email,
- updateLogoutDialog = updateLogoutDialog,
- )
- }
+ AccountManagementLogoutOption(
+ userEmail = userInfo.email,
+ platform = userInfo.platform,
+ updateLogoutDialog = updateLogoutDialog,
+ )
SettingSeparateLine()
@@ -164,6 +163,7 @@ fun AccountManagementScreen(
NicknameChangeBottomSheet(
accountManagementViewModel = accountManagementViewModel,
userName = (userInfoState as UserInfoState.Success).data.name,
+ nicknameMaxLength = nicknameMaxLength,
isValidNickname = isValidNickname,
nicknameMessage = nicknameMessage,
onDismiss = { updateNicknameChangeBottomSheet(false) },
@@ -178,7 +178,7 @@ fun AccountManagementScreen(
contentAlignment = Alignment.BottomCenter,
) {
ClodyToastMessage(
- message = stringResource(R.string.account_management_nickname_change_toast),
+ message = stringResource(R.string.toast_account_management_nickname_change),
iconResId = R.drawable.ic_toast_check_on_18,
backgroundColor = ClodyTheme.colors.gray04,
contentColor = ClodyTheme.colors.white,
@@ -190,10 +190,10 @@ fun AccountManagementScreen(
if (showLogoutDialog) {
LogoutDialog(
- titleMassage = stringResource(R.string.account_management_logout_dialog_title),
- descriptionMassage = stringResource(R.string.account_management_logout_dialog_description),
- confirmOption = stringResource(R.string.account_management_logout_dialog_confirm),
- dismissOption = stringResource(R.string.account_management_logout_dialog_dismiss),
+ titleMassage = stringResource(R.string.dialog_logout_title),
+ descriptionMassage = stringResource(R.string.dialog_logout_description),
+ confirmOption = stringResource(R.string.dialog_logout_confirm),
+ dismissOption = stringResource(R.string.dialog_logout_dismiss),
confirmAction = {
AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.LOGOUT)
accountManagementViewModel.logOutAccount()
@@ -204,10 +204,10 @@ fun AccountManagementScreen(
if (showRevokeDialog) {
ClodyDialog(
- titleMassage = stringResource(R.string.account_management_revoke_dialog_title),
- descriptionMassage = stringResource(R.string.account_management_revoke_dialog_description),
- confirmOption = stringResource(R.string.account_management_revoke_dialog_confirm),
- dismissOption = stringResource(R.string.account_management_revoke_dialog_dismiss),
+ titleMassage = stringResource(R.string.dialog_revoke_title),
+ descriptionMassage = stringResource(R.string.dialog_revoke_description),
+ confirmOption = stringResource(R.string.dialog_revoke_confirm),
+ dismissOption = stringResource(R.string.dialog_revoke_dismiss),
confirmAction = {
AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.REVOKE)
accountManagementViewModel.revokeAccount()
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementViewModel.kt
index fc78e477..0c240316 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementViewModel.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/AccountManagementViewModel.kt
@@ -3,17 +3,19 @@ package com.sopt.clody.presentation.ui.setting.screen
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.sopt.clody.core.network.NetworkConnectivityObserver
+import com.sopt.clody.core.network.NetworkStatus
import com.sopt.clody.data.datastore.TokenDataStore
import com.sopt.clody.data.remote.dto.request.ModifyNicknameRequestDto
-import com.sopt.clody.data.remote.util.NetworkUtil
import com.sopt.clody.domain.repository.AccountManagementRepository
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_NETWORK_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.UNKNOWN_ERROR
+import com.sopt.clody.presentation.ui.auth.signup.NicknameMessage
+import com.sopt.clody.presentation.utils.language.LanguageProvider
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -21,8 +23,10 @@ import javax.inject.Inject
class AccountManagementViewModel @Inject constructor(
private val accountManagementRepository: AccountManagementRepository,
private val tokenDataStore: TokenDataStore,
- private val networkUtil: NetworkUtil,
+ private val networkConnectivityObserver: NetworkConnectivityObserver,
@ApplicationContext private val context: Context,
+ private val languageProvider: LanguageProvider,
+ private val errorMessageProvider: ErrorMessageProvider,
) : ViewModel() {
private val _userInfoState = MutableStateFlow(UserInfoState.Idle)
val userInfoState: StateFlow = _userInfoState
@@ -33,8 +37,8 @@ class AccountManagementViewModel @Inject constructor(
private val _isValidNickname = MutableStateFlow(true)
val isValidNickname: StateFlow = _isValidNickname
- private val _nicknameMessage = MutableStateFlow(DEFAULT_NICKNAME_MESSAGE)
- val nicknameMessage: StateFlow = _nicknameMessage
+ private val _nicknameMessage = MutableStateFlow(NicknameMessage.DEFAULT)
+ val nicknameMessage: StateFlow = _nicknameMessage
private val _logOutState = MutableStateFlow(LogOutState.Idle)
val logOutState: StateFlow = _logOutState
@@ -48,6 +52,9 @@ class AccountManagementViewModel @Inject constructor(
private val _failureDialogMessage = MutableStateFlow("")
val failureDialogMessage: StateFlow = _failureDialogMessage
+ private val _nicknameMaxLength = MutableStateFlow(languageProvider.getNicknameMaxLength())
+ val nicknameMaxLength: StateFlow = _nicknameMaxLength
+
private val maxRetryCount = 3
private var retryCount = 0
@@ -55,8 +62,8 @@ class AccountManagementViewModel @Inject constructor(
if (retryCount >= maxRetryCount) return
_userInfoState.value = UserInfoState.Loading
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _userInfoState.value = UserInfoState.Failure(FAILURE_NETWORK_MESSAGE)
+ if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) {
+ _userInfoState.value = UserInfoState.Failure(errorMessageProvider.getNetworkError())
return@launch
}
val result = accountManagementRepository.getUserInfo()
@@ -68,12 +75,12 @@ class AccountManagementViewModel @Inject constructor(
onFailure = {
retryCount++
if (retryCount >= maxRetryCount) {
- UserInfoState.Failure(FAILURE_TEMPORARY_MESSAGE)
+ UserInfoState.Failure(errorMessageProvider.getTemporaryError())
} else {
val errorMessage = if (it.message?.contains("200") == false) {
- FAILURE_TEMPORARY_MESSAGE
+ errorMessageProvider.getTemporaryError()
} else {
- UNKNOWN_ERROR
+ errorMessageProvider.getUnknownError()
}
UserInfoState.Failure(errorMessage)
}
@@ -84,8 +91,8 @@ class AccountManagementViewModel @Inject constructor(
fun changeNickname(modifyNicknameRequestDto: ModifyNicknameRequestDto) {
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _failureDialogMessage.value = FAILURE_NETWORK_MESSAGE
+ if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) {
+ _failureDialogMessage.value = errorMessageProvider.getNetworkError()
_showFailureDialog.value = true
}
_userNicknameState.value = UserNicknameState.Loading
@@ -94,9 +101,9 @@ class AccountManagementViewModel @Inject constructor(
onSuccess = { UserNicknameState.Success(it) },
onFailure = {
_failureDialogMessage.value = if (it.message?.contains("200") == false) {
- FAILURE_TEMPORARY_MESSAGE
+ errorMessageProvider.getTemporaryError()
} else {
- UNKNOWN_ERROR
+ errorMessageProvider.getUnknownError()
}
_showFailureDialog.value = true
UserNicknameState.Failure(_failureDialogMessage.value)
@@ -107,12 +114,12 @@ class AccountManagementViewModel @Inject constructor(
fun validateNickname(nickname: String) {
if (nickname.isNotEmpty()) {
- val isValid = nickname.matches(Regex(NICKNAME_PATTERN))
+ val isValid = nickname.matches(Regex("^[a-zA-Z가-힣0-9ㄱ-ㅎㅏ-ㅣ가-힣]{2,${_nicknameMaxLength.value}}$"))
_isValidNickname.value = isValid
- _nicknameMessage.value = if (isValid) DEFAULT_NICKNAME_MESSAGE else FAILURE_NICKNAME_MESSAGE
+ _nicknameMessage.value = if (isValid) NicknameMessage.DEFAULT else NicknameMessage.INVALID
} else {
_isValidNickname.value = true
- _nicknameMessage.value = DEFAULT_NICKNAME_MESSAGE
+ _nicknameMessage.value = NicknameMessage.DEFAULT
}
}
@@ -135,8 +142,8 @@ class AccountManagementViewModel @Inject constructor(
fun revokeAccount() {
_revokeAccountState.value = RevokeAccountState.Loading
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _failureDialogMessage.value = FAILURE_NETWORK_MESSAGE
+ if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) {
+ _failureDialogMessage.value = errorMessageProvider.getNetworkError()
_showFailureDialog.value = true
}
val result = accountManagementRepository.revokeAccount()
@@ -147,9 +154,9 @@ class AccountManagementViewModel @Inject constructor(
},
onFailure = {
_failureDialogMessage.value = if (it.message?.contains("200") == false) {
- FAILURE_TEMPORARY_MESSAGE
+ errorMessageProvider.getTemporaryError()
} else {
- it.localizedMessage ?: UNKNOWN_ERROR
+ it.localizedMessage ?: errorMessageProvider.getUnknownError()
}
_showFailureDialog.value = true
RevokeAccountState.Failure(_failureDialogMessage.value)
@@ -162,10 +169,4 @@ class AccountManagementViewModel @Inject constructor(
_showFailureDialog.value = false
_failureDialogMessage.value = ""
}
-
- companion object {
- private const val NICKNAME_PATTERN = "^[a-zA-Z가-힣0-9ㄱ-ㅎㅏ-ㅣ가-힣]{2,10}$"
- private const val DEFAULT_NICKNAME_MESSAGE = "특수문자, 띄어쓰기 없이 작성해주세요"
- private const val FAILURE_NICKNAME_MESSAGE = "사용할 수 없는 닉네임이에요"
- }
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingOptionUrls.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingOptionUrls.kt
index 1348d8e0..7560af1e 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingOptionUrls.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingOptionUrls.kt
@@ -1,8 +1,23 @@
package com.sopt.clody.presentation.ui.setting.screen
-object SettingOptionUrls {
- const val ANNOUNCEMENT_URL = "https://www.notion.so/1c7e3fedb3f48029b36cf9d76c5fb6d6?pvs=21"
- const val INQUIRIES_SUGGESTIONS_URL = "https://forms.gle/WnLC7VwHacufVHiv7"
- const val TERMS_OF_SERVICE_URL = "https://www.notion.so/1c7e3fedb3f4802c8db1f3056c03973f?pvs=21"
- const val PRIVACY_POLICY_URL = "https://www.notion.so/1c7e3fedb3f48024a334c8116255b378?pvs=21"
+enum class SettingOptionUrls(
+ val enUrl: String,
+ val koUrl: String,
+) {
+ NOTICES_URL(
+ enUrl = "https://tropical-buckthorn-d17.notion.site/Notice-22ae3fedb3f480feb229e7dcc7a23887?source=copy_link",
+ koUrl = "https://www.notion.so/1c7e3fedb3f48029b36cf9d76c5fb6d6?pvs=21",
+ ),
+ SUPPORT_FEEDBACK_URL(
+ enUrl = "https://docs.google.com/forms/d/e/1FAIpQLSe1LJg6tYaWBY2ji3O1smCH1ux5ItbVyGVUQko-Mg609Xt9eg/viewform",
+ koUrl = "https://forms.gle/WnLC7VwHacufVHiv7",
+ ),
+ TERMS_OF_SERVICE_URL(
+ enUrl = "https://tropical-buckthorn-d17.notion.site/Clody-Terms-of-Use-22ae3fedb3f48092ace1fba817df8605?source=copy_link",
+ koUrl = "https://www.notion.so/1c7e3fedb3f4802c8db1f3056c03973f?pvs=21",
+ ),
+ PRIVACY_POLICY_URL(
+ enUrl = "https://tropical-buckthorn-d17.notion.site/Clody-Privacy-Policy-22ae3fedb3f4808ab8dcc8ba60ad6cd6?source=copy_link",
+ koUrl = "https://www.notion.so/1c7e3fedb3f48024a334c8116255b378?pvs=21",
+ ),
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingScreen.kt
index fff76bd5..8af5e2c6 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingScreen.kt
@@ -1,8 +1,5 @@
package com.sopt.clody.presentation.ui.setting.screen
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -21,29 +18,38 @@ import com.sopt.clody.presentation.ui.setting.component.SettingOption
import com.sopt.clody.presentation.ui.setting.component.SettingSeparateLine
import com.sopt.clody.presentation.ui.setting.component.SettingTopAppBar
import com.sopt.clody.presentation.ui.setting.component.SettingVersionInfo
-import com.sopt.clody.presentation.ui.setting.navigation.SettingNavigator
import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
+import com.sopt.clody.presentation.utils.openExternalBrowser
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun SettingRoute(
- navigator: SettingNavigator,
+ navigateToAccountManagement: () -> Unit,
+ navigateToNotification: () -> Unit,
+ navigateToPrevious: () -> Unit,
settingViewModel: SettingViewModel = hiltViewModel(),
) {
+ val notice by settingViewModel::noticeUrl
+ val supportFeedback by settingViewModel::supportFeedbackUrl
+ val termsOfService by settingViewModel::termsOfServiceUrl
+ val privacyPolicy by settingViewModel::privacyPolicyUrl
val versionInfo by settingViewModel::versionInfo
+ val context = LocalContext.current
LaunchedEffect(Unit) {
- settingViewModel.getVersionInfo()
AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.SETTING)
}
SettingScreen(
- versionInfo = versionInfo ?: stringResource(R.string.setting_version_info_failure),
- onClickBack = { navigator.navigateBack() },
- onClickAccountManagement = { navigator.navigateAccountManagement() },
- onClickNotificationSetting = { navigator.navigateNotificationSetting() },
- onClickInquiriesSuggestions = { navigator.navigateWebView(SettingOptionUrls.INQUIRIES_SUGGESTIONS_URL) },
+ versionInfo = versionInfo ?: stringResource(R.string.setting_option_app_version_info_failure),
+ onClickBack = navigateToPrevious,
+ onClickAccountManagement = navigateToAccountManagement,
+ onClickNotificationSetting = navigateToNotification,
+ onClickNotice = { openExternalBrowser(context, notice) },
+ onClickSupportFeedback = { openExternalBrowser(context, supportFeedback) },
+ onClickTerms = { openExternalBrowser(context, termsOfService) },
+ onClickPrivacy = { openExternalBrowser(context, privacyPolicy) },
)
}
@@ -54,60 +60,33 @@ fun SettingScreen(
onClickBack: () -> Unit,
onClickAccountManagement: () -> Unit,
onClickNotificationSetting: () -> Unit,
- onClickInquiriesSuggestions: () -> Unit,
+ onClickNotice: () -> Unit,
+ onClickSupportFeedback: () -> Unit,
+ onClickTerms: () -> Unit,
+ onClickPrivacy: () -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
- val context = LocalContext.current
Scaffold(
- modifier = Modifier
- .nestedScroll(scrollBehavior.nestedScrollConnection),
+ modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { SettingTopAppBar(stringResource(R.string.setting_title), onClickBack) },
containerColor = ClodyTheme.colors.white,
) { innerPadding ->
- Column(
- modifier = Modifier
- .padding(innerPadding),
- ) {
+ Column(modifier = Modifier.padding(innerPadding)) {
SettingOption(option = stringResource(R.string.setting_option_account_management), onClickAccountManagement)
SettingSeparateLine()
- SettingOption(
- option = stringResource(R.string.setting_option_notification_setting),
- onClickNotificationSetting,
- )
- SettingOption(option = stringResource(R.string.setting_option_announcement)) {
- onClickSettingOption(
- context,
- SettingOptionUrls.ANNOUNCEMENT_URL,
- )
- }
- SettingOption(
- option = stringResource(R.string.setting_option_inquiries_suggestions),
- onClickInquiriesSuggestions,
- )
+ SettingOption(option = stringResource(R.string.setting_option_notification_setting), onClickNotificationSetting)
+ SettingOption(option = stringResource(R.string.setting_option_announcement), onClickNotice)
+ SettingOption(option = stringResource(R.string.setting_option_inquiries_suggestions), onClickSupportFeedback)
SettingSeparateLine()
- SettingOption(option = stringResource(R.string.setting_option_terms_of_service)) {
- onClickSettingOption(
- context,
- SettingOptionUrls.TERMS_OF_SERVICE_URL,
- )
- }
- SettingOption(option = stringResource(R.string.setting_option_privacy_policy)) {
- onClickSettingOption(
- context,
- SettingOptionUrls.PRIVACY_POLICY_URL,
- )
- }
+ SettingOption(option = stringResource(R.string.setting_option_terms_of_service), onClickTerms)
+ SettingOption(option = stringResource(R.string.setting_option_privacy_policy), onClickPrivacy)
+
SettingVersionInfo(versionInfo = versionInfo)
}
}
}
-
-fun onClickSettingOption(context: Context, url: String) {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
- context.startActivity(intent)
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingViewModel.kt
index 3944f4e0..d379ed02 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingViewModel.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/SettingViewModel.kt
@@ -8,6 +8,8 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sopt.clody.R
+import com.sopt.clody.data.remote.datasource.RemoteConfigDataSource
+import com.sopt.clody.presentation.utils.language.LanguageProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch
@@ -16,17 +18,32 @@ import javax.inject.Inject
@HiltViewModel
class SettingViewModel @Inject constructor(
@ApplicationContext private val context: Context,
+ private val remoteConfigDataSource: RemoteConfigDataSource,
+ private val languageProvider: LanguageProvider,
) : ViewModel() {
+ val noticeUrl = languageProvider.getWebViewUrlFor(SettingOptionUrls.NOTICES_URL)
+ val supportFeedbackUrl = languageProvider.getWebViewUrlFor(SettingOptionUrls.SUPPORT_FEEDBACK_URL)
+ val termsOfServiceUrl = languageProvider.getWebViewUrlFor(SettingOptionUrls.TERMS_OF_SERVICE_URL)
+ val privacyPolicyUrl = languageProvider.getWebViewUrlFor(SettingOptionUrls.PRIVACY_POLICY_URL)
var versionInfo by mutableStateOf(null)
private set
- fun getVersionInfo() {
+ init {
+ getVersionInfo()
+ }
+
+ private fun getVersionInfo() {
viewModelScope.launch {
runCatching {
val info: PackageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
info.versionName
}.onSuccess { versionName ->
- versionInfo = versionName
+ val latestVersion = remoteConfigDataSource.getLatestVersion()
+ versionInfo = if (versionName == latestVersion) {
+ context.getString(R.string.setting_option_app_version_info_latest)
+ } else {
+ versionName
+ }
}.onFailure {
versionInfo = context.getString(R.string.setting_option_app_version_info_failure)
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/WebViewScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/WebViewScreen.kt
deleted file mode 100644
index 7cfed543..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/setting/screen/WebViewScreen.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-package com.sopt.clody.presentation.ui.setting.screen
-
-import android.annotation.SuppressLint
-import android.webkit.WebSettings
-import android.webkit.WebView
-import android.webkit.WebViewClient
-import androidx.activity.compose.BackHandler
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Scaffold
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
-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.viewinterop.AndroidView
-import com.sopt.clody.presentation.ui.setting.navigation.SettingNavigator
-
-@Composable
-fun WebViewRoute(
- navigator: SettingNavigator,
- encodedUrl: String,
-) {
- WebViewScreen(
- encodedUrl = encodedUrl,
- onClickBack = { navigator.navigateBack() },
- )
-}
-
-@SuppressLint("SetJavaScriptEnabled")
-@Composable
-fun WebViewScreen(
- encodedUrl: String,
- onClickBack: () -> Unit,
-) {
- var webView: WebView? by remember { mutableStateOf(null) }
- val canGoBack by remember { derivedStateOf { webView?.canGoBack() ?: false } }
-
- Scaffold(
- modifier = Modifier.fillMaxSize(),
- content = { innerPadding ->
- AndroidView(
- factory = { context ->
- WebView(context).apply {
- webViewClient = WebViewClient()
- settings.apply {
- javaScriptEnabled = true
- domStorageEnabled = true
- useWideViewPort = true
- loadWithOverviewMode = true
- allowFileAccess = true
- allowContentAccess = true
- javaScriptCanOpenWindowsAutomatically = true
- mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
- }
- loadUrl(encodedUrl)
- webView = this
- }
- },
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding),
- )
- },
- )
-
- BackHandler(enabled = canGoBack) {
- if (canGoBack) {
- webView?.goBack()
- } else {
- onClickBack()
- }
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashContract.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashContract.kt
new file mode 100644
index 00000000..ae19ecff
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashContract.kt
@@ -0,0 +1,29 @@
+package com.sopt.clody.presentation.ui.splash
+
+import android.content.Intent
+import com.airbnb.mvrx.MavericksState
+import com.sopt.clody.domain.model.AppUpdateState
+
+class SplashContract {
+ data class SplashState(
+ val updateState: AppUpdateState? = null,
+ val showInspectionDialog: Boolean = false,
+ val inspectionTimeText: String? = null,
+ ) : MavericksState
+
+ sealed class SplashIntent {
+ data class InitSplash(val startIntent: Intent) : SplashIntent()
+ data class HandleHardUpdate(val isConfirm: Boolean) : SplashIntent()
+ data object HandleSoftUpdateConfirm : SplashIntent()
+ data object ClearUpdateState : SplashIntent()
+ data object DismissInspectionDialog : SplashIntent()
+ }
+
+ sealed interface SplashSideEffect {
+ data object NavigateToLogin : SplashSideEffect
+ data object NavigateToHome : SplashSideEffect
+ data object NavigateToMarket : SplashSideEffect
+ data object NavigateToMarketAndFinish : SplashSideEffect
+ data object FinishApp : SplashSideEffect
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt
index 10505c0b..b3d6374a 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashScreen.kt
@@ -1,6 +1,7 @@
package com.sopt.clody.presentation.ui.splash
import android.app.Activity
+import android.content.Intent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -16,64 +17,91 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
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.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.airbnb.mvrx.compose.collectAsState
+import com.airbnb.mvrx.compose.mavericksViewModel
import com.sopt.clody.R
import com.sopt.clody.domain.model.AppUpdateState
-import com.sopt.clody.presentation.ui.auth.navigation.AuthNavigator
+import com.sopt.clody.presentation.ui.component.dialog.InspectionDialog
import com.sopt.clody.presentation.utils.appupdate.AppUpdateUtils
+import com.sopt.clody.presentation.utils.base.BasePreview
+import com.sopt.clody.presentation.utils.base.ClodyPreview
+import com.sopt.clody.presentation.utils.extension.repeatOnStarted
import com.sopt.clody.ui.theme.ClodyTheme
-import kotlinx.coroutines.delay
-import java.time.LocalDate
@Composable
fun SplashRoute(
- navigator: AuthNavigator,
- viewModel: SplashViewModel = hiltViewModel(),
+ viewModel: SplashViewModel = mavericksViewModel(),
+ startIntent: Intent,
+ onLoginRequired: () -> Unit,
+ onAlreadyLoggedIn: () -> Unit,
) {
- val isUserLoggedIn by viewModel.isUserLoggedIn.collectAsStateWithLifecycle()
- val updateState by viewModel.updateState.collectAsStateWithLifecycle()
+ val state by viewModel.collectAsState()
val context = LocalContext.current
val activity = context as Activity
+ val lifecycleOwner = LocalLifecycleOwner.current
- LaunchedEffect(isUserLoggedIn, updateState) {
- if (isUserLoggedIn != null && updateState == AppUpdateState.Latest) {
- delay(1000)
- navigator.navController.navigate(
- if (isUserLoggedIn == true) {
- "home/${LocalDate.now().year}/${LocalDate.now().monthValue}"
- } else {
- "register_graph"
- },
- ) {
- popUpTo("splash") { inclusive = true }
+ LaunchedEffect(viewModel) {
+ viewModel.postIntent(SplashContract.SplashIntent.InitSplash(startIntent))
+
+ lifecycleOwner.repeatOnStarted {
+ viewModel.sideEffects.collect { effect ->
+ when (effect) {
+ is SplashContract.SplashSideEffect.NavigateToLogin -> onLoginRequired()
+ is SplashContract.SplashSideEffect.NavigateToHome -> onAlreadyLoggedIn()
+ is SplashContract.SplashSideEffect.NavigateToMarketAndFinish -> {
+ AppUpdateUtils.navigateToMarketAndFinish(activity)
+ }
+
+ is SplashContract.SplashSideEffect.FinishApp -> {
+ activity.finishAffinity()
+ }
+
+ is SplashContract.SplashSideEffect.NavigateToMarket -> {
+ AppUpdateUtils.navigateToMarket(context)
+ }
+ }
}
}
}
- when (val state = updateState) {
+ when (val updateState = state.updateState) {
is AppUpdateState.SoftUpdate -> {
SoftUpdateDialog(
- latestVersion = state.latestVersion,
- onDismiss = { viewModel.clearUpdateState() },
- onConfirm = { AppUpdateUtils.navigateToMarket(context) },
+ latestVersion = updateState.latestVersion,
+ onDismiss = { viewModel.postIntent(SplashContract.SplashIntent.ClearUpdateState) },
+ onConfirm = {
+ viewModel.postIntent(SplashContract.SplashIntent.HandleSoftUpdateConfirm)
+ },
)
}
is AppUpdateState.HardUpdate -> {
HardUpdateDialog(
- latestVersion = state.latestVersion,
- onConfirm = { AppUpdateUtils.navigateToMarketAndFinish(activity) },
- onExit = { activity.finishAffinity() },
+ latestVersion = updateState.latestVersion,
+ onConfirm = {
+ viewModel.postIntent(SplashContract.SplashIntent.HandleHardUpdate(isConfirm = true))
+ },
+ onExit = {
+ viewModel.postIntent(SplashContract.SplashIntent.HandleHardUpdate(isConfirm = false))
+ },
)
}
else -> {}
}
+ if (state.showInspectionDialog) {
+ InspectionDialog(
+ inspectionTime = state.inspectionTimeText.orEmpty(),
+ onDismiss = {
+ viewModel.postIntent(SplashContract.SplashIntent.DismissInspectionDialog)
+ },
+ )
+ }
SplashScreen()
}
@@ -97,20 +125,28 @@ fun SplashScreen() {
@Composable
fun SoftUpdateDialog(
latestVersion: String,
- onDismiss: () -> Unit,
onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
- title = { Text("업데이트 필요") },
+ title = { Text(stringResource(R.string.dialog_soft_update_title)) },
text = {
Text(
- text = "새로운 버전 ${latestVersion}을 사용할 수 있습니다.\n지금 업데이트하시겠습니까?",
+ text = stringResource(R.string.dialog_soft_update_description, latestVersion),
textAlign = TextAlign.Center,
)
},
- confirmButton = { TextButton(onClick = onConfirm) { Text("업데이트") } },
- dismissButton = { TextButton(onClick = onDismiss) { Text("나중에") } },
+ confirmButton = {
+ TextButton(onClick = onConfirm) {
+ Text(stringResource(R.string.dialog_soft_update_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(R.string.dialog_soft_update_dismiss))
+ }
+ },
)
}
@@ -122,15 +158,27 @@ fun HardUpdateDialog(
) {
AlertDialog(
onDismissRequest = {},
- title = { Text("필수 업데이트") },
- text = { Text("버전 ${latestVersion}으로 업데이트가 필요합니다.") },
- confirmButton = { TextButton(onClick = onConfirm) { Text("업데이트") } },
- dismissButton = { TextButton(onClick = onExit) { Text("앱 종료") } },
+ title = { Text(stringResource(R.string.dialog_hard_update_title)) },
+ text = {
+ Text(stringResource(R.string.dialog_hard_update_description, latestVersion))
+ },
+ confirmButton = {
+ TextButton(onClick = onConfirm) {
+ Text(stringResource(R.string.dialog_hard_update_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onExit) {
+ Text(stringResource(R.string.dialog_hard_update_exit))
+ }
+ },
)
}
-@Preview(showBackground = true)
+@ClodyPreview
@Composable
fun SplashScreenPreview() {
- SplashScreen()
+ BasePreview {
+ SplashScreen()
+ }
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt
index b52627bb..db366f59 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/SplashViewModel.kt
@@ -1,48 +1,127 @@
package com.sopt.clody.presentation.ui.splash
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
+import com.airbnb.mvrx.MavericksViewModel
+import com.airbnb.mvrx.MavericksViewModelFactory
+import com.airbnb.mvrx.hilt.AssistedViewModelFactory
+import com.airbnb.mvrx.hilt.hiltMavericksViewModelFactory
import com.sopt.clody.BuildConfig
import com.sopt.clody.domain.appupdate.AppUpdateChecker
import com.sopt.clody.domain.model.AppUpdateState
import com.sopt.clody.domain.repository.TokenRepository
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
+import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
+import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.BUFFERED
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
-import javax.inject.Inject
-@HiltViewModel
-class SplashViewModel @Inject constructor(
+class SplashViewModel @AssistedInject constructor(
+ @Assisted initialState: SplashContract.SplashState,
private val tokenRepository: TokenRepository,
private val appUpdateChecker: AppUpdateChecker,
-) : ViewModel() {
+) : MavericksViewModel(initialState) {
- private val _isUserLoggedIn = MutableStateFlow(null)
- val isUserLoggedIn: StateFlow = _isUserLoggedIn
-
- private val _updateState = MutableStateFlow(null)
- val updateState: StateFlow = _updateState
+ private val _intents = Channel(BUFFERED)
+ private val _sideEffects = Channel(BUFFERED)
+ val sideEffects = _sideEffects.receiveAsFlow()
init {
- attemptAutoLogin()
- checkVersion()
+ _intents
+ .receiveAsFlow()
+ .onEach(::handleIntent)
+ .launchIn(viewModelScope)
+ }
+
+ fun postIntent(intent: SplashContract.SplashIntent) {
+ viewModelScope.launch { _intents.send(intent) }
+ }
+
+ private suspend fun handleIntent(intent: SplashContract.SplashIntent) {
+ when (intent) {
+ is SplashContract.SplashIntent.InitSplash -> handleInitSplash(intent)
+ is SplashContract.SplashIntent.HandleHardUpdate -> handleHardUpdate(intent)
+ is SplashContract.SplashIntent.HandleSoftUpdateConfirm -> handleSoftUpdateConfirm()
+ is SplashContract.SplashIntent.ClearUpdateState -> clearUpdateState()
+ is SplashContract.SplashIntent.DismissInspectionDialog -> handleDismissInspection()
+ }
+ }
+
+ private suspend fun handleInitSplash(intent: SplashContract.SplashIntent.InitSplash) {
+ if (intent.startIntent.hasExtra("google.message_id")) {
+ AmplitudeUtils.trackEvent(AmplitudeConstraints.ALARM)
+ }
+ if (!BuildConfig.DEBUG && checkInspectionAndHandle()) return
+
+ val isLoggedIn = attemptAutoLogin()
+ checkVersionAndNavigate(isLoggedIn)
+ }
+
+ private suspend fun checkInspectionAndHandle(): Boolean {
+ if (appUpdateChecker.isUnderInspection()) {
+ val inspectionText = appUpdateChecker.getInspectionTimeText()
+ setState {
+ copy(
+ showInspectionDialog = true,
+ inspectionTimeText = inspectionText,
+ )
+ }
+ return true
+ }
+ return false
+ }
+
+ private fun attemptAutoLogin(): Boolean {
+ val isLoggedIn = tokenRepository.getAccessToken().isNotBlank() &&
+ tokenRepository.getRefreshToken().isNotBlank()
+ return isLoggedIn
}
- private fun attemptAutoLogin() {
- val accessToken = tokenRepository.getAccessToken()
- val refreshToken = tokenRepository.getRefreshToken()
- _isUserLoggedIn.value = accessToken.isNotBlank() && refreshToken.isNotBlank()
+ private suspend fun checkVersionAndNavigate(isLoggedIn: Boolean) {
+ val updateState = appUpdateChecker.getAppUpdateState(BuildConfig.VERSION_NAME)
+ setState { copy(updateState = updateState) }
+
+ if (updateState == AppUpdateState.Latest) {
+ delay(1000)
+ if (isLoggedIn) {
+ _sideEffects.send(SplashContract.SplashSideEffect.NavigateToHome)
+ } else {
+ _sideEffects.send(SplashContract.SplashSideEffect.NavigateToLogin)
+ }
+ }
}
- private fun checkVersion() {
- viewModelScope.launch {
- val state = appUpdateChecker.getAppUpdateState(BuildConfig.VERSION_NAME)
- _updateState.value = state
+ private suspend fun handleHardUpdate(intent: SplashContract.SplashIntent.HandleHardUpdate) {
+ if (intent.isConfirm) {
+ _sideEffects.send(SplashContract.SplashSideEffect.NavigateToMarketAndFinish)
+ } else {
+ _sideEffects.send(SplashContract.SplashSideEffect.FinishApp)
}
}
- fun clearUpdateState() {
- _updateState.value = AppUpdateState.Latest
+ private suspend fun handleSoftUpdateConfirm() {
+ _sideEffects.send(SplashContract.SplashSideEffect.NavigateToMarket)
+ }
+
+ private fun clearUpdateState() {
+ setState { copy(updateState = AppUpdateState.Latest) }
+ }
+
+ private suspend fun handleDismissInspection() {
+ setState { copy(showInspectionDialog = false) }
+ _sideEffects.send(SplashContract.SplashSideEffect.FinishApp)
}
+
+ @AssistedFactory
+ interface Factory : AssistedViewModelFactory {
+ override fun create(state: SplashContract.SplashState): SplashViewModel
+ }
+
+ companion object :
+ MavericksViewModelFactory by hiltMavericksViewModelFactory()
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/splash/navigation/SplashNavigation.kt b/app/src/main/java/com/sopt/clody/presentation/ui/splash/navigation/SplashNavigation.kt
new file mode 100644
index 00000000..e6ed33a2
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/splash/navigation/SplashNavigation.kt
@@ -0,0 +1,21 @@
+package com.sopt.clody.presentation.ui.splash.navigation
+
+import android.content.Intent
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import com.sopt.clody.presentation.ui.splash.SplashRoute
+import com.sopt.clody.presentation.utils.navigation.Route
+
+fun NavGraphBuilder.splashScreen(
+ startIntent: Intent,
+ navigateToLogin: () -> Unit,
+ navigateToHome: () -> Unit,
+) {
+ composable {
+ SplashRoute(
+ startIntent = startIntent,
+ onLoginRequired = navigateToLogin,
+ onAlreadyLoggedIn = navigateToHome,
+ )
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/type/DiaryCloverType.kt b/app/src/main/java/com/sopt/clody/presentation/ui/type/DiaryCloverType.kt
index dd162022..bb02178a 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/type/DiaryCloverType.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/type/DiaryCloverType.kt
@@ -1,29 +1,56 @@
package com.sopt.clody.presentation.ui.type
+import androidx.annotation.DrawableRes
import com.sopt.clody.R
import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
+import com.sopt.clody.domain.model.ReplyStatus
-enum class DiaryCloverType(val iconRes: Int) {
+/**
+ * DiaryData를 기반으로 해당 날짜에 보여줄 클로버 아이콘 타입을 반환.
+ *
+ * - 오늘이고 일기가 없으면 👉 [TODAY_UNWRITTEN]
+ * - 오늘이고 일기와 읽지 않은 답장이 있으면 👉 [TODAY_WRITTEN]
+ * - 임시저장이 존재하면 👉 [DRAFT_SAVED]
+ * - 임시저장이 만료되었으면 👉 [EXPIRED_WRITTEN]
+ * - 일기가 있고 답장이 없거나 읽지 않았으면 👉 [UNGIVEN_CLOVER]
+ * - 일기 수에 따라 👉 [BOTTOM_CLOVER], [MID_CLOVER], [TOP_CLOVER] 구분
+ * - 이 외의 경우 기본값 👉 [UNGIVEN_CLOVER]
+ */
+
+enum class DiaryCloverType(@DrawableRes val iconRes: Int) {
TODAY_UNWRITTEN(R.drawable.ic_home_today_unwritten_clover),
TODAY_WRITTEN(R.drawable.ic_home_today_written_clover),
UNGIVEN_CLOVER(R.drawable.ic_home_ungiven_clover),
BOTTOM_CLOVER(R.drawable.ic_home_bottom_clover),
MID_CLOVER(R.drawable.ic_home_mid_clover),
TOP_CLOVER(R.drawable.ic_home_top_clover),
+ DRAFT_SAVED(R.drawable.ic_home_draft_saved_clover),
+ EXPIRED_WRITTEN(R.drawable.ic_home_expired_written_clover),
;
companion object {
- fun getCalendarCloverType(diaryData: MonthlyCalendarResponseDto.Diary, isToday: Boolean): DiaryCloverType {
+ fun getCalendarCloverType(
+ diaryData: MonthlyCalendarResponseDto.Diary,
+ isToday: Boolean,
+ ): DiaryCloverType {
+ val count = diaryData.diaryCount
+ val reply = diaryData.replyStatus
+
+ val hasDiary = count > 0
+ val noDiary = count == 0
+ val hasDraft = reply == ReplyStatus.HAS_DRAFT
+ val draftExpired = reply == ReplyStatus.INVALID_DRAFT
+ val hasUnreadOrNoReply = reply.isUnreadOrNotRead
+
return when {
- isToday && diaryData.diaryCount == 0 -> TODAY_UNWRITTEN
- isToday && diaryData.diaryCount > 0 &&
- (diaryData.replyStatus == "UNREADY" || diaryData.replyStatus == "READY_NOT_READ") -> TODAY_WRITTEN
- diaryData.replyStatus == "READY_NOT_READ" && diaryData.diaryCount > 0 -> UNGIVEN_CLOVER
- diaryData.replyStatus == "UNREADY" && diaryData.diaryCount > 0 -> UNGIVEN_CLOVER
- diaryData.diaryCount == 0 -> UNGIVEN_CLOVER
- diaryData.diaryCount in 1..2 -> BOTTOM_CLOVER
- diaryData.diaryCount in 3..4 -> MID_CLOVER
- diaryData.diaryCount == 5 -> TOP_CLOVER
+ hasDraft -> DRAFT_SAVED
+ isToday && noDiary -> TODAY_UNWRITTEN
+ isToday && hasUnreadOrNoReply -> TODAY_WRITTEN
+ draftExpired -> EXPIRED_WRITTEN
+ hasDiary && hasUnreadOrNoReply -> UNGIVEN_CLOVER
+ count in 1..2 -> BOTTOM_CLOVER
+ count in 3..4 -> MID_CLOVER
+ count >= 5 -> TOP_CLOVER
else -> UNGIVEN_CLOVER
}
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/bottomsheet/DeleteWriteDiaryBottomSheet.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/bottomsheet/DeleteWriteDiaryBottomSheet.kt
index 5446dbc3..30e0c758 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/bottomsheet/DeleteWriteDiaryBottomSheet.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/bottomsheet/DeleteWriteDiaryBottomSheet.kt
@@ -9,7 +9,6 @@ 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.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@@ -19,6 +18,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sopt.clody.R
@@ -61,13 +61,12 @@ fun DeleteWriteDiaryBottomSheet(
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "삭제하기",
+ text = stringResource(R.string.bottom_sheet_diary_delete),
style = ClodyTheme.typography.body4SemiBold,
color = ClodyTheme.colors.gray01,
)
}
Spacer(modifier = Modifier.navigationBarsPadding())
- Spacer(modifier = Modifier.height(60.dp))
}
},
)
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/button/AddDiaryEntryFAB.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/button/AddDiaryEntryFAB.kt
new file mode 100644
index 00000000..9331489c
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/button/AddDiaryEntryFAB.kt
@@ -0,0 +1,96 @@
+package com.sopt.clody.presentation.ui.writediary.component.button
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+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
+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.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.sopt.clody.R
+import com.sopt.clody.presentation.utils.extension.clickableWithoutRipple
+import com.sopt.clody.ui.theme.ClodyTheme
+
+@Composable
+fun BoxScope.AddDiaryEntryFAB(
+ isKeyboardVisible: Boolean,
+ isMaxReached: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val containerColor = if (isMaxReached) ClodyTheme.colors.gray04 else ClodyTheme.colors.gray02
+ val contentColor = ClodyTheme.colors.white
+
+ AnimatedContent(
+ targetState = isKeyboardVisible,
+ transitionSpec = {
+ fadeIn(tween(300)) togetherWith fadeOut(tween(300))
+ },
+ modifier = modifier
+ .align(Alignment.BottomEnd)
+ .imePadding()
+ .padding(end = 24.dp, bottom = 12.dp),
+ label = "FAB Animation",
+ ) { keyboardVisible ->
+ if (keyboardVisible) {
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(RoundedCornerShape(28.dp))
+ .background(containerColor)
+ .clickableWithoutRipple(enabled = !isMaxReached) {
+ onClick()
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_writediary_add),
+ contentDescription = null,
+ tint = contentColor,
+ )
+ }
+ } else {
+ Row(
+ modifier = Modifier
+ .height(42.dp)
+ .clip(RoundedCornerShape(24.dp))
+ .background(containerColor)
+ .clickableWithoutRipple(enabled = !isMaxReached) {
+ onClick()
+ }
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_writediary_add),
+ contentDescription = null,
+ tint = contentColor,
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ Text(
+ text = stringResource(R.string.write_diary_fab_add_entry),
+ color = contentColor,
+ style = ClodyTheme.typography.body2SemiBold,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/button/SendButton.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/button/SendButton.kt
new file mode 100644
index 00000000..062120c1
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/button/SendButton.kt
@@ -0,0 +1,43 @@
+package com.sopt.clody.presentation.ui.writediary.component.button
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.sizeIn
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.sopt.clody.R
+import com.sopt.clody.ui.theme.ClodyTheme
+
+@Composable
+fun SendButton(
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+
+ Box(
+ modifier = modifier
+ .sizeIn(minWidth = 48.dp, minHeight = 48.dp)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = onClick,
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(R.string.write_diary_btn_send),
+ color = if (isPressed) ClodyTheme.colors.gray07 else ClodyTheme.colors.gray01,
+ style = ClodyTheme.typography.body2SemiBold,
+ )
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/text/DiaryTitleText.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/text/DiaryTitleText.kt
deleted file mode 100644
index a1376eae..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/text/DiaryTitleText.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.sopt.clody.presentation.ui.writediary.component.text
-
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import com.sopt.clody.ui.theme.ClodyTheme
-
-@Composable
-fun DiaryTitleText(date: String, separator: String, day: String, modifier: Modifier = Modifier) {
- Text(
- text = "$date$separator$day",
- style = ClodyTheme.typography.head2,
- color = ClodyTheme.colors.gray01,
- )
-}
-
-@Preview(showBackground = true)
-@Composable
-fun PreviewDiaryTitleText() {
- DiaryTitleText(date = "6월 26일", separator = " / ", day = "목요일")
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/textfield/WriteDiaryTextField.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/textfield/WriteDiaryTextField.kt
index 1f469b4c..0f0f0a35 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/textfield/WriteDiaryTextField.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/textfield/WriteDiaryTextField.kt
@@ -30,6 +30,7 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -52,7 +53,7 @@ fun WriteDiaryTextField(
) {
var isTextValid by remember {
mutableStateOf(
- text.replace("\\s".toRegex(), "").matches(Regex("^[a-zA-Z가-힣0-9ㄱ-ㅎㅏ-ㅣ가-힣\\W]{2,50}$")),
+ text.replace("\\s".toRegex(), "").matches(Regex("^[a-zA-Z가-힣0-9ㄱ-ㅎㅏ-ㅣ가-힣\\W]{2,$maxLength}$")),
)
}
var isFocused by remember { mutableStateOf(false) }
@@ -117,7 +118,7 @@ fun WriteDiaryTextField(
if (it.length <= maxLength) {
onTextChange(it)
val textWithoutSpaces = it.replace("\\s".toRegex(), "")
- isTextValid = textWithoutSpaces.matches(Regex("^[a-zA-Z가-힣0-9ㄱ-ㅎㅏ-ㅣ가-힣]{2,50}$"))
+ isTextValid = textWithoutSpaces.matches(Regex("^[a-zA-Z가-힣0-9ㄱ-ㅎㅏ-ㅣ가-힣]{2,$maxLength}$"))
isTextTooLong = false
} else {
isTextTooLong = true
@@ -130,7 +131,7 @@ fun WriteDiaryTextField(
decorationBox = { innerTextField ->
if (text.isEmpty()) {
Text(
- text = "일상 속 작은 감사함을 적어보세요",
+ text = stringResource(R.string.write_diary_text_field_hint),
style = ClodyTheme.typography.body3Medium,
color = ClodyTheme.colors.gray06,
)
@@ -142,14 +143,18 @@ fun WriteDiaryTextField(
.onFocusChanged { isFocused = it.isFocused },
)
- IconButton(
- onClick = onRemove,
- modifier = Modifier.size(28.dp),
- ) {
- Image(
- painter = painterResource(id = R.drawable.ic_writediary_kebab),
- contentDescription = "Remove",
- )
+ if (isRemovable) {
+ IconButton(
+ onClick = onRemove,
+ modifier = Modifier.size(28.dp),
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.ic_writediary_kebab),
+ contentDescription = "Remove",
+ )
+ }
+ } else {
+ Spacer(modifier = Modifier.size(28.dp))
}
}
}
@@ -159,9 +164,9 @@ fun WriteDiaryTextField(
.fillMaxWidth()
.padding(start = 8.dp, top = 6.dp),
) {
- if ((showWarning && !isTextValid && text.isNotEmpty()) || isTextTooLong) {
+ if ((showWarning && !isTextValid) || isTextTooLong) {
Text(
- text = "2~50자 까지 입력할 수 있어요.",
+ text = stringResource(R.string.write_diary_text_field_warning),
color = ClodyTheme.colors.red,
style = ClodyTheme.typography.detail1Medium,
modifier = Modifier.weight(1f),
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/topbar/WriteDiaryTopBar.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/topbar/WriteDiaryTopBar.kt
new file mode 100644
index 00000000..ac85584a
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/component/topbar/WriteDiaryTopBar.kt
@@ -0,0 +1,51 @@
+package com.sopt.clody.presentation.ui.writediary.component.topbar
+
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.sopt.clody.R
+import com.sopt.clody.presentation.ui.writediary.component.button.SendButton
+import com.sopt.clody.ui.theme.ClodyTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WriteDiaryTopBar(
+ modifier: Modifier = Modifier,
+ onClickBack: () -> Unit,
+ onClickSend: () -> Unit,
+ windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
+) {
+ TopAppBar(
+ modifier = modifier.fillMaxWidth(),
+ title = {},
+ navigationIcon = {
+ IconButton(onClick = onClickBack) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_nickname_back),
+ contentDescription = null,
+ tint = ClodyTheme.colors.gray01,
+ )
+ }
+ },
+ actions = {
+ SendButton(
+ modifier = Modifier
+ .padding(horizontal = 12.dp),
+ onClick = onClickSend,
+ )
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = ClodyTheme.colors.white,
+ ),
+ windowInsets = windowInsets,
+ )
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/navigation/WriteDiaryNavGraph.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/navigation/WriteDiaryNavGraph.kt
deleted file mode 100644
index 26703461..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/navigation/WriteDiaryNavGraph.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.sopt.clody.presentation.ui.writediary.navigation
-
-import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavType
-import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
-import com.sopt.clody.presentation.ui.writediary.screen.WriteDiaryRoute
-import java.time.LocalDate
-
-fun NavGraphBuilder.writeDiaryNavGraph(
- writeDiaryNavigator: WriteDiaryNavigator,
-) {
- composable(
- "write_diary/{year}/{month}/{day}",
- arguments = listOf(
- navArgument("year") { type = NavType.IntType },
- navArgument("month") { type = NavType.IntType },
- navArgument("day") { type = NavType.IntType },
- ),
- ) { backStackEntry ->
- val currentDate = LocalDate.now()
- val year = backStackEntry.arguments?.getInt("year") ?: currentDate.year
- val month = backStackEntry.arguments?.getInt("month") ?: currentDate.monthValue
- val day = backStackEntry.arguments?.getInt("day") ?: currentDate.dayOfMonth
- WriteDiaryRoute(writeDiaryNavigator, year, month, day)
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/navigation/WriteDiaryNavigation.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/navigation/WriteDiaryNavigation.kt
new file mode 100644
index 00000000..bdce0660
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/navigation/WriteDiaryNavigation.kt
@@ -0,0 +1,37 @@
+package com.sopt.clody.presentation.ui.writediary.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptionsBuilder
+import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import com.sopt.clody.presentation.ui.writediary.screen.WriteDiaryRoute
+import com.sopt.clody.presentation.utils.navigation.Route
+
+fun NavGraphBuilder.writeDiaryScreen(
+ navigateToReplyLoading: (year: Int, month: Int, day: Int) -> Unit,
+ navigateToHome: (year: Int, month: Int) -> Unit,
+ navigateToPrevious: () -> Unit,
+) {
+ composable { backStackEntry ->
+ backStackEntry.toRoute().apply {
+ WriteDiaryRoute(
+ year = year,
+ month = month,
+ date = date,
+ navigateToReplyLoading = navigateToReplyLoading,
+ navigateToHome = navigateToHome,
+ navigateToPrevious = navigateToPrevious,
+ )
+ }
+ }
+}
+
+fun NavController.navigateToWriteDiary(
+ year: Int,
+ month: Int,
+ day: Int,
+ navOptions: NavOptionsBuilder.() -> Unit = {},
+) {
+ navigate(Route.WriteDiary(year, month, day), navOptions)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/navigation/WriteDiaryNavigator.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/navigation/WriteDiaryNavigator.kt
deleted file mode 100644
index 9450a6cc..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/navigation/WriteDiaryNavigator.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.sopt.clody.presentation.ui.writediary.navigation
-
-import androidx.navigation.NavHostController
-
-class WriteDiaryNavigator(
- val navController: NavHostController,
-) {
- fun navigateReplyLoading(year: Int, month: Int, day: Int, replyStatus: String = "READY_NOT_READ") {
- navController.navigate("reply_loading/$year/$month/$day?from=write_diary&replyStatus=$replyStatus")
- }
- fun navigateHome(selectedYear: Int, selectedMonth: Int) {
- navController.navigate("home/$selectedYear/$selectedMonth")
- }
- fun navigateBack() {
- navController.navigateUp()
- }
-}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryScreen.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryScreen.kt
index 31db1810..da0b7fa8 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryScreen.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryScreen.kt
@@ -1,64 +1,71 @@
package com.sopt.clody.presentation.ui.writediary.screen
-import androidx.compose.foundation.Image
+import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
import androidx.hilt.navigation.compose.hiltViewModel
import com.sopt.clody.R
import com.sopt.clody.presentation.ui.component.LoadingScreen
-import com.sopt.clody.presentation.ui.component.button.ClodyButton
import com.sopt.clody.presentation.ui.component.dialog.ClodyDialog
import com.sopt.clody.presentation.ui.component.dialog.FailureDialog
import com.sopt.clody.presentation.ui.component.toast.ClodyToastMessage
import com.sopt.clody.presentation.ui.writediary.component.bottomsheet.DeleteWriteDiaryBottomSheet
-import com.sopt.clody.presentation.ui.writediary.component.text.DiaryTitleText
+import com.sopt.clody.presentation.ui.writediary.component.button.AddDiaryEntryFAB
import com.sopt.clody.presentation.ui.writediary.component.textfield.WriteDiaryTextField
import com.sopt.clody.presentation.ui.writediary.component.tooltip.TooltipIcon
-import com.sopt.clody.presentation.ui.writediary.navigation.WriteDiaryNavigator
+import com.sopt.clody.presentation.ui.writediary.component.topbar.WriteDiaryTopBar
import com.sopt.clody.presentation.utils.amplitude.AmplitudeConstraints
import com.sopt.clody.presentation.utils.amplitude.AmplitudeUtils
+import com.sopt.clody.presentation.utils.base.BasePreview
+import com.sopt.clody.presentation.utils.base.ClodyPreview
+import com.sopt.clody.presentation.utils.extension.LaunchedEffectWhenStarted
import com.sopt.clody.presentation.utils.extension.getDayOfWeek
import com.sopt.clody.presentation.utils.extension.heightForScreenPercentage
+import com.sopt.clody.presentation.utils.extension.toLocalizedMonthLabel
import com.sopt.clody.ui.theme.ClodyTheme
@Composable
fun WriteDiaryRoute(
- navigator: WriteDiaryNavigator,
year: Int,
month: Int,
- day: Int,
+ date: Int,
+ navigateToReplyLoading: (year: Int, month: Int, date: Int) -> Unit,
+ navigateToHome: (year: Int, month: Int) -> Unit,
+ navigateToPrevious: () -> Unit,
viewModel: WriteDiaryViewModel = hiltViewModel(),
) {
+ val writeDiaryState by viewModel.writeDiaryState.collectAsState()
+ val showFailureDialog by viewModel.showFailureDialog.collectAsState()
+ val failureMessage by viewModel.failureMessage.collectAsState()
val entries = viewModel.entries
val showWarnings = viewModel.showWarnings
val showLimitMessage by viewModel::showLimitMessage
@@ -66,238 +73,274 @@ fun WriteDiaryRoute(
val showDeleteBottomSheet by viewModel::showDeleteBottomSheet
val entryToDelete by viewModel::entryToDelete
val showDialog by viewModel::showDialog
- val writeDiaryState by viewModel.writeDiaryState.collectAsState()
- val showFailureDialog by viewModel.showFailureDialog.collectAsState()
- val failureMessage by viewModel.failureMessage.collectAsState()
+ val showExitDialog by viewModel::showExitDialog
+ val diaryMaxLength by viewModel.diaryMaxLength.collectAsState()
- val allFieldsEmpty by remember {
- derivedStateOf { entries.all { it.isEmpty() } }
+ LaunchedEffectWhenStarted {
+ viewModel.fetchDraftDiary(year, month, date)
}
LaunchedEffect(writeDiaryState) {
when (writeDiaryState) {
- is WriteDiaryState.Success -> navigator.navigateReplyLoading(year, month, day)
- is WriteDiaryState.NoReply -> navigator.navigateHome(year, month)
+ is WriteDiaryState.Success -> navigateToReplyLoading(year, month, date)
+ is WriteDiaryState.NoReply -> navigateToHome(year, month)
is WriteDiaryState.Failure -> viewModel.updateShowDialog(false)
else -> {}
}
}
+ BackHandler {
+ if (showExitDialog) {
+ viewModel.updateShowExitDialog(false)
+ } else {
+ if (viewModel.hasChangedFromInitial()) {
+ AmplitudeUtils.trackEvent(AmplitudeConstraints.WRITING_DIARY_BACK)
+ viewModel.updateShowExitDialog(true)
+ } else {
+ navigateToPrevious()
+ }
+ }
+ }
+
WriteDiaryScreen(
- viewModel = viewModel,
isLoading = writeDiaryState is WriteDiaryState.Loading,
entries = entries,
showWarnings = showWarnings,
showLimitMessage = showLimitMessage,
showEmptyFieldsMessage = showEmptyFieldsMessage,
showDeleteBottomSheet = showDeleteBottomSheet,
- entryToDelete = entryToDelete,
- allFieldsEmpty = allFieldsEmpty,
showDialog = showDialog,
+ showFailureDialog = showFailureDialog,
+ failureMessage = failureMessage,
+ showExitDialog = showExitDialog,
+ diaryMaxLength = diaryMaxLength,
onClickBack = {
- AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.WRITING_DIARY_BACK)
- navigator.navigateHome(year, month)
+ AmplitudeUtils.trackEvent(AmplitudeConstraints.WRITING_DIARY_BACK)
+ if (!viewModel.hasChangedFromInitial()) {
+ navigateToPrevious()
+ } else {
+ viewModel.updateShowExitDialog(true)
+ }
+ },
+ onClickAdd = {
+ AmplitudeUtils.trackEvent(AmplitudeConstraints.WRITING_DIARY_ADD_LIST)
+ viewModel.addEntry()
+ },
+ onClickRemove = { index ->
+ AmplitudeUtils.trackEvent(AmplitudeConstraints.WRITING_DIARY_DELETE_LIST)
+ viewModel.setEntryToDeleteIndex(index)
+ viewModel.updateShowDeleteBottomSheet(true)
+ },
+ onConfirmDelete = {
+ if (entryToDelete != -1) viewModel.removeEntry(entryToDelete)
+ },
+ onDismissDelete = { viewModel.updateShowDeleteBottomSheet(false) },
+ onTextChange = { index, text ->
+ viewModel.updateEntry(index, text)
+ viewModel.validateEntry(index, text)
+ },
+ onClickComplete = {
+ viewModel.validateEntries()
+ if (showWarnings.all { !it }) {
+ if (entries.size > 1 && entries.any { it.isEmpty() }) {
+ viewModel.updateShowEmptyFieldsMessage(true)
+ } else {
+ AmplitudeUtils.trackEvent(AmplitudeConstraints.WRITING_DIARY_COMPLETE)
+ viewModel.updateShowDialog(true)
+ }
+ }
+ },
+ onConfirmDialog = { viewModel.writeDiary(year, month, date, entries) },
+ onDismissDialog = {
+ AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.WRITING_DIARY_NO_COMPLETE)
+ viewModel.updateShowDialog(false)
+ },
+ onDismissLimitMessage = { viewModel.updateShowLimitMessage(false) },
+ onDismissEmptyFieldsMessage = { viewModel.updateShowEmptyFieldsMessage(false) },
+ onDismissFailureDialog = { viewModel.resetFailureDialog() },
+ onDismiss = { viewModel.updateShowExitDialog(false) },
+ onDismissExitDialog = {
+ viewModel.updateDraftUsage()
+ viewModel.updateShowExitDialog(false)
+ viewModel.saveDraftDiary(year, month, date)
+ navigateToHome(year, month)
+ },
+ onConfirmExitDialog = {
+ viewModel.updateShowExitDialog(false)
+ navigateToPrevious()
},
- onCompleteClick = { viewModel.writeDiary(year, month, day, entries) },
year = year,
month = month,
- day = day,
+ day = date,
)
-
- if (showFailureDialog) {
- FailureDialog(
- message = failureMessage,
- onDismiss = { viewModel.resetFailureDialog() },
- )
- }
}
@Composable
fun WriteDiaryScreen(
- viewModel: WriteDiaryViewModel,
isLoading: Boolean,
entries: List,
showWarnings: List,
showLimitMessage: Boolean,
showEmptyFieldsMessage: Boolean,
showDeleteBottomSheet: Boolean,
- entryToDelete: Int,
- allFieldsEmpty: Boolean,
showDialog: Boolean,
+ diaryMaxLength: Int,
onClickBack: () -> Unit,
- onCompleteClick: () -> Unit,
+ onClickAdd: () -> Unit,
+ onClickRemove: (Int) -> Unit,
+ onConfirmDelete: () -> Unit,
+ onDismissDelete: () -> Unit,
+ onTextChange: (Int, String) -> Unit,
+ onClickComplete: () -> Unit,
+ onConfirmDialog: () -> Unit,
+ onDismissDialog: () -> Unit,
+ onDismissLimitMessage: (Boolean) -> Unit,
+ onDismissEmptyFieldsMessage: (Boolean) -> Unit,
+ showFailureDialog: Boolean,
+ failureMessage: String,
+ showExitDialog: Boolean,
+ onDismissFailureDialog: () -> Unit,
+ onDismiss: () -> Unit,
+ onDismissExitDialog: () -> Unit,
+ onConfirmExitDialog: () -> Unit,
year: Int,
month: Int,
day: Int,
) {
val focusManager = LocalFocusManager.current
+ val density = LocalDensity.current
+ val imeBottom = WindowInsets.ime.getBottom(density)
+ val isKeyboardVisible = imeBottom > 0
Scaffold(
topBar = {
- IconButton(
- onClick = onClickBack,
- modifier = Modifier
- .statusBarsPadding()
- .padding(top = 26.dp)
- .padding(start = 12.dp),
- ) {
- Image(
- painter = painterResource(id = R.drawable.ic_nickname_back),
- contentDescription = null,
- )
- }
+ WriteDiaryTopBar(
+ onClickBack = onClickBack,
+ onClickSend = onClickComplete,
+ )
},
- bottomBar = {
+ content = { innerPadding ->
Box(
modifier = Modifier
- .fillMaxWidth()
- .navigationBarsPadding()
- .background(Color.Transparent),
- contentAlignment = Alignment.Center,
+ .fillMaxSize()
+ .background(ClodyTheme.colors.white)
+ .padding(innerPadding),
) {
Column(
- modifier = Modifier.align(Alignment.BottomCenter),
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 24.dp)
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() },
+ onClick = { focusManager.clearFocus() },
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(end = 24.dp)
- .padding(bottom = 28.dp),
- contentAlignment = Alignment.CenterEnd,
+ Spacer(modifier = Modifier.heightForScreenPercentage(0.017f))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
) {
- IconButton(
- onClick = {
- AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.WRITING_DIARY_ADD_LIST)
- if (entries.size < 5) {
- viewModel.addEntry()
- }
+ Text(
+ text = stringResource(
+ R.string.write_diary_month_date_day_of_week,
+ getDayOfWeek(year, month, day),
+ month.toLocalizedMonthLabel(),
+ day,
+ ),
+ style = ClodyTheme.typography.head2,
+ color = ClodyTheme.colors.gray01,
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ TooltipIcon(tooltipsText = stringResource(id = R.string.write_diary_help_message))
+ }
+ Spacer(modifier = Modifier.heightForScreenPercentage(0.02f))
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentPadding = PaddingValues(
+ bottom = with(density) {
+ (imeBottom - innerPadding.calculateBottomPadding().toPx()).coerceAtLeast(0f).toDp()
},
- enabled = entries.size < 5,
- modifier = Modifier
- .background(
- color = if (entries.size < 5) ClodyTheme.colors.gray02 else ClodyTheme.colors.gray06,
- shape = RoundedCornerShape(10.dp),
- )
- .size(41.dp),
- ) {
- Image(
- painter = painterResource(id = R.drawable.ic_writediary_add),
- contentDescription = "Add",
+ ),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ itemsIndexed(entries, key = { index, _ -> index }) { index, text ->
+ WriteDiaryTextField(
+ entryNumber = index + 1,
+ text = text,
+ onTextChange = { newText -> onTextChange(index, newText) },
+ onRemove = { onClickRemove(index) },
+ isRemovable = entries.size > 1,
+ maxLength = diaryMaxLength,
+ showWarning = showWarnings[index],
)
}
}
- ClodyButton(
- onClick = {
- AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.WRITING_DIARY_COMPLETE)
- viewModel.validateEntries()
- if (showWarnings.all { !it }) {
- if (entries.size > 1 && entries.any { it.isEmpty() }) {
- viewModel.updateShowEmptyFieldsMessage(true)
- } else {
- viewModel.updateShowDialog(true)
- }
- }
- },
- text = stringResource(R.string.write_diary_confirm_button),
- enabled = !allFieldsEmpty,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp)
- .padding(bottom = 28.dp),
- )
- }
+ if (showDeleteBottomSheet) {
+ DeleteWriteDiaryBottomSheet(
+ onDismissRequest = onDismissDelete,
+ onDeleteConfirm = onConfirmDelete,
+ )
+ }
- ShowToastMessages(
- showLimitMessage = showLimitMessage,
- showEmptyFieldsMessage = showEmptyFieldsMessage,
- onShowLimitMessageChange = { viewModel.updateShowLimitMessage(it) },
- onShowEmptyFieldsMessageChange = { viewModel.updateShowEmptyFieldsMessage(it) },
- modifier = Modifier.align(Alignment.Center),
- )
- }
- },
- content = { innerPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .background(ClodyTheme.colors.white)
- .padding(innerPadding)
- .padding(horizontal = 24.dp)
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() },
- ) { focusManager.clearFocus() },
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- Spacer(modifier = Modifier.heightForScreenPercentage(0.017f))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- DiaryTitleText(
- date = stringResource(R.string.write_diary_month_and_date, month, day),
- separator = " ",
- day = getDayOfWeek(year, month, day),
- )
- Spacer(modifier = Modifier.weight(1f))
- TooltipIcon(
- tooltipsText = stringResource(id = R.string.write_diary_help_message),
- )
- }
- Spacer(modifier = Modifier.heightForScreenPercentage(0.02f))
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- itemsIndexed(
- items = entries,
- key = { index, _ -> index },
- ) { index, text ->
- WriteDiaryTextField(
- entryNumber = index + 1,
- text = text,
- onTextChange = { newText ->
- viewModel.updateEntry(index, newText)
- viewModel.validateEntry(index, newText)
- },
- onRemove = {
- viewModel.setEntryToDeleteIndex(index)
- viewModel.updateShowDeleteBottomSheet(true)
- },
- isRemovable = entries.size > 1,
- maxLength = 50,
- showWarning = showWarnings[index],
+ if (showDialog) {
+ ClodyDialog(
+ onDismiss = onDismissDialog,
+ titleMassage = stringResource(R.string.dialog_write_diary_title),
+ descriptionMassage = stringResource(R.string.dialog_write_diary_description),
+ confirmOption = stringResource(R.string.dialog_write_diary_confirm),
+ dismissOption = stringResource(R.string.dialog_write_diary_dismiss),
+ confirmAction = onConfirmDialog,
+ confirmButtonColor = ClodyTheme.colors.mainYellow,
+ confirmButtonTextColor = ClodyTheme.colors.gray01,
)
}
- }
- if (showDeleteBottomSheet) {
- DeleteWriteDiaryBottomSheet(
- onDismissRequest = { viewModel.updateShowDeleteBottomSheet(false) },
- onDeleteConfirm = {
- AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.WRITING_DIARY_DELETE_LIST)
- if (entryToDelete != -1) {
- viewModel.removeEntry(entryToDelete)
- }
- },
- )
+ if (showFailureDialog) {
+ FailureDialog(
+ message = failureMessage,
+ onDismiss = onDismissFailureDialog,
+ )
+ }
+
+ if (showExitDialog) {
+ ClodyDialog(
+ titleMassage = stringResource(R.string.dialog_draft_save_title),
+ descriptionMassage = stringResource(R.string.dialog_draft_save_description),
+ confirmOption = stringResource(R.string.dialog_draft_save_confirm),
+ dismissOption = stringResource(R.string.dialog_draft_save_dismiss),
+ confirmAction = onConfirmExitDialog,
+ confirmButtonColor = ClodyTheme.colors.red,
+ confirmButtonTextColor = ClodyTheme.colors.white,
+ onDismiss = onDismiss,
+ onDismissButtonClick = onDismissExitDialog,
+ )
+ }
}
+ AddDiaryEntryFAB(
+ isKeyboardVisible = isKeyboardVisible,
+ isMaxReached = entries.size >= 5,
+ onClick = onClickAdd,
+ )
- if (showDialog) {
- ClodyDialog(
- onDismiss = {
- AmplitudeUtils.trackEvent(eventName = AmplitudeConstraints.WRITING_DIARY_NO_COMPLETE)
- viewModel.updateShowDialog(false)
- },
- titleMassage = stringResource(R.string.write_diary_dialog_title),
- descriptionMassage = stringResource(R.string.write_diary_dialog_description),
- confirmOption = stringResource(R.string.write_diary_dialog_confirm_option),
- dismissOption = stringResource(R.string.write_diary_dialog_dismiss_option),
- confirmAction = { onCompleteClick() },
- confirmButtonColor = ClodyTheme.colors.mainYellow,
- confirmButtonTextColor = ClodyTheme.colors.gray01,
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 12.dp)
+ .zIndex(1f),
+ contentAlignment = Alignment.Center,
+ ) {
+ ShowToastMessages(
+ showLimitMessage = showLimitMessage,
+ showEmptyFieldsMessage = showEmptyFieldsMessage,
+ onShowLimitMessageChange = onDismissLimitMessage,
+ onShowEmptyFieldsMessageChange = onDismissEmptyFieldsMessage,
+ modifier = Modifier.imePadding(),
)
}
}
@@ -314,27 +357,70 @@ private fun ShowToastMessages(
showEmptyFieldsMessage: Boolean,
onShowLimitMessageChange: (Boolean) -> Unit,
onShowEmptyFieldsMessageChange: (Boolean) -> Unit,
- modifier: Modifier,
+ modifier: Modifier = Modifier,
) {
- if (showLimitMessage) {
- ClodyToastMessage(
- message = stringResource(R.string.toast_limit_message),
- iconResId = R.drawable.ic_toast_error,
- backgroundColor = ClodyTheme.colors.gray04,
- contentColor = ClodyTheme.colors.white,
- durationMillis = 3000,
- onDismiss = { onShowLimitMessageChange(false) },
- )
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (showLimitMessage) {
+ ClodyToastMessage(
+ message = stringResource(R.string.toast_write_diary_entry_limit),
+ iconResId = R.drawable.ic_toast_error,
+ backgroundColor = ClodyTheme.colors.gray04,
+ contentColor = ClodyTheme.colors.white,
+ durationMillis = 3000,
+ onDismiss = { onShowLimitMessageChange(false) },
+ )
+ }
+
+ if (showEmptyFieldsMessage) {
+ ClodyToastMessage(
+ message = stringResource(R.string.toast_write_diary_entry_empty),
+ iconResId = R.drawable.ic_toast_error,
+ backgroundColor = ClodyTheme.colors.gray04,
+ contentColor = ClodyTheme.colors.white,
+ durationMillis = 3000,
+ onDismiss = { onShowEmptyFieldsMessageChange(false) },
+ )
+ }
}
+}
- if (showEmptyFieldsMessage) {
- ClodyToastMessage(
- message = stringResource(R.string.toast_empty_fields_message),
- iconResId = R.drawable.ic_toast_error,
- backgroundColor = ClodyTheme.colors.gray04,
- contentColor = ClodyTheme.colors.white,
- durationMillis = 3000,
- onDismiss = { onShowEmptyFieldsMessageChange(false) },
+@ClodyPreview
+@Composable
+private fun WriteDiaryScreenPreview() {
+ BasePreview {
+ WriteDiaryScreen(
+ isLoading = false,
+ entries = listOf("Entry 1", "Entry 2", "Entry 3"),
+ showWarnings = listOf(false, true, false),
+ showLimitMessage = false,
+ showEmptyFieldsMessage = false,
+ showDeleteBottomSheet = false,
+ showDialog = false,
+ diaryMaxLength = 100,
+ onClickBack = {},
+ onClickAdd = {},
+ onClickRemove = {},
+ onConfirmDelete = {},
+ onDismissDelete = {},
+ onTextChange = { _, _ -> },
+ onClickComplete = {},
+ onConfirmDialog = {},
+ onDismissDialog = {},
+ onDismissLimitMessage = {},
+ onDismissEmptyFieldsMessage = {},
+ showFailureDialog = false,
+ failureMessage = "",
+ showExitDialog = false,
+ onDismissFailureDialog = {},
+ onDismiss = {},
+ onDismissExitDialog = {},
+ onConfirmExitDialog = {},
+ year = 2023,
+ month = 10,
+ day = 5,
)
}
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryViewModel.kt b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryViewModel.kt
index 9fe15c3b..fff225a0 100644
--- a/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryViewModel.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/ui/writediary/screen/WriteDiaryViewModel.kt
@@ -7,21 +7,32 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.sopt.clody.data.remote.util.NetworkUtil
+import com.sopt.clody.core.network.NetworkConnectivityObserver
+import com.sopt.clody.core.network.NetworkStatus
import com.sopt.clody.domain.repository.DiaryRepository
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_NETWORK_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE
-import com.sopt.clody.presentation.utils.network.ErrorMessages.UNKNOWN_ERROR
+import com.sopt.clody.domain.repository.DraftRepository
+import com.sopt.clody.domain.usecase.FetchDraftDiaryUseCase
+import com.sopt.clody.domain.usecase.SaveDraftDiaryUseCase
+import com.sopt.clody.presentation.utils.extension.convertDateToKstDateTime
+import com.sopt.clody.presentation.utils.language.LanguageProvider
+import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
+import java.time.LocalDate
import javax.inject.Inject
@HiltViewModel
class WriteDiaryViewModel @Inject constructor(
private val diaryRepository: DiaryRepository,
- private val networkUtil: NetworkUtil,
+ private val fetchDraftDiaryUseCase: FetchDraftDiaryUseCase,
+ private val saveDraftDiaryUseCase: SaveDraftDiaryUseCase,
+ private val networkConnectivityObserver: NetworkConnectivityObserver,
+ private val draftRepository: DraftRepository,
+ private val languageProvider: LanguageProvider,
+ private val errorMessageProvider: ErrorMessageProvider,
) : ViewModel() {
private val _writeDiaryState = MutableStateFlow(WriteDiaryState.Idle)
@@ -54,29 +65,42 @@ class WriteDiaryViewModel @Inject constructor(
var showDialog by mutableStateOf(false)
private set
+ var showExitDialog by mutableStateOf(false)
+ private set
+
+ private var initialEntries: List = emptyList()
+
+ private val _diaryMaxLength = MutableStateFlow(languageProvider.getDiaryMaxLength())
+ val diaryMaxLength: StateFlow = _diaryMaxLength
+
fun writeDiary(year: Int, month: Int, day: Int, contents: List) {
viewModelScope.launch {
- if (!networkUtil.isNetworkAvailable()) {
- _failureMessage.value = FAILURE_NETWORK_MESSAGE
+ if (networkConnectivityObserver.networkStatus.first() == NetworkStatus.Unavailable) {
+ _failureMessage.value = errorMessageProvider.getNetworkError()
_showFailureDialog.value = true
return@launch
}
_writeDiaryState.value = WriteDiaryState.Loading
- val date = String.format("%04d-%02d-%02d", year, month, day)
- val result = diaryRepository.writeDiary(date, contents)
+ val lang = languageProvider.getCurrentLanguageTag()
+ val date = convertDateToKstDateTime(year, month, day)
+ val result = diaryRepository.writeDiary(lang, date, contents)
_writeDiaryState.value = result.fold(
onSuccess = { response ->
- when (response.replyType) {
- "DELETED" -> WriteDiaryState.NoReply
- else -> WriteDiaryState.Success(response.createdAt)
+ if (isDiaryExpired(year, month, day)) {
+ WriteDiaryState.NoReply
+ } else {
+ when (response.replyType) {
+ "DELETED" -> WriteDiaryState.NoReply
+ else -> WriteDiaryState.Success(response.createdAt)
+ }
}
},
onFailure = {
_failureMessage.value = if (it.message?.contains("200") == false) {
- FAILURE_TEMPORARY_MESSAGE
+ errorMessageProvider.getTemporaryError()
} else {
- it.localizedMessage ?: UNKNOWN_ERROR
+ it.localizedMessage ?: errorMessageProvider.getUnknownError()
}
_showFailureDialog.value = true
WriteDiaryState.Failure(_failureMessage.value)
@@ -85,6 +109,12 @@ class WriteDiaryViewModel @Inject constructor(
}
}
+ private fun isDiaryExpired(year: Int, month: Int, day: Int): Boolean {
+ val diaryDate = LocalDate.of(year, month, day)
+ val yesterday = LocalDate.now().minusDays(1)
+ return diaryDate.isBefore(yesterday)
+ }
+
fun resetFailureDialog() {
_showFailureDialog.value = false
_failureMessage.value = ""
@@ -131,7 +161,7 @@ class WriteDiaryViewModel @Inject constructor(
private fun isValidEntry(text: String): Boolean {
val textWithoutSpaces = text.replace("\\s".toRegex(), "")
- return textWithoutSpaces.matches(Regex(ENTRY_REGEX))
+ return textWithoutSpaces.matches(Regex("^[a-zA-Z가-힣0-9ㄱ-ㅎㅏ-ㅣ가-힣\\W]{2,${_diaryMaxLength.value}}$"))
}
private fun checkLimitMessage() {
@@ -162,8 +192,70 @@ class WriteDiaryViewModel @Inject constructor(
entryToDelete = index
}
+ fun updateShowExitDialog(show: Boolean) {
+ showExitDialog = show
+ }
+
+ fun hasChangedFromInitial(): Boolean {
+ if (initialEntries.isEmpty()) return false
+ val current = entries.map { it.trim() }
+ val initial = initialEntries.map { it.trim() }
+ return current != initial
+ }
+
+ fun fetchDraftDiary(year: Int, month: Int, day: Int) {
+ viewModelScope.launch {
+ _entries.clear()
+ _showWarnings.clear()
+
+ val result = fetchDraftDiaryUseCase(year, month, day)
+ result.onSuccess { response ->
+ val drafts = response.draftDiaries.ifEmpty { listOf("") }
+ _entries.addAll(drafts)
+ initialEntries = drafts.toList()
+
+ _showWarnings.addAll(List(_entries.size) { false })
+ checkLimitMessage()
+ checkEmptyFieldsMessage()
+ }.onFailure {
+ ensureDefaultEntry()
+ _failureMessage.value = errorMessageProvider.getFetchTempDiaryFailedError()
+ _showFailureDialog.value = true
+ }
+ }
+ }
+
+ fun saveDraftDiary(year: Int, month: Int, day: Int) {
+ viewModelScope.launch {
+ val date = String.format("%04d-%02d-%02d", year, month, day)
+ val result = saveDraftDiaryUseCase(date, _entries.toList())
+ result.onSuccess {
+ _failureMessage.value = ""
+ _showFailureDialog.value = false
+ }.onFailure { e ->
+ _failureMessage.value = e.localizedMessage ?: errorMessageProvider.getUnknownError()
+ _showFailureDialog.value = true
+ }
+ }
+ }
+
+ private fun ensureDefaultEntry() {
+ _entries.clear()
+ _entries.add("")
+ _showWarnings.clear()
+ _showWarnings.add(false)
+ checkLimitMessage()
+ checkEmptyFieldsMessage()
+ }
+
+ fun updateDraftUsage() {
+ if (!draftRepository.getIsDraftUsed()) {
+ draftRepository.setIsDraftUsed(true)
+ draftRepository.setIsFirstUse(true)
+ }
+ }
+
companion object {
const val MAX_ENTRIES = 5
- const val ENTRY_REGEX = "^[a-zA-Z가-힣0-9ㄱ-ㅎㅏ-ㅣ가-힣\\W]{2,50}$"
}
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/OpenExternalBrowser.kt b/app/src/main/java/com/sopt/clody/presentation/utils/OpenExternalBrowser.kt
new file mode 100644
index 00000000..aaab7895
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/OpenExternalBrowser.kt
@@ -0,0 +1,17 @@
+package com.sopt.clody.presentation.utils
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+
+fun openExternalBrowser(context: Context, url: String) {
+ val uri = Uri.parse(url)
+ val intent = Intent(Intent.ACTION_VIEW, uri).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+
+ // 웹 브라우저 앱이 설치되어 있지 않은 경우
+ context.packageManager.resolveActivity(intent, 0)?.let {
+ context.startActivity(intent)
+ } ?: return
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/appupdate/AppUpdateUtils.kt b/app/src/main/java/com/sopt/clody/presentation/utils/appupdate/AppUpdateUtils.kt
index 1295fcc9..cc4b2b57 100644
--- a/app/src/main/java/com/sopt/clody/presentation/utils/appupdate/AppUpdateUtils.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/appupdate/AppUpdateUtils.kt
@@ -3,27 +3,41 @@ package com.sopt.clody.presentation.utils.appupdate
import android.app.Activity
import android.content.Context
import android.content.Intent
-import android.net.Uri
+import android.util.Log
+import androidx.core.net.toUri
object AppUpdateUtils {
+ private const val PLAY_STORE_PACKAGE = "com.android.vending"
+ private const val MARKET_URI_PREFIX = "market://details?id="
+ private const val WEB_URI_PREFIX = "https://play.google.com/store/apps/details?id="
+
/**
* 마켓 이동
* @param context Context
*/
fun navigateToMarket(context: Context) {
val packageName = context.packageName
- val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")).apply {
+
+ val marketIntent = Intent(Intent.ACTION_VIEW, "$MARKET_URI_PREFIX$packageName".toUri()).apply {
+ setPackage(PLAY_STORE_PACKAGE)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ val webIntent = Intent(Intent.ACTION_VIEW, "$WEB_URI_PREFIX$packageName".toUri()).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
- if (marketIntent.resolveActivity(context.packageManager) != null) {
- context.startActivity(marketIntent)
- } else {
- val webIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=$packageName")).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ runCatching {
+ val safeIntent = if (marketIntent.resolveActivity(context.packageManager) != null) {
+ marketIntent
+ } else {
+ webIntent
}
- context.startActivity(webIntent)
+ context.startActivity(safeIntent)
+ }.onFailure {
+ // 예외 상황 처리 (마켓 앱도, 브라우저도 없는 극단적 상황 과연 있을까?)
+ Log.e("AppUpdateUtils", "Failed to open market", it)
}
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/base/ClodyPreview.kt b/app/src/main/java/com/sopt/clody/presentation/utils/base/ClodyPreview.kt
new file mode 100644
index 00000000..d6e8001a
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/base/ClodyPreview.kt
@@ -0,0 +1,23 @@
+package com.sopt.clody.presentation.utils.base
+
+import android.content.res.Configuration
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewScreenSizes
+import com.sopt.clody.ui.theme.ClodyTheme
+
+// 아래 폴드는 예시이고 fontScale 같은 값도 조정이 가능합니다.
+// @Preview(name = "Galaxy Z Fold3 접힌화면 (840x2289)", widthDp = 320, heightDp = 870, showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
+@PreviewScreenSizes
+annotation class ClodyPreview
+
+@Composable
+fun BasePreview(content: @Composable () -> Unit = {}) {
+ ClodyTheme {
+ Surface(color = ClodyTheme.colors.white) {
+ content()
+ }
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/extension/DateLabelExtension.kt b/app/src/main/java/com/sopt/clody/presentation/utils/extension/DateLabelExtension.kt
new file mode 100644
index 00000000..f326f70c
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/extension/DateLabelExtension.kt
@@ -0,0 +1,23 @@
+package com.sopt.clody.presentation.utils.extension
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import com.sopt.clody.R
+import kotlinx.datetime.Month
+import java.time.format.TextStyle
+import java.util.Locale
+
+@Composable
+fun Int.toLocalizedYearLabel(): String = stringResource(R.string.year_format, this)
+
+@Composable
+fun Int.toLocalizedMonthLabel(): String {
+ val locales = LocalContext.current.resources.configuration.locales
+ val locale = if (locales.isEmpty) Locale.getDefault() else locales[0]
+ return if (locale.language == "ko") {
+ stringResource(R.string.month_format, this)
+ } else {
+ Month.of(this).getDisplayName(TextStyle.FULL, Locale.ENGLISH)
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/extension/GetDayOfWeek.kt b/app/src/main/java/com/sopt/clody/presentation/utils/extension/GetDayOfWeek.kt
index 52ceb54f..d07a5ab8 100644
--- a/app/src/main/java/com/sopt/clody/presentation/utils/extension/GetDayOfWeek.kt
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/extension/GetDayOfWeek.kt
@@ -1,10 +1,13 @@
package com.sopt.clody.presentation.utils.extension
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalConfiguration
import java.time.LocalDate
import java.time.format.TextStyle
-import java.util.Locale
+@Composable
fun getDayOfWeek(year: Int, month: Int, day: Int): String {
val date = LocalDate.of(year, month, day)
- return date.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.KOREAN)
+ val locale = LocalConfiguration.current.locales.let { if (it.isEmpty) java.util.Locale.getDefault() else it[0] }
+ return date.dayOfWeek.getDisplayName(TextStyle.FULL, locale)
}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/extension/LifeCycle.kt b/app/src/main/java/com/sopt/clody/presentation/utils/extension/LifeCycle.kt
new file mode 100644
index 00000000..8e0208ab
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/extension/LifeCycle.kt
@@ -0,0 +1,55 @@
+package com.sopt.clody.presentation.utils.extension
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * [LifecycleOwner]의 생명주기가 [Lifecycle.State.STARTED] 상태 이상일 때만
+ * 지정한 [block]을 실행하고, 그렇지 않으면 자동으로 중단.
+ *
+ * 내부적으로 [lifecycleScope]에서 코루틴을 실행하고,
+ * [Lifecycle.repeatOnLifecycle]을 STARTED 상태 기준으로 래핑.
+ *
+ * @param block STARTED 상태에서 실행할 suspend 함수 블록
+ *
+ * @see Lifecycle.repeatOnLifecycle
+ * @see Lifecycle.State.STARTED
+ */
+
+fun LifecycleOwner.repeatOnStarted(block: suspend CoroutineScope.() -> Unit) {
+ lifecycleScope.launch {
+ lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED, block)
+ }
+}
+
+/**
+ * [LifecycleOwner]가 [lifecycleState] 이상일 때만 [block]을 실행하는 [LaunchedEffect] 기반의 Composable 유틸함수.
+ * 내부적으로 [Lifecycle.repeatOnLifecycle]을 사용하여 생명주기 안전성을 보장.
+ * SideEffect 처리를 lifecycle-aware하게 실행하고 싶을 때 사용하면 됨.
+ *
+ * @param key [LaunchedEffect]를 트리거할 key. 일반적으로 의존성이 되는 상태나 객체.
+ * @param lifecycleState 반복 실행을 시작할 최소 생명주기 상태 (기본값: STARTED)
+ * @param block 지정한 생명주기 상태 이상일 때만 실행할 suspend 블록
+ */
+
+@Composable
+fun LaunchedEffectWhenStarted(
+ key: Any? = Unit,
+ lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
+ block: suspend CoroutineScope.() -> Unit,
+) {
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ LaunchedEffect(key, lifecycleOwner) {
+ lifecycleOwner.lifecycle.repeatOnLifecycle(lifecycleState) {
+ block()
+ }
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/extension/TimePeriod.kt b/app/src/main/java/com/sopt/clody/presentation/utils/extension/TimePeriod.kt
new file mode 100644
index 00000000..e40ce77f
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/extension/TimePeriod.kt
@@ -0,0 +1,15 @@
+package com.sopt.clody.presentation.utils.extension
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.sopt.clody.R
+
+enum class TimePeriod(@StringRes val labelResId: Int) {
+ AM(R.string.time_am),
+ PM(R.string.time_pm),
+ ;
+
+ @Composable
+ fun getLabel(): String = stringResource(labelResId)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/extension/TimeZoneExt.kt b/app/src/main/java/com/sopt/clody/presentation/utils/extension/TimeZoneExt.kt
new file mode 100644
index 00000000..4314fdb4
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/extension/TimeZoneExt.kt
@@ -0,0 +1,82 @@
+package com.sopt.clody.presentation.utils.extension
+
+import java.time.LocalDate
+import java.time.LocalTime
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+
+/**
+ * @param time 서버로부터 수신받은 시간으로 "21:30" 와 같은 형태로 전달받는다.
+ * */
+fun convertKSTtoUTZ(time: String, referenceDate: LocalDate = LocalDate.now()): Triple {
+ val kstZoneId = ZoneId.of("Asia/Seoul")
+ val userZoneId = ZoneId.systemDefault()
+
+ val formatter = DateTimeFormatter.ofPattern("HH:mm")
+ val localTime = LocalTime.parse(time, formatter)
+
+ val kstDateTime = ZonedDateTime.of(referenceDate, localTime, kstZoneId)
+ val userDateTime = kstDateTime.withZoneSameInstant(userZoneId)
+
+ val hour24 = userDateTime.hour
+ val minute = userDateTime.minute
+
+ val timePeriod = if (hour24 < 12) TimePeriod.AM else TimePeriod.PM
+ val hour12 = when {
+ hour24 == 0 -> 12
+ hour24 > 12 -> hour24 - 12
+ else -> hour24
+ }
+
+ val hourFormatted = hour12.toString()
+ val minuteFormatted = String.format("%02d", minute)
+
+ return Triple(timePeriod, hourFormatted, minuteFormatted)
+}
+
+/**
+ * @param timePeriod 오전/오후
+ * @param hour 시간
+ * @param minute 분
+ * */
+fun convertUTZtoKST(timePeriod: TimePeriod, hour: String, minute: String, referenceDate: LocalDate = LocalDate.now()): String {
+ val userZoneId = ZoneId.systemDefault()
+ val kstZoneId = ZoneId.of("Asia/Seoul")
+
+ val hour24 = when (timePeriod) {
+ TimePeriod.AM -> if (hour == "12") 0 else hour.toInt()
+ TimePeriod.PM -> if (hour == "12") 12 else hour.toInt() + 12
+ }
+
+ val userTime = LocalTime.of(hour24, minute.toInt())
+ val userZoned = ZonedDateTime.of(referenceDate, userTime, userZoneId)
+ val kstZoned = userZoned.withZoneSameInstant(kstZoneId)
+
+ val kstHour = kstZoned.hour
+ val kstMinute = kstZoned.minute
+
+ return String.format(java.util.Locale.ROOT, "%02d:%02d", kstHour, kstMinute)
+}
+
+/**
+ * 일기작성 API 호출 시 유저의 현재 시점(연/월/일/시각)을 KST 시간대로 변환 후 "yyyy-MM-dd'T'HH:mm:ss" 형식으로 전달한다.
+ * @param year 작성된 일기의 연도
+ * @param month 작성된 일기의 월
+ * @param day 작성된 일기의 일
+ * */
+fun convertDateToKstDateTime(year: Int, month: Int, day: Int): String {
+ val localNowDate = LocalDate.now()
+ val targetDate = LocalDate.of(year, month, day)
+ val kstZone = ZoneId.of("Asia/Seoul")
+ val nowKst = ZonedDateTime.now(kstZone)
+
+ val targetZonedDateTime = if (targetDate == localNowDate) {
+ nowKst
+ } else {
+ nowKst.minusDays(1)
+ }
+
+ val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss")
+ return targetZonedDateTime.format(formatter)
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/extension/YearMonthLabelUtil.kt b/app/src/main/java/com/sopt/clody/presentation/utils/extension/YearMonthLabelUtil.kt
new file mode 100644
index 00000000..0e1896e2
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/extension/YearMonthLabelUtil.kt
@@ -0,0 +1,6 @@
+package com.sopt.clody.presentation.utils.extension
+
+object YearMonthLabelUtil {
+ const val MIN_YEAR = 2000
+ const val MAX_YEAR = 2030
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProvider.kt b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProvider.kt
new file mode 100644
index 00000000..57a7889a
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProvider.kt
@@ -0,0 +1,12 @@
+package com.sopt.clody.presentation.utils.language
+
+import com.sopt.clody.data.datastore.OAuthProvider
+import com.sopt.clody.presentation.ui.setting.screen.SettingOptionUrls
+
+interface LanguageProvider {
+ fun getCurrentLanguageTag(): String
+ fun getLoginType(): OAuthProvider
+ fun getNicknameMaxLength(): Int
+ fun getDiaryMaxLength(): Int
+ fun getWebViewUrlFor(option: SettingOptionUrls): String
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProviderImpl.kt b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProviderImpl.kt
new file mode 100644
index 00000000..933ecc97
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/language/LanguageProviderImpl.kt
@@ -0,0 +1,35 @@
+package com.sopt.clody.presentation.utils.language
+
+import com.sopt.clody.data.datastore.OAuthProvider
+import com.sopt.clody.presentation.ui.setting.screen.SettingOptionUrls
+import java.util.Locale
+import javax.inject.Inject
+
+class LanguageProviderImpl @Inject constructor() : LanguageProvider {
+ private val locale = Locale.getDefault()
+
+ private fun isKorean(): Boolean = locale.language == LANGUAGE_KO
+
+ override fun getCurrentLanguageTag(): String =
+ locale.toLanguageTag() // e.g., "ko-KR" or "en-US"
+
+ override fun getLoginType(): OAuthProvider =
+ if (isKorean()) OAuthProvider.KAKAO else OAuthProvider.GOOGLE
+
+ override fun getNicknameMaxLength(): Int =
+ if (isKorean()) NICKNAME_MAX_LENGTH_KO else NICKNAME_MAX_LENGTH_EN
+
+ override fun getDiaryMaxLength(): Int =
+ if (isKorean()) DIARY_MAX_LENGTH_KO else DIARY_MAX_LENGTH_EN
+
+ override fun getWebViewUrlFor(option: SettingOptionUrls): String =
+ if (isKorean()) option.koUrl else option.enUrl
+
+ companion object {
+ const val LANGUAGE_KO = "ko"
+ const val NICKNAME_MAX_LENGTH_EN = 15
+ const val NICKNAME_MAX_LENGTH_KO = 10
+ const val DIARY_MAX_LENGTH_EN = 100
+ const val DIARY_MAX_LENGTH_KO = 50
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/navigation/NavControllerExtensions.kt b/app/src/main/java/com/sopt/clody/presentation/utils/navigation/NavControllerExtensions.kt
new file mode 100644
index 00000000..982bbdd9
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/navigation/NavControllerExtensions.kt
@@ -0,0 +1,12 @@
+package com.sopt.clody.presentation.utils.navigation
+
+import androidx.navigation.NavController
+
+/**
+ * 현재 backStack 상단에서 popBackStack 동작을 수행하는 helper 함수.
+ */
+fun NavController.safePopBackStack() {
+ if (currentBackStackEntry?.lifecycle?.currentState == androidx.lifecycle.Lifecycle.State.RESUMED) {
+ popBackStack()
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/navigation/Route.kt b/app/src/main/java/com/sopt/clody/presentation/utils/navigation/Route.kt
new file mode 100644
index 00000000..d00a7cff
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/navigation/Route.kt
@@ -0,0 +1,81 @@
+package com.sopt.clody.presentation.utils.navigation
+
+import com.sopt.clody.domain.model.ReplyStatus
+import kotlinx.serialization.Serializable
+
+/**
+ * sealed interface로 정의한 앱 내 네비게이션 Route
+ * @Serializable을 활용해 arguments를 type-safe하게 전달.
+ */
+
+@Serializable
+sealed interface Route {
+
+ @Serializable
+ data object Splash : Route
+
+ @Serializable
+ data object Login : Route
+
+ @Serializable
+ data object SignUp : Route
+
+ @Serializable
+ data object TimeReminder : Route
+
+ @Serializable
+ data object Guide : Route
+
+ @Serializable
+ data class Home(
+ val selectedYear: Int,
+ val selectedMonth: Int,
+ val selectedDay: Int? = null,
+ val isFromReplyDiary: Boolean = false,
+ ) : Route
+
+ @Serializable
+ data class DiaryList(
+ val selectedYearFromHome: Int,
+ val selectedMonthFromHome: Int,
+ ) : Route
+
+ @Serializable
+ data class WriteDiary(
+ val year: Int,
+ val month: Int,
+ val date: Int,
+ ) : Route
+
+ @Serializable
+ data class ReplyLoading(
+ val year: Int,
+ val month: Int,
+ val date: Int,
+ val from: ReplyLoadingFrom = ReplyLoadingFrom.HOME,
+ val replyStatus: ReplyStatus = ReplyStatus.UNREADY,
+ ) : Route {
+ @Serializable
+ enum class ReplyLoadingFrom {
+ HOME,
+ DIARY_LIST,
+ }
+ }
+
+ @Serializable
+ data class ReplyDiary(
+ val year: Int,
+ val month: Int,
+ val date: Int,
+ val replyStatus: ReplyStatus = ReplyStatus.UNREADY,
+ ) : Route
+
+ @Serializable
+ data object Setting : Route
+
+ @Serializable
+ data object AccountManagement : Route
+
+ @Serializable
+ data object NotificationSetting : Route
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessageModule.kt b/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessageModule.kt
new file mode 100644
index 00000000..71eb58cc
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessageModule.kt
@@ -0,0 +1,22 @@
+package com.sopt.clody.presentation.utils.network
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ErrorMessageModule {
+
+ @Provides
+ @Singleton
+ fun provideErrorMessageProvider(
+ @ApplicationContext context: Context,
+ ): ErrorMessageProvider {
+ return ErrorMessageProviderImpl(context)
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessageProvider.kt b/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessageProvider.kt
new file mode 100644
index 00000000..bac76132
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessageProvider.kt
@@ -0,0 +1,16 @@
+package com.sopt.clody.presentation.utils.network
+
+import com.sopt.clody.data.remote.util.ApiError
+
+interface ErrorMessageProvider {
+ fun getTemporaryError(): String
+ fun getNetworkError(): String
+ fun getServerError(): String
+ fun getFetchTempDiaryFailedError(): String
+ fun getUnknownError(): String
+ fun getLoginFailedError(): String
+ fun getSignupFailedError(): String
+ fun getGoogleIdTokenMissingError(): String
+ fun getNetworkCheckError(): String
+ fun getApiError(apiError: ApiError): String
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessageProviderImpl.kt b/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessageProviderImpl.kt
new file mode 100644
index 00000000..13b6f193
--- /dev/null
+++ b/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessageProviderImpl.kt
@@ -0,0 +1,52 @@
+package com.sopt.clody.presentation.utils.network
+
+import android.content.Context
+import com.sopt.clody.R
+import com.sopt.clody.data.remote.util.ApiError
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+
+class ErrorMessageProviderImpl @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : ErrorMessageProvider {
+
+ override fun getTemporaryError(): String {
+ return context.getString(R.string.error_temporary)
+ }
+
+ override fun getNetworkError(): String {
+ return context.getString(R.string.error_network)
+ }
+
+ override fun getServerError(): String {
+ return context.getString(R.string.error_server)
+ }
+
+ override fun getFetchTempDiaryFailedError(): String {
+ return context.getString(R.string.error_fetch_temp_diary_failed)
+ }
+
+ override fun getUnknownError(): String {
+ return context.getString(R.string.error_unknown)
+ }
+
+ override fun getLoginFailedError(): String {
+ return context.getString(R.string.error_login_failed)
+ }
+
+ override fun getSignupFailedError(): String {
+ return context.getString(R.string.error_signup_failed)
+ }
+
+ override fun getGoogleIdTokenMissingError(): String {
+ return context.getString(R.string.error_google_id_token_missing)
+ }
+
+ override fun getNetworkCheckError(): String {
+ return context.getString(R.string.error_network_check)
+ }
+
+ override fun getApiError(apiError: ApiError): String {
+ return apiError.message
+ }
+}
diff --git a/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessages.kt b/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessages.kt
deleted file mode 100644
index 3aa8a1a2..00000000
--- a/app/src/main/java/com/sopt/clody/presentation/utils/network/ErrorMessages.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.sopt.clody.presentation.utils.network
-
-object ErrorMessages {
- const val FAILURE_NETWORK_MESSAGE = "서비스 접속이 원활하지 않아요.\n네트워크 연결을 확인해주세요."
- const val FAILURE_TEMPORARY_MESSAGE = "일시적인 오류가 발생했어요.\n잠시 후 다시 시도해주세요."
- const val FAILURE_SERVER_MESSAGE = "서버 오류가 발생했어요.\n잠시 후 다시 시도해주세요."
- const val UNKNOWN_ERROR = "알수없는 에러"
-}
diff --git a/app/src/main/java/com/sopt/clody/ui/theme/Theme.kt b/app/src/main/java/com/sopt/clody/ui/theme/Theme.kt
index 9d5045f6..c48dab6d 100644
--- a/app/src/main/java/com/sopt/clody/ui/theme/Theme.kt
+++ b/app/src/main/java/com/sopt/clody/ui/theme/Theme.kt
@@ -3,12 +3,29 @@ package com.sopt.clody.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalConfiguration
@Composable
-fun CLODYTheme(
+fun provideTypographyByLocale(): ClodyTypography {
+ val configuration = LocalConfiguration.current
+ val locale = remember(configuration) { configuration.locales[0] }
+
+ return if (locale.language == "ko") clodyKoreanTypography else clodyEnglishTypography
+}
+
+@Composable
+fun ClodyTheme(
content: @Composable () -> Unit,
) {
- CompositionLocalProvider(content = content)
+ val colors = defaultClodyColors
+ val typography = provideTypographyByLocale()
+
+ CompositionLocalProvider(
+ LocalClodyColors provides colors,
+ LocalClodyTypography provides typography,
+ content = content,
+ )
}
object ClodyTheme {
diff --git a/app/src/main/java/com/sopt/clody/ui/theme/Type.kt b/app/src/main/java/com/sopt/clody/ui/theme/Type.kt
index daa8bfee..bc07b181 100644
--- a/app/src/main/java/com/sopt/clody/ui/theme/Type.kt
+++ b/app/src/main/java/com/sopt/clody/ui/theme/Type.kt
@@ -7,145 +7,255 @@ import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.sopt.clody.R
+@Immutable
+data class ClodyTypography(
+ val head1: TextStyle,
+ val head2: TextStyle,
+ val head3: TextStyle,
+ val head3Medium: TextStyle,
+ val head4: TextStyle,
+ val body1SemiBold: TextStyle,
+ val body1Medium: TextStyle,
+ val body2SemiBold: TextStyle,
+ val body2Medium: TextStyle,
+ val body3SemiBold: TextStyle,
+ val body3Medium: TextStyle,
+ val body3Regular: TextStyle,
+ val body4SemiBold: TextStyle,
+ val body4Medium: TextStyle,
+ val detail1SemiBold: TextStyle,
+ val detail1Medium: TextStyle,
+ val detail1Regular: TextStyle,
+ val detail2SemiBold: TextStyle,
+ val detail2Medium: TextStyle,
+ val letterMedium: TextStyle,
+)
+
+fun TextUnit.lineHeight(ratio: Float): TextUnit = (this.value * ratio).sp
+
val pretendardFontFamily = FontFamily(
Font(R.font.pretendard_medium, FontWeight.Medium, FontStyle.Normal),
Font(R.font.pretendard_regular, FontWeight.Normal, FontStyle.Normal),
Font(R.font.pretendard_semibold, FontWeight.SemiBold, FontStyle.Normal),
)
-private val pretendardTextStyle = TextStyle(
+private val pretendardKoreanTextStyle = TextStyle(
fontFamily = pretendardFontFamily,
letterSpacing = (-0.2).sp,
)
-val defaultClodyTypography = ClodyTypography(
- head1 = pretendardTextStyle.copy(
+private val pretendardEnglishTextStyle = TextStyle(
+ fontFamily = pretendardFontFamily,
+)
+
+val clodyKoreanTypography = ClodyTypography(
+ head1 = pretendardKoreanTextStyle.copy(
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
- lineHeight = 33.sp,
+ lineHeight = 22.sp.lineHeight(1.5f),
),
- head2 = pretendardTextStyle.copy(
+ head2 = pretendardKoreanTextStyle.copy(
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
- lineHeight = 30.sp,
+ lineHeight = 20.sp.lineHeight(1.5f),
),
- head3 = pretendardTextStyle.copy(
+ head3 = pretendardKoreanTextStyle.copy(
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
- lineHeight = 27.sp,
+ lineHeight = 18.sp.lineHeight(1.5f),
),
- head3Medium = pretendardTextStyle.copy(
+ head3Medium = pretendardKoreanTextStyle.copy(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
- lineHeight = 27.sp,
+ lineHeight = 18.sp.lineHeight(1.5f),
),
- head4 = pretendardTextStyle.copy(
+ head4 = pretendardKoreanTextStyle.copy(
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
- lineHeight = 25.5.sp,
+ lineHeight = 17.sp.lineHeight(1.5f),
),
- body1SemiBold = pretendardTextStyle.copy(
+ body1SemiBold = pretendardKoreanTextStyle.copy(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
- lineHeight = 24.sp,
+ lineHeight = 16.sp.lineHeight(1.5f),
),
- body1Medium = pretendardTextStyle.copy(
+ body1Medium = pretendardKoreanTextStyle.copy(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
- lineHeight = 24.sp,
+ lineHeight = 16.sp.lineHeight(1.5f),
),
- body2SemiBold = pretendardTextStyle.copy(
+ body2SemiBold = pretendardKoreanTextStyle.copy(
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
- lineHeight = 22.5.sp,
+ lineHeight = 15.sp.lineHeight(1.5f),
),
- body2Medium = pretendardTextStyle.copy(
+ body2Medium = pretendardKoreanTextStyle.copy(
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
- lineHeight = 22.5.sp,
+ lineHeight = 15.sp.lineHeight(1.5f),
),
- body3SemiBold = pretendardTextStyle.copy(
+ body3SemiBold = pretendardKoreanTextStyle.copy(
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
- lineHeight = 21.sp,
+ lineHeight = 14.sp.lineHeight(1.5f),
),
- body3Medium = pretendardTextStyle.copy(
+ body3Medium = pretendardKoreanTextStyle.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
- lineHeight = 21.sp,
+ lineHeight = 14.sp.lineHeight(1.5f),
),
- body3Regular = pretendardTextStyle.copy(
+ body3Regular = pretendardKoreanTextStyle.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
- lineHeight = 21.sp,
+ lineHeight = 14.sp.lineHeight(1.5f),
),
- body4Medium = pretendardTextStyle.copy(
+ body4SemiBold = pretendardKoreanTextStyle.copy(
fontSize = 13.sp,
- fontWeight = FontWeight.Medium,
- lineHeight = 19.5.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 13.sp.lineHeight(1.5f),
),
- body4SemiBold = pretendardTextStyle.copy(
+ body4Medium = pretendardKoreanTextStyle.copy(
fontSize = 13.sp,
- fontWeight = FontWeight.SemiBold,
- lineHeight = 19.5.sp,
+ fontWeight = FontWeight.Medium,
+ lineHeight = 13.sp.lineHeight(1.5f),
),
- detail1SemiBold = pretendardTextStyle.copy(
+ detail1SemiBold = pretendardKoreanTextStyle.copy(
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
- lineHeight = 18.sp,
+ lineHeight = 12.sp.lineHeight(1.5f),
),
- detail1Medium = pretendardTextStyle.copy(
+ detail1Medium = pretendardKoreanTextStyle.copy(
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
- lineHeight = 18.sp,
+ lineHeight = 12.sp.lineHeight(1.5f),
),
- detail1Regular = pretendardTextStyle.copy(
+ detail1Regular = pretendardKoreanTextStyle.copy(
fontSize = 12.sp,
fontWeight = FontWeight.Normal,
- lineHeight = 18.sp,
+ lineHeight = 12.sp.lineHeight(1.5f),
),
- detail2SemiBold = pretendardTextStyle.copy(
+ detail2SemiBold = pretendardKoreanTextStyle.copy(
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
- lineHeight = 15.sp,
+ lineHeight = 10.sp.lineHeight(1.5f),
),
- detail2Medium = pretendardTextStyle.copy(
+ detail2Medium = pretendardKoreanTextStyle.copy(
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
- lineHeight = 15.sp,
+ lineHeight = 10.sp.lineHeight(1.5f),
),
- letterMedium = pretendardTextStyle.copy(
+ letterMedium = pretendardKoreanTextStyle.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
- lineHeight = 26.6.sp,
+ lineHeight = 14.sp.lineHeight(1.9f),
),
)
-@Immutable
-data class ClodyTypography(
- val head1: TextStyle,
- val head2: TextStyle,
- val head3: TextStyle,
- val head3Medium: TextStyle,
- val head4: TextStyle,
- val body1SemiBold: TextStyle,
- val body1Medium: TextStyle,
- val body2SemiBold: TextStyle,
- val body2Medium: TextStyle,
- val body3SemiBold: TextStyle,
- val body3Medium: TextStyle,
- val body3Regular: TextStyle,
- val body4SemiBold: TextStyle,
- val body4Medium: TextStyle,
- val detail1SemiBold: TextStyle,
- val detail1Medium: TextStyle,
- val detail1Regular: TextStyle,
- val detail2SemiBold: TextStyle,
- val detail2Medium: TextStyle,
- val letterMedium: TextStyle,
+val clodyEnglishTypography = ClodyTypography(
+ head1 = pretendardEnglishTextStyle.copy(
+ fontSize = 22.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 22.sp.lineHeight(1.4f),
+ ),
+ head2 = pretendardEnglishTextStyle.copy(
+ fontSize = 20.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 20.sp.lineHeight(1.4f),
+ ),
+ head3 = pretendardEnglishTextStyle.copy(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 18.sp.lineHeight(1.4f),
+ ),
+ head3Medium = pretendardEnglishTextStyle.copy(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ lineHeight = 18.sp.lineHeight(1.4f),
+ ),
+ head4 = pretendardEnglishTextStyle.copy(
+ fontSize = 17.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 17.sp.lineHeight(1.4f),
+ ),
+ body1SemiBold = pretendardEnglishTextStyle.copy(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 16.sp.lineHeight(1.3f),
+ ),
+ body1Medium = pretendardEnglishTextStyle.copy(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ lineHeight = 16.sp.lineHeight(1.3f),
+ ),
+ body2SemiBold = pretendardEnglishTextStyle.copy(
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 15.sp.lineHeight(1.3f),
+ ),
+ body2Medium = pretendardEnglishTextStyle.copy(
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Medium,
+ lineHeight = 15.sp.lineHeight(1.3f),
+ ),
+ body3SemiBold = pretendardEnglishTextStyle.copy(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 14.sp.lineHeight(1.3f),
+ ),
+ body3Medium = pretendardEnglishTextStyle.copy(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ lineHeight = 14.sp.lineHeight(1.3f),
+ ),
+ body3Regular = pretendardEnglishTextStyle.copy(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Normal,
+ lineHeight = 14.sp.lineHeight(1.3f),
+ ),
+ body4SemiBold = pretendardEnglishTextStyle.copy(
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 13.sp.lineHeight(1.3f),
+ ),
+ body4Medium = pretendardEnglishTextStyle.copy(
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Medium,
+ lineHeight = 13.sp.lineHeight(1.3f),
+ ),
+ detail1SemiBold = pretendardEnglishTextStyle.copy(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 12.sp.lineHeight(1.3f),
+ ),
+ detail1Medium = pretendardEnglishTextStyle.copy(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium,
+ lineHeight = 12.sp.lineHeight(1.3f),
+ ),
+ detail1Regular = pretendardEnglishTextStyle.copy(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Normal,
+ lineHeight = 12.sp.lineHeight(1.3f),
+ ),
+ detail2SemiBold = pretendardEnglishTextStyle.copy(
+ fontSize = 10.sp,
+ fontWeight = FontWeight.SemiBold,
+ lineHeight = 10.sp.lineHeight(1.3f),
+ ),
+ detail2Medium = pretendardEnglishTextStyle.copy(
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Medium,
+ lineHeight = 10.sp.lineHeight(1.3f),
+ ),
+ letterMedium = pretendardEnglishTextStyle.copy(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ lineHeight = 14.sp.lineHeight(1.8f),
+ ),
)
-val LocalClodyTypography = staticCompositionLocalOf { defaultClodyTypography }
+val LocalClodyTypography = staticCompositionLocalOf { clodyEnglishTypography }
diff --git a/app/src/main/res/drawable-xxhdpi/img_splash_logo.png b/app/src/main/res/drawable-ko/img_splash_logo.png
similarity index 100%
rename from app/src/main/res/drawable-xxhdpi/img_splash_logo.png
rename to app/src/main/res/drawable-ko/img_splash_logo.png
diff --git a/app/src/main/res/drawable-xhdpi/img_splash_logo.png b/app/src/main/res/drawable-xhdpi/img_splash_logo.png
deleted file mode 100644
index aa7878f5..00000000
Binary files a/app/src/main/res/drawable-xhdpi/img_splash_logo.png and /dev/null differ
diff --git a/app/src/main/res/drawable/ic_home_draft_saved_clover.xml b/app/src/main/res/drawable/ic_home_draft_saved_clover.xml
new file mode 100644
index 00000000..5b677aeb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_draft_saved_clover.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_home_expired_written_clover.xml b/app/src/main/res/drawable/ic_home_expired_written_clover.xml
new file mode 100644
index 00000000..8ca7a53c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_home_expired_written_clover.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_dev_background.xml b/app/src/main/res/drawable/ic_launcher_dev_background.xml
new file mode 100644
index 00000000..ca3826a4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_dev_background.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/img_google_button_logo.png b/app/src/main/res/drawable/img_google_button_logo.png
new file mode 100644
index 00000000..dfb8a747
Binary files /dev/null and b/app/src/main/res/drawable/img_google_button_logo.png differ
diff --git a/app/src/main/res/drawable/img_inspection_dialog.png b/app/src/main/res/drawable/img_inspection_dialog.png
new file mode 100644
index 00000000..892de40a
Binary files /dev/null and b/app/src/main/res/drawable/img_inspection_dialog.png differ
diff --git a/app/src/main/res/drawable/img_splash_logo.png b/app/src/main/res/drawable/img_splash_logo.png
index a562faec..568df3be 100644
Binary files a/app/src/main/res/drawable/img_splash_logo.png and b/app/src/main/res/drawable/img_splash_logo.png differ
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev.xml
new file mode 100644
index 00000000..a1465e77
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev_round.xml
new file mode 100644
index 00000000..a1465e77
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_dev_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_dev.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_dev.webp
new file mode 100644
index 00000000..19cc7930
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_dev.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_dev_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_dev_foreground.webp
new file mode 100644
index 00000000..70663309
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_dev_foreground.webp differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_dev_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_dev_round.webp
new file mode 100644
index 00000000..d3de5972
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_dev_round.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_dev.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_dev.webp
new file mode 100644
index 00000000..d8558d30
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_dev.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_dev_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_dev_foreground.webp
new file mode 100644
index 00000000..ef90b1a5
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_dev_foreground.webp differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_dev_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_dev_round.webp
new file mode 100644
index 00000000..e04d4157
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_dev_round.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_dev.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_dev.webp
new file mode 100644
index 00000000..3a4af545
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_dev.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_dev_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_dev_foreground.webp
new file mode 100644
index 00000000..f21e4e81
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_dev_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_dev_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_dev_round.webp
new file mode 100644
index 00000000..8457e5a9
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_dev_round.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_dev.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_dev.webp
new file mode 100644
index 00000000..504f3757
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_dev.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_dev_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_dev_foreground.webp
new file mode 100644
index 00000000..31f4cf3c
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_dev_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_dev_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_dev_round.webp
new file mode 100644
index 00000000..c529e029
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_dev_round.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_dev.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_dev.webp
new file mode 100644
index 00000000..b9a09b42
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_dev.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_dev_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_dev_foreground.webp
new file mode 100644
index 00000000..ba5a6609
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_dev_foreground.webp differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_dev_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_dev_round.webp
new file mode 100644
index 00000000..68293a57
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_dev_round.webp differ
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
new file mode 100644
index 00000000..913d128c
--- /dev/null
+++ b/app/src/main/res/values-ko/strings.xml
@@ -0,0 +1,210 @@
+
+
+ 클로디
+
+
+ 카카오로 로그인
+ Sign Up With Google
+
+
+ "Clody 이용을 위해\n약관에 동의해 주세요"
+ 전체 동의하기
+ (필수) 서비스 이용약관
+ (필수) 개인정보 처리방침
+ 다음
+
+
+ 만나서 반가워요!\n어떻게 불러 드릴까요?
+ 닉네임을 입력해주세요
+ 특수문자, 띄어쓰기 없이 작성해주세요
+ 사용할 수 없는 닉네임이에요
+ 다음
+
+
+ 몇시에 감사일기\n작성 알림을 드릴까요?
+ %1$s %2$s시 %3$s분
+ 완료
+ 다음에 설정할게요
+
+
+ 안녕하세요!\n저는 로디라고 해요
+ 여러분이 써준 감사일기를 받고,\n칭찬과 응원을 담아 답장을 쓴답니다
+
+ 답장마다 행운의\n네잎클로버를 함께 드려요
+ 하루에 받은 감사의 수가 많을수록\n색이 진한 네잎클로버를 전달해요
+
+ 오늘과 전날 일기만\n작성할 수 있어요
+ 그전이나 다음날의 일기는 작성할 수\n없으니, 잊지 말고 기록해주세요
+
+ 이제 일기를 써볼까요?\n기다리고 있을게요!
+ 두번째 일기부터는 네잎클로버를 찾는 데\n12시간이 걸리니 조금만 기다려 주세요
+
+ 다음
+ 시작하기
+
+
+ 클로버 %1$d개
+ %1$d년
+ %1$d월
+ %1$s %2$s
+
+ 작성된 감사 일기가 없어요!
+ 임시저장된 일기가 있어요.
+ %1$d. %2$s
+
+ 일기 쓰기
+ 답장 확인
+ 이어 쓰기
+
+
+ 보내기
+ %2$s %3$s일 %1$s
+ 신조어, 비속어, 이모지 작성은 불가능해요
+ 일상 속 작은 감사함을 적어보세요
+ 2~50자 까지 입력할 수 있어요.
+ 추가하기
+
+
+ 로디가 열심히 답장을 쓰고 있어요
+ 로디가 답장을 거의 다 써가요!\n조금만 기다려주세요
+ 로디가 쓴 행운의 답장이 도착했어요!
+ 광고 보고 바로 답장 받기
+ 열어보기
+
+
+ %1$s %2$s일
+ %1$s님을 위한 행운의 답장
+
+
+ %1$s %2$s
+ 작성된 감사일기가 없어요
+ %1$s일
+ /%1$s
+ 답장 확인
+
+
+ 설정
+ 프로필 및 계정 관리
+ 알림 설정
+ 공지사항
+ 문의/제안하기
+ 서비스 이용 약관
+ 개인정보 처리방침
+ 앱 버전
+ 최신 버전
+ 버전 정보를 불러오는데 실패했습니다.
+
+
+ 프로필 및 계정 관리
+ 님
+ 변경하기
+ 로그아웃
+ 계정을 삭제하시겠어요?
+ 회원탈퇴
+
+
+ 알림 설정
+ 일기 작성 알림 받기
+ 이어쓰기 알림 받기
+ 알림 시간
+ %1$s %2$s시 %3$s분
+ 답장 도착 알림 받기
+
+
+ 다시 시도
+ 확인
+
+
+ 업데이트 필요
+ 새로운 버전 %1$s을 사용할 수 있습니다.\n지금 업데이트하시겠습니까?
+ 업데이트
+ 나중에
+
+ 필수 업데이트
+ 버전 %1$s으로 업데이트가 필요합니다.
+ 업데이트
+ 앱 종료
+
+ 임시저장된 일기를 이어 쓸까요?
+ 답장 기한이 지나서 답장은 받을 수 없어요.
+ 이어쓰기
+ 아니오
+
+ 정말 일기를 삭제할까요?
+ 아직 답장이 오지 않았거나 삭제하고\n다시 작성한 일기는 답장을 받을 수 없어요.
+ 삭제할래요
+ 아니요
+
+ 일기를 로디에게 보낼까요?
+ 보낸 일기는 수정이 어려워요.
+ 보내기
+ 취소
+
+ 지금까지 쓴 일기를 임시저장할까요?
+ 나가기를 누르면 작성 중인 내용이 모두 사라져요.
+ 나가기
+ 임시저장
+
+ %1$s님을 위한 행운 도착
+ 1개의 네잎클로버 획득
+ 확인
+
+ 로그아웃 하시겠어요?
+ 기다릴게요, 다음에 다시 만나요!
+ 로그아웃
+ 아니요
+
+ 서비스를 탈퇴하시겠어요?
+ 작성하신 일기와 받은 답장 및 클로버가\n 모두 삭제되며 복구할 수 없어요.
+ 탈퇴할래요
+ 아니요
+
+
+ 이어쓰기 알림 설정을 완료했어요.
+ 최대 5개까지 작성할 수 있어요.
+ 빈 칸을 채워야 보낼 수 있어요.
+ 변경을 완료했어요.
+ 알람 시간 설정을 완료했어요.
+
+
+ 발송 시간 변경
+ 완료
+
+ 기한이 지나면\n로디의 답장을 받을 수 없어요!
+ 답장 마감 전에 일기를 이어쓸 수 있도록\n알려드리기 위해서는 알림 설정이 필요해요.
+ [설정 > 애플리케이션 > 클로디 > 알림 > 알림표시]
+ 알림 받기
+ 다음에 하기
+
+ 삭제하기
+
+ 다른 날짜 보기
+ 완료
+
+ 닉네임 변경
+ 특수문자, 띄어쓰기 없이 작성해주세요
+ 변경하기
+
+ 다른 시간 보기
+ 완료
+
+
+ 오전
+ 오후
+ 데이터를 불러오는데 실패했습니다.
+ 알 수 없는 오류가 발생했습니다.
+ 일기 삭제 중 오류가 발생했습니다.
+ Chrome이 설치되어 있어야 로그인이 가능합니다.
+ 루팅된 기기에서는 로그인할 수 없습니다.
+
+
+ 서비스 접속이 원활하지 않아요.\n네트워크 연결을 확인해주세요.
+ 일시적인 오류가 발생했어요.\n잠시 후 다시 시도해주세요.
+ 서버 오류가 발생했어요.\n잠시 후 다시 시도해주세요.
+ 임시저장 불러오기에 실패했어요.\n잠시 후 다시 시도해주세요.
+ 알수없는 에러
+ 로그인에 실패했어요.
+ 회원가입에 실패했어요.
+ Google ID Token이 없습니다.
+ 네트워크 연결을 확인해주세요.
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 54533794..5db7f1b3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,138 +1,211 @@
+
- 클로디
-
-
- 행운을 전하는 감사일기
- 카카오로 로그인
-
-
- 만나서 반가워요!\n어떻게 불러 드릴까요?
- 닉네임을 입력해주세요
- 다음
-
-
- "Clody 이용을 위해\n약관에 동의해 주세요"
- 전체 동의하기
- (필수) 서비스 이용약관
- (필수) 개인정보 처리방침
- 다음
-
-
- 다른 시간 보기
-
-
- 몇시에 감사일기\n작성 알림을 드릴까요?
- 다음에 설정할게요
- 완료
-
-
- 안녕하세요!
- 저는 로디라고 해요
- 여러분이 써준 감사일기를 받고,\n칭찬과 응원을 담아 답장을 쓴답니다
-
- 답장마다 행운의
- 네잎클로버를 함께 드려요
- 하루에 받은 감사의 수가 많을수록\n색이 진한 네잎클로버를 전달해요
-
- 오늘과 전날 일기만
- 작성할 수 있어요
- 그전이나 다음날의 일기는 작성할 수\n없으니, 잊지 말고 기록해주세요
-
- 이제 일기를 써볼까요?
- 기다리고 있을게요!
- 두번째 일기부터는 네잎클로버를 찾는 데\n12시간이 걸리니 조금만 기다려 주세요
-
- 다음
- 시작하기
-
-
- 로디가 열심히 답장을 쓰고 있어요
- 로디가 쓴 행운의 답장이 도착했어요!
- 로디가 답장을 거의 다 써가요!\n조금만 기다려주세요
- 열어보기
- 확인
- 답장 확인
-
-
- %1$d월 %2$d일
- %1$s님을 위한 행운의 답장
-
-
- %1$s님을 위한 행운 도착
- 1개의 네잎클로버 획득
- 확인
-
-
- 일기를 저장할까요?
- 저장한 일기는 수정이 어려워요.
- 저장하기
- 아니오
-
-
- 저장
- %1$d월 %2$d일
-
-
- 최대 5개까지 작성할 수 있어요.
- 모든 감사 일기 작성이 필요해요.
-
-
- 신조어, 비속어, 이모지 작성은 불가능해요
-
-
- 정말 일기를 삭제할까요?
- 아직 답장이 오지 않았거나 삭제하고\n다시 작성한 일기는 답장을 받을 수 없어요.
- 삭제할래요
- 아니요
-
-
- %1$s년 %2$s월
- 작성된 감사일기가 없어요
- %1$s일
- /%1$s
-
-
-
- 설정
- 버전 정보를 불러오는데 실패했습니다.
- 프로필 및 계정 관리
- 알림 설정
- 공지사항
- 문의/제안하기
- 서비스 이용 약관
- 개인정보 처리방침
- 앱 버전
- 버전 정보를 불러오는데 실패했습니다.
-
-
- 프로필 및 계정 관리
- 님
- 변경하기
- 닉네임 변경
- 특수문자, 띄어쓰기 없이 작성해주세요
- 변경하기
- 변경을 완료했어요.
- 로그아웃
- 로그아웃 하시겠어요?
- 기다릴게요, 다음에 다시 만나요!
- 로그아웃
- 아니요
- 계정을 삭제하시겠어요?
- 회원탈퇴
- 서비스를 탈퇴하시겠어요?
- 작성하신 일기와 받은 답장 및 클로버가\n 모두 삭제되며 복구할 수 없어요.
- 탈퇴할래요
- 아니요
-
-
- 알림 설정
- 일기 작성 알림 받기
- 알림 시간
- 답장 도착 알림 받기
- 완료
- %1$s %2$s시 %3$s분
- 다시 시도
- 확인
- 알람 시간 설정을 완료했어요.
-
+ Clody
+
+
+ Sign Up With KaKao
+ Sign Up With Google
+
+
+ To use Clody,\nPlease agree to the terms\nand conditions
+ Agree to all
+ (Required) Terms of Service
+ (Required) Privacy Policy
+ Next
+
+
+ Nice to meet you!\nWhat should I call you?
+ Please enter your nickname
+ Please write without spaces or special characters
+ This nickname isn\'t available
+ Next
+
+
+
+ What time would you\nlike us to remind you to write?
+ %2$s:%3$s %1$s
+ Save
+ Skip for now
+
+
+ Hello!\nI\'m Lody
+ I read your journal entries\nand reply with compliments\nand words of encouragement!
+
+ Each reply comes\nwith a lucky four-leaf clover
+ The more you confide in Lody\nthe luckier your clover becomes
+
+ You can only\njournal for today
+ Past or future dates aren\'t available,\nDon\'t miss your moment
+
+ Ready to write\nyour first journal?\nI\'ll be waiting!
+ From your second journal onward,\nyour next clover will take 12 hours to grow
+
+ Next
+ Get Started
+
+
+ %1$d Clovers
+ %1$d
+ %1$d
+ %2$s %1$s
+
+ No gratitude entries yet.
+ There are existing entries in drafts.
+ %1$d. %2$s
+
+ Write a Journal
+ See my Reply
+ Continue Writing
+
+
+ Send
+ %1$s, %2$s %3$s
+ Please avoid using slang, profanity, or emojis.
+ Something you\'re grateful for today.
+ Please enter between 2 and 100 characters.
+ Add
+
+
+ Lody\'s working hard on your reply!
+ You\'ve got a lucky reply from Lody!
+ Lody\'s almost done writing your reply\\njust a little longer!
+ Watch Ad for Instant Reply
+ Open Reply
+
+
+ %1$s %2$s
+ A lucky reply for %1$s
+
+
+ %2$s %1$s
+ No gratitude entries yet.
+ %1$s
+ /%1$s
+ Reply
+
+
+ Settings
+ Profile and Account
+ Notification
+ Notices
+ Support/Feedback
+ Terms of Service
+ Privacy Policy
+ Version
+ Latest version
+ Fail to Fetch
+
+
+ Profile and Account
+ " "
+ Edit
+ Logout
+ Are you sure you want to delete your account?
+ Withdraw
+
+
+ Notification
+ Journal Reminder
+ Draft Reminder
+ Reminder Time
+ %2$s:%3$s %1$s
+ Reply Notification
+
+
+ Try Again
+ Close
+
+
+ Update Available
+ A new version %1$s is available. Do you want to update now?
+ Update
+ Later
+
+ Update Required
+ Please update to version %1$s to continue
+ Update
+ Exit App
+
+ Pick up where you left off?
+ This journal\'s reply time has expired.
+ Continue
+ Cancel
+
+ Delete this entry?
+ You won\'t receive another reply\nif you delete and rewrite your entry.
+ Delete
+ Cancel
+
+ Ready to share\nyour journal with Lody?
+ Sent journals can\'t be edited.
+ Send
+ Cancel
+
+ Want to send this later instead?
+ Your journal will be lost if you exit now.
+ Exit
+ Draft
+
+ %1$s, your luck is here!
+ 1 Four-Leaf Clover
+ Done
+
+ Log out now?
+ I\'ll be here, see you again soon!
+ Logout
+ Cancel
+
+ Delete your account?
+ Your journals, replies, and clovers will be\\npermanently deleted and can\'t be restored.
+ Withdraw
+ Cancel
+
+
+ Continue writing reminders are now on!
+ Field required to send.
+ You can write up to 5 entries.
+ Save Changes
+ Save your reminder time
+
+
+ Change reminder time
+ Save
+
+ Heads up- drafts expire\nDon\'t miss Lody\'s reply!
+ To receive a reminder before the reply deadline,\nconfirm notification permissions.
+ [Settings > Apps > Clody > Notifications]
+ Turn on Notifications
+ Skip for now
+
+ Delete
+
+ View Another Day
+ Done
+
+ Edit Nickname
+ Please write without spaces or special characters
+ Save
+
+ Choose another time
+ Save
+
+
+ AM
+ PM
+ An error occurred while deleting the diary.
+ Chrome must be installed to log in.
+ Login is not available on rooted devices for security reasons.
+ Failed to load data.
+ An unexpected error has occurred.
+
+
+ Network connection is unstable.\nPlease check your network connection.
+ A temporary error has occurred.\nPlease try again later.
+ A server error has occurred.\nPlease try again later.
+ Failed to load draft.\nPlease try again later.
+ Unknown error
+ Login failed.
+ Sign up failed.
+ Google ID Token is missing.
+ Please check your network connection.
diff --git a/app/src/test/java/com/sopt/clody/datasource/FakeDiaryRemoteDataSource.kt b/app/src/test/java/com/sopt/clody/datasource/FakeDiaryRemoteDataSource.kt
new file mode 100644
index 00000000..0ab3fe21
--- /dev/null
+++ b/app/src/test/java/com/sopt/clody/datasource/FakeDiaryRemoteDataSource.kt
@@ -0,0 +1,79 @@
+package com.sopt.clody.datasource
+
+import com.sopt.clody.data.remote.datasource.DiaryRemoteDataSource
+import com.sopt.clody.data.remote.dto.base.ApiResponse
+import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto
+import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto
+import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto
+import com.sopt.clody.data.remote.dto.response.DraftDiariesResponseDto
+import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
+import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto
+import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto
+import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto
+import com.sopt.clody.data.remote.util.ApiError
+
+class FakeDiaryRemoteDataSource : DiaryRemoteDataSource {
+
+ var draftDiariesResponse: ApiResponse? = null
+ var saveDraftResponse: ApiResponse? = null
+
+ override suspend fun writeDiary(lang: String, date: String, content: List): Result {
+ throw NotImplementedError()
+ }
+
+ override suspend fun deleteDailyDiary(year: Int, month: Int, date: Int): Result {
+ throw NotImplementedError()
+ }
+
+ override suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): Result {
+ throw NotImplementedError()
+ }
+
+ override suspend fun getDiaryTime(year: Int, month: Int, date: Int): Result {
+ throw NotImplementedError()
+ }
+
+ override suspend fun getMonthlyCalendarData(year: Int, month: Int): Result {
+ throw NotImplementedError()
+ }
+
+ override suspend fun getMonthlyDiary(year: Int, month: Int): Result {
+ throw NotImplementedError()
+ }
+
+ override suspend fun getReplyDiary(year: Int, month: Int, date: Int): Result {
+ throw NotImplementedError()
+ }
+
+ override suspend fun fetchDraftDiary(
+ year: Int,
+ month: Int,
+ date: Int,
+ ): Result {
+ return draftDiariesResponse?.let { Result.success(it.data!!) }
+ ?: Result.failure(ApiError("draftDiariesResponse not set"))
+ }
+
+ override suspend fun saveDraftDiary(
+ request: SaveDraftDiaryRequestDto,
+ ): Result {
+ return saveDraftResponse?.let { Result.success(Unit) }
+ ?: Result.failure(ApiError("saveDraftResponse not set"))
+ }
+
+ fun setDraftDiariesResponse(list: List) {
+ draftDiariesResponse = ApiResponse(
+ status = 200,
+ message = "성공",
+ data = DraftDiariesResponseDto(draftDiaries = list),
+ )
+ }
+
+ fun setSaveDraftDiaryResponse(createdAt: String) {
+ saveDraftResponse = ApiResponse(
+ status = 201,
+ message = "성공",
+ data = Unit,
+ )
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 134ae3ba..061b625b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -47,6 +47,17 @@ accompanist = "0.25.1"
accompanist-insets = "0.28.0"
firebase-config-ktx = "22.1.0"
+mavericks = "3.0.9"
+kotestVersion = "5.9.0"
+mockk = "1.13.10"
+
+play-review = "2.0.2"
+
+androidxCredentials = "1.5.0"
+googleAuth = "21.3.0"
+datastore = "1.1.7"
+
+airbridge = "4.7.0"
[libraries]
# AndroidX Core
@@ -110,11 +121,32 @@ lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", versio
coil = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
kakao-user = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao" }
admob = { group = "com.google.android.gms", name = "play-services-ads", version.ref = "admob" }
+airbridge = { group = "io.airbridge", name = "sdk-android", version.ref = "airbridge"}
# Accompanist
accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" }
accompanist-insets = { group = "com.google.accompanist", name = "accompanist-insets", version.ref = "accompanist-insets" }
+# Mavericks
+mavericks = { module = "com.airbnb.android:mavericks", version.ref = "mavericks" }
+mavericks-compose = { module = "com.airbnb.android:mavericks-compose", version.ref = "mavericks" }
+mavericks-hilt = { module = "com.airbnb.android:mavericks-hilt", version.ref = "mavericks" }
+
+# Kotest
+kotest-runner = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotestVersion" }
+kotest-assertions = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotestVersion" }
+kotest-property = { group = "io.kotest", name = "kotest-property", version.ref = "kotestVersion" }
+
+mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
+
+# PlayStore In App Review
+play-review = { group = "com.google.android.play", name = "review", version.ref = "play-review"}
+play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "play-review"}
+
+androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "androidxCredentials" }
+google-auth = { module = "com.google.android.gms:play-services-auth", version.ref = "googleAuth" }
+androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
+
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
@@ -152,7 +184,6 @@ test = [
"androidx-junit",
"espresso-core",
"ui-test-junit4",
- "coroutines-test"
]
debug = [
@@ -180,3 +211,20 @@ firebase = [
"firebase-analytics",
"firebase-crashlytics"
]
+
+mavericks = [
+ "mavericks",
+ "mavericks-compose",
+ "mavericks-hilt"
+]
+
+kotest = [
+ "kotest-runner",
+ "kotest-assertions",
+ "kotest-property"
+]
+
+plays = [
+ "play-review",
+ "play-review-ktx"
+]
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 9b8bbe25..ee351d1a 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -20,6 +20,7 @@ dependencyResolutionManagement {
mavenCentral()
maven(url = "https://jitpack.io")
maven { url = java.net.URI("https://devrepo.kakao.com/nexus/content/groups/public/") }
+ maven(url = "https://sdk-download.airbridge.io/maven")
}
}