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") } }