diff --git a/.github/workflows/debug_build_ci.yml b/.github/workflows/debug_build_ci.yml index 261ac31ab..89506ee3f 100644 --- a/.github/workflows/debug_build_ci.yml +++ b/.github/workflows/debug_build_ci.yml @@ -25,7 +25,7 @@ jobs: echo '${{ secrets.LOCAL_PROPERTIES }}' >> ./local.properties - name: Access Google-Service file - run: echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > ./app/google-services.json + run: echo '${{ secrets.GOOGLE_SERVICES_JSON }}' > ./acon/google-services.json - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/app/.gitignore b/acon/.gitignore similarity index 100% rename from app/.gitignore rename to acon/.gitignore diff --git a/app/build.gradle.kts b/acon/build.gradle.kts similarity index 72% rename from app/build.gradle.kts rename to acon/build.gradle.kts index e0d174453..2732be763 100644 --- a/app/build.gradle.kts +++ b/acon/build.gradle.kts @@ -1,5 +1,3 @@ -import utils.androidTestImplementation - /** See AndroidApplicationConventionPlugin.kt */ plugins { @@ -9,11 +7,24 @@ plugins { alias(libs.plugins.acon.android.library.haze) alias(libs.plugins.acon.android.library.naver.map) alias(libs.plugins.acon.firebase) + alias(libs.plugins.acon.common.unit.test) } android { namespace = "com.acon.acon" + flavorDimensions += "distributionChannel" + productFlavors { + create("qa") { + dimension = "distributionChannel" + buildConfigField("boolean", "IS_QA_BUILD", "true") + } + create("production") { + dimension = "distributionChannel" + buildConfigField("boolean", "IS_QA_BUILD", "false") + } + } + buildTypes { debug { applicationIdSuffix = ".debug" @@ -35,41 +46,39 @@ android { } } +androidComponents { + beforeVariants(selector().all()) { variant -> + if (variant.name == "productionDebug") { + variant.enable = false + } + } +} + dependencies { implementation(projects.core.designsystem) implementation(projects.core.map) - implementation(projects.core.adsApi) + implementation(projects.core.ads) implementation(projects.core.analytics) implementation(projects.core.navigation) implementation(projects.core.ui) implementation(projects.core.launcher) + implementation(projects.core.data) + implementation(projects.core.social) implementation(projects.domain) - implementation(projects.data) implementation(projects.feature.signin) implementation(projects.feature.spot) implementation(projects.feature.onboarding) - implementation(projects.feature.areaverification) implementation(projects.feature.upload) implementation(projects.feature.settings) implementation(projects.feature.profile) - implementation(projects.provider.adsImpl) - implementation(libs.branch.io) implementation(libs.google.services.ads) implementation(libs.play.services.location) implementation(libs.startup) implementation(libs.androidx.core.splashscreen) - - testImplementation(libs.bundles.non.android.test) - testRuntimeOnly(libs.bundles.junit5.runtime) - androidTestImplementation(libs.bundles.android.test) -} - -tasks.withType { - useJUnitPlatform() } \ No newline at end of file diff --git a/app/keystore/keystore_base64.txt b/acon/keystore/keystore_base64.txt similarity index 100% rename from app/keystore/keystore_base64.txt rename to acon/keystore/keystore_base64.txt diff --git a/app/proguard-rules.pro b/acon/proguard-rules.pro similarity index 97% rename from app/proguard-rules.pro rename to acon/proguard-rules.pro index 1abedafc5..652c73392 100644 --- a/app/proguard-rules.pro +++ b/acon/proguard-rules.pro @@ -138,4 +138,6 @@ -dontwarn androidx.** -keep class androidx.** { *; } --keep interface androidx.** { *; } \ No newline at end of file +-keep interface androidx.** { *; } + +-keep class com.acon.core.data.dto.entity.OnboardingPreferencesEntity { *; } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/acon/src/main/AndroidManifest.xml similarity index 100% rename from app/src/main/AndroidManifest.xml rename to acon/src/main/AndroidManifest.xml diff --git a/app/src/main/ic_launcher-playstore.png b/acon/src/main/ic_launcher-playstore.png similarity index 100% rename from app/src/main/ic_launcher-playstore.png rename to acon/src/main/ic_launcher-playstore.png diff --git a/app/src/main/java/com/acon/acon/AconApplication.kt b/acon/src/main/java/com/acon/acon/AconApplication.kt similarity index 100% rename from app/src/main/java/com/acon/acon/AconApplication.kt rename to acon/src/main/java/com/acon/acon/AconApplication.kt diff --git a/app/src/main/java/com/acon/acon/MainActivity.kt b/acon/src/main/java/com/acon/acon/MainActivity.kt similarity index 79% rename from app/src/main/java/com/acon/acon/MainActivity.kt rename to acon/src/main/java/com/acon/acon/MainActivity.kt index cd4684e33..1ea474b0e 100644 --- a/app/src/main/java/com/acon/acon/MainActivity.kt +++ b/acon/src/main/java/com/acon/acon/MainActivity.kt @@ -35,12 +35,9 @@ import androidx.core.view.WindowCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController -import com.acon.acon.core.ads_api.AdProvider -import com.acon.acon.core.ads_api.LocalSpotListAdProvider import com.acon.acon.core.analytics.amplitude.AconAmplitude import com.acon.acon.core.analytics.constants.EventNames import com.acon.acon.core.common.DeepLinkHandler -import com.acon.acon.core.common.utils.firstNotNull import com.acon.acon.core.designsystem.R import com.acon.acon.core.designsystem.component.bottomsheet.SignInBottomSheet import com.acon.acon.core.designsystem.component.dialog.AconPermissionDialog @@ -51,22 +48,25 @@ import com.acon.acon.core.designsystem.effect.rememberHazeState import com.acon.acon.core.designsystem.theme.AconTheme import com.acon.acon.core.navigation.LocalNavController import com.acon.acon.core.navigation.route.AreaVerificationRoute +import com.acon.acon.core.navigation.route.OnboardingRoute import com.acon.acon.core.navigation.route.SpotRoute +import com.acon.acon.core.navigation.utils.navigateAndClear +import com.acon.acon.core.ui.activityComponentEntryPoint import com.acon.acon.core.ui.android.launchPlayStore import com.acon.acon.core.ui.compose.LocalDeepLinkHandler import com.acon.acon.core.ui.compose.LocalLocation import com.acon.acon.core.ui.compose.LocalRequestLocationPermission import com.acon.acon.core.ui.compose.LocalRequestSignIn +import com.acon.acon.core.ui.compose.LocalSignInStatus import com.acon.acon.core.ui.compose.LocalSnackbarHostState -import com.acon.acon.core.ui.compose.LocalUserType import com.acon.acon.domain.repository.AconAppRepository -import com.acon.acon.domain.repository.SocialRepository +import com.acon.acon.domain.repository.OnboardingRepository import com.acon.acon.domain.repository.UserRepository import com.acon.acon.navigation.AconNavigation -import com.acon.acon.provider.ads_impl.SpotListAdProvider import com.acon.acon.update.AppUpdateHandler import com.acon.acon.update.AppUpdateHandlerImpl import com.acon.acon.update.UpdateState +import com.acon.core.social.di.AuthClientEntryPoint import com.google.android.gms.common.api.ResolvableApiException import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest @@ -82,12 +82,14 @@ import com.google.android.play.core.install.model.ActivityResult import com.google.android.play.core.install.model.InstallStatus import dagger.hilt.android.AndroidEntryPoint import io.branch.referral.Branch +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -97,12 +99,13 @@ import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - @Inject - lateinit var socialRepository: SocialRepository @Inject lateinit var userRepository: UserRepository + @Inject + lateinit var onboardingRepository: OnboardingRepository + @Inject lateinit var aconAppRepository: AconAppRepository @@ -110,7 +113,6 @@ class MainActivity : ComponentActivity() { private val deepLinkHandler = DeepLinkHandler() - private val spotListAdProvider: AdProvider = SpotListAdProvider() private val gpsResolutionResultLauncher = registerForActivityResult( ActivityResultContracts.StartIntentSenderForResult() ) { result -> @@ -122,16 +124,23 @@ class MainActivity : ComponentActivity() { } private val appUpdateManager by lazy { - AppUpdateManagerFactory.create(application) + if (!BuildConfig.IS_QA_BUILD) + AppUpdateManagerFactory.create(application) + else null } + private val appUpdateActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> - if (result.resultCode == RESULT_OK) { // Immediate에서는 받을 일 없음 - Timber.d("유저 업데이트 수락") - } else if (result.resultCode == RESULT_CANCELED) { - Timber.d("유저 업데이트 거부") - } else if (result.resultCode == ActivityResult.RESULT_IN_APP_UPDATE_FAILED) { - Timber.d("업데이트 실패") + when (result.resultCode) { + RESULT_OK -> { // Immediate에서는 받을 일 없음 + Timber.d("유저 업데이트 수락") + } + RESULT_CANCELED -> { + Timber.d("유저 업데이트 거부") + } + ActivityResult.RESULT_IN_APP_UPDATE_FAILED -> { + Timber.d("업데이트 실패") + } } } @@ -146,14 +155,14 @@ class MainActivity : ComponentActivity() { ) when (result) { SnackbarResult.ActionPerformed -> { - appUpdateManager.completeUpdate() + appUpdateManager?.completeUpdate() } SnackbarResult.Dismissed -> Unit } } } else if (state.installStatus() == InstallStatus.INSTALLED) { - appUpdateManager.unregisterListener(this) + appUpdateManager?.unregisterListener(this) } } } @@ -161,7 +170,7 @@ class MainActivity : ComponentActivity() { private val appUpdateHandler: AppUpdateHandler by lazy { AppUpdateHandlerImpl( - appUpdateManager = appUpdateManager.apply { + appUpdateManager = appUpdateManager?.apply { registerListener(appInstallStateListener) }, aconAppRepository = aconAppRepository, @@ -186,42 +195,43 @@ class MainActivity : ComponentActivity() { ) @SuppressLint("MissingPermission") - private val currentLocationFlow = callbackFlow { - isLocationPermissionGranted.collect { granted -> - if (granted) { - val fusedLocationClient = - LocationServices.getFusedLocationProviderClient(this@MainActivity.applicationContext) - trySend( - fusedLocationClient.getCurrentLocation( - Priority.PRIORITY_HIGH_ACCURACY, - null - ).await() - ) + val liveLocationFlow = callbackFlow { + val fusedLocationClient = + LocationServices.getFusedLocationProviderClient(this@MainActivity.applicationContext) + trySend( + fusedLocationClient.getCurrentLocation( + Priority.PRIORITY_HIGH_ACCURACY, + null + ).await() + ) - val locationRequest = LocationRequest.Builder(3_000).setPriority( - Priority.PRIORITY_HIGH_ACCURACY - ).build() + val locationRequest = LocationRequest.Builder(3_000).setPriority( + Priority.PRIORITY_HIGH_ACCURACY + ).build() - val locationCallback = object : LocationCallback() { - override fun onLocationResult(locationResult: LocationResult) { - for (location in locationResult.locations) { - Timber.d("새 좌표 획득: [${location.latitude}, ${location.longitude}]") - trySend(location) - } - } + val locationCallback = object : LocationCallback() { + override fun onLocationResult(locationResult: LocationResult) { + for (location in locationResult.locations) { + Timber.d("새 좌표 획득: [${location.latitude}, ${location.longitude}]") + trySend(location) } + } + } - fusedLocationClient.requestLocationUpdates( - locationRequest, - locationCallback, - Looper.getMainLooper() - ) + fusedLocationClient.requestLocationUpdates( + locationRequest, + locationCallback, + Looper.getMainLooper() + ) - awaitClose { - fusedLocationClient.removeLocationUpdates(locationCallback) - } - } + awaitClose { + fusedLocationClient.removeLocationUpdates(locationCallback) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val currentLocationFlow = isLocationPermissionGranted.filter { it }.flatMapLatest { + liveLocationFlow }.stateIn( scope = lifecycleScope, started = SharingStarted.WhileSubscribed(5_000), @@ -315,13 +325,12 @@ class MainActivity : ComponentActivity() { LocalSnackbarHostState provides appState.snackbarHostState, LocalNavController provides navController, LocalHazeState provides hazeState, - LocalUserType provides appState.userType, + LocalSignInStatus provides appState.signInStatus, LocalRequestSignIn provides { viewModel.updateShowSignInBottomSheet(true) viewModel.updateAmplPropertyKey(it) }, LocalRequestLocationPermission provides ::requestLocationPermission, - LocalSpotListAdProvider provides spotListAdProvider, LocalDeepLinkHandler provides deepLinkHandler ) { AconNavigation( @@ -335,38 +344,37 @@ class MainActivity : ComponentActivity() { onDismissRequest = { viewModel.updateShowSignInBottomSheet(false) }, onGoogleSignIn = { scope.launch { - socialRepository.googleSignIn() - .onSuccess { - if (it.hasVerifiedArea) { - navController.navigate(SpotRoute.SpotList) { - popUpTo(navController.graph.id) { - inclusive = true - } - } - } else { - navController.navigate( - AreaVerificationRoute.AreaVerification( - verifiedAreaId = null, - route = "onboarding" - ) - ) { - popUpTo(navController.graph.id) { - inclusive = true - } - } - } + val client = activityComponentEntryPoint().googleAuthClient() + val code = client.getCredentialCode() ?: return@launch + + userRepository.signIn(client.platform, code) + .onSuccess { externalUUID -> if (appState.propertyKey.isNotBlank()) { AconAmplitude.trackEvent( eventName = EventNames.GUEST, property = appState.propertyKey to true ) } - AconAmplitude.setUserId(it.externalUUID) - }.onFailure { - + AconAmplitude.setUserId(externalUUID.value) + + onboardingRepository.getOnboardingPreferences().onSuccess { pref -> + if (pref.shouldShowIntroduce) { + navController.navigateAndClear(OnboardingRoute.Introduce) + } else if (pref.shouldVerifyArea) { + navController.navigateAndClear(AreaVerificationRoute.AreaVerification) + } else if (pref.shouldChooseDislikes) { + navController.navigateAndClear(OnboardingRoute.ChooseDislikes) + } else { + navController.navigateAndClear(SpotRoute.SpotList) + } + }.onFailure { + navController.navigateAndClear(SpotRoute.SpotList) + } + }.onFailure { e -> + Timber.e(e) } - viewModel.updateShowSignInBottomSheet(false) } + viewModel.updateShowSignInBottomSheet(false) }, modifier = Modifier ) } @@ -475,9 +483,9 @@ class MainActivity : ComponentActivity() { } DisposableEffect(appUpdateManager) { - appUpdateManager.registerListener(appInstallStateListener) + appUpdateManager?.registerListener(appInstallStateListener) onDispose { - appUpdateManager.unregisterListener(appInstallStateListener) + appUpdateManager?.unregisterListener(appInstallStateListener) } } } diff --git a/app/src/main/java/com/acon/acon/MainViewModel.kt b/acon/src/main/java/com/acon/acon/MainViewModel.kt similarity index 87% rename from app/src/main/java/com/acon/acon/MainViewModel.kt rename to acon/src/main/java/com/acon/acon/MainViewModel.kt index 2048713ed..554a0fe97 100644 --- a/app/src/main/java/com/acon/acon/MainViewModel.kt +++ b/acon/src/main/java/com/acon/acon/MainViewModel.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.acon.acon.domain.repository.UserRepository -import com.acon.acon.core.model.type.UserType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,8 +22,8 @@ class MainViewModel @Inject constructor( init { viewModelScope.launch { - userRepository.getUserType().collectLatest { - _state.value = state.value.copy(userType = it) + userRepository.getSignInStatus().collectLatest { + _state.value = state.value.copy(signInStatus = it) } } } @@ -57,7 +56,7 @@ class MainViewModel @Inject constructor( @Immutable data class AconAppState( val snackbarHostState: SnackbarHostState = SnackbarHostState(), - val userType: com.acon.acon.core.model.type.UserType = com.acon.acon.core.model.type.UserType.GUEST, + val signInStatus: com.acon.acon.core.model.type.SignInStatus = com.acon.acon.core.model.type.SignInStatus.GUEST, val showSignInBottomSheet: Boolean = false, val showPermissionDialog: Boolean = false, val propertyKey: String = "", diff --git a/app/src/main/java/com/acon/acon/di/AuthenticatorModule.kt b/acon/src/main/java/com/acon/acon/di/AuthenticatorModule.kt similarity index 72% rename from app/src/main/java/com/acon/acon/di/AuthenticatorModule.kt rename to acon/src/main/java/com/acon/acon/di/AuthenticatorModule.kt index dc0573a15..c87925dfe 100644 --- a/app/src/main/java/com/acon/acon/di/AuthenticatorModule.kt +++ b/acon/src/main/java/com/acon/acon/di/AuthenticatorModule.kt @@ -2,11 +2,11 @@ package com.acon.acon.di import android.content.Context import com.acon.acon.core.launcher.AppLauncher -import com.acon.acon.data.api.remote.noauth.UserNoAuthApi -import com.acon.acon.data.authentication.AuthAuthenticator -import com.acon.acon.data.datasource.local.TokenLocalDataSource -import com.acon.acon.data.session.SessionHandler import com.acon.acon.launcher.AppLauncherImpl +import com.acon.core.data.api.remote.noauth.UserNoAuthApi +import com.acon.core.data.authentication.AuthAuthenticator +import com.acon.core.data.datasource.local.TokenLocalDataSource +import com.acon.core.data.session.SessionHandler import dagger.Binds import dagger.Module import dagger.Provides @@ -28,7 +28,8 @@ object AuthenticatorModule { sessionHandler: SessionHandler, userNoAuthApi: UserNoAuthApi, appLauncher: AppLauncher - ): Authenticator = AuthAuthenticator(context, tokenLocalDataSource, sessionHandler, userNoAuthApi, appLauncher) + ): Authenticator = + AuthAuthenticator(context, tokenLocalDataSource, sessionHandler, userNoAuthApi, appLauncher) } @Module diff --git a/app/src/main/java/com/acon/acon/di/CoroutineDispatchersModule.kt b/acon/src/main/java/com/acon/acon/di/CoroutineDispatchersModule.kt similarity index 100% rename from app/src/main/java/com/acon/acon/di/CoroutineDispatchersModule.kt rename to acon/src/main/java/com/acon/acon/di/CoroutineDispatchersModule.kt diff --git a/app/src/main/java/com/acon/acon/di/CoroutineScopesModule.kt b/acon/src/main/java/com/acon/acon/di/CoroutineScopesModule.kt similarity index 100% rename from app/src/main/java/com/acon/acon/di/CoroutineScopesModule.kt rename to acon/src/main/java/com/acon/acon/di/CoroutineScopesModule.kt diff --git a/app/src/main/java/com/acon/acon/launcher/AppLauncherImpl.kt b/acon/src/main/java/com/acon/acon/launcher/AppLauncherImpl.kt similarity index 100% rename from app/src/main/java/com/acon/acon/launcher/AppLauncherImpl.kt rename to acon/src/main/java/com/acon/acon/launcher/AppLauncherImpl.kt diff --git a/app/src/main/java/com/acon/acon/navigation/AconNavigation.kt b/acon/src/main/java/com/acon/acon/navigation/AconNavigation.kt similarity index 95% rename from app/src/main/java/com/acon/acon/navigation/AconNavigation.kt rename to acon/src/main/java/com/acon/acon/navigation/AconNavigation.kt index 839efa8cc..fde5e0ec0 100644 --- a/app/src/main/java/com/acon/acon/navigation/AconNavigation.kt +++ b/acon/src/main/java/com/acon/acon/navigation/AconNavigation.kt @@ -34,10 +34,10 @@ import com.acon.acon.core.navigation.route.SpotRoute import com.acon.acon.core.ui.compose.LocalDeepLinkHandler import com.acon.acon.core.ui.compose.LocalSnackbarHostState import com.acon.acon.navigation.nested.areaVerificationNavigation -import com.acon.acon.navigation.nested.onboardingNavigationNavigation +import com.acon.acon.navigation.nested.onboardingNavigation import com.acon.acon.navigation.nested.profileNavigation import com.acon.acon.navigation.nested.settingsNavigation -import com.acon.acon.navigation.nested.signInNavigationNavigation +import com.acon.acon.navigation.nested.signInNavigation import com.acon.acon.navigation.nested.spotNavigation import com.acon.acon.navigation.nested.uploadNavigation import kotlinx.coroutines.delay @@ -142,17 +142,17 @@ fun AconNavigation( defaultPopExitTransition() } ) { - signInNavigationNavigation(navController) + signInNavigation(navController) areaVerificationNavigation(navController) - onboardingNavigationNavigation(navController) + onboardingNavigation(navController) spotNavigation(navController) uploadNavigation(navController) - profileNavigation(navController, snackbarHostState) + profileNavigation(navController) settingsNavigation(navController) } diff --git a/acon/src/main/java/com/acon/acon/navigation/nested/AreaVerificationNavigation.kt b/acon/src/main/java/com/acon/acon/navigation/nested/AreaVerificationNavigation.kt new file mode 100644 index 000000000..17361b5e0 --- /dev/null +++ b/acon/src/main/java/com/acon/acon/navigation/nested/AreaVerificationNavigation.kt @@ -0,0 +1,69 @@ +package com.acon.acon.navigation.nested + +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import com.acon.acon.core.designsystem.effect.screenDefault +import com.acon.acon.core.navigation.LocalNavController +import com.acon.acon.core.navigation.route.AreaVerificationRoute +import com.acon.acon.core.navigation.route.OnboardingRoute +import com.acon.acon.core.navigation.route.SettingsRoute +import com.acon.acon.core.navigation.route.SpotRoute +import com.acon.acon.core.navigation.utils.contains +import com.acon.acon.core.navigation.utils.hasPreviousBackStackEntry +import com.acon.acon.core.navigation.utils.navigateAndClear +import com.acon.feature.onboarding.area.composable.AreaVerificationScreenContainer +import com.acon.feature.onboarding.area.composable.VerifyInMapScreenContainer + +fun NavGraphBuilder.areaVerificationNavigation( + navController: NavHostController +) { + navigation( + startDestination = AreaVerificationRoute.AreaVerification + ) { + composable { + AreaVerificationScreenContainer( + modifier = Modifier + .screenDefault() + .statusBarsPadding(), + onNavigateToVerifyInMap = { + navController.navigate(AreaVerificationRoute.VerifyInMap) + }, + skippable = LocalNavController.current.hasPreviousBackStackEntry().not(), + onNavigateToChooseDislikes = { navController.navigateAndClear(OnboardingRoute.ChooseDislikes) }, + onNavigateToIntroduce = { navController.navigateAndClear(OnboardingRoute.Introduce) }, + onNavigateToSpotList = { navController.navigateAndClear(SpotRoute.SpotList) }, + onNavigateBack = navController::navigateUp + ) + } + + composable { + VerifyInMapScreenContainer( + onNavigateToNextScreen = { + if (navController.contains()) { + navController.popBackStack( + route = SettingsRoute.UserVerifiedAreas, + inclusive = false + ) + } + else if (navController.contains()) { + navController.popBackStack( + route = SpotRoute.SpotList, + inclusive = false + ) + } + else { + navController.navigateAndClear(OnboardingRoute.ChooseDislikes) + } + }, + onNavigateBack = { navController.popBackStack() }, + modifier = Modifier + .screenDefault() + .statusBarsPadding() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/acon/acon/navigation/nested/OnboardingNavigation.kt b/acon/src/main/java/com/acon/acon/navigation/nested/OnboardingNavigation.kt similarity index 84% rename from app/src/main/java/com/acon/acon/navigation/nested/OnboardingNavigation.kt rename to acon/src/main/java/com/acon/acon/navigation/nested/OnboardingNavigation.kt index 601acf433..f32d25578 100644 --- a/app/src/main/java/com/acon/acon/navigation/nested/OnboardingNavigation.kt +++ b/acon/src/main/java/com/acon/acon/navigation/nested/OnboardingNavigation.kt @@ -1,7 +1,5 @@ package com.acon.acon.navigation.nested -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.ui.Modifier @@ -11,8 +9,8 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import com.acon.acon.core.designsystem.effect.screenDefault -import com.acon.acon.core.designsystem.theme.AconTheme import com.acon.acon.core.navigation.LocalNavController +import com.acon.acon.core.navigation.route.AreaVerificationRoute import com.acon.acon.core.navigation.route.OnboardingRoute import com.acon.acon.core.navigation.route.SettingsRoute import com.acon.acon.core.navigation.route.SpotRoute @@ -21,7 +19,7 @@ import com.acon.feature.onboarding.dislikes.composable.ChooseDislikesScreenConta import com.acon.feature.onboarding.introduce.composable.IntroduceScreenContainer -internal fun NavGraphBuilder.onboardingNavigationNavigation( +internal fun NavGraphBuilder.onboardingNavigation( navController: NavHostController ) { @@ -54,6 +52,12 @@ internal fun NavGraphBuilder.onboardingNavigationNavigation( modifier = Modifier.screenDefault().statusBarsPadding(), onNavigateToHome = { navController.navigateAndClear(SpotRoute.Graph) + }, + onNavigateToAreaVerification = { + navController.navigateAndClear(AreaVerificationRoute.Graph) + }, + onNavigateToChooseDislikes = { + navController.navigateAndClear(OnboardingRoute.ChooseDislikes) } ) } diff --git a/acon/src/main/java/com/acon/acon/navigation/nested/ProfileNavigation.kt b/acon/src/main/java/com/acon/acon/navigation/nested/ProfileNavigation.kt new file mode 100644 index 000000000..08de06551 --- /dev/null +++ b/acon/src/main/java/com/acon/acon/navigation/nested/ProfileNavigation.kt @@ -0,0 +1,90 @@ +package com.acon.acon.navigation.nested + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import com.acon.acon.core.designsystem.effect.screenDefault +import com.acon.acon.core.model.model.spot.SpotNavigationParameter +import com.acon.acon.core.navigation.route.ProfileRoute +import com.acon.acon.core.navigation.route.SettingsRoute +import com.acon.acon.core.navigation.route.SpotRoute +import com.acon.acon.core.navigation.route.UploadRoute +import com.acon.feature.profile.savedspot.composable.BookmarkScreenContainer +import com.acon.feature.profile.info.composable.ProfileInfoScreenContainer +import com.acon.feature.profile.update.composable.ProfileUpdateScreenContainer + +internal fun NavGraphBuilder.profileNavigation( + navController: NavHostController, +) { + navigation( + startDestination = ProfileRoute.ProfileInfo, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None } + ) { + composable { + ProfileInfoScreenContainer( + modifier = Modifier + .screenDefault() + .statusBarsPadding(), + onNavigateToProfileUpdate = { + navController.navigate(ProfileRoute.ProfileUpdate) + }, + onNavigateToSpotDetail = { + navController.navigate(SpotRoute.SpotDetail(it)) + }, + onNavigateToSavedSpots = { + navController.navigate(ProfileRoute.Bookmark) + }, + onNavigateToSetting = { + navController.navigate(SettingsRoute.Settings) + }, + onNavigateToSpotList = { + navController.popBackStack( + route = SpotRoute.SpotList, + inclusive = false + ) + }, + onNavigateToUpload = { + navController.navigate(UploadRoute.Graph) + } + ) + } + + composable { backStackEntry -> + ProfileUpdateScreenContainer( + onNavigateBack = navController::navigateUp, + modifier = Modifier.screenDefault().systemBarsPadding() + ) + } + + composable { + BookmarkScreenContainer( + modifier = Modifier.fillMaxSize(), + onNavigateToBack = { + navController.popBackStack() + }, + onNavigateToSpotDetailScreen = { + navController.navigate( + SpotRoute.SpotDetail( + SpotNavigationParameter( + it, + emptyList(), + null, + null, + null, + true + ) + ) + ) + }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/acon/acon/navigation/nested/SettingsNavigation.kt b/acon/src/main/java/com/acon/acon/navigation/nested/SettingsNavigation.kt similarity index 75% rename from app/src/main/java/com/acon/acon/navigation/nested/SettingsNavigation.kt rename to acon/src/main/java/com/acon/acon/navigation/nested/SettingsNavigation.kt index 4614d11f8..60fafb0a6 100644 --- a/app/src/main/java/com/acon/acon/navigation/nested/SettingsNavigation.kt +++ b/acon/src/main/java/com/acon/acon/navigation/nested/SettingsNavigation.kt @@ -9,13 +9,14 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import com.acon.acon.BuildConfig import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.core.navigation.route.SettingsRoute import com.acon.acon.core.navigation.route.AreaVerificationRoute import com.acon.acon.core.navigation.route.OnboardingRoute import com.acon.acon.core.navigation.route.ProfileRoute -import com.acon.acon.feature.settings.screen.composable.SettingsScreenContainer +import com.acon.acon.core.navigation.route.SettingsRoute import com.acon.acon.core.navigation.route.SignInRoute -import com.acon.acon.feature.verification.screen.composable.LocalVerificationScreenContainer +import com.acon.acon.core.navigation.utils.navigateAndClear +import com.acon.acon.feature.settings.screen.composable.SettingsScreenContainer +import com.acon.acon.feature.verification.screen.composable.UserVerifiedAreasScreenContainer import com.acon.acon.feature.withdraw.screen.composable.DeleteAccountScreenContainer internal fun NavGraphBuilder.settingsNavigation( @@ -31,7 +32,7 @@ internal fun NavGraphBuilder.settingsNavigation( modifier = Modifier.fillMaxSize(), versionName = versionName, onNavigateToProfileScreen = { - navController.navigate(ProfileRoute.Profile) { + navController.navigate(ProfileRoute.ProfileInfo) { popUpTo(SettingsRoute.Graph) { inclusive = true } @@ -40,15 +41,11 @@ internal fun NavGraphBuilder.settingsNavigation( onNavigateToOnboardingScreen = { navController.navigate(OnboardingRoute.Graph) }, - onNavigateLocalVerificationScreen = { - navController.navigate(SettingsRoute.LocalVerification) + onNavigateUserVerifiedAreasScreen = { + navController.navigate(SettingsRoute.UserVerifiedAreas) }, onNavigateToSignInScreen = { - navController.navigate(SignInRoute.SignIn) { - popUpTo(SettingsRoute.Graph) { - inclusive = true - } - } + navController.navigateAndClear(SignInRoute.SignIn) }, onNavigateToDeleteAccountScreen = { navController.navigate(SettingsRoute.DeleteAccount) @@ -56,16 +53,13 @@ internal fun NavGraphBuilder.settingsNavigation( ) } - composable { - LocalVerificationScreenContainer( + composable { + UserVerifiedAreasScreenContainer( modifier = Modifier.fillMaxSize().background(AconTheme.color.Gray900), - navigateToSettingsScreen = { navController.popBackStack() }, + navigateToSettingsScreen = navController::popBackStack, navigateToAreaVerification = { navController.navigate( - AreaVerificationRoute.AreaVerification( - verifiedAreaId = it, - route = "settings" - ) + AreaVerificationRoute.AreaVerification ) } ) diff --git a/app/src/main/java/com/acon/acon/navigation/nested/SignInNavigation.kt b/acon/src/main/java/com/acon/acon/navigation/nested/SignInNavigation.kt similarity index 88% rename from app/src/main/java/com/acon/acon/navigation/nested/SignInNavigation.kt rename to acon/src/main/java/com/acon/acon/navigation/nested/SignInNavigation.kt index 39e8a00d5..8ab83b5d0 100644 --- a/app/src/main/java/com/acon/acon/navigation/nested/SignInNavigation.kt +++ b/acon/src/main/java/com/acon/acon/navigation/nested/SignInNavigation.kt @@ -9,11 +9,11 @@ import androidx.navigation.compose.navigation import com.acon.acon.core.navigation.route.AreaVerificationRoute import com.acon.acon.core.navigation.route.OnboardingRoute import com.acon.acon.core.navigation.route.SignInRoute -import com.acon.acon.feature.signin.screen.SignInScreenContainer import com.acon.acon.core.navigation.route.SpotRoute import com.acon.acon.core.navigation.utils.navigateAndClear +import com.acon.acon.feature.signin.screen.SignInScreenContainer -internal fun NavGraphBuilder.signInNavigationNavigation( +internal fun NavGraphBuilder.signInNavigation( navController: NavHostController, ) { @@ -34,10 +34,7 @@ internal fun NavGraphBuilder.signInNavigationNavigation( }, navigateToAreaVerification = { navController.navigate( - AreaVerificationRoute.AreaVerification( - verifiedAreaId = null, - route = "onboarding" - ) + AreaVerificationRoute.AreaVerification ) { popUpTo(SignInRoute.Graph) { inclusive = true diff --git a/app/src/main/java/com/acon/acon/navigation/nested/SpotNavigation.kt b/acon/src/main/java/com/acon/acon/navigation/nested/SpotNavigation.kt similarity index 94% rename from app/src/main/java/com/acon/acon/navigation/nested/SpotNavigation.kt rename to acon/src/main/java/com/acon/acon/navigation/nested/SpotNavigation.kt index 1707e8417..f9e920e4c 100644 --- a/app/src/main/java/com/acon/acon/navigation/nested/SpotNavigation.kt +++ b/acon/src/main/java/com/acon/acon/navigation/nested/SpotNavigation.kt @@ -10,14 +10,14 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.composable import androidx.navigation.navigation import com.acon.acon.core.designsystem.theme.AconTheme +import com.acon.acon.core.model.model.spot.SpotNavigationParameter import com.acon.acon.core.navigation.route.AreaVerificationRoute import com.acon.acon.core.navigation.route.ProfileRoute import com.acon.acon.core.navigation.route.SpotRoute -import com.acon.acon.feature.spot.screen.spotdetail.composable.SpotDetailScreenContainer -import com.acon.acon.feature.spot.screen.spotlist.composable.SpotListScreenContainer -import com.acon.acon.core.model.model.spot.SpotNavigationParameter import com.acon.acon.core.navigation.route.UploadRoute import com.acon.acon.core.navigation.type.spotNavigationParameterNavType +import com.acon.acon.feature.spot.screen.spotdetail.composable.SpotDetailScreenContainer +import com.acon.acon.feature.spot.screen.spotlist.composable.SpotListScreenContainer internal fun NavGraphBuilder.spotNavigation( navController: NavHostController @@ -55,12 +55,10 @@ internal fun NavGraphBuilder.spotNavigation( navController.navigate(SpotRoute.SpotDetail(spotNav)) }, onNavigateToAreaVerificationScreen = { lat, lon -> - navController.navigate(AreaVerificationRoute.CheckInMap( - latitude = lat, - longitude = lon, - verifiedAreaId = -1, - route = "spotlist" - )) + navController.navigate(AreaVerificationRoute.VerifyInMap) + }, + onNavigateToUploadPlace = { + navController.navigate(UploadRoute.Place) }, modifier = Modifier .fillMaxSize() diff --git a/app/src/main/java/com/acon/acon/navigation/nested/UploadNavigation.kt b/acon/src/main/java/com/acon/acon/navigation/nested/UploadNavigation.kt similarity index 100% rename from app/src/main/java/com/acon/acon/navigation/nested/UploadNavigation.kt rename to acon/src/main/java/com/acon/acon/navigation/nested/UploadNavigation.kt diff --git a/app/src/main/java/com/acon/acon/startup/AconAmplitudeInitializer.kt b/acon/src/main/java/com/acon/acon/startup/AconAmplitudeInitializer.kt similarity index 100% rename from app/src/main/java/com/acon/acon/startup/AconAmplitudeInitializer.kt rename to acon/src/main/java/com/acon/acon/startup/AconAmplitudeInitializer.kt diff --git a/app/src/main/java/com/acon/acon/startup/BranchInitializer.kt b/acon/src/main/java/com/acon/acon/startup/BranchInitializer.kt similarity index 100% rename from app/src/main/java/com/acon/acon/startup/BranchInitializer.kt rename to acon/src/main/java/com/acon/acon/startup/BranchInitializer.kt diff --git a/app/src/main/java/com/acon/acon/startup/MobileAdsInitializer.kt b/acon/src/main/java/com/acon/acon/startup/MobileAdsInitializer.kt similarity index 100% rename from app/src/main/java/com/acon/acon/startup/MobileAdsInitializer.kt rename to acon/src/main/java/com/acon/acon/startup/MobileAdsInitializer.kt diff --git a/app/src/main/java/com/acon/acon/startup/TimberInitializer.kt b/acon/src/main/java/com/acon/acon/startup/TimberInitializer.kt similarity index 100% rename from app/src/main/java/com/acon/acon/startup/TimberInitializer.kt rename to acon/src/main/java/com/acon/acon/startup/TimberInitializer.kt diff --git a/app/src/main/java/com/acon/acon/update/AppUpdateHandler.kt b/acon/src/main/java/com/acon/acon/update/AppUpdateHandler.kt similarity index 93% rename from app/src/main/java/com/acon/acon/update/AppUpdateHandler.kt rename to acon/src/main/java/com/acon/acon/update/AppUpdateHandler.kt index 6bff5f2d4..29ee94d26 100644 --- a/app/src/main/java/com/acon/acon/update/AppUpdateHandler.kt +++ b/acon/src/main/java/com/acon/acon/update/AppUpdateHandler.kt @@ -35,7 +35,7 @@ interface AppUpdateHandler { } class AppUpdateHandlerImpl( - private val appUpdateManager: AppUpdateManager, + private val appUpdateManager: AppUpdateManager?, private val aconAppRepository: AconAppRepository, private val appUpdateActivityResultLauncher: ActivityResultLauncher, private val application: Application, @@ -43,7 +43,9 @@ class AppUpdateHandlerImpl( ) : AppUpdateHandler { private val appUpdateInfo = flow { - emit(appUpdateManager.appUpdateInfo.await()) + appUpdateManager?.appUpdateInfo?.let { + emit(it.await()) + } }.stateIn( scope = scope, started = SharingStarted.WhileSubscribed(5_000), @@ -56,7 +58,7 @@ class AppUpdateHandlerImpl( val packageInfo = application.packageManager.getPackageInfo(application.packageName, 0) packageInfo.versionName - } catch (e: Exception) { + } catch (_: Exception) { null } if (currentAppVersion == null) @@ -80,7 +82,7 @@ class AppUpdateHandlerImpl( } override fun startFlexibleUpdate() { - appUpdateManager.startUpdateFlowForResult( + appUpdateManager?.startUpdateFlowForResult( appUpdateInfo.value ?: return, appUpdateActivityResultLauncher, AppUpdateOptions.defaultOptions(AppUpdateType.FLEXIBLE) diff --git a/app/src/main/res/drawable/aconlogo.xml b/acon/src/main/res/drawable/aconlogo.xml similarity index 100% rename from app/src/main/res/drawable/aconlogo.xml rename to acon/src/main/res/drawable/aconlogo.xml diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/acon/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_background.xml rename to acon/src/main/res/drawable/ic_launcher_background.xml diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/acon/src/main/res/drawable/ic_launcher_foreground.xml similarity index 100% rename from app/src/main/res/drawable/ic_launcher_foreground.xml rename to acon/src/main/res/drawable/ic_launcher_foreground.xml diff --git a/app/src/main/res/drawable/launch_background.xml b/acon/src/main/res/drawable/launch_background.xml similarity index 100% rename from app/src/main/res/drawable/launch_background.xml rename to acon/src/main/res/drawable/launch_background.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/acon/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to acon/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/acon/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to acon/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/acon/src/main/res/mipmap-hdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-hdpi/ic_launcher.webp rename to acon/src/main/res/mipmap-hdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/acon/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp similarity index 100% rename from app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp rename to acon/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/acon/src/main/res/mipmap-hdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-hdpi/ic_launcher_round.webp rename to acon/src/main/res/mipmap-hdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/acon/src/main/res/mipmap-mdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-mdpi/ic_launcher.webp rename to acon/src/main/res/mipmap-mdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/acon/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp similarity index 100% rename from app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp rename to acon/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/acon/src/main/res/mipmap-mdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-mdpi/ic_launcher_round.webp rename to acon/src/main/res/mipmap-mdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/acon/src/main/res/mipmap-xhdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-xhdpi/ic_launcher.webp rename to acon/src/main/res/mipmap-xhdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/acon/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp similarity index 100% rename from app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp rename to acon/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/acon/src/main/res/mipmap-xhdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp rename to acon/src/main/res/mipmap-xhdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/acon/src/main/res/mipmap-xxhdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-xxhdpi/ic_launcher.webp rename to acon/src/main/res/mipmap-xxhdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/acon/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp similarity index 100% rename from app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp rename to acon/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/acon/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp rename to acon/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/acon/src/main/res/mipmap-xxxhdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp rename to acon/src/main/res/mipmap-xxxhdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/acon/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp similarity index 100% rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp rename to acon/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/acon/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp similarity index 100% rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp rename to acon/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/app/src/main/res/values-v31/themes.xml b/acon/src/main/res/values-v31/themes.xml similarity index 100% rename from app/src/main/res/values-v31/themes.xml rename to acon/src/main/res/values-v31/themes.xml diff --git a/app/src/main/res/values/colors.xml b/acon/src/main/res/values/colors.xml similarity index 100% rename from app/src/main/res/values/colors.xml rename to acon/src/main/res/values/colors.xml diff --git a/app/src/main/res/values/ic_launcher_background.xml b/acon/src/main/res/values/ic_launcher_background.xml similarity index 100% rename from app/src/main/res/values/ic_launcher_background.xml rename to acon/src/main/res/values/ic_launcher_background.xml diff --git a/app/src/main/res/values/strings.xml b/acon/src/main/res/values/strings.xml similarity index 100% rename from app/src/main/res/values/strings.xml rename to acon/src/main/res/values/strings.xml diff --git a/app/src/main/res/values/themes.xml b/acon/src/main/res/values/themes.xml similarity index 100% rename from app/src/main/res/values/themes.xml rename to acon/src/main/res/values/themes.xml diff --git a/app/src/main/res/xml/backup_rules.xml b/acon/src/main/res/xml/backup_rules.xml similarity index 100% rename from app/src/main/res/xml/backup_rules.xml rename to acon/src/main/res/xml/backup_rules.xml diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/acon/src/main/res/xml/data_extraction_rules.xml similarity index 100% rename from app/src/main/res/xml/data_extraction_rules.xml rename to acon/src/main/res/xml/data_extraction_rules.xml diff --git a/app/src/test/kotlin/com/acon/acon/update/AppUpdateHandlerImplTest.kt b/acon/src/test/kotlin/com/acon/acon/update/AppUpdateHandlerImplTest.kt similarity index 100% rename from app/src/test/kotlin/com/acon/acon/update/AppUpdateHandlerImplTest.kt rename to acon/src/test/kotlin/com/acon/acon/update/AppUpdateHandlerImplTest.kt diff --git a/app/src/androidTest/java/com/acon/acon/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/acon/acon/ExampleInstrumentedTest.kt deleted file mode 100644 index 4cb637e97..000000000 --- a/app/src/androidTest/java/com/acon/acon/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.acon.acon - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.android.acon", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/acon/acon/navigation/nested/AreaVerificationNavigation.kt b/app/src/main/java/com/acon/acon/navigation/nested/AreaVerificationNavigation.kt deleted file mode 100644 index 5a9624ad3..000000000 --- a/app/src/main/java/com/acon/acon/navigation/nested/AreaVerificationNavigation.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.acon.acon.navigation.nested - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.composable -import androidx.navigation.compose.navigation -import androidx.navigation.toRoute -import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.core.navigation.route.AreaVerificationRoute -import com.acon.acon.core.navigation.route.OnboardingRoute -import com.acon.acon.core.navigation.route.SettingsRoute -import com.acon.acon.core.navigation.route.SpotRoute -import com.acon.acon.core.navigation.utils.navigateAndClear -import com.acon.acon.core.ui.android.showToast -import com.acon.acon.feature.areaverification.composable.AreaVerificationScreenContainer -import com.acon.acon.feature.areaverification.composable.PreferenceMapScreen - -fun NavGraphBuilder.areaVerificationNavigation( - navController: NavHostController -) { - navigation( - startDestination = AreaVerificationRoute.AreaVerification() - ) { - composable { backStackEntry -> - val routeData = backStackEntry.toRoute() - - AreaVerificationScreenContainer( - modifier = Modifier.fillMaxSize().background(AconTheme.color.Gray900).statusBarsPadding(), - route = routeData.route ?: "onboarding", - onNextScreen = { latitude, longitude -> - navController.navigate( - AreaVerificationRoute.CheckInMap( - latitude = latitude, - longitude = longitude, - verifiedAreaId = routeData.verifiedAreaId ?: -1, - route = routeData.route - ) - ) - }, onNavigateToOnboarding = { navController.navigateAndClear(OnboardingRoute.Graph) }, - onNavigateToSpotList = { navController.navigateAndClear(SpotRoute.Graph) } - ) - } - - composable { backStackEntry -> - val route = backStackEntry.toRoute() - val context = LocalContext.current - - PreferenceMapScreen( - latitude = route.latitude, - longitude = route.longitude, - previousVerifiedAreaId = route.verifiedAreaId, - onNavigateToNext = { didOnboarding -> - if (route.route == "settings") { - context.showToast("인증 되었습니다") - navController.popBackStack(route = SettingsRoute.LocalVerification, inclusive = false) - } else if (didOnboarding) { - navController.navigateAndClear(SpotRoute.Graph) - } else { - navController.navigate(OnboardingRoute.Graph) { - popUpTo(0) { inclusive = true } - } - } - }, - onBackClick = { - navController.popBackStack() - } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/acon/acon/navigation/nested/ProfileNavigation.kt b/app/src/main/java/com/acon/acon/navigation/nested/ProfileNavigation.kt deleted file mode 100644 index 0c7169316..000000000 --- a/app/src/main/java/com/acon/acon/navigation/nested/ProfileNavigation.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.acon.acon.navigation.nested - -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController -import androidx.navigation.compose.composable -import androidx.navigation.compose.navigation -import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.core.navigation.route.SettingsRoute -import com.acon.acon.core.navigation.route.ProfileRoute -import com.acon.acon.feature.profile.composable.screen.bookmark.composable.BookmarkScreenContainer -import com.acon.acon.feature.profile.composable.screen.profile.composable.ProfileScreenContainer -import com.acon.acon.feature.profile.composable.screen.profileMod.composable.ProfileModScreenContainer -import com.acon.acon.core.model.model.spot.SpotNavigationParameter -import com.acon.acon.core.navigation.route.SpotRoute -import com.acon.acon.core.navigation.route.UploadRoute - -internal fun NavGraphBuilder.profileNavigation( - navController: NavHostController, - snackbarHostState: SnackbarHostState -) { - navigation( - startDestination = ProfileRoute.Profile, - enterTransition = { EnterTransition.None }, - exitTransition = { ExitTransition.None } - ) { - composable { - ProfileScreenContainer( - snackbarHostState = snackbarHostState, - modifier = Modifier - .fillMaxSize() - .background(AconTheme.color.Gray900) - .statusBarsPadding(), - onNavigateToSpotDetailScreen = { - navController.navigate( - SpotRoute.SpotDetail( - com.acon.acon.core.model.model.spot.SpotNavigationParameter( - it, - emptyList(), - null, - null, - null, - true - ) - ) - ) - }, - onNavigateToBookMarkScreen = { - navController.navigate(ProfileRoute.Bookmark) - }, - onNavigateToSpotListScreen = { - navController.popBackStack( - route = SpotRoute.SpotList, - inclusive = false - ) - }, - onNavigateToSettingsScreen = { navController.navigate(SettingsRoute.Settings) }, - onNavigateToProfileEditScreen = { - navController.navigate( - ProfileRoute.ProfileMod( - null - ) - ) - }, - onNavigateToUploadScreen = { - navController.navigate(UploadRoute.Graph) - } - ) - } - - composable { backStackEntry -> - val savedStateHandle = backStackEntry.savedStateHandle - val selectedPhotoId by savedStateHandle - .getStateFlow("selectedPhotoId", null) - .collectAsState() - - ProfileModScreenContainer( - modifier = Modifier.fillMaxSize(), - selectedPhotoId = selectedPhotoId, - onNavigateToBack = { - navController.popBackStack() - }, - onClickComplete = { - navController.popBackStack() - }, - ) - } - - composable { - BookmarkScreenContainer( - modifier = Modifier.fillMaxSize(), - onNavigateToBack = { - navController.popBackStack() - }, - onNavigateToSpotDetailScreen = { - navController.navigate( - SpotRoute.SpotDetail( - com.acon.acon.core.model.model.spot.SpotNavigationParameter( - it, - emptyList(), - null, - null, - null, - true - ) - ) - ) - }, - ) - } - } -} \ No newline at end of file diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 8eaa808fb..6ad99feda 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -61,5 +61,13 @@ gradlePlugin { id = "com.acon.firebase" implementationClass = "FirebaseConventionPlugin" } + register("featureTest") { + id = "com.acon.feature.test" + implementationClass = "test.FeatureTestConventionPlugin" + } + register("commonUnitTest") { + id = "com.acon.common.unit.test" + implementationClass = "test.CommonUnitTestConventionPlugin" + } } } \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt index f0dd0ec47..b8d8970c4 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt @@ -20,6 +20,7 @@ class AndroidApplicationComposeConventionPlugin: Plugin { extensions.configure { buildFeatures { compose = true + buildConfig = true } composeOptions { kotlinCompilerExtensionVersion = diff --git a/build-logic/convention/src/main/kotlin/test/CommonUnitTestConventionPlugin.kt b/build-logic/convention/src/main/kotlin/test/CommonUnitTestConventionPlugin.kt new file mode 100644 index 000000000..b57d5d597 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/test/CommonUnitTestConventionPlugin.kt @@ -0,0 +1,33 @@ +package test + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.withType +import utils.catalog +import utils.testImplementation +import utils.testRuntimeOnly + +/** + * androidTest를 하지 않는 모듈에서 공통으로 사용하는 unitTest 라이브러리 모음 컨벤션 플러그인 + */ +class CommonUnitTestConventionPlugin: Plugin { + + override fun apply(target: Project) { + target.run { + dependencies { + testImplementation(catalog.findBundle("test-unit").get()) + testImplementation(catalog.findBundle("test-coroutine").get()) + testImplementation(catalog.findBundle("kotest").get()) + testRuntimeOnly(catalog.findBundle("test-runtime").get()) + + testImplementation(catalog.findLibrary("mockk").get()) + } + + tasks.withType { + useJUnitPlatform() + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/test/FeatureTestConventionPlugin.kt b/build-logic/convention/src/main/kotlin/test/FeatureTestConventionPlugin.kt new file mode 100644 index 000000000..d0b4221f3 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/test/FeatureTestConventionPlugin.kt @@ -0,0 +1,49 @@ +package test + +import com.android.build.api.dsl.LibraryExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.withType +import utils.androidTestImplementation +import utils.catalog +import utils.testImplementation +import utils.testRuntimeOnly + +/** + * Feature 모듈에서 공통으로 사용하는 테스트 라이브러리 모음 컨벤션 플러그인 + * unitTest, androidTest 모두 포함 + */ +class FeatureTestConventionPlugin: Plugin { + + override fun apply(target: Project) { + target.run { + extensions.configure { + packaging { + resources { + excludes += "META-INF/LICENSE.md" + excludes += "META-INF/LICENSE-notice.md" + } + } + } + dependencies { + androidTestImplementation(catalog.findBundle("android-test").get()) + androidTestImplementation(catalog.findBundle("test-unit").get()) + testImplementation(catalog.findBundle("test-unit").get()) + testImplementation(catalog.findBundle("test-coroutine").get()) + testImplementation(catalog.findBundle("orbit-test").get()) + testImplementation(catalog.findBundle("kotest").get()) + testRuntimeOnly(catalog.findBundle("test-runtime").get()) + + testImplementation(catalog.findLibrary("mockk").get()) + androidTestImplementation(catalog.findLibrary("mockk-android").get()) + } + + tasks.withType { + useJUnitPlatform() + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/utils/DependencyExtensions.kt b/build-logic/convention/src/main/kotlin/utils/DependencyExtensions.kt index 8c1876bad..5ae21e9cf 100644 --- a/build-logic/convention/src/main/kotlin/utils/DependencyExtensions.kt +++ b/build-logic/convention/src/main/kotlin/utils/DependencyExtensions.kt @@ -28,4 +28,8 @@ fun DependencyHandler.androidTestImplementation(dependency: Any) { fun DependencyHandler.testImplementation(dependency: Any) { add("testImplementation", dependency) +} + +fun DependencyHandler.testRuntimeOnly(dependency: Any) { + add("testRuntimeOnly", dependency) } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 05fe971f3..b7b68db38 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,4 +10,5 @@ plugins { alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.firebase.crashlytics) apply false alias(libs.plugins.google.services) apply false + alias(libs.plugins.protobuf) apply false } \ No newline at end of file diff --git a/core/ads-api/build.gradle.kts b/core/ads-api/build.gradle.kts deleted file mode 100644 index 238e6fa39..000000000 --- a/core/ads-api/build.gradle.kts +++ /dev/null @@ -1,8 +0,0 @@ -plugins { - alias(libs.plugins.acon.android.library) - alias(libs.plugins.acon.android.library.compose) -} - -android { - namespace = "com.acon.core.ads_api" -} \ No newline at end of file diff --git a/core/ads-api/src/main/java/com/acon/acon/core/ads_api/AdProvider.kt b/core/ads-api/src/main/java/com/acon/acon/core/ads_api/AdProvider.kt deleted file mode 100644 index 03007b431..000000000 --- a/core/ads-api/src/main/java/com/acon/acon/core/ads_api/AdProvider.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.acon.acon.core.ads_api - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Modifier - -interface AdProvider { - - @Composable - fun NativeAd(modifier: Modifier) -} - -val LocalSpotListAdProvider = staticCompositionLocalOf { - error("AdProvider가 제공되지 않았습니다.") -} \ No newline at end of file diff --git a/core/ads-api/.gitignore b/core/ads/.gitignore similarity index 100% rename from core/ads-api/.gitignore rename to core/ads/.gitignore diff --git a/provider/ads-impl/build.gradle.kts b/core/ads/build.gradle.kts similarity index 89% rename from provider/ads-impl/build.gradle.kts rename to core/ads/build.gradle.kts index a64850e22..bca35620d 100644 --- a/provider/ads-impl/build.gradle.kts +++ b/core/ads/build.gradle.kts @@ -1,4 +1,5 @@ import java.util.Properties +import kotlin.apply plugins { alias(libs.plugins.acon.android.library) @@ -12,7 +13,7 @@ val localProperties = Properties().apply { } android { - namespace = "com.acon.feature.ads_impl" + namespace = "com.acon.core.ads" defaultConfig { buildConfigField("String", "NATIVE_ADMOB_ID", "\"${localProperties["native_admob_id"]}\"") @@ -21,7 +22,6 @@ android { } dependencies { - implementation(projects.core.adsApi) implementation(projects.core.designsystem) implementation(libs.google.services.ads) diff --git a/core/ads-api/consumer-rules.pro b/core/ads/consumer-rules.pro similarity index 100% rename from core/ads-api/consumer-rules.pro rename to core/ads/consumer-rules.pro diff --git a/core/ads-api/proguard-rules.pro b/core/ads/proguard-rules.pro similarity index 100% rename from core/ads-api/proguard-rules.pro rename to core/ads/proguard-rules.pro diff --git a/provider/ads-impl/src/main/AndroidManifest.xml b/core/ads/src/main/AndroidManifest.xml similarity index 74% rename from provider/ads-impl/src/main/AndroidManifest.xml rename to core/ads/src/main/AndroidManifest.xml index ec0cec507..b9ed92ccb 100644 --- a/provider/ads-impl/src/main/AndroidManifest.xml +++ b/core/ads/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - \ No newline at end of file diff --git a/provider/ads-impl/src/main/java/com/acon/acon/provider/ads_impl/SpotListAdProvider.kt b/core/ads/src/main/java/com/acon/core/ads/SpotListAdProvider.kt similarity index 94% rename from provider/ads-impl/src/main/java/com/acon/acon/provider/ads_impl/SpotListAdProvider.kt rename to core/ads/src/main/java/com/acon/core/ads/SpotListAdProvider.kt index 905f70353..d3c7dad5c 100644 --- a/provider/ads-impl/src/main/java/com/acon/acon/provider/ads_impl/SpotListAdProvider.kt +++ b/core/ads/src/main/java/com/acon/core/ads/SpotListAdProvider.kt @@ -1,7 +1,8 @@ -package com.acon.acon.provider.ads_impl +package com.acon.core.ads import android.annotation.SuppressLint import android.view.Gravity +import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.TextView @@ -35,11 +36,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.zIndex import coil3.compose.AsyncImage +import com.acon.acon.core.designsystem.R import com.acon.acon.core.designsystem.component.button.v2.AconFilledButton import com.acon.acon.core.designsystem.effect.imageGradientLayer import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.core.ads_api.AdProvider -import com.acon.feature.ads_impl.BuildConfig import com.google.android.gms.ads.AdListener import com.google.android.gms.ads.AdLoader import com.google.android.gms.ads.AdRequest @@ -50,19 +50,12 @@ import com.google.android.gms.ads.nativead.NativeAd import com.google.android.gms.ads.nativead.NativeAdOptions import com.google.android.gms.ads.nativead.NativeAdView -class SpotListAdProvider : AdProvider { - - @Composable - override fun NativeAd(modifier: Modifier) { - SpotListNativeAd(modifier) - } -} - @SuppressLint("MissingPermission") @Composable -private fun SpotListNativeAd(modifier: Modifier) { +fun SpotListNativeAd(modifier: Modifier) { val context = LocalContext.current var adUiState by remember { mutableStateOf(AdUiState.Loading) } + val callToActionClickTrigger = remember { View(context) } DisposableEffect(Unit) { val adLoader = AdLoader.Builder(context, BuildConfig.NATIVE_ADMOB_ID) @@ -122,6 +115,9 @@ private fun SpotListNativeAd(modifier: Modifier) { } layout.addView(adChoicesView, adChoicesLayoutParams) + layout.addView(callToActionClickTrigger) + nativeAdView.callToActionView = callToActionClickTrigger + nativeAdView.addView(layout) nativeAdView.setNativeAd(ad) nativeAdView @@ -151,7 +147,7 @@ private fun SpotListNativeAd(modifier: Modifier) { contentAlignment = Alignment.Center ) { Text( - text = stringResource(com.acon.acon.core.designsystem.R.string.advertisement), + text = stringResource(R.string.advertisement), style = AconTheme.typography.Body1, color = AconTheme.color.White, fontWeight = FontWeight.W400, @@ -179,7 +175,7 @@ private fun SpotListNativeAd(modifier: Modifier) { ad.callToAction?.let { AconFilledButton( modifier = Modifier, - onClick = {}, + onClick = callToActionClickTrigger::performClick, contentPadding = PaddingValues( horizontal = 23.dp, vertical = 8.dp diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts index 133bf0e94..238d54d35 100644 --- a/core/analytics/build.gradle.kts +++ b/core/analytics/build.gradle.kts @@ -19,7 +19,4 @@ android { dependencies { implementation(libs.amplitude) - - testImplementation(libs.bundles.android.test) - testImplementation(libs.bundles.non.android.test) } \ No newline at end of file diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 64f4f837e..adfa1dd7f 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -1,10 +1,9 @@ plugins { alias(libs.plugins.acon.non.android.library) + alias(libs.plugins.acon.common.unit.test) } dependencies { implementation(libs.javax.inject) implementation(libs.kotlinx.coroutines.core) - - testImplementation(libs.bundles.non.android.test) } \ No newline at end of file diff --git a/core/common/src/main/java/com/acon/acon/core/common/utils/DateExtensions.kt b/core/common/src/main/java/com/acon/acon/core/common/utils/DateExtensions.kt new file mode 100644 index 000000000..7c852e681 --- /dev/null +++ b/core/common/src/main/java/com/acon/acon/core/common/utils/DateExtensions.kt @@ -0,0 +1,31 @@ +package com.acon.acon.core.common.utils + +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +private val yyyyMMddFormatter by lazy { + DateTimeFormatter.ofPattern("yyyyMMdd") +} + +/** + * [LocalDate]를 `yyyyMMdd` 형식으로 변환 + */ +fun LocalDate.toyyyyMMdd(): String { + return format(yyyyMMddFormatter) +} + +/** + * yyyyMMdd을 [LocalDate]로 변환. + * 파싱 실패 시 null 반환 + * ``` + * "20250915".toLocalDate() // == LocalDate.of(2025, 9, 15) + * ``` + */ +fun String.toLocalDate(): LocalDate? { + return try { + LocalDate.parse(this, yyyyMMddFormatter) + } catch (_: DateTimeParseException) { + null + } +} \ No newline at end of file diff --git a/data/.gitignore b/core/data/.gitignore similarity index 100% rename from data/.gitignore rename to core/data/.gitignore diff --git a/data/build.gradle.kts b/core/data/build.gradle.kts similarity index 61% rename from data/build.gradle.kts rename to core/data/build.gradle.kts index 980822769..4240087cc 100644 --- a/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -1,10 +1,13 @@ -import utils.androidTestImplementation +import org.gradle.configurationcache.extensions.capitalized +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.util.Properties plugins { alias(libs.plugins.acon.android.library) alias(libs.plugins.acon.android.library.hilt) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.protobuf) + alias(libs.plugins.acon.common.unit.test) } val localProperties = Properties().apply { @@ -12,7 +15,7 @@ val localProperties = Properties().apply { } android { - namespace = "com.acon.acon.data" + namespace = "com.acon.core.data" defaultConfig { buildConfigField("String", "GOOGLE_CLIENT_ID", "\"${localProperties["GOOGLE_CLIENT_ID"]}\"") @@ -24,6 +27,35 @@ android { } } +androidComponents { + onVariants(selector().all()) { variant -> + afterEvaluate { + val capName = variant.name.capitalized() + tasks.getByName("ksp${capName}Kotlin") { + setSource(tasks.getByName("generate${capName}Proto").outputs) + } + } + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.32.0" + } + generateProtoTasks { + all().forEach { task -> + task.builtins { + create("java") { + option("lite") + } + create("kotlin") { + option("lite") + } + } + } + } +} + dependencies { implementation(projects.domain) @@ -43,10 +75,8 @@ dependencies { implementation(libs.bundles.googleSignIn) implementation(libs.preferences.datastore) - - testImplementation(libs.bundles.non.android.test) - testRuntimeOnly(libs.bundles.junit5.runtime) - androidTestImplementation(libs.bundles.android.test) + implementation(libs.proto.datastore) + implementation(libs.protobuf.kotlin) } tasks.withType { diff --git a/provider/ads-impl/consumer-rules.pro b/core/data/consumer-rules.pro similarity index 100% rename from provider/ads-impl/consumer-rules.pro rename to core/data/consumer-rules.pro diff --git a/provider/ads-impl/proguard-rules.pro b/core/data/proguard-rules.pro similarity index 100% rename from provider/ads-impl/proguard-rules.pro rename to core/data/proguard-rules.pro diff --git a/core/ads-api/src/main/AndroidManifest.xml b/core/data/src/main/AndroidManifest.xml similarity index 100% rename from core/ads-api/src/main/AndroidManifest.xml rename to core/data/src/main/AndroidManifest.xml diff --git a/data/src/main/kotlin/com/acon/acon/data/api/remote/MapApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/MapApi.kt similarity index 76% rename from data/src/main/kotlin/com/acon/acon/data/api/remote/MapApi.kt rename to core/data/src/main/kotlin/com/acon/core/data/api/remote/MapApi.kt index 9d807cd19..ecb219bfe 100644 --- a/data/src/main/kotlin/com/acon/acon/data/api/remote/MapApi.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/MapApi.kt @@ -1,6 +1,6 @@ -package com.acon.acon.data.api.remote +package com.acon.core.data.api.remote -import com.acon.acon.data.dto.response.ReverseGeocodingResponse +import com.acon.core.data.dto.response.ReverseGeocodingResponse import retrofit2.http.GET import retrofit2.http.Query diff --git a/data/src/main/kotlin/com/acon/acon/data/api/remote/MapSearchApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/MapSearchApi.kt similarity index 76% rename from data/src/main/kotlin/com/acon/acon/data/api/remote/MapSearchApi.kt rename to core/data/src/main/kotlin/com/acon/core/data/api/remote/MapSearchApi.kt index 69a234706..6214f6cb9 100644 --- a/data/src/main/kotlin/com/acon/acon/data/api/remote/MapSearchApi.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/MapSearchApi.kt @@ -1,6 +1,6 @@ -package com.acon.acon.data.api.remote +package com.acon.core.data.api.remote -import com.acon.acon.data.dto.response.MapSearchResponse +import com.acon.core.data.dto.response.MapSearchResponse import retrofit2.http.GET import retrofit2.http.Query diff --git a/core/data/src/main/kotlin/com/acon/core/data/api/remote/ProfileApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/ProfileApi.kt new file mode 100644 index 000000000..99dc4ef40 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/ProfileApi.kt @@ -0,0 +1,42 @@ +package com.acon.core.data.api.remote + +import com.acon.core.data.dto.request.ReplaceVerifiedAreaRequest +import com.acon.core.data.dto.request.profile.UpdateProfileRequest +import com.acon.core.data.dto.response.area.VerifiedAreaListResponse +import com.acon.core.data.dto.response.profile.ProfileResponse +import com.acon.core.data.dto.response.profile.SavedSpotsResponse +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +interface ProfileApi { + + @GET("/api/v1/members/me") + suspend fun getProfile() : ProfileResponse + + @PATCH("/api/v1/members/me") + suspend fun updateProfile(@Body updateProfileRequest: UpdateProfileRequest) + + @GET("/api/v1/nickname/validate") + suspend fun validateNickname(@Query("nickname") nickname: String) + + @GET("/api/v1/saved-spots") + suspend fun getSavedSpots() : SavedSpotsResponse + + @GET("/api/v1/verified-areas") + suspend fun getVerifiedAreas() : VerifiedAreaListResponse + + @POST("/api/v1/verified-areas/replacement") + suspend fun replaceVerifiedArea( + @Body request: ReplaceVerifiedAreaRequest + ) + + @DELETE("/api/v1/verified-areas/{verifiedAreaId}") + suspend fun deleteVerifiedArea( + @Path("verifiedAreaId") verifiedAreaId: Long + ) +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/AconAppApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/AconAppApi.kt new file mode 100644 index 000000000..71f439e82 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/AconAppApi.kt @@ -0,0 +1,14 @@ +package com.acon.core.data.api.remote.auth + +import com.acon.core.data.dto.request.GetPresignedUrlRequest +import com.acon.core.data.dto.response.PresignedUrlResponse +import retrofit2.http.Body +import retrofit2.http.POST + +interface AconAppApi { + + @POST("/api/v1/images/presigned-url") + suspend fun getPresignedUrl( + @Body request: GetPresignedUrlRequest + ): PresignedUrlResponse +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/OnboardingAuthApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/OnboardingAuthApi.kt new file mode 100644 index 000000000..3db9425b9 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/OnboardingAuthApi.kt @@ -0,0 +1,20 @@ +package com.acon.core.data.api.remote.auth + +import com.acon.core.data.dto.request.VerifyAreaRequest +import com.acon.core.data.dto.request.TastePreferenceRequest +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.PUT + +interface OnboardingAuthApi { + + @PUT("/api/v1/preference") + suspend fun submitTastePreferenceResult( + @Body tastePreferenceRequest: TastePreferenceRequest + ) + + @POST("/api/v1/verified-areas") + suspend fun verifyArea( + @Body request: VerifyAreaRequest + ) +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/SpotAuthApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/SpotAuthApi.kt similarity index 63% rename from data/src/main/kotlin/com/acon/acon/data/api/remote/auth/SpotAuthApi.kt rename to core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/SpotAuthApi.kt index c70c9b2a3..5354323c5 100644 --- a/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/SpotAuthApi.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/SpotAuthApi.kt @@ -1,10 +1,9 @@ -package com.acon.acon.data.api.remote.auth +package com.acon.core.data.api.remote.auth -import com.acon.acon.data.dto.request.AddBookmarkRequest -import com.acon.acon.data.dto.request.SpotListRequest -import com.acon.acon.data.dto.response.SpotDetailResponse -import com.acon.acon.data.dto.response.SpotListResponse -import com.acon.acon.data.dto.response.profile.SavedSpotsResponse +import com.acon.core.data.dto.request.AddBookmarkRequest +import com.acon.core.data.dto.request.SpotListRequest +import com.acon.core.data.dto.response.SpotDetailResponse +import com.acon.core.data.dto.response.SpotListResponse import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET @@ -19,9 +18,6 @@ interface SpotAuthApi { @Body request: SpotListRequest ): SpotListResponse - @GET("/api/v1/saved-spots") - suspend fun fetchSavedSpotList(): SavedSpotsResponse - @POST("/api/v1/saved-spots") suspend fun addBookmark( @Body addBookmarkRequest: AddBookmarkRequest diff --git a/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/UploadAuthApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/UploadAuthApi.kt similarity index 83% rename from data/src/main/kotlin/com/acon/acon/data/api/remote/auth/UploadAuthApi.kt rename to core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/UploadAuthApi.kt index f6a9d49b4..9e167eadb 100644 --- a/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/UploadAuthApi.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/UploadAuthApi.kt @@ -1,9 +1,9 @@ -package com.acon.acon.data.api.remote.auth +package com.acon.core.data.api.remote.auth -import com.acon.acon.data.dto.request.ReviewRequest -import com.acon.acon.data.dto.request.ReviewRequestV2 -import com.acon.acon.data.dto.request.SubmitUploadPlaceRequest -import com.acon.acon.data.dto.response.profile.PreSignedUrlResponse +import com.acon.core.data.dto.request.ReviewRequest +import com.acon.core.data.dto.request.ReviewRequestV2 +import com.acon.core.data.dto.request.SubmitUploadPlaceRequest +import com.acon.core.data.dto.response.profile.PreSignedUrlResponse import com.acon.acon.data.dto.response.upload.SearchedSpotsResponse import com.acon.acon.data.dto.response.upload.UploadSpotSuggestionsResponse import com.acon.acon.data.dto.response.upload.VerifyLocationResponse diff --git a/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/UserAuthApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/UserAuthApi.kt similarity index 79% rename from data/src/main/kotlin/com/acon/acon/data/api/remote/auth/UserAuthApi.kt rename to core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/UserAuthApi.kt index 2616bac97..37baf6b48 100644 --- a/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/UserAuthApi.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/auth/UserAuthApi.kt @@ -1,7 +1,7 @@ -package com.acon.acon.data.api.remote.auth +package com.acon.core.data.api.remote.auth import com.acon.acon.data.dto.request.DeleteAccountRequest -import com.acon.acon.data.dto.request.SignOutRequest +import com.acon.core.data.dto.request.SignOutRequest import retrofit2.http.Body import retrofit2.http.POST diff --git a/data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/AconAppNoAuthApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/AconAppNoAuthApi.kt similarity index 72% rename from data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/AconAppNoAuthApi.kt rename to core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/AconAppNoAuthApi.kt index 07c7a4ad2..15df9e71f 100644 --- a/data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/AconAppNoAuthApi.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/AconAppNoAuthApi.kt @@ -1,6 +1,6 @@ -package com.acon.acon.data.api.remote.noauth +package com.acon.core.data.api.remote.noauth -import com.acon.acon.data.dto.response.app.ShouldUpdateResponse +import com.acon.core.data.dto.response.app.ShouldUpdateResponse import retrofit2.http.GET import retrofit2.http.Query diff --git a/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/FileUploadApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/FileUploadApi.kt new file mode 100644 index 000000000..6832bc822 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/FileUploadApi.kt @@ -0,0 +1,14 @@ +package com.acon.core.data.api.remote.noauth + +import okhttp3.RequestBody +import retrofit2.http.Body +import retrofit2.http.PUT +import retrofit2.http.Url + +interface FileUploadApi { + @PUT + suspend fun uploadFile( + @Url url: String, + @Body body: RequestBody + ) +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/SpotNoAuthApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/SpotNoAuthApi.kt similarity index 69% rename from data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/SpotNoAuthApi.kt rename to core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/SpotNoAuthApi.kt index 0e1125e65..45480cb5e 100644 --- a/data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/SpotNoAuthApi.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/SpotNoAuthApi.kt @@ -1,10 +1,10 @@ -package com.acon.acon.data.api.remote.noauth +package com.acon.core.data.api.remote.noauth -import com.acon.acon.data.dto.request.RecentNavigationLocationRequest -import com.acon.acon.data.dto.request.SpotListRequest -import com.acon.acon.data.dto.response.MenuBoardListResponse -import com.acon.acon.data.dto.response.SpotDetailResponse -import com.acon.acon.data.dto.response.SpotListResponse +import com.acon.core.data.dto.request.RecentNavigationLocationRequest +import com.acon.core.data.dto.request.SpotListRequest +import com.acon.core.data.dto.response.MenuBoardListResponse +import com.acon.core.data.dto.response.SpotDetailResponse +import com.acon.core.data.dto.response.SpotListResponse import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST diff --git a/data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/UserNoAuthApi.kt b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/UserNoAuthApi.kt similarity index 56% rename from data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/UserNoAuthApi.kt rename to core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/UserNoAuthApi.kt index d5916a215..4ffedc57b 100644 --- a/data/src/main/kotlin/com/acon/acon/data/api/remote/noauth/UserNoAuthApi.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/api/remote/noauth/UserNoAuthApi.kt @@ -1,9 +1,9 @@ -package com.acon.acon.data.api.remote.noauth +package com.acon.core.data.api.remote.noauth -import com.acon.acon.data.dto.request.ReissueRequest -import com.acon.acon.data.dto.request.SignInRequest -import com.acon.acon.data.dto.response.SignInResponse -import com.acon.acon.data.dto.response.TokenResponse +import com.acon.core.data.dto.request.ReissueRequest +import com.acon.core.data.dto.request.SignInRequest +import com.acon.core.data.dto.response.SignInResponse +import com.acon.core.data.dto.response.TokenResponse import retrofit2.http.Body import retrofit2.http.POST diff --git a/data/src/main/kotlin/com/acon/acon/data/authentication/AuthAuthenticator.kt b/core/data/src/main/kotlin/com/acon/core/data/authentication/AuthAuthenticator.kt similarity index 91% rename from data/src/main/kotlin/com/acon/acon/data/authentication/AuthAuthenticator.kt rename to core/data/src/main/kotlin/com/acon/core/data/authentication/AuthAuthenticator.kt index c211df7df..5806f0218 100644 --- a/data/src/main/kotlin/com/acon/acon/data/authentication/AuthAuthenticator.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/authentication/AuthAuthenticator.kt @@ -1,14 +1,14 @@ -package com.acon.acon.data.authentication +package com.acon.core.data.authentication import android.content.Context import com.acon.acon.core.launcher.AppLauncher -import com.acon.acon.data.api.remote.noauth.UserNoAuthApi -import com.acon.acon.data.datasource.local.TokenLocalDataSource +import com.acon.core.data.api.remote.noauth.UserNoAuthApi +import com.acon.core.data.datasource.local.TokenLocalDataSource import com.acon.acon.data.dto.request.DeleteAccountRequest -import com.acon.acon.data.dto.request.ReissueRequest -import com.acon.acon.data.dto.request.SignOutRequest -import com.acon.acon.data.error.runCatchingWith -import com.acon.acon.data.session.SessionHandler +import com.acon.core.data.dto.request.ReissueRequest +import com.acon.core.data.dto.request.SignOutRequest +import com.acon.core.data.error.runCatchingWith +import com.acon.core.data.session.SessionHandler import com.acon.acon.domain.error.user.ReissueError import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex diff --git a/data/src/main/kotlin/com/acon/acon/data/cache/base/ReadOnlyCache.kt b/core/data/src/main/kotlin/com/acon/core/data/cache/base/ReadOnlyCache.kt similarity index 89% rename from data/src/main/kotlin/com/acon/acon/data/cache/base/ReadOnlyCache.kt rename to core/data/src/main/kotlin/com/acon/core/data/cache/base/ReadOnlyCache.kt index b2cb113ae..9c73522e6 100644 --- a/data/src/main/kotlin/com/acon/acon/data/cache/base/ReadOnlyCache.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/cache/base/ReadOnlyCache.kt @@ -1,6 +1,6 @@ -package com.acon.acon.data.cache.base +package com.acon.core.data.cache.base -import com.acon.acon.data.error.runCatchingWith +import com.acon.core.data.error.runCatchingWith import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted diff --git a/data/src/main/kotlin/com/acon/acon/data/cache/base/ReadWriteCache.kt b/core/data/src/main/kotlin/com/acon/core/data/cache/base/ReadWriteCache.kt similarity index 92% rename from data/src/main/kotlin/com/acon/acon/data/cache/base/ReadWriteCache.kt rename to core/data/src/main/kotlin/com/acon/core/data/cache/base/ReadWriteCache.kt index 9363f3393..6f8476201 100644 --- a/data/src/main/kotlin/com/acon/acon/data/cache/base/ReadWriteCache.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/cache/base/ReadWriteCache.kt @@ -1,6 +1,6 @@ -package com.acon.acon.data.cache.base +package com.acon.core.data.cache.base -import com.acon.acon.data.error.runCatchingWith +import com.acon.core.data.error.runCatchingWith import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/local/AconAppLocalDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/local/AconAppLocalDataSource.kt similarity index 90% rename from data/src/main/kotlin/com/acon/acon/data/datasource/local/AconAppLocalDataSource.kt rename to core/data/src/main/kotlin/com/acon/core/data/datasource/local/AconAppLocalDataSource.kt index 1ec44c399..79d4b986d 100644 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/local/AconAppLocalDataSource.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/local/AconAppLocalDataSource.kt @@ -1,10 +1,10 @@ -package com.acon.acon.data.datasource.local +package com.acon.core.data.datasource.local import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.longPreferencesKey -import com.acon.acon.data.di.AconAppDataStore +import com.acon.core.data.di.AconAppDataStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject diff --git a/core/data/src/main/kotlin/com/acon/core/data/datasource/local/OnboardingLocalDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/local/OnboardingLocalDataSource.kt new file mode 100644 index 000000000..323ef1134 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/local/OnboardingLocalDataSource.kt @@ -0,0 +1,50 @@ +package com.acon.core.data.datasource.local + +import androidx.datastore.core.DataStore +import com.acon.core.data.dto.entity.OnboardingPreferencesEntity +import com.acon.core.data.dto.entity.copy +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class OnboardingLocalDataSource @Inject constructor( + private val onboardingDataStore: DataStore +) { + + suspend fun updateOnboardingPreferences(pref: OnboardingPreferencesEntity) { + onboardingDataStore.updateData { prefs -> + prefs.copy { + shouldShowIntroduce = pref.shouldShowIntroduce + shouldChooseDislikes = pref.shouldChooseDislikes + shouldVerifyArea = pref.shouldVerifyArea + } + } + } + + suspend fun updateShouldShowIntroduce(shouldShow: Boolean) { + onboardingDataStore.updateData { prefs -> + prefs.copy { + shouldShowIntroduce = shouldShow + } + } + } + + suspend fun updateShouldChooseDislikes(shouldChooseDislikes: Boolean) { + onboardingDataStore.updateData { prefs -> + prefs.copy { + this.shouldChooseDislikes = shouldChooseDislikes + } + } + } + + suspend fun updateShouldVerifyArea(shouldVerifyArea: Boolean) { + onboardingDataStore.updateData { prefs -> + prefs.copy { + this.shouldVerifyArea = shouldVerifyArea + } + } + } + + suspend fun getOnboardingPreferences(): OnboardingPreferencesEntity { + return onboardingDataStore.data.first() + } +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/datasource/local/ProfileLocalDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/local/ProfileLocalDataSource.kt new file mode 100644 index 000000000..6ffe8a1e5 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/local/ProfileLocalDataSource.kt @@ -0,0 +1,51 @@ +package com.acon.core.data.datasource.local + +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.SavedSpot +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +interface ProfileLocalDataSource { + + suspend fun cacheProfile(profile: Profile) + fun getProfile() : Flow + + suspend fun cacheSavedSpots(savedSpots: List) + fun getSavedSpots(): Flow?> + + suspend fun clearCache() +} + +class ProfileLocalDataSourceImpl @Inject constructor( + +) : ProfileLocalDataSource { + + private val _profile = MutableStateFlow(null) + private val profile = _profile.asStateFlow() + + private val _savedSpots = MutableStateFlow?>(null) + private val savedSpots = _savedSpots.asStateFlow() + + override suspend fun cacheProfile(profile: Profile) { + _profile.value = profile + } + + override fun getProfile(): Flow { + return profile + } + + override suspend fun cacheSavedSpots(savedSpots: List) { + _savedSpots.value = savedSpots + } + + override fun getSavedSpots(): Flow?> { + return savedSpots + } + + override suspend fun clearCache() { + _profile.value = null + _savedSpots.value = null + } +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/local/TimeLocalDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/local/TimeLocalDataSource.kt similarity index 91% rename from data/src/main/kotlin/com/acon/acon/data/datasource/local/TimeLocalDataSource.kt rename to core/data/src/main/kotlin/com/acon/core/data/datasource/local/TimeLocalDataSource.kt index ad51a5f10..b1e574e66 100644 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/local/TimeLocalDataSource.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/local/TimeLocalDataSource.kt @@ -1,11 +1,11 @@ -package com.acon.acon.data.datasource.local +package com.acon.core.data.datasource.local import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.longPreferencesKey import com.acon.acon.core.model.type.UserActionType -import com.acon.acon.data.di.TimeDataStore +import com.acon.core.data.di.TimeDataStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject diff --git a/core/data/src/main/kotlin/com/acon/core/data/datasource/local/TokenLocalDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/local/TokenLocalDataSource.kt new file mode 100644 index 000000000..174345705 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/local/TokenLocalDataSource.kt @@ -0,0 +1,50 @@ +package com.acon.core.data.datasource.local + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.acon.acon.core.common.IODispatcher +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class TokenLocalDataSource @Inject constructor( + @IODispatcher private val dispatcher: CoroutineDispatcher, + private val preferences: SharedPreferences +) { + + internal suspend fun saveAccessToken( + accessToken: String, + ) = withContext(dispatcher) { + preferences.edit { + putString(SHARED_PREF_KEY, accessToken) + } + } + + internal suspend fun saveRefreshToken( + refreshToken: String, + ) = withContext(dispatcher) { + preferences.edit { + putString(SHARED_PREF_REFRESH_KEY, refreshToken) + } + } + + internal suspend fun getAccessToken(): String? = withContext(dispatcher) { + preferences.getString(SHARED_PREF_KEY, null) + } + + internal suspend fun getRefreshToken(): String? = withContext(dispatcher) { + preferences.getString(SHARED_PREF_REFRESH_KEY, null) + } + + internal suspend fun removeAllTokens() = withContext(dispatcher) { + preferences.edit { + remove(SHARED_PREF_KEY) + remove(SHARED_PREF_REFRESH_KEY) + } + } + + companion object { + private const val SHARED_PREF_KEY = "access_token" + private const val SHARED_PREF_REFRESH_KEY = "refresh_token" + } +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/AconAppRemoteDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/AconAppRemoteDataSource.kt new file mode 100644 index 000000000..833cbad3b --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/AconAppRemoteDataSource.kt @@ -0,0 +1,28 @@ +package com.acon.core.data.datasource.remote + +import com.acon.core.data.api.remote.auth.AconAppApi +import com.acon.core.data.dto.response.app.ShouldUpdateResponse +import com.acon.core.data.api.remote.noauth.AconAppNoAuthApi +import com.acon.core.data.api.remote.noauth.FileUploadApi +import com.acon.core.data.dto.request.GetPresignedUrlRequest +import com.acon.core.data.dto.response.PresignedUrlResponse +import okhttp3.RequestBody +import javax.inject.Inject + +class AconAppRemoteDataSource @Inject constructor( + private val aconAppNoAuthApi: AconAppNoAuthApi, + private val aconAppApi: AconAppApi, + private val fileUploadApi: FileUploadApi, +) { + suspend fun fetchShouldUpdateApp(currentVersion: String): ShouldUpdateResponse { + return aconAppNoAuthApi.fetchShouldUpdateApp(currentVersion) + } + + suspend fun getPresignedUrl(request: GetPresignedUrlRequest): PresignedUrlResponse { + return aconAppApi.getPresignedUrl(request) + } + + suspend fun uploadFile(presignedUrl: String, body: RequestBody) { + fileUploadApi.uploadFile(presignedUrl, body) + } +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/MapRemoteDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/MapRemoteDataSource.kt similarity index 74% rename from data/src/main/kotlin/com/acon/acon/data/datasource/remote/MapRemoteDataSource.kt rename to core/data/src/main/kotlin/com/acon/core/data/datasource/remote/MapRemoteDataSource.kt index 3f9702ece..b24a565db 100644 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/MapRemoteDataSource.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/MapRemoteDataSource.kt @@ -1,6 +1,6 @@ -package com.acon.acon.data.datasource.remote +package com.acon.core.data.datasource.remote -import com.acon.acon.data.api.remote.MapApi +import com.acon.core.data.api.remote.MapApi import javax.inject.Inject class MapRemoteDataSource @Inject constructor( diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/MapSearchRemoteDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/MapSearchRemoteDataSource.kt similarity index 70% rename from data/src/main/kotlin/com/acon/acon/data/datasource/remote/MapSearchRemoteDataSource.kt rename to core/data/src/main/kotlin/com/acon/core/data/datasource/remote/MapSearchRemoteDataSource.kt index dc20d05ed..72b2f386d 100644 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/MapSearchRemoteDataSource.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/MapSearchRemoteDataSource.kt @@ -1,6 +1,6 @@ -package com.acon.acon.data.datasource.remote +package com.acon.core.data.datasource.remote -import com.acon.acon.data.api.remote.MapSearchApi +import com.acon.core.data.api.remote.MapSearchApi import javax.inject.Inject class MapSearchRemoteDataSource @Inject constructor( diff --git a/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/OnboardingRemoteDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/OnboardingRemoteDataSource.kt new file mode 100644 index 000000000..22537a7e0 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/OnboardingRemoteDataSource.kt @@ -0,0 +1,27 @@ +package com.acon.core.data.datasource.remote + +import com.acon.core.data.dto.request.TastePreferenceRequest +import com.acon.core.data.api.remote.auth.OnboardingAuthApi +import com.acon.core.data.dto.request.VerifyAreaRequest +import javax.inject.Inject + +class OnboardingRemoteDataSource @Inject constructor( + private val onboardingAuthApi: OnboardingAuthApi +) { + + suspend fun submitTastePreferenceResult(request: TastePreferenceRequest) { + return onboardingAuthApi.submitTastePreferenceResult(request) + } + + suspend fun verifyArea( + latitude: Double, + longitude: Double + ) { + return onboardingAuthApi.verifyArea( + request = VerifyAreaRequest( + latitude = latitude, + longitude = longitude + ) + ) + } +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/ProfileRemoteDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/ProfileRemoteDataSource.kt new file mode 100644 index 000000000..d121a7eb7 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/ProfileRemoteDataSource.kt @@ -0,0 +1,48 @@ +package com.acon.core.data.datasource.remote + +import com.acon.core.data.api.remote.ProfileApi +import com.acon.core.data.dto.request.ReplaceVerifiedAreaRequest +import com.acon.core.data.dto.request.profile.UpdateProfileRequest +import com.acon.core.data.dto.response.area.VerifiedAreaListResponse +import com.acon.core.data.dto.response.profile.ProfileResponse +import com.acon.core.data.dto.response.profile.SavedSpotResponse +import javax.inject.Inject + +interface ProfileRemoteDataSource { + + suspend fun getProfile() : ProfileResponse + suspend fun updateProfile(updateProfileRequest: UpdateProfileRequest) + suspend fun validateNickname(nickname: String) + suspend fun getSavedSpots() : List + suspend fun getVerifiedAreas(): VerifiedAreaListResponse + suspend fun deleteVerifiedArea(verifiedAreaId: Long) +} + +class ProfileRemoteDataSourceImpl @Inject constructor( + private val profileApi: ProfileApi +) : ProfileRemoteDataSource { + + override suspend fun getProfile(): ProfileResponse { + return profileApi.getProfile() + } + + override suspend fun updateProfile(updateProfileRequest: UpdateProfileRequest) { + profileApi.updateProfile(updateProfileRequest) + } + + override suspend fun validateNickname(nickname: String) { + profileApi.validateNickname(nickname) + } + + override suspend fun getSavedSpots(): List { + return profileApi.getSavedSpots().savedSpotList + } + + override suspend fun getVerifiedAreas(): VerifiedAreaListResponse { + return profileApi.getVerifiedAreas() + } + + override suspend fun deleteVerifiedArea(verifiedAreaId: Long) { + return profileApi.deleteVerifiedArea(verifiedAreaId) + } +} diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/SpotRemoteDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/SpotRemoteDataSource.kt similarity index 55% rename from data/src/main/kotlin/com/acon/acon/data/datasource/remote/SpotRemoteDataSource.kt rename to core/data/src/main/kotlin/com/acon/core/data/datasource/remote/SpotRemoteDataSource.kt index 8249b70d1..bddc46568 100644 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/SpotRemoteDataSource.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/SpotRemoteDataSource.kt @@ -1,15 +1,14 @@ -package com.acon.acon.data.datasource.remote - -import com.acon.acon.core.model.type.UserType -import com.acon.acon.data.api.remote.auth.SpotAuthApi -import com.acon.acon.data.api.remote.noauth.SpotNoAuthApi -import com.acon.acon.data.dto.request.AddBookmarkRequest -import com.acon.acon.data.dto.request.RecentNavigationLocationRequest -import com.acon.acon.data.dto.request.SpotListRequest -import com.acon.acon.data.dto.response.MenuBoardListResponse -import com.acon.acon.data.dto.response.SpotDetailResponse -import com.acon.acon.data.dto.response.SpotListResponse -import com.acon.acon.data.dto.response.profile.SavedSpotsResponse +package com.acon.core.data.datasource.remote + +import com.acon.acon.core.model.type.SignInStatus +import com.acon.core.data.api.remote.auth.SpotAuthApi +import com.acon.core.data.api.remote.noauth.SpotNoAuthApi +import com.acon.core.data.dto.request.AddBookmarkRequest +import com.acon.core.data.dto.request.RecentNavigationLocationRequest +import com.acon.core.data.dto.request.SpotListRequest +import com.acon.core.data.dto.response.MenuBoardListResponse +import com.acon.core.data.dto.response.SpotDetailResponse +import com.acon.core.data.dto.response.SpotListResponse import javax.inject.Inject class SpotRemoteDataSource @Inject constructor( @@ -17,8 +16,8 @@ class SpotRemoteDataSource @Inject constructor( private val spotAuthApi: SpotAuthApi ) { - suspend fun fetchSpotList(request: SpotListRequest, userType: UserType): SpotListResponse { - return if (userType == UserType.GUEST) + suspend fun fetchSpotList(request: SpotListRequest, signInStatus: SignInStatus): SpotListResponse { + return if (signInStatus == SignInStatus.GUEST) spotNoAuthApi.fetchSpotList(request) else spotAuthApi.fetchSpotList(request) } @@ -39,10 +38,6 @@ class SpotRemoteDataSource @Inject constructor( return spotAuthApi.fetchSpotDetailFromUser(spotId) } - suspend fun fetchSavedSpotList(): SavedSpotsResponse { - return spotAuthApi.fetchSavedSpotList() - } - suspend fun addBookmark(request: AddBookmarkRequest) { return spotAuthApi.addBookmark(request) } diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/UploadRemoteDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/UploadRemoteDataSource.kt similarity index 82% rename from data/src/main/kotlin/com/acon/acon/data/datasource/remote/UploadRemoteDataSource.kt rename to core/data/src/main/kotlin/com/acon/core/data/datasource/remote/UploadRemoteDataSource.kt index e386a29e6..493d4d3bb 100644 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/UploadRemoteDataSource.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/UploadRemoteDataSource.kt @@ -1,10 +1,10 @@ -package com.acon.acon.data.datasource.remote +package com.acon.core.data.datasource.remote -import com.acon.acon.data.api.remote.auth.UploadAuthApi -import com.acon.acon.data.dto.request.ReviewRequest -import com.acon.acon.data.dto.request.ReviewRequestV2 -import com.acon.acon.data.dto.request.SubmitUploadPlaceRequest -import com.acon.acon.data.dto.response.profile.PreSignedUrlResponse +import com.acon.core.data.api.remote.auth.UploadAuthApi +import com.acon.core.data.dto.request.ReviewRequest +import com.acon.core.data.dto.request.ReviewRequestV2 +import com.acon.core.data.dto.request.SubmitUploadPlaceRequest +import com.acon.core.data.dto.response.profile.PreSignedUrlResponse import com.acon.acon.data.dto.response.upload.UploadSpotSuggestionsResponse import com.acon.acon.data.dto.response.upload.VerifyLocationResponse import javax.inject.Inject diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/UserRemoteDataSource.kt b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/UserRemoteDataSource.kt similarity index 66% rename from data/src/main/kotlin/com/acon/acon/data/datasource/remote/UserRemoteDataSource.kt rename to core/data/src/main/kotlin/com/acon/core/data/datasource/remote/UserRemoteDataSource.kt index 3e5dd0739..91ec61850 100644 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/UserRemoteDataSource.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/datasource/remote/UserRemoteDataSource.kt @@ -1,11 +1,11 @@ -package com.acon.acon.data.datasource.remote +package com.acon.core.data.datasource.remote -import com.acon.acon.data.api.remote.auth.UserAuthApi -import com.acon.acon.data.api.remote.noauth.UserNoAuthApi +import com.acon.core.data.api.remote.auth.UserAuthApi +import com.acon.core.data.api.remote.noauth.UserNoAuthApi import com.acon.acon.data.dto.request.DeleteAccountRequest -import com.acon.acon.data.dto.request.SignInRequest -import com.acon.acon.data.dto.request.SignOutRequest -import com.acon.acon.data.dto.response.SignInResponse +import com.acon.core.data.dto.request.SignInRequest +import com.acon.core.data.dto.request.SignOutRequest +import com.acon.core.data.dto.response.SignInResponse import javax.inject.Inject class UserRemoteDataSource @Inject constructor( diff --git a/data/src/main/kotlin/com/acon/acon/data/di/ApiModule.kt b/core/data/src/main/kotlin/com/acon/core/data/di/ApiModule.kt similarity index 65% rename from data/src/main/kotlin/com/acon/acon/data/di/ApiModule.kt rename to core/data/src/main/kotlin/com/acon/core/data/di/ApiModule.kt index c74b76445..806afff8b 100644 --- a/data/src/main/kotlin/com/acon/acon/data/di/ApiModule.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/di/ApiModule.kt @@ -1,19 +1,21 @@ -package com.acon.acon.data.di +package com.acon.core.data.di import com.acon.acon.core.common.Auth import com.acon.acon.core.common.Naver import com.acon.acon.core.common.NaverDevelopers import com.acon.acon.core.common.NoAuth -import com.acon.acon.data.api.remote.noauth.AconAppNoAuthApi -import com.acon.acon.data.api.remote.MapApi -import com.acon.acon.data.api.remote.MapSearchApi -import com.acon.acon.data.api.remote.auth.OnboardingAuthApi -import com.acon.acon.data.api.remote.auth.ProfileAuthApi -import com.acon.acon.data.api.remote.auth.SpotAuthApi -import com.acon.acon.data.api.remote.noauth.SpotNoAuthApi -import com.acon.acon.data.api.remote.auth.UploadAuthApi -import com.acon.acon.data.api.remote.auth.UserAuthApi -import com.acon.acon.data.api.remote.noauth.UserNoAuthApi +import com.acon.core.data.api.remote.noauth.AconAppNoAuthApi +import com.acon.core.data.api.remote.MapApi +import com.acon.core.data.api.remote.MapSearchApi +import com.acon.core.data.api.remote.ProfileApi +import com.acon.core.data.api.remote.auth.AconAppApi +import com.acon.core.data.api.remote.auth.OnboardingAuthApi +import com.acon.core.data.api.remote.auth.SpotAuthApi +import com.acon.core.data.api.remote.noauth.SpotNoAuthApi +import com.acon.core.data.api.remote.auth.UploadAuthApi +import com.acon.core.data.api.remote.auth.UserAuthApi +import com.acon.core.data.api.remote.noauth.FileUploadApi +import com.acon.core.data.api.remote.noauth.UserNoAuthApi import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -77,8 +79,8 @@ internal object ApiModule { @Provides fun providesProfileApi( @Auth retrofit: Retrofit - ): ProfileAuthApi { - return retrofit.create(ProfileAuthApi::class.java) + ): ProfileApi { + return retrofit.create(ProfileApi::class.java) } @Singleton @@ -99,9 +101,25 @@ internal object ApiModule { @Singleton @Provides - fun providesAconAppApi( + fun providesAconAppNoAuthApi( @NoAuth retrofit: Retrofit ): AconAppNoAuthApi { return retrofit.create(AconAppNoAuthApi::class.java) } + + @Singleton + @Provides + fun providesAconAppApi( + @Auth retrofit: Retrofit + ): AconAppApi { + return retrofit.create(AconAppApi::class.java) + } + + @Singleton + @Provides + fun providesFileUploadApi( + @NoAuth retrofit: Retrofit + ): FileUploadApi { + return retrofit.create(FileUploadApi::class.java) + } } \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/di/DataStoreModule.kt b/core/data/src/main/kotlin/com/acon/core/data/di/DataStoreModule.kt similarity index 69% rename from data/src/main/kotlin/com/acon/acon/data/di/DataStoreModule.kt rename to core/data/src/main/kotlin/com/acon/core/data/di/DataStoreModule.kt index 039d4556e..2cbf6352f 100644 --- a/data/src/main/kotlin/com/acon/acon/data/di/DataStoreModule.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/di/DataStoreModule.kt @@ -1,9 +1,12 @@ -package com.acon.acon.data.di +package com.acon.core.data.di import android.content.Context import androidx.datastore.core.DataStore +import androidx.datastore.dataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore +import com.acon.core.data.dto.entity.OnboardingPreferencesEntity +import com.acon.core.data.serializer.OnboardingPreferencesSerializer import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,8 +21,10 @@ object DataStoreModule { private val Context.aconAppDataStore: DataStore by preferencesDataStore(name = "acon_app.ds") private val Context.timeDataStore: DataStore by preferencesDataStore(name = "time.ds") - private val Context.userDataStore: DataStore by preferencesDataStore(name = "user.ds") - private val Context.onboardingDataStore: DataStore by preferencesDataStore(name = "onboarding.ds") + private val Context.onboardingDataStore: DataStore by dataStore( + fileName = "onboarding.ds", + serializer = OnboardingPreferencesSerializer() + ) @Provides @Singleton @@ -37,14 +42,6 @@ object DataStoreModule { @Provides @Singleton - @UserDataStore - fun providesUserDataStore( - @ApplicationContext context: Context - ) = context.userDataStore - - @Provides - @Singleton - @OnboardingDataStore fun providesOnboardingDataStore( @ApplicationContext context: Context ) = context.onboardingDataStore @@ -57,12 +54,3 @@ annotation class AconAppDataStore @Qualifier @Retention(AnnotationRetention.BINARY) annotation class TimeDataStore - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class UserDataStore - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class OnboardingDataStore - diff --git a/core/data/src/main/kotlin/com/acon/core/data/di/DataStreamModule.kt b/core/data/src/main/kotlin/com/acon/core/data/di/DataStreamModule.kt new file mode 100644 index 000000000..9c6bfd4e1 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/di/DataStreamModule.kt @@ -0,0 +1,27 @@ +package com.acon.core.data.di + +import com.acon.core.data.stream.DataStream +import com.acon.core.data.stream.DataStreamImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DataStreamModule { + + @Singleton + @Binds + @VerifiedArea + abstract fun bindsVerifiedAreaDataStream( + impl: DataStreamImpl + ) : DataStream + +} + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class VerifiedArea \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/di/LocalDataSourceModule.kt b/core/data/src/main/kotlin/com/acon/core/data/di/LocalDataSourceModule.kt new file mode 100644 index 000000000..23d13f62f --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/di/LocalDataSourceModule.kt @@ -0,0 +1,20 @@ +package com.acon.core.data.di + +import com.acon.core.data.datasource.local.ProfileLocalDataSource +import com.acon.core.data.datasource.local.ProfileLocalDataSourceImpl +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 LocalDataSourceModule { + + @Binds + @Singleton + abstract fun bindsProfileLocalDataSource( + impl: ProfileLocalDataSourceImpl + ) : ProfileLocalDataSource +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/di/NetworkModule.kt b/core/data/src/main/kotlin/com/acon/core/data/di/NetworkModule.kt similarity index 97% rename from data/src/main/kotlin/com/acon/acon/data/di/NetworkModule.kt rename to core/data/src/main/kotlin/com/acon/core/data/di/NetworkModule.kt index f171e811b..8cbb9050b 100644 --- a/data/src/main/kotlin/com/acon/acon/data/di/NetworkModule.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/di/NetworkModule.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.di +package com.acon.core.data.di import com.acon.acon.core.common.Auth import com.acon.acon.core.common.Naver @@ -8,9 +8,9 @@ import com.acon.acon.core.common.NaverDevelopersAuthInterceptor import com.acon.acon.core.common.NoAuth import com.acon.acon.core.common.TokenInterceptor import com.acon.acon.core.common.UrlConstants -import com.acon.acon.data.BuildConfig -import com.acon.acon.data.datasource.local.TokenLocalDataSource -import com.acon.acon.data.error.RemoteErrorCallAdapterFactory +import com.acon.core.data.BuildConfig +import com.acon.core.data.datasource.local.TokenLocalDataSource +import com.acon.core.data.error.RemoteErrorCallAdapterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import dagger.Module import dagger.Provides diff --git a/core/data/src/main/kotlin/com/acon/core/data/di/RemoteDataSourceModule.kt b/core/data/src/main/kotlin/com/acon/core/data/di/RemoteDataSourceModule.kt new file mode 100644 index 000000000..477f4810c --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/di/RemoteDataSourceModule.kt @@ -0,0 +1,20 @@ +package com.acon.core.data.di + +import com.acon.core.data.datasource.remote.ProfileRemoteDataSource +import com.acon.core.data.datasource.remote.ProfileRemoteDataSourceImpl +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 RemoteDataSourceModule { + + @Binds + @Singleton + abstract fun bindsProfileRemoteDataSource( + impl: ProfileRemoteDataSourceImpl + ) : ProfileRemoteDataSource +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/di/RepositoryModule.kt b/core/data/src/main/kotlin/com/acon/core/data/di/RepositoryModule.kt similarity index 75% rename from data/src/main/kotlin/com/acon/acon/data/di/RepositoryModule.kt rename to core/data/src/main/kotlin/com/acon/core/data/di/RepositoryModule.kt index 6138a0571..d46eea422 100644 --- a/data/src/main/kotlin/com/acon/acon/data/di/RepositoryModule.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/di/RepositoryModule.kt @@ -1,16 +1,5 @@ -package com.acon.acon.data.di +package com.acon.core.data.di -import com.acon.acon.data.session.SessionHandler -import com.acon.acon.data.session.SessionHandlerImpl -import com.acon.acon.data.repository.AconAppRepositoryImpl -import com.acon.acon.data.repository.MapRepositoryImpl -import com.acon.acon.data.repository.MapSearchRepositoryImpl -import com.acon.acon.data.repository.OnboardingRepositoryImpl -import com.acon.acon.data.repository.ProfileRepositoryImpl -import com.acon.acon.data.repository.SpotRepositoryImpl -import com.acon.acon.data.repository.TimeRepositoryImpl -import com.acon.acon.data.repository.UploadRepositoryImpl -import com.acon.acon.data.repository.UserRepositoryImpl import com.acon.acon.domain.repository.AconAppRepository import com.acon.acon.domain.repository.MapRepository import com.acon.acon.domain.repository.MapSearchRepository @@ -20,6 +9,17 @@ import com.acon.acon.domain.repository.SpotRepository import com.acon.acon.domain.repository.TimeRepository import com.acon.acon.domain.repository.UploadRepository import com.acon.acon.domain.repository.UserRepository +import com.acon.core.data.repository.AconAppRepositoryImpl +import com.acon.core.data.repository.MapRepositoryImpl +import com.acon.core.data.repository.MapSearchRepositoryImpl +import com.acon.core.data.repository.OnboardingRepositoryImpl +import com.acon.core.data.repository.ProfileRepositoryImpl +import com.acon.core.data.repository.SpotRepositoryImpl +import com.acon.core.data.repository.TimeRepositoryImpl +import com.acon.core.data.repository.UploadRepositoryImpl +import com.acon.core.data.repository.UserRepositoryImpl +import com.acon.core.data.session.SessionHandler +import com.acon.core.data.session.SessionHandlerImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -30,6 +30,12 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) internal abstract class RepositoryModule { + @Singleton + @Binds + abstract fun bindsProfileRepository( + impl: ProfileRepositoryImpl + ): ProfileRepository + @Singleton @Binds abstract fun bindsUserRepository( @@ -60,12 +66,6 @@ internal abstract class RepositoryModule { impl: UploadRepositoryImpl ): UploadRepository - @Singleton - @Binds - abstract fun bindsProfileRepository( - impl: ProfileRepositoryImpl - ): ProfileRepository - @Singleton @Binds abstract fun bindsMapRepository( diff --git a/core/data/src/main/kotlin/com/acon/core/data/di/SharedPreferencesModule.kt b/core/data/src/main/kotlin/com/acon/core/data/di/SharedPreferencesModule.kt new file mode 100644 index 000000000..40cd46eb2 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/di/SharedPreferencesModule.kt @@ -0,0 +1,35 @@ +package com.acon.core.data.di + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +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 SharedPreferencesModule { + + @Provides + @Singleton + fun providesSharedPreferences( + @ApplicationContext context: Context, + ): SharedPreferences { + val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + return EncryptedSharedPreferences.create( + context, + "secrets.sp", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/AddBookmarkRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/AddBookmarkRequest.kt similarity index 81% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/AddBookmarkRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/AddBookmarkRequest.kt index b08f1e3f1..28cbaab85 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/AddBookmarkRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/AddBookmarkRequest.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/DeleteAccountRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/DeleteAccountRequest.kt similarity index 100% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/DeleteAccountRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/DeleteAccountRequest.kt diff --git a/core/data/src/main/kotlin/com/acon/core/data/dto/request/GetPresignedUrlRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/GetPresignedUrlRequest.kt new file mode 100644 index 000000000..68c31cdd5 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/GetPresignedUrlRequest.kt @@ -0,0 +1,11 @@ +package com.acon.core.data.dto.request + +import com.acon.acon.core.model.type.ImageType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetPresignedUrlRequest( + @SerialName("imageType") val imageType: ImageType, + @SerialName("originalFileName") val fileName: String +) diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/RecentNavigationLocationRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/RecentNavigationLocationRequest.kt similarity index 82% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/RecentNavigationLocationRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/RecentNavigationLocationRequest.kt index 0adec56ac..79a5a5970 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/RecentNavigationLocationRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/RecentNavigationLocationRequest.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/ReissueRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/ReissueRequest.kt similarity index 82% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/ReissueRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/ReissueRequest.kt index 94526f362..e7d0c699e 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/ReissueRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/ReissueRequest.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/ReplaceVerifiedAreaRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/ReplaceVerifiedAreaRequest.kt similarity index 88% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/ReplaceVerifiedAreaRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/ReplaceVerifiedAreaRequest.kt index 42d510525..a60f07e8c 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/ReplaceVerifiedAreaRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/ReplaceVerifiedAreaRequest.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/ReviewRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/ReviewRequest.kt similarity index 91% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/ReviewRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/ReviewRequest.kt index 26c867faf..b1d1c938e 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/ReviewRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/ReviewRequest.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/SaveSpotRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/SaveSpotRequest.kt similarity index 81% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/SaveSpotRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/SaveSpotRequest.kt index 9b9496da5..28c5dfc87 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/SaveSpotRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/SaveSpotRequest.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/SignInRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/SignInRequest.kt similarity index 52% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/SignInRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/SignInRequest.kt index 9fa810976..c1c5a6ffc 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/SignInRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/SignInRequest.kt @@ -1,10 +1,11 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request +import com.acon.acon.core.model.model.user.SocialPlatform import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class SignInRequest( - @SerialName("socialType") val socialType: com.acon.acon.core.model.type.SocialType?, + @SerialName("socialType") val platform: SocialPlatform?, @SerialName("idToken") val idToken: String?, ) diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/SignOutRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/SignOutRequest.kt similarity index 82% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/SignOutRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/SignOutRequest.kt index 8ad334d08..04d3d26e4 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/SignOutRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/SignOutRequest.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/SpotListRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/SpotListRequest.kt similarity index 94% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/SpotListRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/SpotListRequest.kt index 408cdb05d..559b7d1f0 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/SpotListRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/SpotListRequest.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/SubmitUploadPlaceRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/SubmitUploadPlaceRequest.kt similarity index 94% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/SubmitUploadPlaceRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/SubmitUploadPlaceRequest.kt index a725e3a9e..e1211300a 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/SubmitUploadPlaceRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/SubmitUploadPlaceRequest.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request import com.acon.acon.core.model.type.SpotType import kotlinx.serialization.SerialName diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/OnboardingRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/TastePreferenceRequest.kt similarity index 69% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/OnboardingRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/TastePreferenceRequest.kt index 500dd9357..9367c7267 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/OnboardingRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/TastePreferenceRequest.kt @@ -1,9 +1,9 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class OnboardingRequest( +data class TastePreferenceRequest( @SerialName("dislikeFoodList") val dislikeFoods: List ) \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/AreaVerificationRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/VerifyAreaRequest.kt similarity index 72% rename from data/src/main/kotlin/com/acon/acon/data/dto/request/AreaVerificationRequest.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/request/VerifyAreaRequest.kt index d88761c18..839674850 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/AreaVerificationRequest.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/VerifyAreaRequest.kt @@ -1,10 +1,10 @@ -package com.acon.acon.data.dto.request +package com.acon.core.data.dto.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class AreaVerificationRequest( +data class VerifyAreaRequest( @SerialName("latitude") val latitude: Double, @SerialName("longitude") val longitude: Double ) diff --git a/core/data/src/main/kotlin/com/acon/core/data/dto/request/profile/UpdateProfileRequest.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/request/profile/UpdateProfileRequest.kt new file mode 100644 index 000000000..b05fcf524 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/request/profile/UpdateProfileRequest.kt @@ -0,0 +1,30 @@ +package com.acon.core.data.dto.request.profile + +import com.acon.acon.core.model.model.profile.BirthDateStatus +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateProfileRequest( + @SerialName("nickname") val nickname: String, + @SerialName("birthDate") val birthDate: String?, + @SerialName("profileImage") val image: String? +) + +fun Profile.toUpdateProfileRequest() : UpdateProfileRequest { + val requestNickname = nickname + val requestBirthDate: String? = when(birthDate) { + is BirthDateStatus.Specified -> with((birthDate as BirthDateStatus.Specified).date) { + "%04d.%02d.%02d".format(year, monthValue, dayOfMonth) + } + BirthDateStatus.NotSpecified -> null + } + val requestImage: String? = when(image) { + is ProfileImageStatus.Custom -> (image as ProfileImageStatus.Custom).url + else -> null + } + + return UpdateProfileRequest(requestNickname, requestBirthDate, requestImage) +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/GeocodingResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/GeocodingResponse.kt similarity index 96% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/GeocodingResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/GeocodingResponse.kt index 7c89a2846..fbf70d4da 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/GeocodingResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/GeocodingResponse.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.response +package com.acon.core.data.dto.response import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/MapSearchResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/MapSearchResponse.kt similarity index 91% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/MapSearchResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/MapSearchResponse.kt index d7ffe3ee0..21d6d1b2b 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/MapSearchResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/MapSearchResponse.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.response +package com.acon.core.data.dto.response import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/MenuBoardListResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/MenuBoardListResponse.kt similarity index 73% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/MenuBoardListResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/MenuBoardListResponse.kt index 2714373f0..87c64f345 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/MenuBoardListResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/MenuBoardListResponse.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.response +package com.acon.core.data.dto.response import com.acon.acon.core.model.model.spot.MenuBoardList import kotlinx.serialization.SerialName @@ -8,7 +8,7 @@ import kotlinx.serialization.Serializable data class MenuBoardListResponse( @SerialName("menuboardImageList") val menuBoardImageList: List ) { - fun toMenuBoardList() = com.acon.acon.core.model.model.spot.MenuBoardList( + fun toMenuBoardList() = MenuBoardList( menuBoardImageList = menuBoardImageList ) } \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/dto/response/PresignedUrlResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/PresignedUrlResponse.kt new file mode 100644 index 000000000..6d9d5db97 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/PresignedUrlResponse.kt @@ -0,0 +1,10 @@ +package com.acon.core.data.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PresignedUrlResponse( + @SerialName("fileUrl") val fileUrl: String, + @SerialName("preSignedUrl") val presignedUrl: String +) diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/SignInResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/SignInResponse.kt similarity index 59% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/SignInResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/SignInResponse.kt index 42297b739..f0cb772c3 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/SignInResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/SignInResponse.kt @@ -1,6 +1,5 @@ -package com.acon.acon.data.dto.response +package com.acon.core.data.dto.response -import com.acon.acon.core.model.model.user.VerificationStatus import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -11,10 +10,4 @@ data class SignInResponse( @SerialName("refreshToken") val refreshToken: String?, @SerialName("hasVerifiedArea") val hasVerifiedArea: Boolean, @SerialName("hasPreference") val hasPreference: Boolean -) { - fun toVerificationStatus() = VerificationStatus( - externalUUID = externalUUID, - hasVerifiedArea = hasVerifiedArea, - hasPreference = hasPreference - ) -} +) \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/SpotDetailResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/SpotDetailResponse.kt similarity index 89% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/SpotDetailResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/SpotDetailResponse.kt index a625e94c4..165990a88 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/SpotDetailResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/SpotDetailResponse.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.response +package com.acon.core.data.dto.response import com.acon.acon.core.model.model.spot.SignatureMenu import com.acon.acon.core.model.model.spot.SpotDetail @@ -21,7 +21,7 @@ data class SpotDetailResponse( @SerialName("latitude") val latitude: Double, @SerialName("longitude") val longitude: Double ) { - fun toSpotDetail() = com.acon.acon.core.model.model.spot.SpotDetail( + fun toSpotDetail() = SpotDetail( spotId = spotId, imageList = imageList ?: emptyList(), name = name, @@ -45,7 +45,7 @@ data class SignatureMenuResponse( @SerialName("name") val name: String, @SerialName("price") val price: Int ) { - fun toSignatureMenu() = com.acon.acon.core.model.model.spot.SignatureMenu( + fun toSignatureMenu() = SignatureMenu( name = name, price = price ) diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/SpotListResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/SpotListResponse.kt similarity index 67% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/SpotListResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/SpotListResponse.kt index b1b7e9854..387e545a7 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/SpotListResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/SpotListResponse.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.response +package com.acon.core.data.dto.response import com.acon.acon.core.common.utils.toLocalTime import com.acon.acon.core.model.model.spot.Spot @@ -12,11 +12,11 @@ import java.time.LocalTime @Serializable data class SpotListResponse( - @SerialName("transportMode") val transportMode: com.acon.acon.core.model.type.TransportMode?, + @SerialName("transportMode") val transportMode: TransportMode?, @SerialName("spotList") val spotList: List? ) { - fun toSpotList() = com.acon.acon.core.model.model.spot.SpotList( - transportMode = transportMode ?: com.acon.acon.core.model.type.TransportMode.WALKING, + fun toSpotList() = SpotList( + transportMode = transportMode ?: TransportMode.WALKING, spots = spotList?.map { it.toSpot() } ?: emptyList(), ) } @@ -36,20 +36,20 @@ data class SpotResponse( @SerialName("longitude") val longitude: Double?, ) { - fun toSpot() = com.acon.acon.core.model.model.spot.Spot( + fun toSpot() = Spot( id = id ?: 0L, image = image.orEmpty(), name = name.orEmpty(), acorn = acornCount ?: 0, tags = tags?.mapNotNull { tagString -> when (tagString) { - "NEW" -> com.acon.acon.core.model.type.TagType.NEW - "LOCAL" -> com.acon.acon.core.model.type.TagType.LOCAL - "TOP 1" -> com.acon.acon.core.model.type.TagType.TOP_1 - "TOP 2" -> com.acon.acon.core.model.type.TagType.TOP_2 - "TOP 3" -> com.acon.acon.core.model.type.TagType.TOP_3 - "TOP 4" -> com.acon.acon.core.model.type.TagType.TOP_4 - "TOP 5" -> com.acon.acon.core.model.type.TagType.TOP_5 + "NEW" -> TagType.NEW + "LOCAL" -> TagType.LOCAL + "TOP 1" -> TagType.TOP_1 + "TOP 2" -> TagType.TOP_2 + "TOP 3" -> TagType.TOP_3 + "TOP 4" -> TagType.TOP_4 + "TOP 5" -> TagType.TOP_5 else -> { Timber.e("Unknown tag type: $tagString") null diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/TokenResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/TokenResponse.kt similarity index 85% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/TokenResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/TokenResponse.kt index ae114dd4e..b821e51ab 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/TokenResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/TokenResponse.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.response +package com.acon.core.data.dto.response import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/app/ShouldUpdateResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/app/ShouldUpdateResponse.kt similarity index 81% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/app/ShouldUpdateResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/app/ShouldUpdateResponse.kt index 6698a59fc..f3b162fcd 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/app/ShouldUpdateResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/app/ShouldUpdateResponse.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.response.app +package com.acon.core.data.dto.response.app import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/area/AreaVerificationResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/area/AreaVerificationResponse.kt similarity index 77% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/area/AreaVerificationResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/area/AreaVerificationResponse.kt index 74586297e..76ee3e6f0 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/area/AreaVerificationResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/area/AreaVerificationResponse.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.response.area +package com.acon.core.data.dto.response.area import com.acon.acon.core.model.model.area.Area import kotlinx.serialization.SerialName @@ -9,7 +9,7 @@ data class AreaVerificationResponse( @SerialName("verifiedAreaId") val verifiedAreaId: Long, @SerialName("name") val name: String ) { - fun toArea() = com.acon.acon.core.model.model.area.Area( + fun toArea() = Area( verifiedAreaId = verifiedAreaId, name = name ) diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/area/VerifiedAreaListResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/area/VerifiedAreaListResponse.kt similarity index 80% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/area/VerifiedAreaListResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/area/VerifiedAreaListResponse.kt index 4055ccf40..591294c66 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/area/VerifiedAreaListResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/area/VerifiedAreaListResponse.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.response.area +package com.acon.core.data.dto.response.area import com.acon.acon.core.model.model.area.Area import kotlinx.serialization.SerialName @@ -14,7 +14,7 @@ data class VerifiedAreaResponse( @SerialName("verifiedAreaId") val id: Long, @SerialName("name") val name: String, ) { - fun toVerifiedArea() = com.acon.acon.core.model.model.area.Area( + fun toVerifiedArea() = Area( verifiedAreaId = id, name = name ) diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/profile/PreSignedUrlResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/PreSignedUrlResponse.kt similarity index 74% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/profile/PreSignedUrlResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/PreSignedUrlResponse.kt index 910211a50..90b3a122b 100644 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/profile/PreSignedUrlResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/PreSignedUrlResponse.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.dto.response.profile +package com.acon.core.data.dto.response.profile import com.acon.acon.core.model.model.profile.PreSignedUrl import kotlinx.serialization.SerialName @@ -9,7 +9,7 @@ data class PreSignedUrlResponse( @SerialName("fileName") val fileName: String, @SerialName("preSignedUrl") val preSignedUrl: String, ) { - fun toPreSignedUrl() = com.acon.acon.core.model.model.profile.PreSignedUrl( + fun toPreSignedUrl() = PreSignedUrl( fileName = fileName, preSignedUrl = preSignedUrl, ) diff --git a/core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/ProfileResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/ProfileResponse.kt new file mode 100644 index 000000000..3e1a780f6 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/ProfileResponse.kt @@ -0,0 +1,32 @@ +package com.acon.core.data.dto.response.profile + +import com.acon.acon.core.model.model.profile.BirthDateStatus +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.LocalDate + +@Serializable +data class ProfileResponse( + @SerialName("nickname") val nickname: String, + @SerialName("birthDate") val birthDate: String? = null, + @SerialName("profileImage") val image: String? = null, +) { + + fun toProfile() : Profile { + val nicknameOfModel = nickname + val birthDateOfModel = birthDate?.let { dateString -> + try { + val (year, month, day) = dateString.split(".").map { it.toInt() } + BirthDateStatus.Specified(LocalDate.of(year, month, day)) + } catch (_: Exception) { + BirthDateStatus.NotSpecified + } + } ?: BirthDateStatus.NotSpecified + val imageOfModel = + if (image == null) ProfileImageStatus.Default else ProfileImageStatus.Custom(image) + + return Profile(nicknameOfModel, birthDateOfModel, imageOfModel) + } +} diff --git a/core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/SavedSpotResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/SavedSpotResponse.kt new file mode 100644 index 000000000..03f059fe5 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/SavedSpotResponse.kt @@ -0,0 +1,23 @@ +package com.acon.core.data.dto.response.profile + +import com.acon.acon.core.model.model.profile.SavedSpot +import com.acon.acon.core.model.model.profile.SpotThumbnailStatus +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SavedSpotResponse( + @SerialName("spotId") val spotId: Long, + @SerialName("name") val spotName: String, + @SerialName("image") val spotThumbnail: String? +) { + + fun toSavedSpot() : SavedSpot { + val spotThumbnailStatus = when { + spotThumbnail.isNullOrBlank() -> SpotThumbnailStatus.Empty + else -> SpotThumbnailStatus.Exist(spotThumbnail) + } + + return SavedSpot(spotId, spotName, spotThumbnailStatus) + } +} diff --git a/core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/SavedSpotsResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/SavedSpotsResponse.kt new file mode 100644 index 000000000..92f6929f7 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/dto/response/profile/SavedSpotsResponse.kt @@ -0,0 +1,9 @@ +package com.acon.core.data.dto.response.profile + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SavedSpotsResponse( + @SerialName("savedSpotList") val savedSpotList: List +) diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/upload/SearchedSpotsResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/upload/SearchedSpotsResponse.kt similarity index 100% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/upload/SearchedSpotsResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/upload/SearchedSpotsResponse.kt diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/upload/UploadSpotSuggestionsResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/upload/UploadSpotSuggestionsResponse.kt similarity index 100% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/upload/UploadSpotSuggestionsResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/upload/UploadSpotSuggestionsResponse.kt diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/upload/VerifyLocationResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/dto/response/upload/VerifyLocationResponse.kt similarity index 100% rename from data/src/main/kotlin/com/acon/acon/data/dto/response/upload/VerifyLocationResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/dto/response/upload/VerifyLocationResponse.kt diff --git a/data/src/main/kotlin/com/acon/acon/data/error/ErrorCallAdapter.kt b/core/data/src/main/kotlin/com/acon/core/data/error/ErrorCallAdapter.kt similarity index 99% rename from data/src/main/kotlin/com/acon/acon/data/error/ErrorCallAdapter.kt rename to core/data/src/main/kotlin/com/acon/core/data/error/ErrorCallAdapter.kt index f73273bd6..50c2b74ac 100644 --- a/data/src/main/kotlin/com/acon/acon/data/error/ErrorCallAdapter.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/error/ErrorCallAdapter.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.error +package com.acon.core.data.error import kotlinx.serialization.json.Json import okhttp3.Request diff --git a/data/src/main/kotlin/com/acon/acon/data/error/ErrorUtils.kt b/core/data/src/main/kotlin/com/acon/core/data/error/ErrorUtils.kt similarity index 96% rename from data/src/main/kotlin/com/acon/acon/data/error/ErrorUtils.kt rename to core/data/src/main/kotlin/com/acon/core/data/error/ErrorUtils.kt index ca86f9dd3..47d4257f2 100644 --- a/data/src/main/kotlin/com/acon/acon/data/error/ErrorUtils.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/error/ErrorUtils.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.error +package com.acon.core.data.error import com.acon.acon.domain.error.RootError import timber.log.Timber diff --git a/data/src/main/kotlin/com/acon/acon/data/error/NetworkErrorResponse.kt b/core/data/src/main/kotlin/com/acon/core/data/error/NetworkErrorResponse.kt similarity index 86% rename from data/src/main/kotlin/com/acon/acon/data/error/NetworkErrorResponse.kt rename to core/data/src/main/kotlin/com/acon/core/data/error/NetworkErrorResponse.kt index dfba7c4c0..195f9b2c6 100644 --- a/data/src/main/kotlin/com/acon/acon/data/error/NetworkErrorResponse.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/error/NetworkErrorResponse.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.error +package com.acon.core.data.error import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/main/kotlin/com/acon/acon/data/error/RemoteError.kt b/core/data/src/main/kotlin/com/acon/core/data/error/RemoteError.kt similarity index 96% rename from data/src/main/kotlin/com/acon/acon/data/error/RemoteError.kt rename to core/data/src/main/kotlin/com/acon/core/data/error/RemoteError.kt index 4645edd5c..4342d7abe 100644 --- a/data/src/main/kotlin/com/acon/acon/data/error/RemoteError.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/error/RemoteError.kt @@ -1,4 +1,4 @@ -package com.acon.acon.data.error +package com.acon.core.data.error import retrofit2.HttpException import retrofit2.Response diff --git a/core/data/src/main/kotlin/com/acon/core/data/repository/AconAppRepositoryImpl.kt b/core/data/src/main/kotlin/com/acon/core/data/repository/AconAppRepositoryImpl.kt new file mode 100644 index 000000000..3be1b85a3 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/repository/AconAppRepositoryImpl.kt @@ -0,0 +1,85 @@ +package com.acon.core.data.repository + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import com.acon.acon.core.common.IODispatcher +import com.acon.acon.core.model.type.ImageType +import com.acon.acon.domain.error.app.FetchShouldUpdateError +import com.acon.acon.domain.repository.AconAppRepository +import com.acon.core.data.datasource.remote.AconAppRemoteDataSource +import com.acon.core.data.dto.request.GetPresignedUrlRequest +import com.acon.core.data.dto.response.PresignedUrlResponse +import com.acon.core.data.error.runCatchingWith +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import javax.inject.Inject + +class AconAppRepositoryImpl @Inject constructor( + @ApplicationContext private val context: Context, + @IODispatcher private val dispatcher: CoroutineDispatcher, + private val aconAppRemoteDataSource: AconAppRemoteDataSource +) : AconAppRepository { + + private val availableImageMimeTypes by lazy { + setOf("image/jpg", "image/jpeg", "image/png", "image/webp", "image/heic") + } + + override suspend fun shouldUpdateApp(currentVersion: String): Result { + return runCatchingWith(FetchShouldUpdateError()) { + aconAppRemoteDataSource.fetchShouldUpdateApp(currentVersion).shouldUpdate ?: false + } + } + + override suspend fun uploadImage(imageType: ImageType, url: String): Result { + return runCatchingWith { + val contentUri = url.toUri() + + return@runCatchingWith withContext(dispatcher) { + val presignedUrlResponseDeferred = async { + contentUri.getPresignedUrlResponse(imageType) + } + val requestBodyDeferred = async { + contentUri.getRequestBody() + } + + val presignedUrlResponse = presignedUrlResponseDeferred.await() + val requestBody = requestBodyDeferred.await() + aconAppRemoteDataSource.uploadFile( + presignedUrlResponse.presignedUrl, + requestBody + ) + + return@withContext presignedUrlResponse.fileUrl + } + } + } + + private suspend fun Uri.getPresignedUrlResponse(imageType: ImageType): PresignedUrlResponse { + val fileName = DocumentFile.fromSingleUri(context, this)?.name + ?: error("Failed to read file name: $this") + + return aconAppRemoteDataSource.getPresignedUrl( + GetPresignedUrlRequest( + imageType = imageType, + fileName = fileName + ) + ) + } + + private fun Uri.getRequestBody(): RequestBody { + val uriMimeType = context.contentResolver.getType(this) + val finalMimeType = if (availableImageMimeTypes.contains(uriMimeType)) uriMimeType!! else "image/jpeg" + + val inputStream = context.contentResolver.openInputStream(this) + return inputStream?.use { input -> + input.readBytes().toRequestBody(finalMimeType.toMediaTypeOrNull()) + } ?: error("Failed to read image content: $this") + } +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/repository/MapRepositoryImpl.kt b/core/data/src/main/kotlin/com/acon/core/data/repository/MapRepositoryImpl.kt similarity index 78% rename from data/src/main/kotlin/com/acon/acon/data/repository/MapRepositoryImpl.kt rename to core/data/src/main/kotlin/com/acon/core/data/repository/MapRepositoryImpl.kt index 612b3797b..6e0c4fcd6 100644 --- a/data/src/main/kotlin/com/acon/acon/data/repository/MapRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/repository/MapRepositoryImpl.kt @@ -1,7 +1,7 @@ -package com.acon.acon.data.repository +package com.acon.core.data.repository -import com.acon.acon.data.datasource.remote.MapRemoteDataSource -import com.acon.acon.data.error.runCatchingWith +import com.acon.core.data.datasource.remote.MapRemoteDataSource +import com.acon.core.data.error.runCatchingWith import com.acon.acon.domain.repository.MapRepository import javax.inject.Inject diff --git a/data/src/main/kotlin/com/acon/acon/data/repository/MapSearchRepositoryImpl.kt b/core/data/src/main/kotlin/com/acon/core/data/repository/MapSearchRepositoryImpl.kt similarity index 91% rename from data/src/main/kotlin/com/acon/acon/data/repository/MapSearchRepositoryImpl.kt rename to core/data/src/main/kotlin/com/acon/core/data/repository/MapSearchRepositoryImpl.kt index 44e88e5c0..4a318991c 100644 --- a/data/src/main/kotlin/com/acon/acon/data/repository/MapSearchRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/repository/MapSearchRepositoryImpl.kt @@ -1,8 +1,8 @@ -package com.acon.acon.data.repository +package com.acon.core.data.repository import com.acon.acon.core.model.model.upload.SearchedSpotByMap -import com.acon.acon.data.datasource.remote.MapSearchRemoteDataSource -import com.acon.acon.data.error.runCatchingWith +import com.acon.core.data.datasource.remote.MapSearchRemoteDataSource +import com.acon.core.data.error.runCatchingWith import com.acon.acon.domain.repository.MapSearchRepository import javax.inject.Inject diff --git a/core/data/src/main/kotlin/com/acon/core/data/repository/OnboardingRepositoryImpl.kt b/core/data/src/main/kotlin/com/acon/core/data/repository/OnboardingRepositoryImpl.kt new file mode 100644 index 000000000..38e3d3e31 --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/repository/OnboardingRepositoryImpl.kt @@ -0,0 +1,72 @@ +package com.acon.core.data.repository + +import com.acon.acon.core.model.model.OnboardingPreferences +import com.acon.acon.core.model.type.FoodType +import com.acon.acon.domain.error.onboarding.PostTastePreferenceResultError +import com.acon.acon.domain.error.onboarding.VerifyAreaError +import com.acon.acon.domain.repository.OnboardingRepository +import com.acon.core.data.stream.DataStream +import com.acon.core.data.datasource.local.OnboardingLocalDataSource +import com.acon.core.data.datasource.remote.OnboardingRemoteDataSource +import com.acon.core.data.di.VerifiedArea +import com.acon.core.data.dto.request.TastePreferenceRequest +import com.acon.core.data.error.runCatchingWith +import javax.inject.Inject + +class OnboardingRepositoryImpl @Inject constructor( + private val onboardingRemoteDataSource: OnboardingRemoteDataSource, + private val onboardingLocalDataSource: OnboardingLocalDataSource, + @VerifiedArea private val areaDataStream: DataStream +) : OnboardingRepository { + + override suspend fun submitTastePreferenceResult( + dislikeFoods: List + ): Result { + return runCatchingWith(PostTastePreferenceResultError()) { + val request = TastePreferenceRequest(dislikeFoods = dislikeFoods.map { it.name }) + onboardingRemoteDataSource.submitTastePreferenceResult(request) + onboardingLocalDataSource.updateShouldChooseDislikes(false) + } + } + + override suspend fun verifyArea( + latitude: Double, + longitude: Double + ): Result = runCatchingWith(VerifyAreaError()) { + onboardingRemoteDataSource.verifyArea( + latitude = latitude, + longitude = longitude + ) + onboardingLocalDataSource.updateShouldVerifyArea(false) + areaDataStream.notifyDataChanged() + } + + override suspend fun updateShouldChooseDislikes(shouldChoose: Boolean): Result { + return runCatchingWith { + onboardingLocalDataSource.updateShouldChooseDislikes(shouldChoose) + } + } + + override suspend fun updateShouldShowIntroduce(shouldShow: Boolean): Result { + return runCatchingWith { + onboardingLocalDataSource.updateShouldShowIntroduce(shouldShow) + } + } + + override suspend fun updateShouldVerifyArea(shouldVerify: Boolean): Result { + return runCatchingWith { + onboardingLocalDataSource.updateShouldVerifyArea(shouldVerify) + } + } + + override suspend fun getOnboardingPreferences(): Result { + return runCatchingWith { + val entity = onboardingLocalDataSource.getOnboardingPreferences() + OnboardingPreferences( + shouldShowIntroduce = entity.shouldShowIntroduce, + shouldChooseDislikes = entity.shouldChooseDislikes, + shouldVerifyArea = entity.shouldVerifyArea + ) + } + } +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/repository/ProfileRepositoryImpl.kt b/core/data/src/main/kotlin/com/acon/core/data/repository/ProfileRepositoryImpl.kt new file mode 100644 index 000000000..547e44b3a --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/repository/ProfileRepositoryImpl.kt @@ -0,0 +1,130 @@ +package com.acon.core.data.repository + +import android.content.Context +import com.acon.acon.core.model.model.area.Area +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import com.acon.acon.core.model.model.profile.SavedSpot +import com.acon.acon.core.model.type.ImageType +import com.acon.acon.domain.error.area.DeleteVerifiedAreaError +import com.acon.acon.domain.error.profile.UpdateProfileError +import com.acon.acon.domain.error.profile.ValidateNicknameError +import com.acon.acon.domain.repository.AconAppRepository +import com.acon.acon.domain.repository.ProfileRepository +import com.acon.core.data.datasource.local.ProfileLocalDataSource +import com.acon.core.data.datasource.remote.ProfileRemoteDataSource +import com.acon.core.data.di.VerifiedArea +import com.acon.core.data.dto.request.profile.toUpdateProfileRequest +import com.acon.core.data.error.runCatchingWith +import com.acon.core.data.stream.DataStream +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class ProfileRepositoryImpl @Inject constructor( + private val profileRemoteDataSource: ProfileRemoteDataSource, + private val profileLocalDataSource: ProfileLocalDataSource, + private val aconAppRepository: AconAppRepository, + @VerifiedArea private val areaDataStream: DataStream, + @ApplicationContext private val context: Context +) : ProfileRepository { + + override fun getProfile(): Flow> { + return profileLocalDataSource.getProfile().flatMapLatest { cachedProfile: Profile? -> + if (cachedProfile == null) { + getProfileFromRemote() + } else { + flowOf(Result.success(cachedProfile)) + } + } + } + + private fun getProfileFromRemote(): Flow> { + return flow { + emit(runCatchingWith { + val profileResponse = profileRemoteDataSource.getProfile() + val profile = profileResponse.toProfile() + + profileLocalDataSource.cacheProfile(profile) + + profile + }) + } + } + + override suspend fun updateProfile(newProfile: Profile): Result { + return runCatchingWith(UpdateProfileError()) { + val profileToUpdate: Profile + + val imageStatus = newProfile.image + if (imageStatus is ProfileImageStatus.Custom) { + if (imageStatus.url.startsWith("content://")) { + val uploadUrlResult = aconAppRepository.uploadImage(ImageType.PROFILE, imageStatus.url) + val fileUrl = uploadUrlResult.getOrThrow() + + profileToUpdate = newProfile.copy( + image = ProfileImageStatus.Custom(fileUrl) + ) + } else { + profileToUpdate = newProfile + } + } else { + profileToUpdate = newProfile + } + + profileRemoteDataSource.updateProfile(profileToUpdate.toUpdateProfileRequest()) + + profileLocalDataSource.cacheProfile(profileToUpdate) + + Unit + } + } + + override suspend fun validateNickname(nickname: String) : Result { + return runCatchingWith(ValidateNicknameError()) { + profileRemoteDataSource.validateNickname(nickname) + } + } + + override suspend fun getSavedSpots(): Flow>> { + return profileLocalDataSource.getSavedSpots().flatMapLatest { cachedSavedSpots: List? -> + if (cachedSavedSpots == null) { + getSavedSpotsFromRemote() + } else { + flowOf(Result.success(cachedSavedSpots)) + } + } + } + + private fun getSavedSpotsFromRemote(): Flow>> { + return flow { + emit(runCatchingWith { + val savedSpotResponses = profileRemoteDataSource.getSavedSpots() + val savedSpots = savedSpotResponses.map { it.toSavedSpot() } + + profileLocalDataSource.cacheSavedSpots(savedSpots) + + savedSpots + }) + } + } + + override fun getVerifiedAreas(): Flow>> { + return areaDataStream.subscribe { + emit(runCatchingWith() { + profileRemoteDataSource.getVerifiedAreas().verifiedAreaList + .map { it.toVerifiedArea() } + }) + } + } + + override suspend fun deleteVerifiedArea(verifiedAreaId: Long): Result { + return runCatchingWith(DeleteVerifiedAreaError()) { + profileRemoteDataSource.deleteVerifiedArea(verifiedAreaId) + areaDataStream.notifyDataChanged() + } + } +} diff --git a/data/src/main/kotlin/com/acon/acon/data/repository/SpotRepositoryImpl.kt b/core/data/src/main/kotlin/com/acon/core/data/repository/SpotRepositoryImpl.kt similarity index 67% rename from data/src/main/kotlin/com/acon/acon/data/repository/SpotRepositoryImpl.kt rename to core/data/src/main/kotlin/com/acon/core/data/repository/SpotRepositoryImpl.kt index 3ee4843cc..92c78b283 100644 --- a/data/src/main/kotlin/com/acon/acon/data/repository/SpotRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/repository/SpotRepositoryImpl.kt @@ -1,34 +1,34 @@ -package com.acon.acon.data.repository +package com.acon.core.data.repository -import com.acon.acon.core.model.model.profile.SavedSpot import com.acon.acon.core.model.model.spot.Condition import com.acon.acon.core.model.model.spot.MenuBoardList import com.acon.acon.core.model.model.spot.SpotDetail import com.acon.acon.core.model.model.spot.SpotList -import com.acon.acon.data.cache.ProfileInfoCache -import com.acon.acon.data.datasource.remote.SpotRemoteDataSource -import com.acon.acon.data.dto.request.AddBookmarkRequest -import com.acon.acon.data.dto.request.ConditionRequest -import com.acon.acon.data.dto.request.FilterListRequest -import com.acon.acon.data.dto.request.RecentNavigationLocationRequest -import com.acon.acon.data.dto.request.SpotListRequest -import com.acon.acon.data.error.runCatchingWith -import com.acon.acon.data.session.SessionHandler import com.acon.acon.domain.error.spot.AddBookmarkError import com.acon.acon.domain.error.spot.DeleteBookmarkError import com.acon.acon.domain.error.spot.FetchMenuBoardsError import com.acon.acon.domain.error.spot.FetchRecentNavigationLocationError import com.acon.acon.domain.error.spot.FetchSpotListError import com.acon.acon.domain.error.spot.GetSpotDetailInfoError -import com.acon.acon.domain.repository.ProfileRepository import com.acon.acon.domain.repository.SpotRepository +import com.acon.core.data.datasource.local.ProfileLocalDataSource +import com.acon.core.data.datasource.remote.ProfileRemoteDataSource +import com.acon.core.data.datasource.remote.SpotRemoteDataSource +import com.acon.core.data.dto.request.AddBookmarkRequest +import com.acon.core.data.dto.request.ConditionRequest +import com.acon.core.data.dto.request.FilterListRequest +import com.acon.core.data.dto.request.RecentNavigationLocationRequest +import com.acon.core.data.dto.request.SpotListRequest +import com.acon.core.data.error.runCatchingWith +import com.acon.core.data.session.SessionHandler import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import javax.inject.Inject class SpotRepositoryImpl @Inject constructor( private val spotRemoteDataSource: SpotRemoteDataSource, - private val profileInfoCache: ProfileInfoCache, - private val profileRepository: ProfileRepository, + private val profileLocalDataSource: ProfileLocalDataSource, + private val profileRemoteDataSource: ProfileRemoteDataSource, private val sessionHandler: SessionHandler ) : SpotRepository { @@ -89,24 +89,15 @@ class SpotRepositoryImpl @Inject constructor( } } - override suspend fun fetchSavedSpotList(): Result> { - return runCatchingWith() { - spotRemoteDataSource.fetchSavedSpotList().savedSpotResponseList?.map { - it.toSavedSpot() - }.orEmpty() - } - } - override suspend fun addBookmark(spotId: Long): Result { return runCatchingWith(AddBookmarkError()) { spotRemoteDataSource.addBookmark(AddBookmarkRequest(spotId)) - profileRepository.fetchSavedSpots().onSuccess { fetched -> - (profileInfoCache.data.value.getOrNull() - ?: return@onSuccess).let { profileInfo -> - profileInfoCache.updateData(profileInfo.copy(savedSpots = fetched)) - } - } + val cachedSavedSpots = profileLocalDataSource.getSavedSpots().firstOrNull() + if (cachedSavedSpots != null) + profileLocalDataSource.cacheSavedSpots(profileRemoteDataSource.getSavedSpots().map { + it.toSavedSpot() + }) } } @@ -114,12 +105,11 @@ class SpotRepositoryImpl @Inject constructor( return runCatchingWith(DeleteBookmarkError()) { spotRemoteDataSource.deleteBookmark(spotId) - profileRepository.fetchSavedSpots().onSuccess { fetched -> - (profileInfoCache.data.value.getOrNull() - ?: return@onSuccess).let { profileInfo -> - profileInfoCache.updateData(profileInfo.copy(savedSpots = fetched)) - } - } + val cachedSavedSpots = profileLocalDataSource.getSavedSpots().firstOrNull() + if (cachedSavedSpots != null) + profileLocalDataSource.cacheSavedSpots(profileRemoteDataSource.getSavedSpots().map { + it.toSavedSpot() + }) } } } \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/repository/TimeRepositoryImpl.kt b/core/data/src/main/kotlin/com/acon/core/data/repository/TimeRepositoryImpl.kt similarity index 81% rename from data/src/main/kotlin/com/acon/acon/data/repository/TimeRepositoryImpl.kt rename to core/data/src/main/kotlin/com/acon/core/data/repository/TimeRepositoryImpl.kt index c37fa2cdb..08ba4fe40 100644 --- a/data/src/main/kotlin/com/acon/acon/data/repository/TimeRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/repository/TimeRepositoryImpl.kt @@ -1,8 +1,8 @@ -package com.acon.acon.data.repository +package com.acon.core.data.repository import com.acon.acon.core.model.type.UserActionType -import com.acon.acon.data.datasource.local.TimeLocalDataSource -import com.acon.acon.data.error.runCatchingWith +import com.acon.core.data.datasource.local.TimeLocalDataSource +import com.acon.core.data.error.runCatchingWith import com.acon.acon.domain.repository.TimeRepository import javax.inject.Inject diff --git a/data/src/main/kotlin/com/acon/acon/data/repository/UploadRepositoryImpl.kt b/core/data/src/main/kotlin/com/acon/core/data/repository/UploadRepositoryImpl.kt similarity index 93% rename from data/src/main/kotlin/com/acon/acon/data/repository/UploadRepositoryImpl.kt rename to core/data/src/main/kotlin/com/acon/core/data/repository/UploadRepositoryImpl.kt index fa786cb4a..e710a08dd 100644 --- a/data/src/main/kotlin/com/acon/acon/data/repository/UploadRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/repository/UploadRepositoryImpl.kt @@ -1,14 +1,14 @@ -package com.acon.acon.data.repository +package com.acon.core.data.repository import com.acon.acon.core.model.model.profile.PreSignedUrl import com.acon.acon.core.model.model.upload.Feature import com.acon.acon.core.model.model.upload.SearchedSpot import com.acon.acon.core.model.model.upload.UploadSpotSuggestion import com.acon.acon.core.model.type.SpotType -import com.acon.acon.data.datasource.remote.UploadRemoteDataSource -import com.acon.acon.data.dto.request.FeatureRequest -import com.acon.acon.data.dto.request.SubmitUploadPlaceRequest -import com.acon.acon.data.error.runCatchingWith +import com.acon.core.data.datasource.remote.UploadRemoteDataSource +import com.acon.core.data.dto.request.FeatureRequest +import com.acon.core.data.dto.request.SubmitUploadPlaceRequest +import com.acon.core.data.error.runCatchingWith import com.acon.acon.domain.error.upload.GetUploadPlacePreSignedUrlError import com.acon.acon.domain.error.upload.GetVerifySpotLocationError import com.acon.acon.domain.error.upload.SubmitUploadPlaceError diff --git a/core/data/src/main/kotlin/com/acon/core/data/repository/UserRepositoryImpl.kt b/core/data/src/main/kotlin/com/acon/core/data/repository/UserRepositoryImpl.kt new file mode 100644 index 000000000..427c6e95e --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/repository/UserRepositoryImpl.kt @@ -0,0 +1,102 @@ +package com.acon.core.data.repository + +import com.acon.acon.core.model.model.user.CredentialCode +import com.acon.acon.core.model.model.user.SocialPlatform +import com.acon.acon.core.model.model.user.ExternalUUID +import com.acon.acon.data.dto.request.DeleteAccountRequest +import com.acon.acon.domain.error.user.PostSignInError +import com.acon.acon.domain.error.user.PostSignOutError +import com.acon.acon.domain.repository.OnboardingRepository +import com.acon.acon.domain.repository.UserRepository +import com.acon.core.data.datasource.local.ProfileLocalDataSource +import com.acon.core.data.datasource.local.TokenLocalDataSource +import com.acon.core.data.datasource.remote.UserRemoteDataSource +import com.acon.core.data.dto.request.SignInRequest +import com.acon.core.data.dto.request.SignOutRequest +import com.acon.core.data.error.runCatchingWith +import com.acon.core.data.session.SessionHandler +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import javax.inject.Inject + +class UserRepositoryImpl @Inject constructor( + private val userRemoteDataSource: UserRemoteDataSource, + private val tokenLocalDataSource: TokenLocalDataSource, + private val sessionHandler: SessionHandler, + private val onboardingRepository: OnboardingRepository, + private val profileLocalDataSource: ProfileLocalDataSource +) : UserRepository { + + override fun getSignInStatus() = sessionHandler.getUserType() + + override suspend fun signIn( + socialType: SocialPlatform, + code: CredentialCode + ): Result { + return runCatchingWith(PostSignInError()) { + val signInResponse = userRemoteDataSource.signIn( + SignInRequest( + platform = socialType, + idToken = code.value + ) + ) + + sessionHandler.completeSignIn( + signInResponse.accessToken.orEmpty(), + signInResponse.refreshToken.orEmpty() + ) + + coroutineScope { + val shouldVerifyAreaJob = async { + onboardingRepository.updateShouldVerifyArea(!signInResponse.hasVerifiedArea) + } + val shouldChooseDislikesJob = async { + onboardingRepository.updateShouldChooseDislikes(!signInResponse.hasPreference) + } + + val shouldShowIntroduceJob = async { + onboardingRepository.updateShouldShowIntroduce( + (onboardingRepository.getOnboardingPreferences().getOrNull()?.shouldShowIntroduce == true) + && !signInResponse.hasVerifiedArea + && !signInResponse.hasPreference + ) + } + awaitAll(shouldVerifyAreaJob, shouldChooseDislikesJob, shouldShowIntroduceJob) + } + + ExternalUUID(signInResponse.externalUUID) + } + } + + override suspend fun signOut(): Result { + val refreshToken = tokenLocalDataSource.getRefreshToken() ?: "" + return runCatchingWith(PostSignOutError()) { + userRemoteDataSource.signOut( + SignOutRequest(refreshToken = refreshToken) + ) + }.onSuccess { + profileLocalDataSource.clearCache() + clearSession() + } + } + + override suspend fun deleteAccount(reason: String): Result { + val refreshToken = tokenLocalDataSource.getRefreshToken() ?: "" + return runCatchingWith { + userRemoteDataSource.deleteAccount( + DeleteAccountRequest( + reason = reason, + refreshToken = refreshToken + ) + ) + }.onSuccess { + profileLocalDataSource.clearCache() + clearSession() + } + } + + override suspend fun clearSession() = runCatchingWith { + sessionHandler.clearSession() + } +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/acon/core/data/serializer/OnboardingPreferencesSerializer.kt b/core/data/src/main/kotlin/com/acon/core/data/serializer/OnboardingPreferencesSerializer.kt new file mode 100644 index 000000000..7798424da --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/serializer/OnboardingPreferencesSerializer.kt @@ -0,0 +1,31 @@ +package com.acon.core.data.serializer + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.acon.core.data.dto.entity.OnboardingPreferencesEntity +import com.acon.core.data.dto.entity.copy +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject + +class OnboardingPreferencesSerializer @Inject constructor() : Serializer{ + + override val defaultValue: OnboardingPreferencesEntity = OnboardingPreferencesEntity.getDefaultInstance().copy { + shouldShowIntroduce = true + shouldChooseDislikes = true + shouldVerifyArea = true + } + + override suspend fun readFrom(input: InputStream): OnboardingPreferencesEntity { + return try { + OnboardingPreferencesEntity.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + } + + override suspend fun writeTo(t: OnboardingPreferencesEntity, output: OutputStream) { + t.writeTo(output) + } +} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/session/SessionHandler.kt b/core/data/src/main/kotlin/com/acon/core/data/session/SessionHandler.kt similarity index 52% rename from data/src/main/kotlin/com/acon/acon/data/session/SessionHandler.kt rename to core/data/src/main/kotlin/com/acon/core/data/session/SessionHandler.kt index a675bd874..294cf4b41 100644 --- a/data/src/main/kotlin/com/acon/acon/data/session/SessionHandler.kt +++ b/core/data/src/main/kotlin/com/acon/core/data/session/SessionHandler.kt @@ -1,9 +1,10 @@ -package com.acon.acon.data.session +package com.acon.core.data.session import com.acon.acon.core.analytics.amplitude.AconAmplitude import com.acon.acon.core.common.IODispatcher -import com.acon.acon.core.model.type.UserType -import com.acon.acon.data.datasource.local.TokenLocalDataSource +import com.acon.acon.core.model.type.SignInStatus +import com.acon.core.data.datasource.local.TokenLocalDataSource +import com.acon.core.data.dto.response.SignInResponse import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -14,7 +15,9 @@ import javax.inject.Inject interface SessionHandler { suspend fun clearSession() suspend fun completeSignIn(accessToken: String, refreshToken: String) - fun getUserType(): Flow + fun getUserType(): Flow + + suspend fun onSignInResponse(response: SignInResponse) } class SessionHandlerImpl @Inject constructor( @@ -22,32 +25,39 @@ class SessionHandlerImpl @Inject constructor( @IODispatcher scope: CoroutineScope ) : SessionHandler { - private val _userType = MutableStateFlow(UserType.GUEST) - private val userType = _userType.asStateFlow() + private val _signInStatus = MutableStateFlow(SignInStatus.GUEST) + private val userType = _signInStatus.asStateFlow() init { scope.launch { val accessToken = tokenLocalDataSource.getAccessToken() if (accessToken.isNullOrEmpty()) - _userType.emit(UserType.GUEST) + _signInStatus.emit(SignInStatus.GUEST) else - _userType.emit(UserType.USER) + _signInStatus.emit(SignInStatus.USER) } } - override fun getUserType(): Flow { + override fun getUserType(): Flow { return userType } override suspend fun clearSession() { tokenLocalDataSource.removeAllTokens() - _userType.value = UserType.GUEST + _signInStatus.value = SignInStatus.GUEST AconAmplitude.clearUserId() } override suspend fun completeSignIn(accessToken: String, refreshToken: String) { - _userType.value = UserType.USER + _signInStatus.value = SignInStatus.USER + tokenLocalDataSource.saveAccessToken(accessToken) + tokenLocalDataSource.saveRefreshToken(refreshToken) + } + + override suspend fun onSignInResponse(response: SignInResponse) { + val accessToken = response.accessToken ?: throw IllegalStateException("Access token is null") + val refreshToken = response.refreshToken ?: throw IllegalStateException("Refresh token is null") tokenLocalDataSource.saveAccessToken(accessToken) tokenLocalDataSource.saveRefreshToken(refreshToken) } -} \ No newline at end of file +} diff --git a/core/data/src/main/kotlin/com/acon/core/data/stream/DataStream.kt b/core/data/src/main/kotlin/com/acon/core/data/stream/DataStream.kt new file mode 100644 index 000000000..0f1d6e9ea --- /dev/null +++ b/core/data/src/main/kotlin/com/acon/core/data/stream/DataStream.kt @@ -0,0 +1,28 @@ +package com.acon.core.data.stream + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.transformLatest +import javax.inject.Inject + +interface DataStream { + suspend fun notifyDataChanged() + fun subscribe(block: suspend FlowCollector.() -> Unit): Flow +} + +class DataStreamImpl @Inject constructor() : DataStream { + private val trigger = MutableSharedFlow(replay = 1) + + init { + trigger.tryEmit(Unit) + } + + override suspend fun notifyDataChanged() { + trigger.emit(Unit) + } + + override fun subscribe(block: suspend FlowCollector.() -> Unit): Flow { + return trigger.transformLatest { this.block() } + } +} \ No newline at end of file diff --git a/core/data/src/main/proto/onboarding_preferences_entity.proto b/core/data/src/main/proto/onboarding_preferences_entity.proto new file mode 100644 index 000000000..dbe548c83 --- /dev/null +++ b/core/data/src/main/proto/onboarding_preferences_entity.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +option java_package = "com.acon.core.data.dto.entity"; +option java_multiple_files = true; + +message OnboardingPreferencesEntity { + bool should_show_introduce = 1; + bool should_choose_dislikes = 2; + bool should_verify_area = 3; +} diff --git a/data/src/test/java/com/acon/acon/data/TestUtils.kt b/core/data/src/test/java/com/acon/core/data/TestUtils.kt similarity index 93% rename from data/src/test/java/com/acon/acon/data/TestUtils.kt rename to core/data/src/test/java/com/acon/core/data/TestUtils.kt index 7d5377485..e53a5d135 100644 --- a/data/src/test/java/com/acon/acon/data/TestUtils.kt +++ b/core/data/src/test/java/com/acon/core/data/TestUtils.kt @@ -1,7 +1,7 @@ -package com.acon.acon.data +package com.acon.core.data -import com.acon.acon.data.error.RemoteError import com.acon.acon.domain.error.RootError +import com.acon.core.data.error.RemoteError import io.mockk.mockk import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.params.provider.Arguments diff --git a/data/src/test/java/com/acon/acon/data/authenticator/AuthAuthenticatorTest.kt b/core/data/src/test/java/com/acon/core/data/authenticator/AuthAuthenticatorTest.kt similarity index 94% rename from data/src/test/java/com/acon/acon/data/authenticator/AuthAuthenticatorTest.kt rename to core/data/src/test/java/com/acon/core/data/authenticator/AuthAuthenticatorTest.kt index 4663ee713..69490933d 100644 --- a/data/src/test/java/com/acon/acon/data/authenticator/AuthAuthenticatorTest.kt +++ b/core/data/src/test/java/com/acon/core/data/authenticator/AuthAuthenticatorTest.kt @@ -1,19 +1,19 @@ -package com.acon.acon.data.authenticator +package com.acon.core.data.authenticator import android.content.Context import com.acon.acon.core.launcher.AppLauncher -import com.acon.acon.data.api.remote.noauth.UserNoAuthApi -import com.acon.acon.data.assertValidErrorMapping -import com.acon.acon.data.authentication.AuthAuthenticator -import com.acon.acon.data.createFakeRemoteError -import com.acon.acon.data.datasource.local.TokenLocalDataSource import com.acon.acon.data.dto.request.DeleteAccountRequest -import com.acon.acon.data.dto.request.ReissueRequest -import com.acon.acon.data.dto.request.SignOutRequest -import com.acon.acon.data.dto.response.TokenResponse -import com.acon.acon.data.error.runCatchingWith -import com.acon.acon.data.session.SessionHandler import com.acon.acon.domain.error.user.ReissueError +import com.acon.core.data.api.remote.noauth.UserNoAuthApi +import com.acon.core.data.assertValidErrorMapping +import com.acon.core.data.authentication.AuthAuthenticator +import com.acon.core.data.createFakeRemoteError +import com.acon.core.data.datasource.local.TokenLocalDataSource +import com.acon.core.data.dto.request.ReissueRequest +import com.acon.core.data.dto.request.SignOutRequest +import com.acon.core.data.dto.response.TokenResponse +import com.acon.core.data.error.runCatchingWith +import com.acon.core.data.session.SessionHandler import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -31,6 +31,7 @@ import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.Route +import okio.Buffer import org.junit.Rule import org.junit.Test import java.io.IOException @@ -279,7 +280,7 @@ class AuthAuthenticatorTest { private fun RequestBody?.asString(): String? { if (this == null) return null return try { - val buffer = okio.Buffer() + val buffer = Buffer() this.writeTo(buffer) buffer.readUtf8() } catch (e: IOException) { diff --git a/core/data/src/test/java/com/acon/core/data/datasource/local/ProfileLocalDataSourceTest.kt b/core/data/src/test/java/com/acon/core/data/datasource/local/ProfileLocalDataSourceTest.kt new file mode 100644 index 000000000..2900d1689 --- /dev/null +++ b/core/data/src/test/java/com/acon/core/data/datasource/local/ProfileLocalDataSourceTest.kt @@ -0,0 +1,90 @@ +package com.acon.core.data.datasource.local + +import app.cash.turbine.test +import com.acon.acon.core.model.model.profile.BirthDateStatus +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.time.LocalDate +import kotlin.test.assertEquals + +@ExtendWith(MockKExtension::class) +class ProfileLocalDataSourceTest { + + private lateinit var profileLocalDataSource: ProfileLocalDataSource + + @BeforeEach + fun setUp() { + profileLocalDataSource = ProfileLocalDataSourceImpl() + } + + @Test + fun `getProfile()은 가장 최근에 캐싱된 값을 반환한다`() = runTest { + // Given + val sampleProfile = Profile( + nickname = "Cached Nickname", + birthDate = BirthDateStatus.Specified(LocalDate.of(1999,4,29)), + image = ProfileImageStatus.Custom("Cached Image Url") + ) + + // When + profileLocalDataSource.getProfile().test { + awaitItem() + profileLocalDataSource.cacheProfile(sampleProfile) + + // Then + assertEquals(sampleProfile, awaitItem()) + } + } + + @Test + fun `cacheProfile()은 기존의 캐싱 데이터의 유무에 상관없이, 캐싱 데이터를 새 프로필 값으로 덮어씌운다`() = runTest { + // Given + val originalProfile = Profile( + nickname = "기존에 캐싱된 닉네임", + birthDate = BirthDateStatus.Specified(LocalDate.of(1999, 4, 29)), + image = ProfileImageStatus.Default + ) + val newProfile = Profile( + nickname = "새롭게 캐싱할 닉네임", + birthDate = BirthDateStatus.NotSpecified, + image = ProfileImageStatus.Custom("새롭게 캐싱할 이미지 URL") + ) + + // When & Then + profileLocalDataSource.getProfile().test { + awaitItem() + + profileLocalDataSource.cacheProfile(originalProfile) + assertEquals(originalProfile, awaitItem()) + + profileLocalDataSource.cacheProfile(newProfile) + assertEquals(newProfile, awaitItem()) + } + } + + @Test + fun `clearCache()는 캐시를 null로 초기화한다`() = runTest { + // Given + val originalProfile = Profile( + nickname = "기존에 캐싱된 닉네임", + birthDate = BirthDateStatus.Specified(LocalDate.of(1999, 4, 29)), + image = ProfileImageStatus.Default + ) + + // When & Then + profileLocalDataSource.getProfile().test { + awaitItem() + + profileLocalDataSource.cacheProfile(originalProfile) + assertEquals(originalProfile, awaitItem()) + + profileLocalDataSource.clearCache() + assertEquals(null, awaitItem()) + } + } +} \ No newline at end of file diff --git a/core/data/src/test/java/com/acon/core/data/datasource/remote/ProfileRemoteDataSourceTest.kt b/core/data/src/test/java/com/acon/core/data/datasource/remote/ProfileRemoteDataSourceTest.kt new file mode 100644 index 000000000..99e3e96fb --- /dev/null +++ b/core/data/src/test/java/com/acon/core/data/datasource/remote/ProfileRemoteDataSourceTest.kt @@ -0,0 +1,189 @@ +package com.acon.core.data.datasource.remote + +import com.acon.core.data.api.remote.ProfileApi +import com.acon.core.data.dto.request.profile.UpdateProfileRequest +import com.acon.core.data.dto.response.profile.ProfileResponse +import com.acon.core.data.dto.response.profile.SavedSpotResponse +import com.acon.core.data.dto.response.profile.SavedSpotsResponse +import io.mockk.MockKVerificationScope +import io.mockk.Ordering +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import retrofit2.HttpException +import kotlin.test.assertEquals + +@ExtendWith(MockKExtension::class) +class ProfileRemoteDataSourceTest { + + @MockK + private lateinit var profileApi: ProfileApi + + private lateinit var profileRemoteDataSource: ProfileRemoteDataSource + + @BeforeEach + fun setUp() { + profileRemoteDataSource = ProfileRemoteDataSourceImpl(profileApi) + } + + @Test + fun `getProfile()은 서버로부터 프로필 정보를 가져와 그대로 반환한다`() = runTest { + // Given + val expectedProfileResponse = ProfileResponse( + nickname = "Dummy nickname", + birthDate = "1999.04.29", + image = "Dummy profile image url" + ) + coEvery { profileApi.getProfile() } returns expectedProfileResponse + + // When + val actualProfileResponse = profileRemoteDataSource.getProfile() + + // Then + coVerifyOnce { profileApi.getProfile() } + assertEquals(expectedProfileResponse, actualProfileResponse) + } + + @Test + fun `getProfile()은 서버로부터 프로필 정보를 가져올 때 예외가 발생하면 그대로 던진다`() = runTest { + // Given + val expectedException = mockk() + coEvery { profileApi.getProfile() } throws expectedException + + // When + val actualException = assertThrows { + profileRemoteDataSource.getProfile() + } + + // Then + assertEquals(expectedException, actualException) + } + + @Test + fun `updateProfile()은 서버에 새 프로필 정보를 저장한다`() = runTest { + // Given + val newProfileRequest = UpdateProfileRequest( + nickname = "New nickname", + birthDate = "1999.04.29", + image = "New image url" + ) + + coEvery { profileApi.updateProfile(any()) } just runs + + // When + profileRemoteDataSource.updateProfile(newProfileRequest) + + // Then + coVerifyOnce { profileApi.updateProfile(newProfileRequest) } + } + + @Test + fun `updateProfile()은 서버에 새 프로필 정보를 저장할 때 예외가 발생하면 그대로 던진다`() = runTest { + // Given + val expectedException = mockk() + coEvery { profileApi.updateProfile(any()) } throws expectedException + + // When + val actualException = assertThrows { + profileRemoteDataSource.updateProfile(mockk()) + } + + // Then + assertEquals(expectedException, actualException) + } + + @Test + fun `validateNickname()은 사용 가능한 닉네임인지를 서버로부터 확인한다`() = runTest { + // Given + val sampleNickname = "검사할 닉네임" + coEvery { profileApi.validateNickname(sampleNickname) } just runs + + // When & Then + assertDoesNotThrow { profileRemoteDataSource.validateNickname(sampleNickname) } + coVerifyOnce { profileApi.validateNickname(sampleNickname) } + } + + @Test + fun `validateNickname()은 사용 가능한 닉네임인지를 서버로부터 확인할 때 예외가 발생하면 그대로 던진다`() = runTest { + // Given + val expectedException = mockk() + coEvery { profileApi.validateNickname(any()) } throws expectedException + + // When + val actualException = assertThrows { + profileRemoteDataSource.validateNickname("Dummy nickname") + } + + // Then + assertEquals(expectedException, actualException) + } + + @Test + fun `getSavedSpots()는 서버로부터 저장한 장소를 가져와 그대로 반환한다`() = runTest { + // Given + val expectedSavedSpotsResponse = SavedSpotsResponse( + listOf( + SavedSpotResponse( + spotId = 1, + spotName = "Spot name1", + spotThumbnail = "Thumbnail Image Url1" + ), + SavedSpotResponse( + spotId = 2, + spotName = "Spot name2", + spotThumbnail = "Thumbnail Image Url2" + ), + SavedSpotResponse( + spotId = 3, + spotName = "Spot name3", + spotThumbnail = "Thumbnail Image Url3" + ), + ) + ) + coEvery { profileApi.getSavedSpots() } returns expectedSavedSpotsResponse + + // When + val actualSavedSpotsResponse = profileRemoteDataSource.getSavedSpots() + + // Then + coVerifyOnce { profileApi.getSavedSpots() } + assertEquals(expectedSavedSpotsResponse.savedSpotList, actualSavedSpotsResponse) + } + + @Test + fun `getSavedSpots()는 서버로부터 저장한 장소를 가져올 때 예외가 발생하면 그대로 던진다`() = runTest { + // Given + val expectedException = mockk() + coEvery { profileApi.getSavedSpots() } throws expectedException + + // When + val actualException = assertThrows { + profileRemoteDataSource.getSavedSpots() + } + + // Then + assertEquals(expectedException, actualException) + } +} + + +private fun coVerifyOnce( + ordering: Ordering = Ordering.UNORDERED, + inverse: Boolean = false, + atLeast: Int = 1, + atMost: Int = Int.MAX_VALUE, + timeout: Long = 0, + verifyBlock: suspend MockKVerificationScope.() -> Unit +) { + coVerify(ordering, inverse, atLeast, atMost, 1, timeout, verifyBlock) +} \ No newline at end of file diff --git a/core/data/src/test/java/com/acon/core/data/mapping/ProfileMappingTest.kt b/core/data/src/test/java/com/acon/core/data/mapping/ProfileMappingTest.kt new file mode 100644 index 000000000..9a0aa647c --- /dev/null +++ b/core/data/src/test/java/com/acon/core/data/mapping/ProfileMappingTest.kt @@ -0,0 +1,131 @@ +package com.acon.core.data.mapping + +import com.acon.acon.core.model.model.profile.BirthDateStatus +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import com.acon.core.data.dto.response.profile.ProfileResponse +import io.mockk.junit5.MockKExtension +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.time.Month +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@ExtendWith(MockKExtension::class) +class ProfileMappingTest { + + @Test + fun `nickname Response는 그대로 전달한다`() { + // Given + val expectedNickname = "Nickname Response" + val sampleProfileResponse = ProfileResponse( + nickname = expectedNickname, + birthDate = null, + image = null + ) + + // When + val actualProfile = sampleProfileResponse.toProfile() + val actualNickname = actualProfile.nickname + + // Then + assertEquals(actualNickname, expectedNickname) + } + + @Test + fun `birthDate Response가 null이 아닐 경우 생일을 지정됨 상태로 변환한다`() { + // Given + val expectedYear = 1999 + val expectedMonth = Month.APRIL + val expectedDayOfMonth = 29 + + val sampleBirthDateString = "1999.04.29" + val sampleProfileResponse = ProfileResponse( + nickname = "Dummy Nickname", + birthDate = sampleBirthDateString, + image = "Dummy Image Url" + ) + + // When + val actualProfile = sampleProfileResponse.toProfile() + val actualBirthDateStatus = actualProfile.birthDate + + // Then + assertIs(actualBirthDateStatus) + actualBirthDateStatus.date.let { actual -> + assertEquals(expectedYear, actual.year) + assertEquals(expectedMonth, actual.month) + assertEquals(expectedDayOfMonth, actual.dayOfMonth) + } + } + + @Test + fun `birthDate Response가 null일 경우 생일을 지정되지 않음 상태로 변환한다`() { + // Given + val sampleProfileResponse = ProfileResponse( + nickname = "Dummy Nickname", + birthDate = null, + image = "Dummy Image Url" + ) + + // When + val actualProfile = sampleProfileResponse.toProfile() + val actualBirthDateStatus = actualProfile.birthDate + + // Then + assertIs(actualBirthDateStatus) + } + + @Test + fun `birthDate Response가 null이 아닐 때, 파싱에 실패하면 생일을 지정되지 않음 상태로 변환한다`() { + // Given + val sampleInvalidBirthDateFormat = "1999-04-29" + val sampleProfileResponse = ProfileResponse( + nickname = "Dummy Nickname", + birthDate = sampleInvalidBirthDateFormat, + image = "Dummy Image Url" + ) + + // When + val actualProfile = sampleProfileResponse.toProfile() + val actualBirthDateStatus = actualProfile.birthDate + + // Then + assertIs(actualBirthDateStatus) + } + + @Test + fun `image Response가 null이 아닐 경우 이미지를 커스텀 상태로 변환한다`() { + // Given + val expectedImageUrl = "Custom Profile Image Url" + val sampleProfileResponse = ProfileResponse( + nickname = "Dummy Nickname", + birthDate = "Dummy BirthDate", + image = expectedImageUrl + ) + + // When + val actualProfile = sampleProfileResponse.toProfile() + val actualProfileImageStatus = actualProfile.image + + // Then + assertIs(actualProfileImageStatus) + assertEquals(expectedImageUrl, (actualProfileImageStatus).url) + } + + @Test + fun `image Response가 null일 경우 이미지를 디폴트 상태로 변환한다`() { + // Given + val sampleProfileResponse = ProfileResponse( + nickname = "Dummy Nickname", + birthDate = "Dummy BirthDate", + image = null + ) + + // When + val actualProfile = sampleProfileResponse.toProfile() + val actualProfileImageStatus = actualProfile.image + + // Then + assertIs(actualProfileImageStatus) + } +} \ No newline at end of file diff --git a/data/src/test/java/com/acon/acon/data/repository/AconAppRepositoryImplTest.kt b/core/data/src/test/java/com/acon/core/data/repository/AconAppRepositoryImplTest.kt similarity index 85% rename from data/src/test/java/com/acon/acon/data/repository/AconAppRepositoryImplTest.kt rename to core/data/src/test/java/com/acon/core/data/repository/AconAppRepositoryImplTest.kt index 55784f074..77510c663 100644 --- a/data/src/test/java/com/acon/acon/data/repository/AconAppRepositoryImplTest.kt +++ b/core/data/src/test/java/com/acon/core/data/repository/AconAppRepositoryImplTest.kt @@ -1,10 +1,10 @@ -package com.acon.acon.data.repository +package com.acon.core.data.repository -import com.acon.acon.data.assertValidErrorMapping -import com.acon.acon.data.createErrorStream -import com.acon.acon.data.createFakeRemoteError -import com.acon.acon.data.datasource.remote.AconAppRemoteDataSource import com.acon.acon.domain.error.app.FetchShouldUpdateError +import com.acon.core.data.assertValidErrorMapping +import com.acon.core.data.createErrorStream +import com.acon.core.data.createFakeRemoteError +import com.acon.core.data.datasource.remote.AconAppRemoteDataSource import io.mockk.coEvery import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.RelaxedMockK diff --git a/data/src/test/java/com/acon/acon/data/repository/OnboardingRepositoryImplTest.kt b/core/data/src/test/java/com/acon/core/data/repository/OnboardingRepositoryImplTest.kt similarity index 63% rename from data/src/test/java/com/acon/acon/data/repository/OnboardingRepositoryImplTest.kt rename to core/data/src/test/java/com/acon/core/data/repository/OnboardingRepositoryImplTest.kt index 8f1091691..081c40618 100644 --- a/data/src/test/java/com/acon/acon/data/repository/OnboardingRepositoryImplTest.kt +++ b/core/data/src/test/java/com/acon/core/data/repository/OnboardingRepositoryImplTest.kt @@ -1,10 +1,10 @@ -package com.acon.acon.data.repository +package com.acon.core.data.repository -import com.acon.acon.data.assertValidErrorMapping -import com.acon.acon.data.createErrorStream -import com.acon.acon.data.createFakeRemoteError -import com.acon.acon.data.datasource.remote.OnboardingRemoteDataSource -import com.acon.acon.domain.error.onboarding.PostOnboardingResultError +import com.acon.acon.domain.error.onboarding.PostTastePreferenceResultError +import com.acon.core.data.assertValidErrorMapping +import com.acon.core.data.createErrorStream +import com.acon.core.data.createFakeRemoteError +import com.acon.core.data.datasource.remote.OnboardingRemoteDataSource import io.mockk.coEvery import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.RelaxedMockK @@ -27,7 +27,7 @@ class OnboardingRepositoryImplTest { companion object { @JvmStatic fun postOnboardingResultErrorScenarios() = createErrorStream( - 40013 to PostOnboardingResultError.InvalidDislikeFood::class + 40013 to PostTastePreferenceResultError.InvalidDislikeFood::class ) } @@ -35,14 +35,14 @@ class OnboardingRepositoryImplTest { @MethodSource("postOnboardingResultErrorScenarios") fun `싫어하는 음식 API 실패 시 에러 객체를 반환한다`( errorCode: Int, - expectedErrorClass: KClass + expectedErrorClass: KClass ) = runTest { // Given val fakeRemoteError = createFakeRemoteError(errorCode) - coEvery { onboardingRemoteDataSource.submitOnboardingResult(any()) } throws fakeRemoteError + coEvery { onboardingRemoteDataSource.submitTastePreferenceResult(any()) } throws fakeRemoteError // When - val result = onboardingRepository.submitOnboardingResult(listOf()) + val result = onboardingRepository.submitTastePreferenceResult(listOf()) // Then assertValidErrorMapping(result, expectedErrorClass) diff --git a/core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryTest.kt b/core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryTest.kt new file mode 100644 index 000000000..f4ecf4f88 --- /dev/null +++ b/core/data/src/test/java/com/acon/core/data/repository/ProfileRepositoryTest.kt @@ -0,0 +1,322 @@ +package com.acon.core.data.repository + +import com.acon.acon.core.model.model.profile.BirthDateStatus +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import com.acon.acon.core.model.model.profile.SavedSpot +import com.acon.acon.core.model.model.profile.SpotThumbnailStatus +import com.acon.acon.domain.error.profile.UpdateProfileError +import com.acon.acon.domain.error.profile.ValidateNicknameError +import com.acon.acon.domain.repository.AconAppRepository +import com.acon.acon.domain.repository.ProfileRepository +import com.acon.core.data.assertValidErrorMapping +import com.acon.core.data.createErrorStream +import com.acon.core.data.createFakeRemoteError +import com.acon.core.data.datasource.local.ProfileLocalDataSource +import com.acon.core.data.datasource.remote.AconAppRemoteDataSource +import com.acon.core.data.datasource.remote.ProfileRemoteDataSource +import com.acon.core.data.dto.response.profile.ProfileResponse +import com.acon.core.data.dto.response.profile.SavedSpotResponse +import com.acon.core.data.stream.DataStream +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.time.LocalDate +import kotlin.reflect.KClass +import kotlin.test.assertEquals + +@ExtendWith(MockKExtension::class) +class ProfileRepositoryTest { + + @MockK + private lateinit var profileRemoteDataSource: ProfileRemoteDataSource + + @MockK + private lateinit var profileLocalDataSource: ProfileLocalDataSource + + @MockK + private lateinit var aconAppRepository: AconAppRepository + + @MockK + private lateinit var dataStream: DataStream + + private lateinit var profileRepository: ProfileRepository + + private val sampleNewProfile get() = Profile( + nickname = "New nickname", + birthDate = BirthDateStatus.Specified(LocalDate.of(2000, 1, 1)), + image = ProfileImageStatus.Default + ) + + @BeforeEach + fun setUp() { + profileRepository = ProfileRepositoryImpl( + profileRemoteDataSource, profileLocalDataSource, aconAppRepository, dataStream, + mockk(relaxed = true) + ) + } + + @Test + fun `getProfile()은 서버로부터 프로필 응답을 성공적으로 받아왔을 경우, 모델을 로컬에 캐싱하고 Flow-Result Wrapping하여 반환한다`() = runTest { + // Given + val sampleProfileResponse = ProfileResponse( + nickname = "Sample nickname", + birthDate = null, + image = "Sample profile image" + ) + val sampleProfile = sampleProfileResponse.toProfile() + val expectedProfileResult = Result.success(sampleProfile) + + coEvery { profileLocalDataSource.cacheProfile(sampleProfile) } just runs + coEvery { profileLocalDataSource.getProfile() } returns flowOf(null) + coEvery { profileRemoteDataSource.getProfile() } returns sampleProfileResponse + + // When + val actualProfileResults = profileRepository.getProfile().toList() + + // Then + coVerify(exactly = 1) { profileLocalDataSource.cacheProfile(sampleProfile) } + + assertEquals(1, actualProfileResults.size) + assertEquals(expectedProfileResult, actualProfileResults.first()) + } + + @Test + fun `getProfile()은 로컬에 캐싱된 프로필이 있을 경우 서버 API를 호출하지 않고 캐싱 값을 반환한다`() = runTest { + // Given + val sampleCachedProfile = Profile( + nickname = "Cached nickname", + birthDate = BirthDateStatus.Specified(LocalDate.of(1999, 4, 29)), + image = ProfileImageStatus.Custom("Cached image url") + ) + val expectedProfileResult = Result.success(sampleCachedProfile) + + coEvery { profileLocalDataSource.getProfile() } returns flowOf(sampleCachedProfile) + + // When + val actualProfileResults = profileRepository.getProfile().toList() + + // Then + coVerify(exactly = 0) { profileRemoteDataSource.getProfile() } + assertEquals(1, actualProfileResults.size) + assertEquals(expectedProfileResult, actualProfileResults.first()) + } + + @Test + fun `getProfile()은 로컬에 캐싱된 프로필 불러오기에 실패할 경우 서버로부터 응답을 받아온다`() = runTest { + // Given + val sampleProfileResponse = ProfileResponse( + nickname = "Cached nickname", + birthDate = null, + image = null + ) + val sampleProfile = sampleProfileResponse.toProfile() + val expectedProfileResult = Result.success(sampleProfile) + + coEvery { profileLocalDataSource.getProfile() } returns flowOf(null) + coEvery { profileRemoteDataSource.getProfile() } returns sampleProfileResponse + coEvery { profileLocalDataSource.cacheProfile(sampleProfile) } just runs + + // When + val actualProfileResults = profileRepository.getProfile().toList() + + // Then + coVerify(exactly = 1) { profileRemoteDataSource.getProfile() } + assertEquals(1, actualProfileResults.size) + assertEquals(expectedProfileResult, actualProfileResults.first()) + } + + @Test + fun `getProfile()은 로컬에 캐싱된 프로필이 없을 경우 서버 API를 호출한다`() = runTest { + // Given + coEvery { profileLocalDataSource.getProfile() } returns flowOf(null) + + // When + profileRepository.getProfile().collect { } + + // Then + coVerify(exactly = 1) { profileRemoteDataSource.getProfile() } + } + + @Test + fun `getProfile()은 서버로부터 프로필 응답 받기를 실패하면 발생한 예외를 Result wrapping하여 그대로 전파한다`() = runTest { + // Given + val fakeException = Exception() + coEvery { profileLocalDataSource.getProfile() } returns flowOf(null) + coEvery { profileRemoteDataSource.getProfile() } throws fakeException + val expectedResult = Result.failure(fakeException) + + // When + val actualResult = profileRepository.getProfile().toList() + + // Then + assertEquals(1, actualResult.size) + assertEquals(expectedResult, actualResult.first()) + } + + @Test + fun `updateProfile()은 서버에 프로필 저장을 성공할 경우, 로컬 캐싱을 업데이트하고 Result(Unit)을 반환한다`() = runTest { + // Given + val sampleNewProfile = sampleNewProfile + val expectedResult = Result.success(Unit) + coEvery { profileRemoteDataSource.updateProfile(any()) } just runs + coEvery { profileLocalDataSource.cacheProfile(sampleNewProfile) } just runs + + // When + val actualResult = profileRepository.updateProfile(sampleNewProfile) + + // Then + coVerify(exactly = 1) { profileLocalDataSource.cacheProfile(sampleNewProfile) } + assertEquals(expectedResult, actualResult) + } + + @Test + fun `updateProfile()은 서버에 프로필 저장을 실패할 경우, 로컬 캐싱 값을 업데이트하지 않는다`() = runTest { + // Given + val sampleNewProfile = sampleNewProfile + + coEvery { profileRemoteDataSource.updateProfile(any()) } throws Exception() + + // When + profileRepository.updateProfile(sampleNewProfile) + + // Then + coVerify(exactly = 0) { profileLocalDataSource.cacheProfile(sampleNewProfile) } + } + + @ParameterizedTest + @MethodSource("updateProfileErrorScenarios") + fun `updateProfile()은 서버에 프로필 저장을 실패할 경우 errorCode에 대응하는 예외 객체를 Result Wrapping하여 반환한다`( + errorCode: Int, + expectedErrorClass: KClass + ) = runTest { + // Given + val sampleNewProfile = sampleNewProfile + + val fakeRemoteError = createFakeRemoteError(errorCode) + coEvery { profileRemoteDataSource.updateProfile(any()) } throws fakeRemoteError + + // When + val actualResult = profileRepository.updateProfile(sampleNewProfile) + + // Then + assertValidErrorMapping(actualResult, expectedErrorClass) + } + + @Test + fun `validateNickname()은 서버로부터 유효성 검사 성공 시 Result(Unit)을 반환한다`() = runTest { + // Given + val sampleNickname = "Sample Nickname" + val expectedResult = Result.success(Unit) + coEvery { profileRemoteDataSource.validateNickname(any()) } just runs + + // When + val actualResult = profileRepository.validateNickname(sampleNickname) + + // Then + coVerify(exactly = 1) { profileRemoteDataSource.validateNickname(sampleNickname) } + assertEquals(expectedResult, actualResult) + } + + @ParameterizedTest + @MethodSource("validateNicknameErrorScenarios") + fun `validateNickname()은 서버로부터 유효성 검사를 실패할 경우 errorCode에 대응하는 예외 객체를 Result Wrapping하여 반환한다`( + errorCode: Int, + expectedErrorClass: KClass + ) = runTest { + // Given + val sampleNickname = "Sample Nickname" + val fakeRemoteError = createFakeRemoteError(errorCode) + coEvery { profileRemoteDataSource.validateNickname(any()) } throws fakeRemoteError + + // When + val result = profileRepository.validateNickname(sampleNickname) + + // Then + assertValidErrorMapping(result, expectedErrorClass) + } + + @Test + fun `getSavedSpots()는 로컬에 저장된 캐시 값이 있을 경우, 서버 API를 호출하지 않고 캐시 값을 반환한다`() = runTest { + // Given + val sampleCachedSavedSpots = listOf( + SavedSpot(1, "Spot1", SpotThumbnailStatus.Empty), + SavedSpot(2, "Spot2", SpotThumbnailStatus.Exist("sample url1")), + SavedSpot(3, "Spot3", SpotThumbnailStatus.Exist("sample url2")) + ) + coEvery { profileLocalDataSource.getSavedSpots() } returns flowOf(sampleCachedSavedSpots) + val expectedResult = Result.success(sampleCachedSavedSpots) + + // When + val actualResult = profileRepository.getSavedSpots().first() + + // Then + coVerify(exactly = 0) { profileRemoteDataSource.getSavedSpots() } + assertEquals(expectedResult, actualResult) + } + @Test + fun `getSavedSpots()는 서버로부터 저장한 장소 응답받기를 성공하면, 모델로 변환하고 Result Wrapping하여 반환한다`() = runTest { + // Given + val sampleSavedSpotsResponse = listOf( + SavedSpotResponse(1, "Spot1", null), + SavedSpotResponse(2, "Spot2", "sample url"), + SavedSpotResponse(3, "Spot3", "sample url") + ) + coEvery { profileLocalDataSource.getSavedSpots() } returns flowOf(null) + coEvery { profileLocalDataSource.cacheSavedSpots(any()) } just runs + + coEvery { profileRemoteDataSource.getSavedSpots() } returns sampleSavedSpotsResponse + val sampleSavedSpots = sampleSavedSpotsResponse.map { it.toSavedSpot() } + val expectedResult = Result.success(sampleSavedSpots) + + // When + val actualResult = profileRepository.getSavedSpots().first() + + // Then + assertEquals(expectedResult, actualResult) + } + + @Test + fun `getSavedSpots()는 서버로부터 저장한 장소 응답받기를 실패하면 발생한 예외를 Result wrapping하여 그대로 전파한다`() = runTest { + // Given + val fakeException = Exception() + coEvery { profileLocalDataSource.getSavedSpots() } returns flowOf(null) + coEvery { profileRemoteDataSource.getSavedSpots() } throws fakeException + val expectedResult = Result.failure>(fakeException) + + // When + val actualResult = profileRepository.getSavedSpots().first() + + // Then + assertEquals(expectedResult, actualResult) + } + + companion object { + @JvmStatic + fun updateProfileErrorScenarios() = createErrorStream( + 40901 to UpdateProfileError.AlreadyExistNickname::class, + 40051 to UpdateProfileError.InvalidNicknameFormat::class, + 40053 to UpdateProfileError.InvalidBirthDateFormat::class, + 40052 to UpdateProfileError.InvalidBucketImagePath::class + ) + + @JvmStatic + fun validateNicknameErrorScenarios() = createErrorStream( + 40051 to ValidateNicknameError.InvalidFormat::class, + 40901 to ValidateNicknameError.AlreadyExist::class + ) + } +} \ No newline at end of file diff --git a/data/src/test/java/com/acon/acon/data/repository/SpotRepositoryImplTest.kt b/core/data/src/test/java/com/acon/core/data/repository/SpotRepositoryImplTest.kt similarity index 90% rename from data/src/test/java/com/acon/acon/data/repository/SpotRepositoryImplTest.kt rename to core/data/src/test/java/com/acon/core/data/repository/SpotRepositoryImplTest.kt index 1ec7fac72..1d35d5329 100644 --- a/data/src/test/java/com/acon/acon/data/repository/SpotRepositoryImplTest.kt +++ b/core/data/src/test/java/com/acon/core/data/repository/SpotRepositoryImplTest.kt @@ -1,17 +1,15 @@ -package com.acon.acon.data.repository +package com.acon.core.data.repository -import com.acon.acon.data.assertValidErrorMapping -import com.acon.acon.data.cache.ProfileInfoCache -import com.acon.acon.data.createErrorStream -import com.acon.acon.data.createFakeRemoteError -import com.acon.acon.data.datasource.remote.SpotRemoteDataSource import com.acon.acon.domain.error.spot.AddBookmarkError import com.acon.acon.domain.error.spot.DeleteBookmarkError import com.acon.acon.domain.error.spot.FetchMenuBoardsError import com.acon.acon.domain.error.spot.FetchRecentNavigationLocationError import com.acon.acon.domain.error.spot.FetchSpotListError import com.acon.acon.domain.error.spot.GetSpotDetailInfoError -import com.acon.acon.domain.repository.ProfileRepository +import com.acon.core.data.assertValidErrorMapping +import com.acon.core.data.createErrorStream +import com.acon.core.data.createFakeRemoteError +import com.acon.core.data.datasource.remote.SpotRemoteDataSource import io.mockk.coEvery import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.RelaxedMockK @@ -29,12 +27,6 @@ class SpotRepositoryImplTest { @RelaxedMockK lateinit var spotRemoteDataSource: SpotRemoteDataSource - @RelaxedMockK - lateinit var profileInfoCache: ProfileInfoCache - - @RelaxedMockK - lateinit var profileRepository: ProfileRepository - @InjectMockKs lateinit var spotRepositoryImpl: SpotRepositoryImpl @@ -78,7 +70,7 @@ class SpotRepositoryImplTest { ) = runTest { // Given val fakeRemoteError = createFakeRemoteError(errorCode) - coEvery { spotRemoteDataSource.fetchSpotList(any()) } throws fakeRemoteError + coEvery { spotRemoteDataSource.fetchSpotList(any(), any()) } throws fakeRemoteError // When val result = spotRepositoryImpl.fetchSpotList(.0, .0, mockk(relaxed = true)) diff --git a/data/src/test/java/com/acon/acon/data/repository/UploadRepositoryImplTest.kt b/core/data/src/test/java/com/acon/core/data/repository/UploadRepositoryImplTest.kt similarity index 92% rename from data/src/test/java/com/acon/acon/data/repository/UploadRepositoryImplTest.kt rename to core/data/src/test/java/com/acon/core/data/repository/UploadRepositoryImplTest.kt index a2ce90659..b929061f8 100644 --- a/data/src/test/java/com/acon/acon/data/repository/UploadRepositoryImplTest.kt +++ b/core/data/src/test/java/com/acon/core/data/repository/UploadRepositoryImplTest.kt @@ -1,12 +1,12 @@ -package com.acon.acon.data.repository +package com.acon.core.data.repository -import com.acon.acon.data.assertValidErrorMapping -import com.acon.acon.data.createErrorStream -import com.acon.acon.data.createFakeRemoteError -import com.acon.acon.data.datasource.remote.UploadRemoteDataSource import com.acon.acon.domain.error.upload.GetVerifySpotLocationError import com.acon.acon.domain.error.upload.UploadReviewError import com.acon.acon.domain.error.user.GetSuggestionsError +import com.acon.core.data.assertValidErrorMapping +import com.acon.core.data.createErrorStream +import com.acon.core.data.createFakeRemoteError +import com.acon.core.data.datasource.remote.UploadRemoteDataSource import io.mockk.coEvery import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.RelaxedMockK diff --git a/core/data/src/test/java/com/acon/core/data/repository/UserRepositoryImplTest.kt b/core/data/src/test/java/com/acon/core/data/repository/UserRepositoryImplTest.kt new file mode 100644 index 000000000..a86d8974f --- /dev/null +++ b/core/data/src/test/java/com/acon/core/data/repository/UserRepositoryImplTest.kt @@ -0,0 +1,187 @@ +//package com.acon.core.data.repository +// +//import com.acon.acon.domain.error.user.PostSignInError +//import com.acon.acon.domain.error.user.PostSignOutError +//import com.acon.acon.domain.repository.UserRepository +//import com.acon.core.data.datasource.local.TokenLocalDataSource +//import com.acon.core.data.datasource.remote.UserRemoteDataSource +//import com.acon.core.data.dto.response.SignInResponse +//import com.acon.core.data.error.RemoteError +//import com.acon.core.data.session.SessionHandler +//import io.mockk.coEvery +//import io.mockk.coVerify +//import io.mockk.impl.annotations.RelaxedMockK +//import io.mockk.junit5.MockKExtension +//import io.mockk.mockk +//import kotlinx.coroutines.cancel +//import kotlinx.coroutines.test.TestScope +//import kotlinx.coroutines.test.runTest +//import org.junit.jupiter.api.AfterEach +//import org.junit.jupiter.api.Assertions.assertInstanceOf +//import org.junit.jupiter.api.BeforeEach +//import org.junit.jupiter.api.Test +//import org.junit.jupiter.api.extension.ExtendWith +//import org.junit.jupiter.params.ParameterizedTest +//import org.junit.jupiter.params.provider.Arguments +//import org.junit.jupiter.params.provider.MethodSource +//import java.util.stream.Stream +//import kotlin.reflect.KClass +//import kotlin.test.assertNotNull +//import kotlin.test.assertTrue +// +//@ExtendWith(MockKExtension::class) +//class UserRepositoryImplTest { +// +// @RelaxedMockK +// lateinit var userRemoteDataSource: UserRemoteDataSource +// +// @RelaxedMockK +// lateinit var tokenLocalDataSource: TokenLocalDataSource +// +// @RelaxedMockK +// lateinit var sessionHandler: SessionHandler +// +// private lateinit var testScope: TestScope +// +// private lateinit var userRepository: UserRepository +// +// companion object { +// @JvmStatic +// fun postSignInErrorScenarios(): Stream = Stream.of( +// Arguments.of(40009, PostSignInError.InvalidSocialType::class), +// Arguments.of(40010, PostSignInError.InvalidIdTokenSignature::class), +// Arguments.of(50002, PostSignInError.GooglePublicKeyDownloadFailed::class) +// ) +// +// @JvmStatic +// fun postSignOutErrorScenarios(): Stream = Stream.of( +// Arguments.of(40088, PostSignOutError.InvalidRefreshToken::class) +// ) +// } +// +// @BeforeEach +// fun setUp() { +// testScope = TestScope() +// userRepository = UserRepositoryImpl(userRemoteDataSource, tokenLocalDataSource, sessionHandler) +// } +// +// @AfterEach +// fun tearDown() { +// testScope.cancel() +// } +// +// @Test +// fun `유저 상태는 SessionHandler로 부터 넘겨 받는다`() { +// // When +// userRepository.getUserType() +// +// // Then +// coVerify(exactly = 1) { sessionHandler.getUserType() } +// } +// +// @Test +// fun `로그인 API 성공 시, 로그인 세션 정보를 반영한다`() = runTest { +// // Given +// val signInResponse = SignInResponse( +// externalUUID = "Dummy UUID", +// accessToken = "New Access Token", +// refreshToken = "New Refresh Token", +// hasVerifiedArea = true +// ) +// coEvery { userRemoteDataSource.signIn(any()) } returns signInResponse +// +// // When +// userRepository.signIn(mockk(), "Dummy Token") +// +// // Then +// assertNotNull(signInResponse.accessToken) +// assertNotNull(signInResponse.refreshToken) +// coVerify(exactly = 1) { sessionHandler.completeSignIn(signInResponse.accessToken!!, signInResponse.refreshToken!!) } +// } +// +// @ParameterizedTest +// @MethodSource("postSignInErrorScenarios") +// fun `로그인 API 실패 시, 에러 코드에 대응되는 올바른 로그인 실패 예외 객체를 반환한다`( +// errorCode: Int, +// expectedErrorClass: KClass +// ) = runTest { +// // Given +// val fakeRemoteError = RemoteError(mockk(relaxed = true), errorCode, "") +// coEvery { userRemoteDataSource.signIn(any()) } throws fakeRemoteError +// +// // When +// val result = userRepository.signIn(mockk(), "Dummy Token") +// +// // Then +// assertTrue(result.isFailure) +// val exception = result.exceptionOrNull() +// assertInstanceOf(expectedErrorClass.java, exception, "에러 코드와 예외 클래스가 올바르게 매핑되지 않음") +// } +// +// @Test +// fun `로그아웃 API 성공 시 세션을 초기화한다`() = runTest { +// // Given +// coEvery { userRemoteDataSource.signOut(any()) } returns mockk() +// +// // When +// userRepository.signOut() +// +// // Then +// coVerify(exactly = 1) { userRepository.clearSession() } +// } +// +// @ParameterizedTest +// @MethodSource("postSignOutErrorScenarios") +// fun `로그아웃 API 실패 시, 에러 코드에 대응되는 올바른 로그아웃 실패 예외 객체를 반환한다`( +// errorCode: Int, +// expectedErrorClass: KClass +// ) = runTest { +// // Given +// val fakeRemoteError = RemoteError(mockk(relaxed = true), errorCode, "") +// coEvery { userRemoteDataSource.signOut(any()) } throws fakeRemoteError +// +// // When +// val result = userRepository.signOut() +// +// // Then +// assertTrue(result.isFailure) +// val exception = result.exceptionOrNull() +// assertInstanceOf(expectedErrorClass.java, exception, "에러 코드와 예외 클래스가 올바르게 매핑되지 않음") +// } +// +// @Test +// fun `로그아웃 API 실패 시 세션을 초기화하지 않는다`() = runTest { +// // Given +// coEvery { userRemoteDataSource.signOut(any()) } throws mockk() +// +// // When +// userRepository.signOut() +// +// // Then +// coVerify(exactly = 0) { userRepository.clearSession() } +// } +// +// @Test +// fun `회원탈퇴 성공 시 세션을 초기화한다`() = runTest { +// // Given +// coEvery { userRemoteDataSource.deleteAccount(any()) } returns mockk() +// +// // When +// userRepository.deleteAccount("Dummy Reason") +// +// // Then +// coVerify(exactly = 1) { userRepository.clearSession() } +// } +// +// @Test +// fun `회원탈퇴 실패 시 세션을 초기화하지 않는다`() = runTest { +// // Given +// coEvery { userRemoteDataSource.deleteAccount(any()) } throws mockk() +// +// // When +// userRepository.deleteAccount("Dummy Reason") +// +// // Then +// coVerify(exactly = 0) { userRepository.clearSession() } +// } +//} \ No newline at end of file diff --git a/data/src/test/java/com/acon/acon/data/session/SessionHandlerImplTest.kt b/core/data/src/test/java/com/acon/core/data/session/SessionHandlerImplTest.kt similarity index 89% rename from data/src/test/java/com/acon/acon/data/session/SessionHandlerImplTest.kt rename to core/data/src/test/java/com/acon/core/data/session/SessionHandlerImplTest.kt index 6a5a2194c..5f66e889a 100644 --- a/data/src/test/java/com/acon/acon/data/session/SessionHandlerImplTest.kt +++ b/core/data/src/test/java/com/acon/core/data/session/SessionHandlerImplTest.kt @@ -1,9 +1,9 @@ -package com.acon.acon.data.session +package com.acon.core.data.session import app.cash.turbine.test import com.acon.acon.core.analytics.amplitude.AconAmplitude -import com.acon.acon.core.model.type.UserType -import com.acon.acon.data.datasource.local.TokenLocalDataSource +import com.acon.acon.core.model.type.SignInStatus +import com.acon.core.data.datasource.local.TokenLocalDataSource import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.RelaxedMockK @@ -66,11 +66,11 @@ class SessionHandlerImplTest { // When & Then sessionHandler.getUserType().test { - assertEquals(UserType.GUEST, awaitItem(), "초기 상태는 GUEST여야 합니다.") + assertEquals(SignInStatus.GUEST, awaitItem(), "초기 상태는 GUEST여야 합니다.") sessionHandler.completeSignIn(fakeAccessToken, fakeRefreshToken) - assertEquals(UserType.USER, awaitItem()) + assertEquals(SignInStatus.USER, awaitItem()) cancelAndIgnoreRemainingEvents() } @@ -88,7 +88,7 @@ class SessionHandlerImplTest { // Then val finalUserType = sessionHandler.getUserType().first() - assertEquals(UserType.USER, finalUserType) + assertEquals(SignInStatus.USER, finalUserType) } @OptIn(ExperimentalCoroutinesApi::class) @@ -104,7 +104,7 @@ class SessionHandlerImplTest { // Then val finalUserType = sessionHandler.getUserType().first() - assertEquals(UserType.GUEST, finalUserType) + assertEquals(SignInStatus.GUEST, finalUserType) } @Test @@ -122,7 +122,7 @@ class SessionHandlerImplTest { sessionHandler.getUserType().test { sessionHandler.clearSession() - assertEquals(UserType.GUEST, awaitItem()) + assertEquals(SignInStatus.GUEST, awaitItem()) cancelAndIgnoreRemainingEvents() } diff --git a/core/data/src/test/java/com/acon/core/data/stream/DataStreamImplTest.kt b/core/data/src/test/java/com/acon/core/data/stream/DataStreamImplTest.kt new file mode 100644 index 000000000..100ca27dc --- /dev/null +++ b/core/data/src/test/java/com/acon/core/data/stream/DataStreamImplTest.kt @@ -0,0 +1,75 @@ +package com.acon.core.data.stream + +import app.cash.turbine.test +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(MockKExtension::class) +class DataStreamImplTest { + + private lateinit var dataStream: DataStream + + @BeforeEach + fun setUp() { + dataStream = DataStreamImpl() + } + + @Test + fun `초기 방출 테스트`() = runTest { + var value = 0 + val flow = dataStream.subscribe { + emit(++value) + } + + flow.test { + assertEquals(1, awaitItem()) + } + } + + @Test + fun `notifyDataChanged가 flow를 트리거하는지 테스트`() = runTest { + var value = 0 + val flow = dataStream.subscribe { + emit(++value) + } + + flow.test { + assertEquals(1, awaitItem()) + dataStream.notifyDataChanged() + assertEquals(2, awaitItem()) + dataStream.notifyDataChanged() + assertEquals(3, awaitItem()) + } + } + + @Test + fun `여러 구독이 생겼을 때, 한 번의 notify는 모든 구독자에게 전파되어야 한다`() = runTest { + var value1 = 0 + val flow1 = dataStream.subscribe { + emit(++value1) + } + + var value2 = 10 + val flow2 = dataStream.subscribe { + emit(++value2) + } + + flow1.test { + val f1 = this + flow2.test { + val f2 = this + assertEquals(1, f1.awaitItem()) // flow1의 첫 번째 값은 1 + assertEquals(11, f2.awaitItem()) // flow2의 첫 번째 값은 11 + + dataStream.notifyDataChanged() + + assertEquals(2, f1.awaitItem()) // flow1의 두 번째 값은 2 + assertEquals(12, f2.awaitItem()) // flow2의 두 번째 값은 12 + } + } + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/acon/acon/core/designsystem/component/image/DefaultProfileImage.kt b/core/designsystem/src/main/java/com/acon/acon/core/designsystem/component/image/DefaultProfileImage.kt new file mode 100644 index 000000000..10fac9dcc --- /dev/null +++ b/core/designsystem/src/main/java/com/acon/acon/core/designsystem/component/image/DefaultProfileImage.kt @@ -0,0 +1,20 @@ +package com.acon.acon.core.designsystem.component.image + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import com.acon.acon.core.designsystem.R + +@Composable +fun DefaultProfileImage( + modifier: Modifier = Modifier +) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_default_profile), + contentDescription = stringResource(R.string.content_description_default_profile_image), + modifier = modifier + ) +} \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/acon/acon/core/designsystem/component/textfield/v2/AconOutlinedTextField.kt b/core/designsystem/src/main/java/com/acon/acon/core/designsystem/component/textfield/v2/AconOutlinedTextField.kt index 548e9e138..c3e32b31b 100644 --- a/core/designsystem/src/main/java/com/acon/acon/core/designsystem/component/textfield/v2/AconOutlinedTextField.kt +++ b/core/designsystem/src/main/java/com/acon/acon/core/designsystem/component/textfield/v2/AconOutlinedTextField.kt @@ -3,6 +3,7 @@ package com.acon.acon.core.designsystem.component.textfield.v2 import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField @@ -33,6 +34,7 @@ fun AconOutlinedTextField( width = 1.dp, color = AconTheme.color.GlassWhiteDefault ), + innerPadding: PaddingValues = PaddingValues(horizontal = 10.dp, vertical = 12.dp), backgroundColor: Color = AconTheme.color.Gray900, textStyle: TextStyle = AconTheme.typography.Body1.copy(fontWeight = FontWeight.Normal, color = AconTheme.color.White), keyboardOptions: KeyboardOptions = KeyboardOptions.Default, @@ -55,7 +57,59 @@ fun AconOutlinedTextField( border = border, shape = shape ) - .padding(horizontal = 10.dp, vertical = 12.dp), + .padding(innerPadding), + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = false, + maxLines = maxLines, + minLines = minLines, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + cursorBrush = SolidColor(AconTheme.color.Action) + ) { innerTextField -> + decorationBox(innerTextField) + } +} + +@Composable +fun AconOutlinedTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + shape: Shape = RoundedCornerShape(8.dp), + border: BorderStroke = BorderStroke( + width = 1.dp, + color = AconTheme.color.GlassWhiteDefault + ), + innerPadding: PaddingValues = PaddingValues(horizontal = 10.dp, vertical = 12.dp), + backgroundColor: Color = AconTheme.color.Gray900, + textStyle: TextStyle = AconTheme.typography.Body1.copy(fontWeight = FontWeight.Normal, color = AconTheme.color.White), + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + maxLines: Int = Int.MAX_VALUE, + minLines: Int = 1, + visualTransformation: VisualTransformation = VisualTransformation.None, + onTextLayout: (TextLayoutResult) -> Unit = {}, + decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit = @Composable { innerTextField -> innerTextField() } +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier + .background( + color = backgroundColor, + shape = shape + ) + .border( + border = border, + shape = shape + ) + .padding(innerPadding), enabled = enabled, readOnly = readOnly, textStyle = textStyle, diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index 437b3ffe9..34af84a90 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -121,7 +121,7 @@ settings 지역인증 믿을 수 있는 리뷰를 위해\n지역인증이 필요해요 - 더 정확한 로컬맛집을 추천해드릴 수 있어요 + 내 지역에 남긴 리뷰는 추천 장소에 반영돼요 1초만에 인증하기 인증 지역은 프로필에서 수정 가능합니다. \'Acon\'에 대해 라이브러리\n읽기\/쓰기 권한이 없습니다. @@ -168,7 +168,7 @@ 원하시는 결과를 보여드리지 못해 죄송해요 대신 여기는 어떠세요? 다음에 들어오실 땐,\n꼭 찾아서 추천드릴게요 - 장소 등록 신청하기 + 장소 직접 등록하기 도보 %d분 길찾기 자전거로 %d분 길찾기 지금 위치 기준으로 다시 추천 받기 @@ -270,7 +270,7 @@ 모든 음식을 잘 드시는 분이군요! 확인했어요! 센스있게 추천해드릴게요 시작하기 - 취향탐색을 그만둘까요? + 취향탐색을 그만둘까요? 계속하기 그만두기 저장되었습니다 @@ -293,8 +293,9 @@ 프로필 수정하기 * /14 + %d/%d 닉네임 - 생년월일 + 생년월일 2025.01.01 사용할 수 있는 닉네임이에요. 닉네임을 입력해주세요. diff --git a/core/map/build.gradle.kts b/core/map/build.gradle.kts index a2e303d29..f5f47bc0f 100644 --- a/core/map/build.gradle.kts +++ b/core/map/build.gradle.kts @@ -11,7 +11,7 @@ val localProperties = Properties().apply { } android { - namespace = "com.acon.acon.core.map" + namespace = "com.acon.core.map" defaultConfig { buildConfigField("String", "NAVER_NCP_KEY_ID", "\"${localProperties["naver_ncp_key_id"]}\"") @@ -20,6 +20,7 @@ android { dependencies { - implementation(projects.core.ui) + implementation(projects.core.designsystem) + implementation(libs.play.services.location) } \ No newline at end of file diff --git a/core/map/src/main/java/com/acon/acon/core/map/MapUtils.kt b/core/map/src/main/java/com/acon/core/map/MapUtils.kt similarity index 59% rename from core/map/src/main/java/com/acon/acon/core/map/MapUtils.kt rename to core/map/src/main/java/com/acon/core/map/MapUtils.kt index 79ed47a82..00c959afc 100644 --- a/core/map/src/main/java/com/acon/acon/core/map/MapUtils.kt +++ b/core/map/src/main/java/com/acon/core/map/MapUtils.kt @@ -1,34 +1,12 @@ -package com.acon.acon.core.map +package com.acon.core.map import android.Manifest -import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager import android.location.Location -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext import androidx.core.app.ActivityCompat -import com.acon.acon.core.ui.permission.CheckAndRequestLocationPermission import com.google.android.gms.location.LocationServices -@Composable -@SuppressLint("MissingPermission") -fun ProceedWithLocation(onReady: (Location) -> Unit) { - val context = LocalContext.current - val locationProviderClient = remember { - LocationServices.getFusedLocationProviderClient(context) - } - - CheckAndRequestLocationPermission { - locationProviderClient.lastLocation.addOnSuccessListener { location -> - location?.let { - onReady(it) - } - } - } -} - fun Context.onLocationReady(onReady: (Location) -> Unit) { val locationProviderClient = LocationServices.getFusedLocationProviderClient(this) diff --git a/core/map/src/main/java/com/acon/core/map/composable/NaverMapView.kt b/core/map/src/main/java/com/acon/core/map/composable/NaverMapView.kt new file mode 100644 index 000000000..54dfd745f --- /dev/null +++ b/core/map/src/main/java/com/acon/core/map/composable/NaverMapView.kt @@ -0,0 +1,63 @@ +package com.acon.core.map.composable + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import com.acon.acon.core.designsystem.R +import com.acon.core.map.BuildConfig +import com.naver.maps.geometry.LatLng +import com.naver.maps.map.CameraUpdate +import com.naver.maps.map.MapView +import com.naver.maps.map.NaverMap +import com.naver.maps.map.overlay.Marker +import com.naver.maps.map.overlay.OverlayImage + +private const val MARKER_WIDTH = 240 +private const val MARKER_HEIGHT = 240 + +@Composable +fun NaverMapView( + modifier: Modifier = Modifier, + latitude: Double, + longitude: Double, +) { + val context = LocalContext.current + val density = context.resources.displayMetrics.density + + AndroidView( + modifier = modifier, + factory = { context -> + MapView(context).apply { + getMapAsync { map -> + map.customStyleId = BuildConfig.NAVER_NCP_KEY_ID + map.uiSettings.apply { + isScrollGesturesEnabled = false + isZoomGesturesEnabled = false + isTiltGesturesEnabled = false + isRotateGesturesEnabled = false + isZoomControlEnabled = false + isCompassEnabled = false + isScaleBarEnabled = false + setLogoMargin((20 * density).toInt(), 0, 0, (100 * density).toInt()) + } + + val cameraUpdate = + CameraUpdate.scrollTo(LatLng(latitude, longitude)) + map.moveCamera(cameraUpdate) + createCustomMarker(map, latitude, longitude) + } + } + } + ) +} + +private fun createCustomMarker(map: NaverMap, latitude: Double, longitude: Double) { + Marker().apply { + position = LatLng(latitude, longitude) + width = MARKER_WIDTH + height = MARKER_HEIGHT + icon = OverlayImage.fromResource(R.drawable.ic_mark) + this.map = map + } +} diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/OnboardingPreferences.kt b/core/model/src/main/java/com/acon/acon/core/model/model/OnboardingPreferences.kt new file mode 100644 index 000000000..72d39817b --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/model/OnboardingPreferences.kt @@ -0,0 +1,10 @@ +package com.acon.acon.core.model.model + +import kotlinx.serialization.Serializable + +@Serializable +data class OnboardingPreferences( + val shouldShowIntroduce: Boolean, + val shouldChooseDislikes: Boolean, + val shouldVerifyArea: Boolean +) diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/profile/BirthDateStatus.kt b/core/model/src/main/java/com/acon/acon/core/model/model/profile/BirthDateStatus.kt new file mode 100644 index 000000000..28958f1d1 --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/model/profile/BirthDateStatus.kt @@ -0,0 +1,9 @@ +package com.acon.acon.core.model.model.profile + +import java.time.LocalDate + +sealed interface BirthDateStatus { + + data object NotSpecified : BirthDateStatus + data class Specified(val date: LocalDate) : BirthDateStatus +} \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/profile/PresignedUrl.kt b/core/model/src/main/java/com/acon/acon/core/model/model/profile/PresignedUrl.kt new file mode 100644 index 000000000..766cc55d0 --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/model/profile/PresignedUrl.kt @@ -0,0 +1,6 @@ +package com.acon.acon.core.model.model.profile + +data class PreSignedUrl( + val fileName: String, + val preSignedUrl: String +) \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/profile/Profile.kt b/core/model/src/main/java/com/acon/acon/core/model/model/profile/Profile.kt new file mode 100644 index 000000000..4964cf0d2 --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/model/profile/Profile.kt @@ -0,0 +1,7 @@ +package com.acon.acon.core.model.model.profile + +data class Profile( + val nickname: String, + val birthDate: BirthDateStatus, + val image: ProfileImageStatus +) diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/profile/ProfileImageStatus.kt b/core/model/src/main/java/com/acon/acon/core/model/model/profile/ProfileImageStatus.kt new file mode 100644 index 000000000..d842574d9 --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/model/profile/ProfileImageStatus.kt @@ -0,0 +1,7 @@ +package com.acon.acon.core.model.model.profile + +sealed interface ProfileImageStatus { + + data object Default : ProfileImageStatus + data class Custom(val url: String) : ProfileImageStatus +} \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/profile/ProfileInfo.kt b/core/model/src/main/java/com/acon/acon/core/model/model/profile/ProfileInfo.kt deleted file mode 100644 index c34e2fd27..000000000 --- a/core/model/src/main/java/com/acon/acon/core/model/model/profile/ProfileInfo.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.acon.acon.core.model.model.profile - -data class ProfileInfo( - val image: String, - val nickname: String, - val birthDate: String?, - val savedSpots: List, -) { - companion object { - val Empty = com.acon.acon.core.model.model.profile.ProfileInfo("", "", null, emptyList()) - } -} - -data class PreSignedUrl( - val fileName: String, - val preSignedUrl: String -) \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/profile/SavedSpot.kt b/core/model/src/main/java/com/acon/acon/core/model/model/profile/SavedSpot.kt index d0f6d60f3..37df53b74 100644 --- a/core/model/src/main/java/com/acon/acon/core/model/model/profile/SavedSpot.kt +++ b/core/model/src/main/java/com/acon/acon/core/model/model/profile/SavedSpot.kt @@ -2,6 +2,6 @@ package com.acon.acon.core.model.model.profile data class SavedSpot( val spotId: Long, - val name: String, - val image: String -) \ No newline at end of file + val spotName: String, + val spotThumbnail: SpotThumbnailStatus +) diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/profile/SpotThumbnailStatus.kt b/core/model/src/main/java/com/acon/acon/core/model/model/profile/SpotThumbnailStatus.kt new file mode 100644 index 000000000..89005824e --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/model/profile/SpotThumbnailStatus.kt @@ -0,0 +1,7 @@ +package com.acon.acon.core.model.model.profile + +sealed interface SpotThumbnailStatus { + + data class Exist(val url: String) : SpotThumbnailStatus + data object Empty : SpotThumbnailStatus +} \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/spot/Spot.kt b/core/model/src/main/java/com/acon/acon/core/model/model/spot/Spot.kt index 901a3a155..a3b1fceed 100644 --- a/core/model/src/main/java/com/acon/acon/core/model/model/spot/Spot.kt +++ b/core/model/src/main/java/com/acon/acon/core/model/model/spot/Spot.kt @@ -12,9 +12,9 @@ data class Spot( val name: String, val acorn: Int, val isOpen: Boolean, - @Serializable(with = com.acon.acon.core.model.serializer.LocalTimeSerializer::class) val closingTime: LocalTime, - @Serializable(with = com.acon.acon.core.model.serializer.LocalTimeSerializer::class) val nextOpening: LocalTime, - val tags: List, + @Serializable(with = LocalTimeSerializer::class) val closingTime: LocalTime, + @Serializable(with = LocalTimeSerializer::class) val nextOpening: LocalTime, + val tags: List, val eta: Int, val latitude: Double, val longitude: Double, diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotDetail.kt b/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotDetail.kt index ac2264d05..c9c3e3a28 100644 --- a/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotDetail.kt +++ b/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotDetail.kt @@ -11,7 +11,7 @@ data class SpotDetail( val nextOpening: String, val hasMenuboardImage: Boolean, val isSaved: Boolean, - val signatureMenuList: List, + val signatureMenuList: List, val latitude: Double, val longitude: Double ) diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotList.kt b/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotList.kt index 2fd158e59..7ab8cc505 100644 --- a/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotList.kt +++ b/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotList.kt @@ -3,6 +3,6 @@ package com.acon.acon.core.model.model.spot import com.acon.acon.core.model.type.TransportMode data class SpotList( - val transportMode: com.acon.acon.core.model.type.TransportMode, - val spots: List, + val transportMode: TransportMode, + val spots: List, ) \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotListRequest.kt b/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotListRequest.kt index 30e0ffab7..55ea073fc 100644 --- a/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotListRequest.kt +++ b/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotListRequest.kt @@ -5,11 +5,11 @@ import com.acon.acon.core.model.type.FilterType import com.acon.acon.core.model.type.SpotType data class Condition( - val spotType: com.acon.acon.core.model.type.SpotType, - val filterList: List?, + val spotType: SpotType, + val filterList: List?, ) data class Filter( - val category: com.acon.acon.core.model.type.CategoryType, - val optionList: List + val category: CategoryType, + val optionList: List ) \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotNavigationParameter.kt b/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotNavigationParameter.kt index d1b6138b9..ed689f163 100644 --- a/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotNavigationParameter.kt +++ b/core/model/src/main/java/com/acon/acon/core/model/model/spot/SpotNavigationParameter.kt @@ -7,8 +7,8 @@ import kotlinx.serialization.Serializable @Serializable data class SpotNavigationParameter( val spotId: Long, - val tags: List, - val transportMode: com.acon.acon.core.model.type.TransportMode?, + val tags: List, + val transportMode: TransportMode?, val eta: Int?, val isFromDeepLink: Boolean?, val navFromProfile: Boolean? diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/user/CredentialCode.kt b/core/model/src/main/java/com/acon/acon/core/model/model/user/CredentialCode.kt new file mode 100644 index 000000000..1eb6ba1a7 --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/model/user/CredentialCode.kt @@ -0,0 +1,6 @@ +package com.acon.acon.core.model.model.user + +@JvmInline +value class CredentialCode( + val value: String +) \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/user/ExternalUUID.kt b/core/model/src/main/java/com/acon/acon/core/model/model/user/ExternalUUID.kt new file mode 100644 index 000000000..50519d899 --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/model/user/ExternalUUID.kt @@ -0,0 +1,6 @@ +package com.acon.acon.core.model.model.user + +@JvmInline +value class ExternalUUID( + val value: String, +) \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/user/SocialPlatform.kt b/core/model/src/main/java/com/acon/acon/core/model/model/user/SocialPlatform.kt new file mode 100644 index 000000000..e6e472ff2 --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/model/user/SocialPlatform.kt @@ -0,0 +1,5 @@ +package com.acon.acon.core.model.model.user + +enum class SocialPlatform { + GOOGLE +} \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/model/user/VerificationStatus.kt b/core/model/src/main/java/com/acon/acon/core/model/model/user/VerificationStatus.kt deleted file mode 100644 index e0920e5e8..000000000 --- a/core/model/src/main/java/com/acon/acon/core/model/model/user/VerificationStatus.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.acon.acon.core.model.model.user - -data class VerificationStatus( - val externalUUID: String, - val hasVerifiedArea: Boolean, - val hasPreference: Boolean -) \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/type/ImageType.kt b/core/model/src/main/java/com/acon/acon/core/model/type/ImageType.kt new file mode 100644 index 000000000..32892b6e8 --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/type/ImageType.kt @@ -0,0 +1,5 @@ +package com.acon.acon.core.model.type + +enum class ImageType { + PROFILE, SPOT, MENUBOARD +} \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/type/LocalVerificationType.kt b/core/model/src/main/java/com/acon/acon/core/model/type/LocalVerificationType.kt deleted file mode 100644 index 549d561d9..000000000 --- a/core/model/src/main/java/com/acon/acon/core/model/type/LocalVerificationType.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.acon.acon.core.model.type - -sealed class VerificationState { - data object Loading : com.acon.acon.core.model.type.VerificationState() - data class Loaded(val type: com.acon.acon.core.model.type.LocalVerificationType) : com.acon.acon.core.model.type.VerificationState() -} - -enum class LocalVerificationType { - VERIFIED, NOT_VERIFIED -} diff --git a/core/model/src/main/java/com/acon/acon/core/model/type/SignInStatus.kt b/core/model/src/main/java/com/acon/acon/core/model/type/SignInStatus.kt new file mode 100644 index 000000000..dafc29a38 --- /dev/null +++ b/core/model/src/main/java/com/acon/acon/core/model/type/SignInStatus.kt @@ -0,0 +1,5 @@ +package com.acon.acon.core.model.type + +enum class SignInStatus { + USER, GUEST; +} \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/type/SocialType.kt b/core/model/src/main/java/com/acon/acon/core/model/type/SocialType.kt deleted file mode 100644 index e3f664bac..000000000 --- a/core/model/src/main/java/com/acon/acon/core/model/type/SocialType.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.acon.acon.core.model.type - -enum class SocialType { - GOOGLE, - APPLE -} \ No newline at end of file diff --git a/core/model/src/main/java/com/acon/acon/core/model/type/UserType.kt b/core/model/src/main/java/com/acon/acon/core/model/type/UserType.kt deleted file mode 100644 index a00ba551f..000000000 --- a/core/model/src/main/java/com/acon/acon/core/model/type/UserType.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.acon.acon.core.model.type - -enum class UserType { - ADMIN, USER, GUEST; -} \ No newline at end of file diff --git a/core/navigation/src/main/java/com/acon/acon/core/navigation/route/AreaVerificationRoute.kt b/core/navigation/src/main/java/com/acon/acon/core/navigation/route/AreaVerificationRoute.kt index ad645018e..3f1dc8040 100644 --- a/core/navigation/src/main/java/com/acon/acon/core/navigation/route/AreaVerificationRoute.kt +++ b/core/navigation/src/main/java/com/acon/acon/core/navigation/route/AreaVerificationRoute.kt @@ -9,16 +9,8 @@ sealed interface AreaVerificationRoute { data object Graph : AreaVerificationRoute @Serializable - data class AreaVerification( - val verifiedAreaId: Long? = null, - val route: String? = null - ) : AreaVerificationRoute + data object AreaVerification : AreaVerificationRoute @Serializable - data class CheckInMap( - val latitude: Double, - val longitude: Double, - val verifiedAreaId: Long, - val route: String? = null, - ) : AreaVerificationRoute + data object VerifyInMap: AreaVerificationRoute } \ No newline at end of file diff --git a/core/navigation/src/main/java/com/acon/acon/core/navigation/route/ProfileRoute.kt b/core/navigation/src/main/java/com/acon/acon/core/navigation/route/ProfileRoute.kt index f52e8e3db..3a7275812 100644 --- a/core/navigation/src/main/java/com/acon/acon/core/navigation/route/ProfileRoute.kt +++ b/core/navigation/src/main/java/com/acon/acon/core/navigation/route/ProfileRoute.kt @@ -8,10 +8,10 @@ interface ProfileRoute { data object Graph : ProfileRoute @Serializable - data object Profile : ProfileRoute + data object ProfileInfo : ProfileRoute @Serializable - data class ProfileMod(val photoId: String?) : ProfileRoute + data object ProfileUpdate : ProfileRoute @Serializable data object Bookmark : ProfileRoute diff --git a/core/navigation/src/main/java/com/acon/acon/core/navigation/route/SettingsRoute.kt b/core/navigation/src/main/java/com/acon/acon/core/navigation/route/SettingsRoute.kt index 98f2f765c..17899dfdb 100644 --- a/core/navigation/src/main/java/com/acon/acon/core/navigation/route/SettingsRoute.kt +++ b/core/navigation/src/main/java/com/acon/acon/core/navigation/route/SettingsRoute.kt @@ -10,7 +10,7 @@ interface SettingsRoute { data object Settings : SettingsRoute @Serializable - data object LocalVerification : SettingsRoute + data object UserVerifiedAreas : SettingsRoute @Serializable data object DeleteAccount : SettingsRoute diff --git a/core/navigation/src/main/java/com/acon/acon/core/navigation/utils/NavigationUtils.kt b/core/navigation/src/main/java/com/acon/acon/core/navigation/utils/NavigationUtils.kt index c7c949904..fb98b8599 100644 --- a/core/navigation/src/main/java/com/acon/acon/core/navigation/utils/NavigationUtils.kt +++ b/core/navigation/src/main/java/com/acon/acon/core/navigation/utils/NavigationUtils.kt @@ -5,10 +5,32 @@ import androidx.navigation.NavHostController /** * route로 이동하며 이전 화면들을 모두 제거 */ -fun NavHostController.navigateAndClear(route: T) { +fun NavHostController.navigateAndClear(route: T) { navigate(route = route) { popUpTo(graph.id) { inclusive = true } } -} \ No newline at end of file +} + +/** + * Navigation 백스택에 Route가 포함되어 있는지 검사합니다. + * + * ``` + * // Example + * navController.contains() + * ``` + */ +inline fun NavHostController.contains() : Boolean { + return try { + getBackStackEntry() + true + } catch (_: IllegalArgumentException) { + false + } +} + +/** + * 현재 Entry의 이전 화면이 있는지 검사합니다. + */ +fun NavHostController.hasPreviousBackStackEntry() = previousBackStackEntry != null \ No newline at end of file diff --git a/feature/areaverification/.gitignore b/core/social/.gitignore similarity index 100% rename from feature/areaverification/.gitignore rename to core/social/.gitignore diff --git a/core/social/build.gradle.kts b/core/social/build.gradle.kts new file mode 100644 index 000000000..2ad850837 --- /dev/null +++ b/core/social/build.gradle.kts @@ -0,0 +1,31 @@ +import java.util.Properties +import kotlin.apply + +plugins { + alias(libs.plugins.acon.android.library) + alias(libs.plugins.acon.android.library.hilt) + alias(libs.plugins.acon.common.unit.test) +} + +val localProperties = Properties().apply { + load(project.rootProject.file("local.properties").inputStream()) +} + +android { + namespace = "com.acon.core.social" + + defaultConfig { + buildConfigField("String", "GOOGLE_CLIENT_ID", "\"${localProperties["GOOGLE_CLIENT_ID"]}\"") + } +} + +dependencies { + + implementation(projects.core.model) + + implementation(libs.bundles.googleSignIn) +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/core/social/consumer-rules.pro b/core/social/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/core/social/proguard-rules.pro b/core/social/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/core/social/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/data/src/main/AndroidManifest.xml b/core/social/src/main/AndroidManifest.xml similarity index 100% rename from data/src/main/AndroidManifest.xml rename to core/social/src/main/AndroidManifest.xml diff --git a/core/social/src/main/kotlin/com/acon/core/social/client/GoogleAuthClient.kt b/core/social/src/main/kotlin/com/acon/core/social/client/GoogleAuthClient.kt new file mode 100644 index 000000000..94af3a9f2 --- /dev/null +++ b/core/social/src/main/kotlin/com/acon/core/social/client/GoogleAuthClient.kt @@ -0,0 +1,67 @@ +package com.acon.core.social.client + +import android.content.Context +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.PasswordCredential +import androidx.credentials.PublicKeyCredential +import com.acon.acon.core.model.model.user.CredentialCode +import com.acon.acon.core.model.model.user.SocialPlatform +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import dagger.hilt.android.qualifiers.ActivityContext +import timber.log.Timber + +class GoogleAuthClient internal constructor( + @ActivityContext private val context: Context, + getSignInWithGoogleOption: GetSignInWithGoogleOption, + private val credentialManager: CredentialManager, +) : SocialAuthClient { + + override val platform = SocialPlatform.GOOGLE + + private val request = GetCredentialRequest.Builder() + .addCredentialOption(getSignInWithGoogleOption) + .build() + + override suspend fun getCredentialCode(): CredentialCode? { + try { + val credentialResponse = credentialManager.getCredential( + request = request, + context = context + ) + + when (val credential = credentialResponse.credential) { + is CustomCredential -> { + Timber.d("Credential is CustomCredential. Type: ${credential.type}") + if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { + val idToken = GoogleIdTokenCredential.createFrom(credential.data).idToken + return CredentialCode(idToken) + } else { + Timber.e("Unknown credential type") + return null + } + } + + is PublicKeyCredential -> { + Timber.e("Credential is PublicKeyCredential. Unsupported.") + return null + } + + is PasswordCredential -> { + Timber.e("Credential is PasswordCredential. Unsupported.") + return null + } + + else -> { + Timber.e("Unknown credential class: ${credential::class.java}") + return null + } + } + } catch (e: Exception) { + return null + } + } +} + diff --git a/core/social/src/main/kotlin/com/acon/core/social/client/SocialAuthClient.kt b/core/social/src/main/kotlin/com/acon/core/social/client/SocialAuthClient.kt new file mode 100644 index 000000000..35e7a5c46 --- /dev/null +++ b/core/social/src/main/kotlin/com/acon/core/social/client/SocialAuthClient.kt @@ -0,0 +1,20 @@ +package com.acon.core.social.client + +import com.acon.acon.core.model.model.user.CredentialCode +import com.acon.acon.core.model.model.user.SocialPlatform + +/** + * 소셜 플랫폼 인증 클라이언트 인터페이스 + */ +interface SocialAuthClient { + + val platform: SocialPlatform + + /** + * 소셜 플랫폼의 자격 증명 코드를 가져옵니다. + * 이 코드는 백엔드에서 사용자를 인증하는 데 사용할 수 있습니다. + * + * @return 자격 증명 코드. 실패한 경우 null 반환 + */ + suspend fun getCredentialCode() : CredentialCode? +} \ No newline at end of file diff --git a/core/social/src/main/kotlin/com/acon/core/social/di/GoogleAuthClientModule.kt b/core/social/src/main/kotlin/com/acon/core/social/di/GoogleAuthClientModule.kt new file mode 100644 index 000000000..00d8d1d79 --- /dev/null +++ b/core/social/src/main/kotlin/com/acon/core/social/di/GoogleAuthClientModule.kt @@ -0,0 +1,46 @@ +package com.acon.core.social.di + +import android.content.Context +import androidx.credentials.CredentialManager +import com.acon.core.social.BuildConfig +import com.acon.core.social.client.GoogleAuthClient +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption +import dagger.Module +import dagger.Provides +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped + +@Module +@InstallIn(ActivityComponent::class) +object GoogleAuthClientModule { + + private const val SERVER_CLIENT_ID = BuildConfig.GOOGLE_CLIENT_ID + + @ActivityScoped + @Provides + fun providesCredentialOption() = + GetSignInWithGoogleOption.Builder(SERVER_CLIENT_ID).build() + + @ActivityScoped + @Provides + fun providesCredentialManager( + @ActivityContext context: Context + ) = CredentialManager.create(context) + + @ActivityScoped + @Provides + fun providesGoogleAuthClient( + @ActivityContext context: Context, + credentialOption: GetSignInWithGoogleOption, + credentialManager: CredentialManager + ) = GoogleAuthClient(context, credentialOption, credentialManager) +} + +@EntryPoint +@InstallIn(ActivityComponent::class) +interface AuthClientEntryPoint { + fun googleAuthClient(): GoogleAuthClient +} \ No newline at end of file diff --git a/core/social/src/test/java/com/acon/core/social/client/GoogleAuthClientTest.kt b/core/social/src/test/java/com/acon/core/social/client/GoogleAuthClientTest.kt new file mode 100644 index 000000000..f09959062 --- /dev/null +++ b/core/social/src/test/java/com/acon/core/social/client/GoogleAuthClientTest.kt @@ -0,0 +1,126 @@ +package com.acon.core.social.client + +import android.content.Context +import androidx.credentials.Credential +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PasswordCredential +import androidx.credentials.PublicKeyCredential +import com.acon.acon.core.model.model.user.CredentialCode +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +@ExtendWith(MockKExtension::class) +class GoogleAuthClientTest { + + @MockK + private lateinit var context: Context + + @MockK + private lateinit var getSignInWithGoogleOption: GetSignInWithGoogleOption + + @MockK + private lateinit var credentialManager: CredentialManager + + @InjectMockKs + private lateinit var googleAuthClient: GoogleAuthClient + + @Test + fun `getCredentialCode - Credential이 CustomCredential이고, type이 TYPE_GOOGLE_ID_TOKEN_CREDENTIAL일 경우에만 idToken 정상 반환`() = + runTest { + val testIdToken = "test-google-id-token-12345" + + val mockCredential = mockk() + every { mockCredential.type } returns GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + every { mockCredential.data } returns mockk(relaxed = true) + + val mockTokenCredential = mockk() + every { mockTokenCredential.idToken } returns testIdToken + + mockkObject(GoogleIdTokenCredential.Companion) + every { GoogleIdTokenCredential.createFrom(mockCredential.data) } returns mockTokenCredential + + val mockCredentialResponse = mockk() + every { mockCredentialResponse.credential } returns mockCredential + + coEvery { + credentialManager.getCredential( + request = any(), + context = any() + ) + } returns mockCredentialResponse + + val result: CredentialCode? = googleAuthClient.getCredentialCode() + + assertEquals(testIdToken, result?.value) + + unmockkObject(GoogleIdTokenCredential.Companion) + } + + @ParameterizedTest + @MethodSource("unsupportedCredentialProvider") + fun `getCredentialCode - 지원하지 않는 Credential 타입일 때 null 반환`(mockCredential: Credential) = runTest { + val mockCredentialResponse = mockk() + every { mockCredentialResponse.credential } returns mockCredential + + coEvery { + credentialManager.getCredential( + request = any(), + context = any() + ) + } returns mockCredentialResponse + + val result = googleAuthClient.getCredentialCode() + + assertNull(result) + } + + @Test + fun `getCredentialCode - CredentialManager에서 예외가 발생 시 null 반환`() = runTest { + coEvery { credentialManager.getCredential( + request = any(), + context = any()) + } throws Exception("Authentication failed") + + val result = googleAuthClient.getCredentialCode() + + assertNull(result) + } + + companion object { + @JvmStatic + fun unsupportedCredentialProvider(): Stream { + val unsupportedCustomCredential = mockk().apply { + every { type } returns "unsupported.custom.credential.type" + } + val publicKeyCredential = mockk() + val passwordCredential = mockk() + val unknownCredential = mockk() + + return Stream.of( + unsupportedCustomCredential, + publicKeyCredential, + passwordCredential, + unknownCredential + ) + } + } +} \ No newline at end of file diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 054977676..2b1f9500d 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.acon.android.library) alias(libs.plugins.acon.android.library.compose) alias(libs.plugins.acon.android.library.haze) + alias(libs.plugins.acon.android.library.hilt) alias(libs.plugins.acon.android.library.coil) alias(libs.plugins.acon.android.library.orbit) } @@ -15,4 +16,5 @@ dependencies { api(projects.core.model) implementation(libs.accompanist.permissions) + implementation(libs.androidx.ui.test) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/acon/acon/core/ui/DateVisualTransformation.kt b/core/ui/src/main/java/com/acon/acon/core/ui/DateVisualTransformation.kt new file mode 100644 index 000000000..753bc5152 --- /dev/null +++ b/core/ui/src/main/java/com/acon/acon/core/ui/DateVisualTransformation.kt @@ -0,0 +1,47 @@ +package com.acon.acon.core.ui + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +/** + * 입력 텍스트를 날짜 형식으로 변환하는 [VisualTransformation]입니다. + * + * 이 변환은 "yyyy.MM.dd" 형식에 맞게 .을 자동으로 추가합니다. + * 예를 들어, "20250915"을 입력하면 "2025.09.15"으로 표시됩니다. 사용자가 문자를 + * 입력하거나 삭제할 때 커서 위치를 올바르게 처리합니다. + * + * ``` + * TextField( + * value = text, + * onValueChange = { text = it.filter { char -> char.isDigit() }.take(8) }, + * visualTransformation = DateVisualTransformation() + * ) + * ``` + */ +object DateVisualTransformation : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + + val formatter = DateStringFormatter + val out = formatter.format(text.text) + + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset <= 3) return offset + if (offset <= 5) return offset + 1 + if (offset <= 8) return offset + 2 + return 10 + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset <= 4) return offset + if (offset <= 7) return offset - 1 + if (offset <= 10) return offset - 2 + return 8 + } + } + + return TransformedText(AnnotatedString(out), offsetMapping) + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/acon/acon/core/ui/EntryPointUtils.kt b/core/ui/src/main/java/com/acon/acon/core/ui/EntryPointUtils.kt new file mode 100644 index 000000000..ca35bdd4b --- /dev/null +++ b/core/ui/src/main/java/com/acon/acon/core/ui/EntryPointUtils.kt @@ -0,0 +1,44 @@ +package com.acon.acon.core.ui + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.acon.acon.core.ui.android.findActivityOrNull +import dagger.hilt.android.EntryPointAccessors + +/** + * Hilt EntryPoint 반환 유틸리티 함수 + * + * @param T Type of ActivityComponentEntryPoint + * @return Hilt EntryPoint 객체 + * @throws IllegalArgumentException LocalContext가 Activity가 아닌 경우 + */ +@Composable +inline fun rememberActivityComponentEntryPoint() : T { + val context = LocalContext.current + val activity = remember { + context.findActivityOrNull() + } + + requireNotNull(activity) { "Context must be an Activity or a Context that wraps an Activity" } + + return remember { + EntryPointAccessors.fromActivity(activity, T::class.java) + } +} + +/** + * Hilt EntryPoint 반환 유틸리티 함수 + * + * @param T Type of ActivityComponentEntryPoint. + * @return Hilt EntryPoint 객체 + * @throws IllegalArgumentException Activity Context가 아닌 경우 + */ +inline fun Context.activityComponentEntryPoint() : T { + val activity = findActivityOrNull() + + requireNotNull(activity) { "Context must be an Activity or a Context that wraps an Activity" } + + return EntryPointAccessors.fromActivity(activity, T::class.java) +} diff --git a/core/ui/src/main/java/com/acon/acon/core/ui/StringFormatter.kt b/core/ui/src/main/java/com/acon/acon/core/ui/StringFormatter.kt new file mode 100644 index 000000000..7d2699815 --- /dev/null +++ b/core/ui/src/main/java/com/acon/acon/core/ui/StringFormatter.kt @@ -0,0 +1,26 @@ +package com.acon.acon.core.ui + +interface StringFormatter { + fun format(s: String): String +} + +/** + * `yyyy.MM.dd` to `yyyyMMdd` + * ``` + * DateStringFormatter.format("2025.09.15") // == 20250915 + */ +object DateStringFormatter : StringFormatter { + + override fun format(s: String): String { + val trimmed = if (s.length >= 8) s.substring(0..7) else s + var out = "" + for (i in trimmed.indices) { + out += trimmed[i] + if (i == 3 || i == 5) { + out += "." + } + } + + return out + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/acon/acon/core/ui/android/ContextUtils.kt b/core/ui/src/main/java/com/acon/acon/core/ui/android/ContextUtils.kt index 285488fa0..142014059 100644 --- a/core/ui/src/main/java/com/acon/acon/core/ui/android/ContextUtils.kt +++ b/core/ui/src/main/java/com/acon/acon/core/ui/android/ContextUtils.kt @@ -21,4 +21,12 @@ fun Context.findActivity(): Activity { context = context.baseContext } throw IllegalStateException("No activity found in context") +} + +fun Context.findActivityOrNull() : Activity? { + return try { + findActivity() + } catch (_: IllegalStateException) { + null + } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/acon/acon/core/ui/base/BaseContainerHost.kt b/core/ui/src/main/java/com/acon/acon/core/ui/base/BaseContainerHost.kt index 89920940b..e828d1f07 100644 --- a/core/ui/src/main/java/com/acon/acon/core/ui/base/BaseContainerHost.kt +++ b/core/ui/src/main/java/com/acon/acon/core/ui/base/BaseContainerHost.kt @@ -9,10 +9,11 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import com.acon.acon.core.common.utils.firstNotNull -import com.acon.acon.core.model.type.UserType +import com.acon.acon.core.model.type.SignInStatus import com.acon.acon.core.ui.compose.LocalLocation import com.acon.acon.core.ui.compose.LocalRequestLocationPermission -import com.acon.acon.core.ui.compose.LocalUserType +import com.acon.acon.core.ui.compose.LocalRequestSignIn +import com.acon.acon.core.ui.compose.LocalSignInStatus import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterNotNull @@ -26,8 +27,15 @@ abstract class BaseContainerHost() : private val currentLocation = MutableStateFlow(null) - private val _userType = MutableStateFlow(UserType.GUEST) - protected val userType = _userType.asStateFlow() + private val _signInStatus = MutableStateFlow(SignInStatus.GUEST) + protected val signInStatus = _signInStatus.asStateFlow() + + protected var onRequestSignIn: ((String) -> Unit)? = null + + @Composable + fun initOnRequestSignIn() { + onRequestSignIn = LocalRequestSignIn.current + } @Composable fun requestLocationPermission() { @@ -39,12 +47,12 @@ abstract class BaseContainerHost() : } @Composable - fun useUserType() { - val userType by rememberUpdatedState(LocalUserType.current) + fun useSignInStatus() { + val signInStatus by rememberUpdatedState(LocalSignInStatus.current) LaunchedEffect(Unit) { - snapshotFlow { userType }.collect { - _userType.value = userType + snapshotFlow { signInStatus }.collect { + _signInStatus.value = signInStatus } } } diff --git a/core/ui/src/main/java/com/acon/acon/core/ui/compose/LocalCompositions.kt b/core/ui/src/main/java/com/acon/acon/core/ui/compose/LocalCompositions.kt index 62258b3d2..dd5d1fdb7 100644 --- a/core/ui/src/main/java/com/acon/acon/core/ui/compose/LocalCompositions.kt +++ b/core/ui/src/main/java/com/acon/acon/core/ui/compose/LocalCompositions.kt @@ -5,7 +5,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf import com.acon.acon.core.common.DeepLinkHandler -import com.acon.acon.core.model.type.UserType +import com.acon.acon.core.model.type.SignInStatus val LocalOnRetry = staticCompositionLocalOf { {} @@ -19,8 +19,8 @@ val LocalSnackbarHostState = staticCompositionLocalOf { SnackbarHostState() } -val LocalUserType = compositionLocalOf { - UserType.GUEST +val LocalSignInStatus = compositionLocalOf { + SignInStatus.GUEST } val LocalRequestSignIn = staticCompositionLocalOf<(propertyKey: String) -> Unit> { // TODO: core navigation 모듈 통합 diff --git a/core/ui/src/main/java/com/acon/acon/core/ui/test/AlphaMatcher.kt b/core/ui/src/main/java/com/acon/acon/core/ui/test/AlphaMatcher.kt new file mode 100644 index 000000000..83cff8672 --- /dev/null +++ b/core/ui/src/main/java/com/acon/acon/core/ui/test/AlphaMatcher.kt @@ -0,0 +1,31 @@ +package com.acon.acon.core.ui.test + +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.test.SemanticsNodeInteraction + +val TestAlphaKey = SemanticsPropertyKey("TestAlpha") +var SemanticsPropertyReceiver.testAlpha by TestAlphaKey + +/** + * [TestAlphaKey] 속성이 설정된 시맨틱 노드의 알파(alpha) 값을 가져옵니다. + * + * 이 함수는 UI 테스트에서 컴포저블의 알파(투명도) 값을 확인하는 데 사용됩니다. + * 테스트 대상 컴포저블에 `Modifier.semantics { testAlpha = alphaValue }`와 같이 + * `testAlpha` 시맨틱 속성이 설정되어 있어야 합니다. + * + * @return 시맨틱 노드의 알파 값을 [Float] 타입으로 반환합니다. + * @throws AssertionError 시맨틱 노드에 `TestAlphaKey`가 설정되어 있지 않은 경우 발생합니다. + * @see TestAlphaKey + * @see testAlpha + */ +fun SemanticsNodeInteraction.getAlpha(): Float { + val node = fetchSemanticsNode("Failed to get alpha value.") + + if (!node.config.contains(TestAlphaKey)) { + throw AssertionError("TestAlphaKey를 찾을 수 없습니다. " + + "Modifier.semantics { testAlpha = ... }를 지정하셨나요 ?") + } + + return node.config[TestAlphaKey] +} diff --git a/data/proguard-rules.pro b/data/proguard-rules.pro deleted file mode 100644 index 78e0e03f2..000000000 --- a/data/proguard-rules.pro +++ /dev/null @@ -1,4 +0,0 @@ --if class androidx.credentials.CredentialManager --keep class androidx.credentials.playservices.** { - *; -} \ No newline at end of file diff --git a/data/src/androidTest/java/com/acon/acon/data/ExampleInstrumentedTest.kt b/data/src/androidTest/java/com/acon/acon/data/ExampleInstrumentedTest.kt deleted file mode 100644 index 88b8f8c74..000000000 --- a/data/src/androidTest/java/com/acon/acon/data/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.acon.acon.data - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.acon.data.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/OnboardingAuthApi.kt b/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/OnboardingAuthApi.kt deleted file mode 100644 index 49c21bb8d..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/OnboardingAuthApi.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.acon.acon.data.api.remote.auth - -import com.acon.acon.data.dto.request.OnboardingRequest -import retrofit2.http.Body -import retrofit2.http.PUT - -interface OnboardingAuthApi { - - @PUT("/api/v1/preference") - suspend fun submitOnboardingResult( - @Body onboardingRequest: OnboardingRequest - ) -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/ProfileAuthApi.kt b/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/ProfileAuthApi.kt deleted file mode 100644 index 400ceaefc..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/api/remote/auth/ProfileAuthApi.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.acon.acon.data.api.remote.auth - -import com.acon.acon.data.dto.request.AreaVerificationRequest -import com.acon.acon.data.dto.request.ReplaceVerifiedAreaRequest -import com.acon.acon.data.dto.request.SaveSpotRequest -import com.acon.acon.data.dto.request.UpdateProfileRequest -import com.acon.acon.data.dto.response.area.VerifiedAreaListResponse -import com.acon.acon.data.dto.response.profile.PreSignedUrlResponse -import com.acon.acon.data.dto.response.profile.ProfileResponse -import com.acon.acon.data.dto.response.profile.SavedSpotsResponse -import retrofit2.Response -import retrofit2.http.Body -import retrofit2.http.DELETE -import retrofit2.http.GET -import retrofit2.http.PATCH -import retrofit2.http.POST -import retrofit2.http.Path -import retrofit2.http.Query - -interface ProfileAuthApi { - @GET("/api/v1/members/me") - suspend fun fetchProfile(): ProfileResponse - - @GET("/api/v1/images/presigned-url?imageType=PROFILE") - suspend fun getPreSignedUrl() : PreSignedUrlResponse - - @GET("/api/v1/nickname/validate") - suspend fun validateNickname( - @Query("nickname", encoded = true) nickname: String - ): Response - - @PATCH("/api/v1/members/me") - suspend fun updateProfile( - @Body request: UpdateProfileRequest - ): Response - - @GET("/api/v1/saved-spots") - suspend fun fetchSavedSpots(): SavedSpotsResponse - - @POST("/api/v1/saved-spots") - suspend fun saveSpot( - @Body saveSpotRequest: SaveSpotRequest - ) - - @POST("/api/v1/verified-areas") - suspend fun verifyArea( - @Body request: AreaVerificationRequest - ) - - @GET("/api/v1/verified-areas") - suspend fun fetchVerifiedAreaList() : VerifiedAreaListResponse - - @POST("/api/v1/verified-areas/replacement") - suspend fun replaceVerifiedArea( - @Body request: ReplaceVerifiedAreaRequest - ) - - @DELETE("/api/v1/verified-areas/{verifiedAreaId}") - suspend fun deleteVerifiedArea( - @Path("verifiedAreaId") verifiedAreaId: Long - ) -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/cache/ProfileInfoCache.kt b/data/src/main/kotlin/com/acon/acon/data/cache/ProfileInfoCache.kt deleted file mode 100644 index 365442f54..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/cache/ProfileInfoCache.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.acon.acon.data.cache - -import com.acon.acon.data.cache.base.ReadWriteCache -import com.acon.acon.data.datasource.remote.ProfileRemoteDataSource -import kotlinx.coroutines.CoroutineScope -import javax.inject.Inject - -class ProfileInfoCache @Inject constructor( - private val scope: CoroutineScope, - private val profileRemoteDataSource: ProfileRemoteDataSource -) : ReadWriteCache(scope) { - - override val emptyData = Result.success(com.acon.acon.core.model.model.profile.ProfileInfo.Empty) - - override suspend fun fetchRemoteData(): com.acon.acon.core.model.model.profile.ProfileInfo { - return profileRemoteDataSource.fetchProfile().toProfile() - } -} diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/local/OnboardingLocalDataSource.kt b/data/src/main/kotlin/com/acon/acon/data/datasource/local/OnboardingLocalDataSource.kt deleted file mode 100644 index 7c4fcf3f1..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/local/OnboardingLocalDataSource.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.acon.acon.data.datasource.local - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import com.acon.acon.data.di.OnboardingDataStore -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -class OnboardingLocalDataSource @Inject constructor( - @OnboardingDataStore private val onboardingDataStore: DataStore -) { - - suspend fun saveDidOnboarding(didOnboarding: Boolean) { - onboardingDataStore.edit { prefs -> - prefs[DID_ONBOARDING] = didOnboarding - } - } - - suspend fun getDidOnboarding(): Boolean { - return onboardingDataStore.data.map { prefs -> - prefs[DID_ONBOARDING] ?: false - }.first() - } - - companion object { - private val DID_ONBOARDING = booleanPreferencesKey("did_onboarding") - } -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/local/TokenLocalDataSource.kt b/data/src/main/kotlin/com/acon/acon/data/datasource/local/TokenLocalDataSource.kt deleted file mode 100644 index 258797950..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/local/TokenLocalDataSource.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.acon.acon.data.datasource.local - -import android.content.Context -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import com.acon.acon.core.common.IODispatcher -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext -import javax.inject.Inject - -class TokenLocalDataSource @Inject constructor( - @ApplicationContext applicationContext: Context, - @IODispatcher private val dispatchersIO: CoroutineDispatcher, -) { - private var masterKey = - MasterKey.Builder(applicationContext, MasterKey.DEFAULT_MASTER_KEY_ALIAS) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - private var sharedPreferences = EncryptedSharedPreferences.create( - applicationContext, - SHARED_PREF_FILENAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) - - internal suspend fun saveAccessToken( - accessToken: String, - ) = withContext(dispatchersIO) { - with(sharedPreferences.edit()) { - putString(SHARED_PREF_KEY, accessToken) - apply() - } - } - - internal suspend fun saveRefreshToken( - refreshToken: String, - ) = withContext(dispatchersIO) { - with(sharedPreferences.edit()) { - putString(SHARED_PREF_REFRESH_KEY, refreshToken) - apply() - } - } - - internal suspend fun getAccessToken(): String? = withContext(dispatchersIO) { - sharedPreferences.getString(SHARED_PREF_KEY, null) - } - - internal suspend fun getRefreshToken(): String? = withContext(dispatchersIO) { - sharedPreferences.getString(SHARED_PREF_REFRESH_KEY, null) - } - - internal suspend fun removeAllTokens() = withContext(dispatchersIO) { - with(sharedPreferences.edit()) { - remove(SHARED_PREF_KEY) - remove(SHARED_PREF_REFRESH_KEY) - apply() - } - } - - companion object { - private const val SHARED_PREF_FILENAME = "token" - private const val SHARED_PREF_KEY = "accessToken" - private const val SHARED_PREF_REFRESH_KEY = "refreshToken" - } -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/local/UserLocalDataSource.kt b/data/src/main/kotlin/com/acon/acon/data/datasource/local/UserLocalDataSource.kt deleted file mode 100644 index 136d949d5..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/local/UserLocalDataSource.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.acon.acon.data.datasource.local - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import com.acon.acon.data.di.UserDataStore -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -class UserLocalDataSource @Inject constructor( - @UserDataStore private val userDataStore: DataStore -) { - - suspend fun saveDidOnboarding(didOnboarding: Boolean) { - userDataStore.edit { - it[DID_ONBOARDING] = didOnboarding - } - } - - suspend fun getDidOnboarding(): Boolean { - return userDataStore.data.map { preferences -> - preferences[DID_ONBOARDING] ?: false - }.first() - } - - companion object { - private val DID_ONBOARDING = booleanPreferencesKey("did_onboarding") - } -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/AconAppRemoteDataSource.kt b/data/src/main/kotlin/com/acon/acon/data/datasource/remote/AconAppRemoteDataSource.kt deleted file mode 100644 index 49be95b99..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/AconAppRemoteDataSource.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.acon.acon.data.datasource.remote - -import com.acon.acon.data.dto.response.app.ShouldUpdateResponse -import com.acon.acon.data.api.remote.noauth.AconAppNoAuthApi -import javax.inject.Inject - -class AconAppRemoteDataSource @Inject constructor( - private val aconAppNoAuthApi: AconAppNoAuthApi -) { - suspend fun fetchShouldUpdateApp(currentVersion: String): ShouldUpdateResponse { - return aconAppNoAuthApi.fetchShouldUpdateApp(currentVersion) - } -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/OnboardingRemoteDataSource.kt b/data/src/main/kotlin/com/acon/acon/data/datasource/remote/OnboardingRemoteDataSource.kt deleted file mode 100644 index 47394117f..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/OnboardingRemoteDataSource.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.acon.acon.data.datasource.remote - -import com.acon.acon.data.dto.request.OnboardingRequest -import com.acon.acon.data.api.remote.auth.OnboardingAuthApi -import javax.inject.Inject - -class OnboardingRemoteDataSource @Inject constructor( - private val onboardingAuthApi: OnboardingAuthApi -) { - - suspend fun submitOnboardingResult(request: OnboardingRequest) { - return onboardingAuthApi.submitOnboardingResult(request) - } -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/ProfileRemoteDataSource.kt b/data/src/main/kotlin/com/acon/acon/data/datasource/remote/ProfileRemoteDataSource.kt deleted file mode 100644 index 21f85b4c1..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/ProfileRemoteDataSource.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.acon.acon.data.datasource.remote - -import com.acon.acon.data.dto.request.UpdateProfileRequest -import com.acon.acon.data.dto.response.profile.PreSignedUrlResponse -import com.acon.acon.data.dto.response.profile.ProfileResponse -import com.acon.acon.data.api.remote.auth.ProfileAuthApi -import com.acon.acon.data.dto.request.AreaVerificationRequest -import com.acon.acon.data.dto.request.ReplaceVerifiedAreaRequest -import com.acon.acon.data.dto.request.SaveSpotRequest -import com.acon.acon.data.dto.response.area.VerifiedAreaListResponse -import retrofit2.Response -import javax.inject.Inject - -class ProfileRemoteDataSource @Inject constructor( - private val profileAuthApi: ProfileAuthApi -) { - suspend fun fetchProfile(): ProfileResponse { - return profileAuthApi.fetchProfile() - } - - suspend fun getPreSignedUrl(): PreSignedUrlResponse { - return profileAuthApi.getPreSignedUrl() - } - - suspend fun validateNickname(nickname: String): Response { - return profileAuthApi.validateNickname(nickname) - } - - suspend fun updateProfile(fileName: String, nickname: String, birthday: String?): Response { - return profileAuthApi.updateProfile( - request = UpdateProfileRequest(profileImage = fileName, nickname = nickname, birthDate = formatBirthday(birthday)) - ) - } - - suspend fun fetchSavedSpots() = profileAuthApi.fetchSavedSpots() - suspend fun saveSpot(saveSpotRequest: SaveSpotRequest) = profileAuthApi.saveSpot(saveSpotRequest) - - suspend fun verifyArea( - latitude: Double, - longitude: Double - ) { - return profileAuthApi.verifyArea( - request = AreaVerificationRequest( - latitude = latitude, - longitude = longitude - ) - ) - } - - suspend fun fetchVerifiedAreaList() : VerifiedAreaListResponse { - return profileAuthApi.fetchVerifiedAreaList() - } - - suspend fun replaceVerifiedArea( - previousVerifiedAreaId: Long, - latitude: Double, - longitude: Double - ) { - return profileAuthApi.replaceVerifiedArea( - request = ReplaceVerifiedAreaRequest( - previousVerifiedAreaId = previousVerifiedAreaId, - latitude = latitude, - longitude = longitude - ) - ) - } - - suspend fun deleteVerifiedArea(verifiedAreaId: Long) { - return profileAuthApi.deleteVerifiedArea(verifiedAreaId) - } -} - -private fun formatBirthday(birthday: String?): String? { - return birthday?.let { - "${it.substring(0, 4)}.${it.substring(4, 6)}.${it.substring(6, 8)}" - } -} diff --git a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/TokenRemoteDataSource.kt b/data/src/main/kotlin/com/acon/acon/data/datasource/remote/TokenRemoteDataSource.kt deleted file mode 100644 index 2ff9b4a40..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/datasource/remote/TokenRemoteDataSource.kt +++ /dev/null @@ -1,131 +0,0 @@ -package com.acon.acon.data.datasource.remote - -import android.content.Context -import android.os.Build -import androidx.credentials.CredentialManager -import androidx.credentials.CustomCredential -import androidx.credentials.GetCredentialRequest -import androidx.credentials.PasswordCredential -import androidx.credentials.PublicKeyCredential -import androidx.credentials.exceptions.GetCredentialCancellationException -import androidx.credentials.exceptions.NoCredentialException -import com.acon.acon.data.BuildConfig -import com.acon.acon.data.error.ErrorMessages -import com.acon.acon.domain.error.user.CredentialException -import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption -import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential -import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException -import dagger.hilt.android.qualifiers.ActivityContext -import timber.log.Timber -import java.security.MessageDigest -import java.util.UUID -import javax.inject.Inject - -class TokenRemoteDataSource @Inject constructor( - @ActivityContext private val context: Context -) { - - private val rawNounce = UUID.randomUUID().toString() - private val bytes = rawNounce.toByteArray() - private val md = MessageDigest.getInstance("SHA-256") - private val digest = md.digest(bytes) - private val hashedNonce = digest.fold("") { str, it -> str + "%02x".format(it) } - - private val googleIdOption: GetSignInWithGoogleOption = - GetSignInWithGoogleOption.Builder(BuildConfig.GOOGLE_CLIENT_ID) - .build() - - private val credentialManager: CredentialManager by lazy { - CredentialManager.create(context) - } - - suspend fun googleSignIn(): Result = runCatching { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - credentialManager.prepareGetCredential( - GetCredentialRequest( - listOf(googleIdOption) - ) - ) - } - - val request = GetCredentialRequest.Builder() - .addCredentialOption(googleIdOption) - .build() - - credentialManager.getCredential( - request = request, - context = context - ) - }.fold( - onSuccess = { response -> - Timber.tag(TAG).d("Received credential response: $response") - - when (val credential = response.credential) { - is CustomCredential -> { - Timber.tag(TAG) - .d("Credential is CustomCredential. Type: ${credential.type}") - if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) { - val idToken = GoogleIdTokenCredential.createFrom(credential.data).idToken - Timber.tag(TAG).d("Successfully parsed idToken") - Result.success(idToken) - } else { - Timber.tag(TAG).e("Unknown credential type") - throw IllegalStateException(ErrorMessages.UNKNOWN_CREDENTIAL_TYPE) - } - } - - is PublicKeyCredential -> { - Timber.tag(TAG).e("Credential is PublicKeyCredential. Unsupported.") - throw IllegalStateException(ErrorMessages.UNSUPPORTED_CREDENTIAL_TYPE) - } - - is PasswordCredential -> { - Timber.tag(TAG).e("Credential is PasswordCredential. Unsupported.") - throw IllegalStateException(ErrorMessages.UNSUPPORTED_CREDENTIAL_TYPE) - } - - else -> { - Timber.tag(TAG) - .e("Unknown credential class: ${credential::class.java}") - throw IllegalStateException(ErrorMessages.UNKNOWN_CREDENTIAL_TYPE) - } - } - }, - onFailure = { e -> - Timber.tag(TAG).e(e, "GoogleLogin failed: ${e.message}") - - Result.failure( - when (e) { - is GetCredentialCancellationException -> { - Timber.tag(TAG).e("User cancelled login") - CredentialException.UserCanceled(ErrorMessages.USER_CANCELED) - } - - is NoCredentialException -> { - Timber.tag(TAG).e("No stored credentials available") - CredentialException.NoStoredCredentials(ErrorMessages.NO_CREDENTIAL_AVAILABLE) - } - - is GoogleIdTokenParsingException -> { - Timber.tag(TAG).e(e, "GoogleIdTokenParsingException") - e - } - - is SecurityException -> { - Timber.tag(TAG).e(e, "SecurityException") - e - } - - else -> { - Timber.tag(TAG).e(e, "Unknown error occurred") - IllegalStateException(ErrorMessages.UNKNOWN_ERROR) - } - } - ) - } - ) - - companion object { - const val TAG = "GoogleLogin" - } -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/di/CacheModule.kt b/data/src/main/kotlin/com/acon/acon/data/di/CacheModule.kt deleted file mode 100644 index 9839b7cb6..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/di/CacheModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.acon.acon.data.di - -import com.acon.acon.core.common.IODispatcher -import com.acon.acon.data.cache.ProfileInfoCache -import com.acon.acon.data.datasource.remote.ProfileRemoteDataSource -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object CacheModule { - - @Singleton - @Provides - fun providesProfileInfoCache( - @IODispatcher scope: CoroutineScope, - profileRemoteDataSource: ProfileRemoteDataSource - ) = ProfileInfoCache(scope, profileRemoteDataSource) -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/di/SocialRepositoryModule.kt b/data/src/main/kotlin/com/acon/acon/data/di/SocialRepositoryModule.kt deleted file mode 100644 index fff36fb19..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/di/SocialRepositoryModule.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.acon.acon.data.di - -import com.acon.acon.data.repository.SocialRepositoryImpl -import com.acon.acon.domain.repository.SocialRepository -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.android.scopes.ActivityScoped - -@Module -@InstallIn(ActivityComponent::class) -abstract class SocialRepositoryModule { - @Binds - @ActivityScoped - abstract fun bindGoogleSignInRepository( - impl: SocialRepositoryImpl - ): SocialRepository -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/request/UpdateProfileRequest.kt b/data/src/main/kotlin/com/acon/acon/data/dto/request/UpdateProfileRequest.kt deleted file mode 100644 index 773fa2ca1..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/dto/request/UpdateProfileRequest.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.acon.acon.data.dto.request - -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class UpdateProfileRequest @OptIn(ExperimentalSerializationApi::class) constructor( - @SerialName("profileImage") val profileImage: String? = null, - @SerialName("nickname") val nickname: String, - @SerialName("birthDate") @EncodeDefault(EncodeDefault.Mode.NEVER) val birthDate: String? = null -) \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/profile/ProfileResponse.kt b/data/src/main/kotlin/com/acon/acon/data/dto/response/profile/ProfileResponse.kt deleted file mode 100644 index 67a2b09b3..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/profile/ProfileResponse.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.acon.acon.data.dto.response.profile - -import com.acon.acon.core.model.model.profile.ProfileInfo -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class ProfileResponse( - @SerialName("profileImage") val image: String, - @SerialName("nickname") val nickname: String, - @SerialName("birthDate") val birthDate: String? = null, - @SerialName("savedSpotList") val savedSpotList: List, -) { - fun toProfile() = com.acon.acon.core.model.model.profile.ProfileInfo( - image = image, - nickname = nickname, - birthDate = birthDate, - savedSpots = savedSpotList.map { it.toSavedSpot() } - ) -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/dto/response/profile/SavedSpotsResponse.kt b/data/src/main/kotlin/com/acon/acon/data/dto/response/profile/SavedSpotsResponse.kt deleted file mode 100644 index 574a9c47b..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/dto/response/profile/SavedSpotsResponse.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.acon.acon.data.dto.response.profile - -import com.acon.acon.core.model.model.profile.SavedSpot -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class SavedSpotsResponse( - @SerialName("savedSpotList") val savedSpotResponseList: List? -) - -@Serializable -data class SavedSpotResponse( - @SerialName("spotId") val spotId: Long?, - @SerialName("name") val name: String?, - @SerialName("image") val image: String? -) { - - fun toSavedSpot() = com.acon.acon.core.model.model.profile.SavedSpot( - spotId = spotId ?: 0L, - name = name.orEmpty(), - image = image.orEmpty() - ) -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/error/ErrorMessages.kt b/data/src/main/kotlin/com/acon/acon/data/error/ErrorMessages.kt deleted file mode 100644 index 7b057a1a3..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/error/ErrorMessages.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.acon.acon.data.error - -object ErrorMessages { - const val USER_CANCELED = "사용자가 로그인 과정을 취소했습니다." - const val UNKNOWN_CREDENTIAL_TYPE = "지원되지 않거나 알 수 없는 인증 유형입니다." - const val UNKNOWN_ERROR = "로그인 과정에서 알 수 없는 오류가 발생했습니다." - const val UNSUPPORTED_CREDENTIAL_TYPE = "지원되지 않는 사용자 인증 유형입니다." - const val NO_CREDENTIAL_AVAILABLE = "사용 가능한 사용자 인증 정보가 없습니다." -} diff --git a/data/src/main/kotlin/com/acon/acon/data/repository/AconAppRepositoryImpl.kt b/data/src/main/kotlin/com/acon/acon/data/repository/AconAppRepositoryImpl.kt deleted file mode 100644 index 51fe14662..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/repository/AconAppRepositoryImpl.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.acon.acon.data.repository - -import com.acon.acon.data.datasource.remote.AconAppRemoteDataSource -import com.acon.acon.data.error.runCatchingWith -import com.acon.acon.domain.error.app.FetchShouldUpdateError -import com.acon.acon.domain.repository.AconAppRepository -import javax.inject.Inject - -class AconAppRepositoryImpl @Inject constructor( - private val aconAppRemoteDataSource: AconAppRemoteDataSource -) : AconAppRepository { - - override suspend fun shouldUpdateApp(currentVersion: String): Result { - return runCatchingWith(FetchShouldUpdateError()) { - aconAppRemoteDataSource.fetchShouldUpdateApp(currentVersion).shouldUpdate ?: false - } - } -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/repository/OnboardingRepositoryImpl.kt b/data/src/main/kotlin/com/acon/acon/data/repository/OnboardingRepositoryImpl.kt deleted file mode 100644 index aed15f717..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/repository/OnboardingRepositoryImpl.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.acon.acon.data.repository - -import com.acon.acon.data.datasource.remote.OnboardingRemoteDataSource -import com.acon.acon.data.dto.request.OnboardingRequest -import com.acon.acon.data.error.runCatchingWith -import com.acon.acon.domain.error.onboarding.PostOnboardingResultError -import com.acon.acon.domain.repository.OnboardingRepository -import com.acon.acon.core.model.type.FoodType -import com.acon.acon.data.datasource.local.OnboardingLocalDataSource -import com.acon.acon.domain.repository.UserRepository -import javax.inject.Inject - -class OnboardingRepositoryImpl @Inject constructor( - private val onboardingRemoteDataSource: OnboardingRemoteDataSource, - private val onboardingLocalDataSource: OnboardingLocalDataSource, - private val userRepository: UserRepository -) : OnboardingRepository { - - override suspend fun submitOnboardingResult( - dislikeFoodList: List - ): Result { - return runCatchingWith(PostOnboardingResultError()) { - val request = OnboardingRequest(dislikeFoods = dislikeFoodList.map { it.name }) - onboardingRemoteDataSource.submitOnboardingResult(request) - userRepository.saveDidOnboarding(true) - } - } - - override suspend fun saveDidOnboarding(didOnboarding: Boolean): Result { - return runCatchingWith { - onboardingLocalDataSource.saveDidOnboarding(didOnboarding) - } - } - - override suspend fun getDidOnboarding(): Result { - return runCatchingWith { - onboardingLocalDataSource.getDidOnboarding() - } - } -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/repository/ProfileRepositoryImpl.kt b/data/src/main/kotlin/com/acon/acon/data/repository/ProfileRepositoryImpl.kt deleted file mode 100644 index 5db24ee8b..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/repository/ProfileRepositoryImpl.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.acon.acon.data.repository - -import com.acon.acon.core.common.IODispatcher -import com.acon.acon.core.model.model.area.Area -import com.acon.acon.data.cache.ProfileInfoCache -import com.acon.acon.data.datasource.remote.ProfileRemoteDataSource -import com.acon.acon.data.dto.request.SaveSpotRequest -import com.acon.acon.data.error.runCatchingWith -import com.acon.acon.domain.error.profile.SaveSpotError -import com.acon.acon.domain.error.profile.ValidateNicknameError -import com.acon.acon.core.model.model.profile.PreSignedUrl -import com.acon.acon.core.model.model.profile.ProfileInfo -import com.acon.acon.core.model.model.profile.SavedSpot -import com.acon.acon.domain.repository.ProfileRepository -import com.acon.acon.core.model.type.UpdateProfileType -import com.acon.acon.domain.error.area.DeleteVerifiedAreaError -import com.acon.acon.domain.error.area.ReplaceVerifiedArea -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transform -import javax.inject.Inject - -class ProfileRepositoryImpl @Inject constructor( - @IODispatcher private val scope: CoroutineScope, - private val profileRemoteDataSource: ProfileRemoteDataSource, - private val profileInfoCache: ProfileInfoCache -) : ProfileRepository { - - override fun fetchProfile(): Flow> { - return flow { - emit(runCatchingWith { - profileRemoteDataSource.fetchProfile().toProfile() - }) - } - return profileInfoCache.data - } - - override suspend fun getPreSignedUrl(): Result { - return runCatchingWith() { - profileRemoteDataSource.getPreSignedUrl().toPreSignedUrl() - } - } - - override suspend fun validateNickname(nickname: String): Result { - return runCatchingWith(ValidateNicknameError()) { - profileRemoteDataSource.validateNickname(nickname) - } - } - - override suspend fun updateProfile(fileName: String, nickname: String, birthday: String?, uri: String): Result { - return runCatchingWith() { - profileRemoteDataSource.updateProfile(fileName, nickname, birthday) - profileInfoCache.updateData( - ProfileInfo( - nickname = nickname, - birthDate = birthday, - image = uri, - savedSpots = profileInfoCache.data.value.getOrNull()?.savedSpots.orEmpty() - ) - ) - } - } - - private val _updateProfileType = MutableStateFlow(UpdateProfileType.IDLE) - private val updateProfileType = flow { - emitAll(_updateProfileType) - }.stateIn( - scope = scope, - started = SharingStarted.Lazily, - initialValue = UpdateProfileType.IDLE - ) - - override fun updateProfileType(type: UpdateProfileType) { - _updateProfileType.value = type - } - - override fun getProfileType(): Flow { - return updateProfileType - } - - override suspend fun resetProfileType() { - _updateProfileType.emit(UpdateProfileType.IDLE) - } - - override suspend fun fetchSavedSpots(): Result> { - return runCatchingWith() { - profileRemoteDataSource.fetchSavedSpots().savedSpotResponseList?.map { - it.toSavedSpot() - }.orEmpty() - } - } - - override suspend fun saveSpot(spotId: Long): Result { - return runCatchingWith(SaveSpotError()) { - profileRemoteDataSource.saveSpot(SaveSpotRequest(spotId)) - } - } - - override suspend fun verifyArea( - latitude: Double, - longitude: Double - ): Result = runCatchingWith() { - // TODO - 동네인증 API Error 처리 안됨 - profileRemoteDataSource.verifyArea( - latitude = latitude, - longitude = longitude - ) - } - - override suspend fun fetchVerifiedAreaList(): Result> { - // TODO - 인증 지역 조회 API Error 처리 안됨 - return runCatchingWith() { - profileRemoteDataSource.fetchVerifiedAreaList().verifiedAreaList - .map { it.toVerifiedArea() } - } - } - - override suspend fun replaceVerifiedArea( - previousVerifiedAreaId: Long, - latitude: Double, - longitude: Double - ): Result { - return runCatchingWith(ReplaceVerifiedArea()) { - profileRemoteDataSource.replaceVerifiedArea( - previousVerifiedAreaId = previousVerifiedAreaId, - latitude = latitude, - longitude = longitude - ) - } - } - - override suspend fun deleteVerifiedArea(verifiedAreaId: Long): Result { - return runCatchingWith(DeleteVerifiedAreaError()) { - profileRemoteDataSource.deleteVerifiedArea(verifiedAreaId) - } - } -} \ No newline at end of file diff --git a/data/src/main/kotlin/com/acon/acon/data/repository/SocialRepositoryImpl.kt b/data/src/main/kotlin/com/acon/acon/data/repository/SocialRepositoryImpl.kt deleted file mode 100644 index 7fe9a9b09..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/repository/SocialRepositoryImpl.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.acon.acon.data.repository - -import com.acon.acon.data.datasource.remote.TokenRemoteDataSource -import com.acon.acon.data.error.runCatchingWith -import com.acon.acon.domain.error.user.PostSignInError -import com.acon.acon.core.model.model.user.VerificationStatus -import com.acon.acon.domain.repository.SocialRepository -import com.acon.acon.domain.repository.UserRepository -import com.acon.acon.core.model.type.SocialType -import javax.inject.Inject - -class SocialRepositoryImpl @Inject constructor( - private val tokenRemoteDataSource: TokenRemoteDataSource, - private val userRepository: UserRepository, -) : SocialRepository { - override suspend fun googleSignIn(): Result { - return runCatchingWith(PostSignInError()) { - val idToken = tokenRemoteDataSource.googleSignIn().getOrThrow() - - userRepository.signIn( - socialType = SocialType.GOOGLE, idToken = idToken - ).getOrThrow() - } - } -} diff --git a/data/src/main/kotlin/com/acon/acon/data/repository/UserRepositoryImpl.kt b/data/src/main/kotlin/com/acon/acon/data/repository/UserRepositoryImpl.kt deleted file mode 100644 index 9c5a64ff7..000000000 --- a/data/src/main/kotlin/com/acon/acon/data/repository/UserRepositoryImpl.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.acon.acon.data.repository - -import com.acon.acon.core.model.model.user.VerificationStatus -import com.acon.acon.core.model.type.SocialType -import com.acon.acon.data.cache.ProfileInfoCache -import com.acon.acon.data.session.SessionHandler -import com.acon.acon.data.datasource.local.TokenLocalDataSource -import com.acon.acon.data.datasource.local.UserLocalDataSource -import com.acon.acon.data.datasource.remote.UserRemoteDataSource -import com.acon.acon.data.dto.request.DeleteAccountRequest -import com.acon.acon.data.dto.request.SignInRequest -import com.acon.acon.data.dto.request.SignOutRequest -import com.acon.acon.data.error.runCatchingWith -import com.acon.acon.domain.error.user.PostSignOutError -import com.acon.acon.domain.error.user.PostSignInError -import com.acon.acon.domain.repository.UserRepository -import javax.inject.Inject - -class UserRepositoryImpl @Inject constructor( - private val userRemoteDataSource: UserRemoteDataSource, - private val tokenLocalDataSource: TokenLocalDataSource, - private val userLocalDataSource: UserLocalDataSource, - private val sessionHandler: SessionHandler, - private val profileInfoCache: ProfileInfoCache -) : UserRepository { - - override fun getUserType() = sessionHandler.getUserType() - - override suspend fun signIn( - socialType: SocialType, - idToken: String - ): Result { - return runCatchingWith(PostSignInError()) { - val signInResponse = userRemoteDataSource.signIn( - SignInRequest( - socialType = socialType, - idToken = idToken - ) - ) - - sessionHandler.completeSignIn( - signInResponse.accessToken.orEmpty(), - signInResponse.refreshToken.orEmpty() - ) - saveDidOnboarding(signInResponse.toVerificationStatus().hasPreference) - - signInResponse.toVerificationStatus() - } - } - - override suspend fun signOut(): Result { - val refreshToken = tokenLocalDataSource.getRefreshToken() ?: "" - return runCatchingWith(PostSignOutError()) { - userRemoteDataSource.signOut( - SignOutRequest(refreshToken = refreshToken) - ) - }.onSuccess { - saveDidOnboarding(false) - clearSession() - } - } - - override suspend fun deleteAccount(reason: String): Result { - val refreshToken = tokenLocalDataSource.getRefreshToken() ?: "" - return runCatchingWith { - userRemoteDataSource.deleteAccount( - DeleteAccountRequest( - reason = reason, - refreshToken = refreshToken - ) - ) - }.onSuccess { - saveDidOnboarding(false) - clearSession() - } - } - - override suspend fun saveDidOnboarding(didOnboarding: Boolean): Result { - return runCatchingWith { - userLocalDataSource.saveDidOnboarding(didOnboarding) - } - } - - override suspend fun getDidOnboarding(): Result { - return runCatchingWith { - userLocalDataSource.getDidOnboarding() - } - } - - override suspend fun clearSession() = runCatchingWith { - profileInfoCache.clearData() - sessionHandler.clearSession() - } -} \ No newline at end of file diff --git a/data/src/test/java/com/acon/acon/data/repository/ProfileRepositoryImplTest.kt b/data/src/test/java/com/acon/acon/data/repository/ProfileRepositoryImplTest.kt deleted file mode 100644 index cb0d07d1f..000000000 --- a/data/src/test/java/com/acon/acon/data/repository/ProfileRepositoryImplTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.acon.acon.data.repository - -import com.acon.acon.data.assertValidErrorMapping -import com.acon.acon.data.cache.ProfileInfoCache -import com.acon.acon.data.createErrorStream -import com.acon.acon.data.createFakeRemoteError -import com.acon.acon.data.datasource.remote.ProfileRemoteDataSource -import com.acon.acon.domain.error.area.DeleteVerifiedAreaError -import com.acon.acon.domain.error.area.ReplaceVerifiedArea -import com.acon.acon.domain.error.profile.SaveSpotError -import com.acon.acon.domain.error.profile.ValidateNicknameError -import io.mockk.coEvery -import io.mockk.impl.annotations.RelaxedMockK -import io.mockk.junit5.MockKExtension -import kotlinx.coroutines.cancel -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.MethodSource -import kotlin.reflect.KClass - -@ExtendWith(MockKExtension::class) -class ProfileRepositoryImplTest { - - @RelaxedMockK - lateinit var profileRemoteDataSource: ProfileRemoteDataSource - - @RelaxedMockK - lateinit var profileInfoCache: ProfileInfoCache - - private lateinit var testScope: TestScope - - lateinit var profileRepositoryImpl: ProfileRepositoryImpl - - companion object { - @JvmStatic - fun validNicknameErrorScenarios() = createErrorStream( - 40051 to ValidateNicknameError.UnsatisfiedCondition::class, - 40901 to ValidateNicknameError.AlreadyUsedNickname::class - ) - @JvmStatic - fun saveSpotErrorScenarios() = createErrorStream( - 40403 to SaveSpotError.NotExistSpot::class - ) - @JvmStatic - fun replaceVerifiedAreaErrorScenarios() = createErrorStream( - 40012 to ReplaceVerifiedArea.OutOfServiceAreaError::class, - 40054 to ReplaceVerifiedArea.InvalidVerifiedArea::class, - 40055 to ReplaceVerifiedArea.PeriodRestrictedDeleteError::class, - 40056 to ReplaceVerifiedArea.MultiLocationReplaceError::class, - 40404 to ReplaceVerifiedArea.VerifiedAreaNotFound::class - ) - @JvmStatic - fun deleteVerifiedAreaErrorScenarios() = createErrorStream( - 40054 to DeleteVerifiedAreaError.InvalidVerifiedArea::class, - 40032 to DeleteVerifiedAreaError.VerifiedAreaLimitViolation::class, - 40055 to DeleteVerifiedAreaError.PeriodRestrictedDeleteError::class, - 40404 to DeleteVerifiedAreaError.VerifiedAreaNotFound::class - ) - } - - @BeforeEach - fun setUp() { - testScope = TestScope() - profileRepositoryImpl = ProfileRepositoryImpl(testScope, profileRemoteDataSource, profileInfoCache) - } - - @AfterEach - fun tearDown() { - testScope.cancel() - } - - @ParameterizedTest - @MethodSource("validNicknameErrorScenarios") - fun `닉네임 유효성 검사 API 실패 시 에러 객체를 반환한다`( - errorCode: Int, - expectedErrorClass: KClass - ) = runTest { - // Given - val fakeRemoteError = createFakeRemoteError(errorCode) - coEvery { profileRemoteDataSource.validateNickname(any()) } throws fakeRemoteError - - // When - val result = profileRepositoryImpl.validateNickname("") - - // Then - assertValidErrorMapping(result, expectedErrorClass) - } - - @ParameterizedTest - @MethodSource("saveSpotErrorScenarios") - fun `장소 저장 API 실패 시 에러 객체를 반환한다`( - errorCode: Int, - expectedErrorClass: KClass - ) = runTest { - // Given - val fakeRemoteError = createFakeRemoteError(errorCode) - coEvery { profileRemoteDataSource.saveSpot(any()) } throws fakeRemoteError - - // When - val result = profileRepositoryImpl.saveSpot(0) - - // Then - assertValidErrorMapping(result, expectedErrorClass) - } - - @ParameterizedTest - @MethodSource("replaceVerifiedAreaErrorScenarios") - fun `인증 지역 변경 API 실패 시 에러 객체를 반환한다`( - errorCode: Int, - expectedErrorClass: KClass - ) = runTest { - // Given - val fakeRemoteError = createFakeRemoteError(errorCode) - coEvery { profileRemoteDataSource.replaceVerifiedArea(any(), any(), any()) } throws fakeRemoteError - - // When - val result = profileRepositoryImpl.replaceVerifiedArea(0, .0, .0) - - // Then - assertValidErrorMapping(result, expectedErrorClass) - } - - @ParameterizedTest - @MethodSource("deleteVerifiedAreaErrorScenarios") - fun `인증 지역 삭제 API 실패 시 에러 객체를 반환한다`( - errorCode: Int, - expectedErrorClass: KClass - ) = runTest { - // Given - val fakeRemoteError = createFakeRemoteError(errorCode) - coEvery { profileRemoteDataSource.deleteVerifiedArea(any()) } throws fakeRemoteError - - // When - val result = profileRepositoryImpl.deleteVerifiedArea(0) - - // Then - assertValidErrorMapping(result, expectedErrorClass) - } -} \ No newline at end of file diff --git a/data/src/test/java/com/acon/acon/data/repository/UserRepositoryImplTest.kt b/data/src/test/java/com/acon/acon/data/repository/UserRepositoryImplTest.kt deleted file mode 100644 index 12d19beb1..000000000 --- a/data/src/test/java/com/acon/acon/data/repository/UserRepositoryImplTest.kt +++ /dev/null @@ -1,187 +0,0 @@ -package com.acon.acon.data.repository - -import com.acon.acon.data.session.SessionHandler -import com.acon.acon.data.datasource.local.TokenLocalDataSource -import com.acon.acon.data.datasource.remote.UserRemoteDataSource -import com.acon.acon.data.dto.response.SignInResponse -import com.acon.acon.data.error.RemoteError -import com.acon.acon.domain.error.user.PostSignInError -import com.acon.acon.domain.error.user.PostSignOutError -import com.acon.acon.domain.repository.UserRepository -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.impl.annotations.RelaxedMockK -import io.mockk.junit5.MockKExtension -import io.mockk.mockk -import kotlinx.coroutines.cancel -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertInstanceOf -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.Arguments -import org.junit.jupiter.params.provider.MethodSource -import java.util.stream.Stream -import kotlin.reflect.KClass -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -@ExtendWith(MockKExtension::class) -class UserRepositoryImplTest { - - @RelaxedMockK - lateinit var userRemoteDataSource: UserRemoteDataSource - - @RelaxedMockK - lateinit var tokenLocalDataSource: TokenLocalDataSource - - @RelaxedMockK - lateinit var sessionHandler: SessionHandler - - private lateinit var testScope: TestScope - - private lateinit var userRepository: UserRepository - - companion object { - @JvmStatic - fun postSignInErrorScenarios(): Stream = Stream.of( - Arguments.of(40009, PostSignInError.InvalidSocialType::class), - Arguments.of(40010, PostSignInError.InvalidIdTokenSignature::class), - Arguments.of(50002, PostSignInError.GooglePublicKeyDownloadFailed::class) - ) - - @JvmStatic - fun postSignOutErrorScenarios(): Stream = Stream.of( - Arguments.of(40088, PostSignOutError.InvalidRefreshToken::class) - ) - } - - @BeforeEach - fun setUp() { - testScope = TestScope() - userRepository = UserRepositoryImpl(userRemoteDataSource, tokenLocalDataSource, sessionHandler) - } - - @AfterEach - fun tearDown() { - testScope.cancel() - } - - @Test - fun `유저 상태는 SessionHandler로 부터 넘겨 받는다`() { - // When - userRepository.getUserType() - - // Then - coVerify(exactly = 1) { sessionHandler.getUserType() } - } - - @Test - fun `로그인 API 성공 시, 로그인 세션 정보를 반영한다`() = runTest { - // Given - val signInResponse = SignInResponse( - externalUUID = "Dummy UUID", - accessToken = "New Access Token", - refreshToken = "New Refresh Token", - hasVerifiedArea = true - ) - coEvery { userRemoteDataSource.signIn(any()) } returns signInResponse - - // When - userRepository.signIn(mockk(), "Dummy Token") - - // Then - assertNotNull(signInResponse.accessToken) - assertNotNull(signInResponse.refreshToken) - coVerify(exactly = 1) { sessionHandler.completeSignIn(signInResponse.accessToken!!, signInResponse.refreshToken!!) } - } - - @ParameterizedTest - @MethodSource("postSignInErrorScenarios") - fun `로그인 API 실패 시, 에러 코드에 대응되는 올바른 로그인 실패 예외 객체를 반환한다`( - errorCode: Int, - expectedErrorClass: KClass - ) = runTest { - // Given - val fakeRemoteError = RemoteError(mockk(relaxed = true), errorCode, "") - coEvery { userRemoteDataSource.signIn(any()) } throws fakeRemoteError - - // When - val result = userRepository.signIn(mockk(), "Dummy Token") - - // Then - assertTrue(result.isFailure) - val exception = result.exceptionOrNull() - assertInstanceOf(expectedErrorClass.java, exception, "에러 코드와 예외 클래스가 올바르게 매핑되지 않음") - } - - @Test - fun `로그아웃 API 성공 시 세션을 초기화한다`() = runTest { - // Given - coEvery { userRemoteDataSource.signOut(any()) } returns mockk() - - // When - userRepository.signOut() - - // Then - coVerify(exactly = 1) { userRepository.clearSession() } - } - - @ParameterizedTest - @MethodSource("postSignOutErrorScenarios") - fun `로그아웃 API 실패 시, 에러 코드에 대응되는 올바른 로그아웃 실패 예외 객체를 반환한다`( - errorCode: Int, - expectedErrorClass: KClass - ) = runTest { - // Given - val fakeRemoteError = RemoteError(mockk(relaxed = true), errorCode, "") - coEvery { userRemoteDataSource.signOut(any()) } throws fakeRemoteError - - // When - val result = userRepository.signOut() - - // Then - assertTrue(result.isFailure) - val exception = result.exceptionOrNull() - assertInstanceOf(expectedErrorClass.java, exception, "에러 코드와 예외 클래스가 올바르게 매핑되지 않음") - } - - @Test - fun `로그아웃 API 실패 시 세션을 초기화하지 않는다`() = runTest { - // Given - coEvery { userRemoteDataSource.signOut(any()) } throws mockk() - - // When - userRepository.signOut() - - // Then - coVerify(exactly = 0) { userRepository.clearSession() } - } - - @Test - fun `회원탈퇴 성공 시 세션을 초기화한다`() = runTest { - // Given - coEvery { userRemoteDataSource.deleteAccount(any()) } returns mockk() - - // When - userRepository.deleteAccount("Dummy Reason") - - // Then - coVerify(exactly = 1) { userRepository.clearSession() } - } - - @Test - fun `회원탈퇴 실패 시 세션을 초기화하지 않는다`() = runTest { - // Given - coEvery { userRemoteDataSource.deleteAccount(any()) } throws mockk() - - // When - userRepository.deleteAccount("Dummy Reason") - - // Then - coVerify(exactly = 0) { userRepository.clearSession() } - } -} \ No newline at end of file diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index b2c0b376e..6c0273c1d 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.acon.non.android.library) + alias(libs.plugins.acon.common.unit.test) } dependencies { @@ -7,6 +8,8 @@ dependencies { implementation(libs.javax.inject) implementation(libs.kotlinx.coroutines.core) +} - testImplementation(libs.bundles.non.android.test) +tasks.withType { + useJUnitPlatform() } \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/error/Constants.kt b/domain/src/main/java/com/acon/acon/domain/error/Constants.kt new file mode 100644 index 000000000..33db3af7c --- /dev/null +++ b/domain/src/main/java/com/acon/acon/domain/error/Constants.kt @@ -0,0 +1,8 @@ +package com.acon.acon.domain.error + +/** + * 서버에서 Error Code가 아직 명시되지 않았을 경우 사용 + * + * 최종 릴리즈 전에는 걷어내고 올바른 Error Code를 명시해주어야 합니다. + */ +const val UNSPECIFIED_SERVER_ERROR_CODE = -1 \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/error/onboarding/PostOnboardingResultError.kt b/domain/src/main/java/com/acon/acon/domain/error/onboarding/PostTastePreferenceResultError.kt similarity index 69% rename from domain/src/main/java/com/acon/acon/domain/error/onboarding/PostOnboardingResultError.kt rename to domain/src/main/java/com/acon/acon/domain/error/onboarding/PostTastePreferenceResultError.kt index c7efb81f8..0f8feef61 100644 --- a/domain/src/main/java/com/acon/acon/domain/error/onboarding/PostOnboardingResultError.kt +++ b/domain/src/main/java/com/acon/acon/domain/error/onboarding/PostTastePreferenceResultError.kt @@ -2,9 +2,9 @@ package com.acon.acon.domain.error.onboarding import com.acon.acon.domain.error.RootError -open class PostOnboardingResultError : RootError() { +open class PostTastePreferenceResultError : RootError() { - class InvalidDislikeFood : PostOnboardingResultError() { + class InvalidDislikeFood : PostTastePreferenceResultError() { override val code: Int = 40013 } diff --git a/domain/src/main/java/com/acon/acon/domain/error/onboarding/VerifyAreaError.kt b/domain/src/main/java/com/acon/acon/domain/error/onboarding/VerifyAreaError.kt new file mode 100644 index 000000000..69c9aa91b --- /dev/null +++ b/domain/src/main/java/com/acon/acon/domain/error/onboarding/VerifyAreaError.kt @@ -0,0 +1,21 @@ +package com.acon.acon.domain.error.onboarding + +import com.acon.acon.domain.error.RootError + +open class VerifyAreaError : RootError() { + + class OutOfServiceArea : VerifyAreaError() { + override val code: Int = 40012 + } + + class ExceededVerifiedAreaLimit : VerifyAreaError() { + override val code: Int = 40032 + } + + final override fun createErrorInstances(): Array { + return arrayOf( + OutOfServiceArea(), + ExceededVerifiedAreaLimit() + ) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/error/profile/UpdateProfileError.kt b/domain/src/main/java/com/acon/acon/domain/error/profile/UpdateProfileError.kt new file mode 100644 index 000000000..725a57bc1 --- /dev/null +++ b/domain/src/main/java/com/acon/acon/domain/error/profile/UpdateProfileError.kt @@ -0,0 +1,36 @@ +package com.acon.acon.domain.error.profile + +import com.acon.acon.domain.error.RootError + +open class UpdateProfileError : RootError() { + + class AlreadyExistNickname : UpdateProfileError() { + override val code: Int = 40901 + } + class InvalidNicknameFormat : UpdateProfileError() { + override val code: Int = 40051 + } + class InvalidBirthDateFormat : UpdateProfileError() { + override val code: Int = 40053 + } + class InvalidBucketImagePath : UpdateProfileError() { + override val code: Int = 40052 + } + class InvalidImageType: UpdateProfileError() { + override val code: Int = 40045 + } + class InternalServerError: UpdateProfileError() { + override val code: Int = 50005 + } + + final override fun createErrorInstances(): Array { + return arrayOf( + AlreadyExistNickname(), + InvalidNicknameFormat(), + InvalidBirthDateFormat(), + InvalidBucketImagePath(), + InvalidImageType(), + InternalServerError() + ) + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateBirthDateError.kt b/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateBirthDateError.kt new file mode 100644 index 000000000..862a39a50 --- /dev/null +++ b/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateBirthDateError.kt @@ -0,0 +1,16 @@ +package com.acon.acon.domain.error.profile + +import com.acon.acon.domain.error.RootError + +open class ValidateBirthDateError : RootError() { + + class InputIsFuture : ValidateBirthDateError() + class InputIsTooPast : ValidateBirthDateError() + class InvalidFormat : ValidateBirthDateError() { + override val code = 40053 + } + + override fun createErrorInstances(): Array { + return arrayOf() + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateNicknameError.kt b/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateNicknameError.kt index 357e892cf..9dde90f28 100644 --- a/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateNicknameError.kt +++ b/domain/src/main/java/com/acon/acon/domain/error/profile/ValidateNicknameError.kt @@ -4,17 +4,19 @@ import com.acon.acon.domain.error.RootError open class ValidateNicknameError : RootError() { - class UnsatisfiedCondition : ValidateNicknameError() { + class EmptyInput : ValidateNicknameError() + class InputLengthExceeded : ValidateNicknameError() + class InvalidFormat : ValidateNicknameError() { override val code: Int = 40051 } - class AlreadyUsedNickname : ValidateNicknameError() { + class AlreadyExist : ValidateNicknameError() { override val code: Int = 40901 } final override fun createErrorInstances(): Array { return arrayOf( - UnsatisfiedCondition(), - AlreadyUsedNickname() + InvalidFormat(), + AlreadyExist() ) } } \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/repository/AconAppRepository.kt b/domain/src/main/java/com/acon/acon/domain/repository/AconAppRepository.kt index 629bf7eec..0d8b93967 100644 --- a/domain/src/main/java/com/acon/acon/domain/repository/AconAppRepository.kt +++ b/domain/src/main/java/com/acon/acon/domain/repository/AconAppRepository.kt @@ -1,5 +1,17 @@ package com.acon.acon.domain.repository +import com.acon.acon.core.model.type.ImageType + interface AconAppRepository { suspend fun shouldUpdateApp(currentVersion: String): Result + + /** + * 이미지를 서버에 업로드 + * + * @param imageType 업로드할 이미지 유형 + * @param url 이미지 파일의 로컬 경로 + * @return 업로드된 이미지의 최종 URL + * @see ImageType + */ + suspend fun uploadImage(imageType: ImageType, url: String): Result } \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/repository/OnboardingRepository.kt b/domain/src/main/java/com/acon/acon/domain/repository/OnboardingRepository.kt index 7186c52f6..82c4252f9 100644 --- a/domain/src/main/java/com/acon/acon/domain/repository/OnboardingRepository.kt +++ b/domain/src/main/java/com/acon/acon/domain/repository/OnboardingRepository.kt @@ -1,12 +1,21 @@ package com.acon.acon.domain.repository +import com.acon.acon.core.model.model.OnboardingPreferences import com.acon.acon.core.model.type.FoodType interface OnboardingRepository { - suspend fun submitOnboardingResult( - dislikeFoodList: List + suspend fun submitTastePreferenceResult( + dislikeFoods: List ): Result - suspend fun saveDidOnboarding(didOnboarding: Boolean): Result - suspend fun getDidOnboarding(): Result + suspend fun verifyArea( + latitude: Double, + longitude: Double + ): Result + + suspend fun updateShouldShowIntroduce(shouldShow: Boolean): Result + suspend fun updateShouldChooseDislikes(shouldChoose: Boolean): Result + suspend fun updateShouldVerifyArea(shouldVerify: Boolean): Result + + suspend fun getOnboardingPreferences(): Result } diff --git a/domain/src/main/java/com/acon/acon/domain/repository/ProfileRepository.kt b/domain/src/main/java/com/acon/acon/domain/repository/ProfileRepository.kt index ee251f75f..ce49030fe 100644 --- a/domain/src/main/java/com/acon/acon/domain/repository/ProfileRepository.kt +++ b/domain/src/main/java/com/acon/acon/domain/repository/ProfileRepository.kt @@ -1,43 +1,16 @@ package com.acon.acon.domain.repository import com.acon.acon.core.model.model.area.Area -import com.acon.acon.core.model.model.profile.PreSignedUrl -import com.acon.acon.core.model.model.profile.ProfileInfo +import com.acon.acon.core.model.model.profile.Profile import com.acon.acon.core.model.model.profile.SavedSpot -import com.acon.acon.core.model.type.UpdateProfileType import kotlinx.coroutines.flow.Flow interface ProfileRepository { - fun fetchProfile(): Flow> - - suspend fun getPreSignedUrl(): Result - - suspend fun validateNickname(nickname: String): Result - - suspend fun updateProfile(fileName: String, nickname: String, birthday: String?, uri: String): Result - - fun getProfileType(): Flow - - fun updateProfileType(type: UpdateProfileType) - - suspend fun resetProfileType() - - suspend fun fetchSavedSpots(): Result> - - suspend fun saveSpot(spotId: Long): Result - - suspend fun verifyArea( - latitude: Double, - longitude: Double - ): Result - - suspend fun fetchVerifiedAreaList(): Result> - - suspend fun replaceVerifiedArea( - previousVerifiedAreaId: Long, - latitude: Double, - longitude: Double - ): Result + fun getProfile() : Flow> + suspend fun updateProfile(newProfile: Profile) : Result + suspend fun validateNickname(nickname: String) : Result + suspend fun getSavedSpots() : Flow>> + fun getVerifiedAreas(): Flow>> suspend fun deleteVerifiedArea(verifiedAreaId: Long): Result } \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/repository/SocialRepository.kt b/domain/src/main/java/com/acon/acon/domain/repository/SocialRepository.kt deleted file mode 100644 index 591f33824..000000000 --- a/domain/src/main/java/com/acon/acon/domain/repository/SocialRepository.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.acon.acon.domain.repository - -import com.acon.acon.core.model.model.user.VerificationStatus - -interface SocialRepository { - suspend fun googleSignIn(): Result -} \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/repository/SpotRepository.kt b/domain/src/main/java/com/acon/acon/domain/repository/SpotRepository.kt index 75c843dcd..b7dbc1b95 100644 --- a/domain/src/main/java/com/acon/acon/domain/repository/SpotRepository.kt +++ b/domain/src/main/java/com/acon/acon/domain/repository/SpotRepository.kt @@ -1,6 +1,5 @@ package com.acon.acon.domain.repository -import com.acon.acon.core.model.model.profile.SavedSpot import com.acon.acon.core.model.model.spot.Condition import com.acon.acon.core.model.model.spot.MenuBoardList import com.acon.acon.core.model.model.spot.SpotDetail @@ -31,8 +30,6 @@ interface SpotRepository { spotId: Long ): Result - suspend fun fetchSavedSpotList(): Result> - suspend fun addBookmark( spotId: Long ): Result diff --git a/domain/src/main/java/com/acon/acon/domain/repository/UserRepository.kt b/domain/src/main/java/com/acon/acon/domain/repository/UserRepository.kt index 323854479..7ed0b2444 100644 --- a/domain/src/main/java/com/acon/acon/domain/repository/UserRepository.kt +++ b/domain/src/main/java/com/acon/acon/domain/repository/UserRepository.kt @@ -1,16 +1,15 @@ package com.acon.acon.domain.repository -import com.acon.acon.core.model.model.user.VerificationStatus -import com.acon.acon.core.model.type.SocialType -import com.acon.acon.core.model.type.UserType +import com.acon.acon.core.model.model.user.CredentialCode +import com.acon.acon.core.model.model.user.SocialPlatform +import com.acon.acon.core.model.model.user.ExternalUUID +import com.acon.acon.core.model.type.SignInStatus import kotlinx.coroutines.flow.Flow interface UserRepository { - suspend fun signIn(socialType: SocialType, idToken: String): Result + suspend fun signIn(socialType: SocialPlatform, code: CredentialCode): Result suspend fun signOut(): Result suspend fun deleteAccount(reason: String): Result suspend fun clearSession(): Result - suspend fun saveDidOnboarding(didOnboarding: Boolean): Result - suspend fun getDidOnboarding(): Result - fun getUserType(): Flow + fun getSignInStatus(): Flow } \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/usecase/ValidateBirthDateUseCase.kt b/domain/src/main/java/com/acon/acon/domain/usecase/ValidateBirthDateUseCase.kt new file mode 100644 index 000000000..4b9b45cf1 --- /dev/null +++ b/domain/src/main/java/com/acon/acon/domain/usecase/ValidateBirthDateUseCase.kt @@ -0,0 +1,18 @@ +package com.acon.acon.domain.usecase + +import com.acon.acon.domain.error.profile.ValidateBirthDateError +import java.time.LocalDate +import javax.inject.Inject + +class ValidateBirthDateUseCase @Inject constructor() { + + private val pastThreshold = LocalDate.of(1900, 1, 1) + + suspend operator fun invoke(birthDate: LocalDate) : Result { + return when { + birthDate.isAfter(LocalDate.now()) -> Result.failure(ValidateBirthDateError.InputIsFuture()) + birthDate.isBefore(pastThreshold) -> Result.failure(ValidateBirthDateError.InputIsTooPast()) + else -> Result.success(Unit) + } + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/acon/acon/domain/usecase/ValidateNicknameUseCase.kt b/domain/src/main/java/com/acon/acon/domain/usecase/ValidateNicknameUseCase.kt new file mode 100644 index 000000000..d3469d19a --- /dev/null +++ b/domain/src/main/java/com/acon/acon/domain/usecase/ValidateNicknameUseCase.kt @@ -0,0 +1,31 @@ +package com.acon.acon.domain.usecase + +import com.acon.acon.domain.error.profile.ValidateNicknameError +import com.acon.acon.domain.repository.ProfileRepository +import javax.inject.Inject + +class ValidateNicknameUseCase @Inject constructor( + private val profileRepository: ProfileRepository +) { + + private val nicknameValidationRegex by lazy { + Regex("[^a-z0-9_.]") + } + + suspend operator fun invoke(nickname: String) : Result { + return when { + nickname.isEmpty() -> Result.failure(ValidateNicknameError.EmptyInput()) + nickname.length > MAX_NICKNAME_LENGTH -> Result.failure(ValidateNicknameError.InputLengthExceeded()) + nickname.containsInvalidCharacters() -> Result.failure(ValidateNicknameError.InvalidFormat()) + else -> profileRepository.validateNickname(nickname) + } + } + + private fun String.containsInvalidCharacters(): Boolean { + return nicknameValidationRegex.containsMatchIn(this) + } + + companion object { + const val MAX_NICKNAME_LENGTH = 14 + } +} \ No newline at end of file diff --git a/domain/src/test/kotlin/com/acon/acon/domain/usecase/ValidateBirthDateUseCaseTest.kt b/domain/src/test/kotlin/com/acon/acon/domain/usecase/ValidateBirthDateUseCaseTest.kt new file mode 100644 index 000000000..81faff7d6 --- /dev/null +++ b/domain/src/test/kotlin/com/acon/acon/domain/usecase/ValidateBirthDateUseCaseTest.kt @@ -0,0 +1,80 @@ +package com.acon.acon.domain.usecase + +import com.acon.acon.domain.error.profile.ValidateBirthDateError +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.time.LocalDate +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertIsNot + +@ExtendWith(MockKExtension::class) +class ValidateBirthDateUseCaseTest { + + private lateinit var validateBirthDateUseCase: ValidateBirthDateUseCase + + @BeforeEach + fun setUp() { + validateBirthDateUseCase = ValidateBirthDateUseCase() + } + + @Test + fun `validateBirthDateUseCase()는 입력 생일이 1900년 이전일 경우 예외 객체를 Result Wrapping하여 반환한다`() = runTest { + // Given + val sampleLocalDate = LocalDate.of(1899,12,31) + + // When + val actualException = validateBirthDateUseCase(sampleLocalDate).exceptionOrNull() + + // Then + assertIs(actualException) + } + + @Test + fun `validateBirthDateUseCase()는 입력 생일이 1900년 1월 1일일 경우 '너무 과거'예외를 반환하지 않는다`() = runTest { + // Given + val sampleLocalDate = LocalDate.of(1900, 1, 1) + + // When + val actualException = validateBirthDateUseCase(sampleLocalDate).exceptionOrNull() + + // Then + assertIsNot(actualException) + } + + @Test + fun `validateBirthDateUseCase()는 입력 생일이 오늘 이후일 경우 예외 객체를 Result Wrapping하여 반환한다`() = runTest { + // Given + val sampleLocalDate = LocalDate.now().plusDays(1) + + val actualException = validateBirthDateUseCase(sampleLocalDate).exceptionOrNull() + + assertIs(actualException) + } + + @Test + fun `validateBirthDateUseCase()는 입력 생일이 오늘일 경우 '미래'예외를 반환하지 않는다`() = runTest { + // Given + val sampleLocalDate = LocalDate.now() + + val actualException = validateBirthDateUseCase(sampleLocalDate).exceptionOrNull() + + assertIsNot(actualException) + } + + @Test + fun `validateBirthDateUseCase()는 유효한 생일일 경우 성공처리 한다`() = runTest { + // Given + val sampleLocalDate = LocalDate.of(1999, 4, 29) + val expectedResult = Result.success(Unit) + + // When + val actualResult = validateBirthDateUseCase(sampleLocalDate) + + // Then + assertEquals(expectedResult, actualResult) + } +} \ No newline at end of file diff --git a/domain/src/test/kotlin/com/acon/acon/domain/usecase/ValidateNicknameUseCaseTest.kt b/domain/src/test/kotlin/com/acon/acon/domain/usecase/ValidateNicknameUseCaseTest.kt new file mode 100644 index 000000000..5667a5a51 --- /dev/null +++ b/domain/src/test/kotlin/com/acon/acon/domain/usecase/ValidateNicknameUseCaseTest.kt @@ -0,0 +1,86 @@ +package com.acon.acon.domain.usecase + +import com.acon.acon.domain.error.profile.ValidateNicknameError +import com.acon.acon.domain.repository.ProfileRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@ExtendWith(MockKExtension::class) +class ValidateNicknameUseCaseTest { + + @MockK + private lateinit var profileRepository: ProfileRepository + + private lateinit var validateNicknameUseCase: ValidateNicknameUseCase + + @BeforeEach + fun setUp() { + validateNicknameUseCase = ValidateNicknameUseCase(profileRepository) + } + + @Test + fun `입력이 없을 경우 입력 없음 예외 객체를 Result Wrapping하여 반환한다`() = runTest { + // Given + val sampleEmptyNicknameInput = "" + + // When + val actualException = validateNicknameUseCase(sampleEmptyNicknameInput).exceptionOrNull() + + // Then + assertIs(actualException) + } + + @Test + fun `입력이 14자를 초과했을 경우 길이 초과 예외 객체를 Result Wrapping하여 반환한다`() = runTest { + // Given + val sampleNicknameInput = "VerrrrryLoooooooongNickname" + + // When + val actualException = validateNicknameUseCase(sampleNicknameInput).exceptionOrNull() + + // Then + assertIs(actualException) + } + + @Test + fun `입력에 영어(소문자), 숫자, 밑줄, 마침표가 아닌 것이 포함된 경우 예외 객체를 Result Wrapping하여 반환한다`() = runTest { + // Given + val sampleKoreanNicknameInput = "한글닉네임" + val sampleUppercaseNicknameInput = "Capital" + val sampleSpaceNicknameInput = "very short" + + // When + val actualKoreanException = validateNicknameUseCase(sampleKoreanNicknameInput).exceptionOrNull() + val actualUppercaseException = validateNicknameUseCase(sampleUppercaseNicknameInput).exceptionOrNull() + val actualSpaceException = validateNicknameUseCase(sampleSpaceNicknameInput).exceptionOrNull() + + // Then + assertIs(actualKoreanException) + assertIs(actualUppercaseException) + assertIs(actualSpaceException) + } + + @Test + fun `입력이 유효할 경우 Repository의 유효성 검사 API를 호출하여 그대로 반환한다`() = runTest { + // Given + val sampleValidNickname = "thirfir231._." + val expectedResult = Result.success(Unit) + + coEvery { profileRepository.validateNickname(sampleValidNickname) } returns expectedResult + + // When + val actualResult = validateNicknameUseCase(sampleValidNickname) + + // Then + coVerify(exactly = 1) { profileRepository.validateNickname(sampleValidNickname) } + assertEquals(expectedResult, actualResult) + } +} \ No newline at end of file diff --git a/feature/areaverification/build.gradle.kts b/feature/areaverification/build.gradle.kts deleted file mode 100644 index 8df154a28..000000000 --- a/feature/areaverification/build.gradle.kts +++ /dev/null @@ -1,19 +0,0 @@ -plugins { - alias(libs.plugins.acon.android.library) - alias(libs.plugins.acon.android.feature) - alias(libs.plugins.acon.android.library.compose) - alias(libs.plugins.acon.android.library.hilt) - alias(libs.plugins.acon.android.library.orbit) - alias(libs.plugins.acon.android.library.haze) - alias(libs.plugins.acon.android.library.naver.map) -} - -android { - namespace = "com.acon.acon.feature.areaverification" -} - -dependencies { - implementation(projects.core.map) - - implementation(libs.play.services.location) -} diff --git a/feature/areaverification/src/main/AndroidManifest.xml b/feature/areaverification/src/main/AndroidManifest.xml deleted file mode 100644 index 220f6c7b6..000000000 --- a/feature/areaverification/src/main/AndroidManifest.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/AreaVerificationViewModel.kt b/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/AreaVerificationViewModel.kt deleted file mode 100644 index 0e9841663..000000000 --- a/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/AreaVerificationViewModel.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.acon.acon.feature.areaverification.composable - -import android.Manifest -import android.app.Application -import android.content.Context -import android.content.pm.PackageManager -import android.location.LocationManager -import androidx.core.app.ActivityCompat -import androidx.lifecycle.viewModelScope -import com.acon.acon.core.model.model.area.Area -import com.acon.acon.core.model.type.UserActionType -import com.acon.acon.core.ui.base.BaseContainerHost -import com.acon.acon.domain.error.area.ReplaceVerifiedArea -import com.acon.acon.domain.repository.ProfileRepository -import com.acon.acon.domain.repository.TimeRepository -import com.acon.acon.domain.repository.UserRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.async -import org.orbitmvi.orbit.viewmodel.container -import timber.log.Timber -import javax.inject.Inject - -@HiltViewModel -class AreaVerificationViewModel @Inject constructor( - private val application: Application, - private val profileRepository: ProfileRepository, - private val userRepository: UserRepository, - private val timeRepository: TimeRepository -) : BaseContainerHost() { - - override val container = container( - AreaVerificationUiState() - ) { - checkDeviceGPSStatus() - } - - fun checkDeviceGPSStatus() = intent { - val locationManager = application.getSystemService(Context.LOCATION_SERVICE) as LocationManager - val isGPSEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) - - if (isGPSEnabled) { - reduce { state.copy(isGPSEnabled = true, showDeviceGPSDialog = false) } - } else { - reduce { state.copy(isGPSEnabled = false, showDeviceGPSDialog = true) } - } - } - - private fun showLocationDialog() = intent { - reduce { - state.copy(showLocationDialog = true) - } - } - - fun onNextButtonClick() = intent { - postSideEffect( - AreaVerificationSideEffect.NavigateToNextScreen( - state.latitude, - state.longitude - ) - ) - } - - fun onSkipButtonClick() = intent { - userRepository.getDidOnboarding().onSuccess { did -> - if (did) postSideEffect(AreaVerificationSideEffect.NavigateToSpotList) - else postSideEffect(AreaVerificationSideEffect.NavigateToOnboarding) - }.onFailure { postSideEffect(AreaVerificationSideEffect.NavigateToOnboarding) } - timeRepository.saveUserActionTime(UserActionType.SKIP_AREA_VERIFICATION, System.currentTimeMillis()) - } - - fun onDeviceGPSSettingClick(packageName: String) = intent { - postSideEffect( - AreaVerificationSideEffect.NavigateToSystemLocationSettings(packageName) - ) - reduce { - state.copy(showDeviceGPSDialog = false) - } - } - - fun editVerifiedArea(previousVerifiedAreaId: Long, latitude: Double, longitude: Double) = intent { - profileRepository.replaceVerifiedArea( - previousVerifiedAreaId = previousVerifiedAreaId, - latitude = latitude, - longitude = longitude - ).onSuccess { - reduce { - state.copy( - isVerifySuccess = true - ) - } - }.onFailure { error -> - when(error) { - is ReplaceVerifiedArea.OutOfServiceAreaError -> { - postSideEffect(AreaVerificationSideEffect.ShowErrorToast("서비스를 제공하지 않는 지역입니다.")) - } - is ReplaceVerifiedArea.InvalidVerifiedArea -> { - postSideEffect(AreaVerificationSideEffect.ShowErrorToast("유효하지 않은 인증 지역입니다.")) - } - is ReplaceVerifiedArea.VerifiedAreaNotFound -> { - postSideEffect(AreaVerificationSideEffect.ShowErrorToast("존재하지 않는 인증 지역입니다.")) - } - } - } - } - - fun checkSupportLocation(context: Context) = intent { - if (ActivityCompat.checkSelfPermission( - context, Manifest.permission.ACCESS_FINE_LOCATION - ) != PackageManager.PERMISSION_GRANTED - ) { - return@intent - } - - var isSupportLocation: Boolean - - getCurrentLocation().let { location -> - val latitude = location.latitude - val longitude = location.longitude - isSupportLocation = latitude in 33.1..38.6 && longitude in 124.6..131.9 - - if (!isSupportLocation) { - Timber.tag(TAG).d("GPS 불가 지역(해외)") - showLocationDialog() - } - } - } - - fun verifyArea(latitude: Double, longitude: Double) = intent { - val didOnboarding = userRepository.getDidOnboarding().takeIf { it.isSuccess }?.getOrElse { true }!! - profileRepository.verifyArea(latitude, longitude) - .onSuccess { - reduce { - state.copy( - isVerifySuccess = true, - didOnboarding = didOnboarding - ) - } - } - .onFailure { - postSideEffect(AreaVerificationSideEffect.ShowErrorToast("지역인증에 실패했습니다. 다시 시도해주세요.")) - } - } - - companion object { - const val TAG = "AreaVerificationViewModel" - } -} - -data class AreaVerificationUiState( - val isGPSEnabled: Boolean = false, - val showDeviceGPSDialog: Boolean = false, - val showLocationDialog: Boolean = false, - val latitude: Double = 0.0, - val longitude: Double = 0.0, - val isVerifySuccess: Boolean = false, - val verifiedAreaList: List = emptyList(), - val didOnboarding: Boolean = false, -) - -sealed interface AreaVerificationSideEffect { - - data class NavigateToAppLocationSettings( - val packageName: String - ) : AreaVerificationSideEffect - - data class NavigateToSystemLocationSettings( - val packageName: String - ) : AreaVerificationSideEffect - - data class NavigateToNextScreen( - val latitude: Double, - val longitude: Double - ) : AreaVerificationSideEffect - - data class ShowErrorToast(val errorMessage: String) : AreaVerificationSideEffect - - data object NavigateToOnboarding : AreaVerificationSideEffect - data object NavigateToSpotList: AreaVerificationSideEffect -} \ No newline at end of file diff --git a/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/LocationMapScreen.kt b/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/LocationMapScreen.kt deleted file mode 100644 index e93f35bcf..000000000 --- a/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/LocationMapScreen.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.acon.acon.feature.areaverification.composable - -import android.location.Location -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import com.acon.acon.core.designsystem.R -import com.acon.acon.core.designsystem.component.button.v2.AconFilledButton -import com.acon.acon.core.designsystem.component.popup.AconToastPopup -import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.core.map.BuildConfig -import com.acon.acon.core.map.ProceedWithLocation -import com.naver.maps.geometry.LatLng -import com.naver.maps.map.CameraUpdate -import com.naver.maps.map.MapView -import com.naver.maps.map.NaverMap -import com.naver.maps.map.overlay.Marker -import com.naver.maps.map.overlay.OverlayImage - -private const val ZOOM = 20.0 -private const val MARKER_WIDTH = 240 -private const val MARKER_HEIGHT = 240 - -@Composable -internal fun LocationMapScreen( - onLocationObtained: (Double, Double) -> Unit, - modifier: Modifier = Modifier, - initialLatitude: Double = 0.0, - initialLongitude: Double = 0.0, - onClickConfirm: () -> Unit = {} -) { - val context = LocalContext.current - val density = context.resources.displayMetrics.density - - var naverMap: NaverMap? by remember { mutableStateOf(null) } - var currentLocation by remember { mutableStateOf(null) } - - fun createCustomMarker(map: NaverMap, latitude: Double, longitude: Double) { - Marker().apply { - position = LatLng(latitude, longitude) - width = MARKER_WIDTH - height = MARKER_HEIGHT - icon = OverlayImage.fromResource(R.drawable.ic_mark) - this.map = map - } - } - - if (initialLatitude != 0.0 && initialLongitude != 0.0) { - naverMap?.let { map -> - val cameraUpdate = CameraUpdate.scrollAndZoomTo( - LatLng(initialLatitude, initialLongitude), - ZOOM - ) - map.moveCamera(cameraUpdate) - createCustomMarker(map, initialLatitude, initialLongitude) - } - } else { - ProceedWithLocation( - onReady = { location -> - currentLocation = location - onLocationObtained(location.latitude, location.longitude) - naverMap?.let { map -> - val cameraUpdate = - CameraUpdate.scrollTo(LatLng(location.latitude, location.longitude)) - map.moveCamera(cameraUpdate) - createCustomMarker(map, location.latitude, location.longitude) - } - } - ) - } - - Box( - modifier = modifier - ) { - AndroidView( - factory = { context -> - MapView(context).apply { - getMapAsync { map -> - naverMap = map - map.customStyleId = BuildConfig.NAVER_NCP_KEY_ID - map.uiSettings.apply { - isScrollGesturesEnabled = false - isZoomGesturesEnabled = false - isTiltGesturesEnabled = false - isRotateGesturesEnabled = false - isZoomControlEnabled = false - isCompassEnabled = false - isScaleBarEnabled = false - setLogoMargin((20 * density).toInt(), 0, 0, (100 * density).toInt()) - } - - if (initialLatitude != 0.0 && initialLongitude != 0.0) { - val cameraUpdate = - CameraUpdate.scrollTo(LatLng(initialLatitude, initialLongitude)) - map.moveCamera(cameraUpdate) - createCustomMarker(map, initialLatitude, initialLongitude) - } else { - currentLocation?.let { location -> - val cameraUpdate = CameraUpdate.scrollTo( - LatLng( - location.latitude, - location.longitude - ) - ) - map.moveCamera(cameraUpdate) - createCustomMarker(map, location.latitude, location.longitude) - } - } - } - } - } - ) - - AconToastPopup( - modifier = Modifier - .align(Alignment.TopCenter) - .padding(top = 17.dp) - .padding(horizontal = 16.dp), - color = AconTheme.color.DimDefault.copy( - alpha = 0.8f - ), - content = { - Text( - text = stringResource(R.string.area_verification_popup), - color = AconTheme.color.White, - style = AconTheme.typography.Body1, - textAlign = TextAlign.Center, - modifier = Modifier - .padding(vertical = 13.dp) - ) - } - ) - - AconFilledButton( - onClick = { onClickConfirm() }, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(vertical = 24.dp, horizontal = 16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = AconTheme.color.DimDefault.copy( - alpha = 0.8f - ), - contentColor = AconTheme.color.White - ), - content = { - Text( - text = stringResource(R.string.area_verification_btn_content), - style = AconTheme.typography.Title4, - fontWeight = FontWeight.SemiBold - ) - } - ) - } -} \ No newline at end of file diff --git a/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/PreferenceMapScreen.kt b/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/PreferenceMapScreen.kt deleted file mode 100644 index 37f0a9519..000000000 --- a/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/PreferenceMapScreen.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.acon.acon.feature.areaverification.composable - -import android.app.Activity -import android.content.Intent -import android.provider.Settings -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -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.mutableDoubleStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import com.acon.acon.core.designsystem.R -import com.acon.acon.core.designsystem.component.dialog.v2.AconDefaultDialog -import com.acon.acon.core.designsystem.component.topbar.AconTopBar -import com.acon.acon.core.designsystem.theme.AconTheme -import org.orbitmvi.orbit.compose.collectSideEffect - -@Composable -fun PreferenceMapScreen( - latitude: Double, - longitude: Double, - previousVerifiedAreaId: Long, - modifier: Modifier = Modifier, - onBackClick: () -> Unit = {}, - onNavigateToNext: (didOnboarding: Boolean) -> Unit = {}, - viewModel: AreaVerificationViewModel = hiltViewModel() -) { - var currentLatitude by remember { mutableDoubleStateOf(latitude) } - var currentLongitude by remember { mutableDoubleStateOf(longitude) } - - val context = LocalContext.current - val state by viewModel.container.stateFlow.collectAsState() - - viewModel.collectSideEffect { effect -> - when (effect) { - is AreaVerificationSideEffect.NavigateToSystemLocationSettings -> { - val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) - context.startActivity(intent) - } - - else -> {} - } - } - - LaunchedEffect(Unit) { - viewModel.checkDeviceGPSStatus() - viewModel.checkSupportLocation(context) - } - - LaunchedEffect(state.isGPSEnabled) { - viewModel.checkDeviceGPSStatus() - viewModel.checkSupportLocation(context) - } - - LaunchedEffect(state.isVerifySuccess) { - if (state.isVerifySuccess) { - onNavigateToNext(state.didOnboarding) - } - } - - if (state.showDeviceGPSDialog) { - AconDefaultDialog( - title = stringResource(R.string.location_permission_denied_title), - action = stringResource(R.string.go_to_setting), - onAction = { - viewModel.onDeviceGPSSettingClick(context.packageName) - }, - onDismissRequest = {}, - content = { - Text( - text = stringResource(R.string.location_permission_denied_content), - style = AconTheme.typography.Title4, - color = AconTheme.color.White, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 20.dp) - ) - } - ) - } - - if (state.showLocationDialog) { - AconDefaultDialog( - title = stringResource(R.string.location_unsupported_area_title), - action = stringResource(R.string.ok), - onDismissRequest = {}, - onAction = { (context as? Activity)?.finishAffinity() }, - content = { - Text( - text = stringResource(R.string.location_unsupported_area_content), - style = AconTheme.typography.Title4, - color = AconTheme.color.White, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 20.dp) - ) - } - ) - } - - Column( - modifier = modifier - .background( - color = AconTheme.color.DimDefault.copy(alpha = 0.8f) - ) - .statusBarsPadding() - .navigationBarsPadding() - .fillMaxSize() - ) { - AconTopBar( - leadingIcon = { - IconButton( - onClick = { onBackClick() } - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_topbar_arrow_left), - contentDescription = stringResource(R.string.back), - tint = AconTheme.color.Gray50 - ) - } - }, - content = { - Text( - text = stringResource(R.string.area_verification_topbar), - style = AconTheme.typography.Title4, - color = AconTheme.color.White - ) - }, - modifier = Modifier - .background( - color = AconTheme.color.DimDefault.copy(alpha = 0.8f) - ) - .padding(vertical = 14.dp) - ) - - Box( - modifier = Modifier - .weight(1f) - .fillMaxSize() - ) { - LocationMapScreen( - onLocationObtained = { lat, lng -> - currentLatitude = lat - currentLongitude = lng - }, - initialLatitude = latitude, - initialLongitude = longitude, - modifier = Modifier.fillMaxSize(), - onClickConfirm = { - if (previousVerifiedAreaId > (0).toLong()) { - viewModel.editVerifiedArea( - previousVerifiedAreaId = previousVerifiedAreaId, - latitude = currentLatitude, - longitude = currentLongitude - ) - } else { - viewModel.verifyArea(currentLatitude, currentLongitude) - } - } - ) - } - } -} \ No newline at end of file diff --git a/feature/onboarding/build.gradle.kts b/feature/onboarding/build.gradle.kts index 91906ab93..4f8ab9615 100644 --- a/feature/onboarding/build.gradle.kts +++ b/feature/onboarding/build.gradle.kts @@ -14,5 +14,7 @@ android { dependencies { + implementation(projects.core.map) + implementation(libs.lottie.compose) } \ No newline at end of file diff --git a/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/AreaVerificationScreen.kt b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/AreaVerificationScreen.kt similarity index 62% rename from feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/AreaVerificationScreen.kt rename to feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/AreaVerificationScreen.kt index 0f0f8117a..702985e63 100644 --- a/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/AreaVerificationScreen.kt +++ b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/AreaVerificationScreen.kt @@ -1,6 +1,5 @@ -package com.acon.acon.feature.areaverification.composable +package com.acon.feature.onboarding.area.composable -import androidx.activity.compose.BackHandler import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -17,16 +16,20 @@ 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.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.paint +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -36,20 +39,20 @@ import com.acon.acon.core.designsystem.component.button.v2.AconFilledButton import com.acon.acon.core.designsystem.noRippleClickable import com.acon.acon.core.designsystem.theme.AconTheme import com.acon.acon.core.ui.compose.getScreenHeight +import com.acon.feature.onboarding.area.viewmodel.AreaVerificationState @Composable internal fun AreaVerificationScreen( - state: AreaVerificationUiState, - route: String, + state: AreaVerificationState, onNextButtonClick: () -> Unit, modifier: Modifier = Modifier, - onSkip: () -> Unit = {} + onSkipClick: () -> Unit = {}, + onBack: () -> Unit = {} ) { val screenHeightDp = getScreenHeight() val offsetY = (screenHeightDp * 0.65f) val infiniteTransition = rememberInfiniteTransition(label = "infinite transition") - val skipAlertTextAlpha by infiniteTransition.animateFloat( initialValue = 1f, targetValue = 0f, @@ -59,8 +62,6 @@ internal fun AreaVerificationScreen( ), label = "alpha" ) - BackHandler { } - Box( modifier = modifier .paint( @@ -68,27 +69,48 @@ internal fun AreaVerificationScreen( contentScale = ContentScale.FillWidth ) ) { - Text( - text = stringResource(R.string.skip_area_verification), - style = AconTheme.typography.Body1, - color = AconTheme.color.White, - fontWeight = FontWeight.W400, - modifier = Modifier.align(Alignment.TopEnd).padding( - end = 16.dp, top = 10.dp - ).noRippleClickable { - onSkip() - }.padding(8.dp) - ) - Text( - text = stringResource(R.string.alert_about_skip_area_verification), - style = AconTheme.typography.Body1, - color = AconTheme.color.Gray500, - fontWeight = FontWeight.W400, - modifier = Modifier.align(Alignment.TopCenter).padding( - top = 96.dp - ).alpha(skipAlertTextAlpha), - textAlign = TextAlign.Center - ) + if (state.shouldShowSkipButton) { + Text( + text = stringResource(R.string.skip_area_verification), + style = AconTheme.typography.Body1, + color = AconTheme.color.White, + fontWeight = FontWeight.W400, + modifier = Modifier + .align(Alignment.TopEnd) + .padding( + end = 16.dp, top = 10.dp + ) + .noRippleClickable { + onSkipClick() + } + .padding(8.dp) + ) + + Text( + text = stringResource(R.string.alert_about_skip_area_verification), + style = AconTheme.typography.Body1, + color = AconTheme.color.Gray500, + fontWeight = FontWeight.W400, + modifier = Modifier + .align(Alignment.TopCenter) + .padding( + top = 96.dp + ) + .graphicsLayer { + alpha = skipAlertTextAlpha + }, + textAlign = TextAlign.Center + ) + } else { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_topbar_arrow_left), + contentDescription = stringResource(R.string.back), + modifier = Modifier.padding(start = 16.dp, top = 32.dp).noRippleClickable { + onBack() + }, tint = Color.Unspecified + ) + } + Column( modifier = Modifier .fillMaxSize() @@ -113,7 +135,7 @@ internal fun AreaVerificationScreen( Spacer(Modifier.weight(1f)) AconFilledButton( - onClick = { onNextButtonClick() }, + onClick = onNextButtonClick, modifier = Modifier .fillMaxWidth() .padding(top = 16.dp, bottom = 20.dp) @@ -133,12 +155,12 @@ internal fun AreaVerificationScreen( @Preview @Composable private fun AreaVerificationHomeScreenPreview() { - AconTheme { - AreaVerificationScreen( - modifier = Modifier.fillMaxSize().background(AconTheme.color.Gray900).statusBarsPadding(), - state = AreaVerificationUiState(), - route = "", - onNextButtonClick = {} - ) - } + AreaVerificationScreen( + modifier = Modifier + .fillMaxSize() + .background(AconTheme.color.Gray900) + .statusBarsPadding(), + onNextButtonClick = {}, + state = AreaVerificationState(shouldShowSkipButton = true) + ) } \ No newline at end of file diff --git a/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/AreaVerificationScreenContainer.kt b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/AreaVerificationScreenContainer.kt similarity index 63% rename from feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/AreaVerificationScreenContainer.kt rename to feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/AreaVerificationScreenContainer.kt index 194b56294..189d92390 100644 --- a/feature/areaverification/src/main/java/com/acon/acon/feature/areaverification/composable/AreaVerificationScreenContainer.kt +++ b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/AreaVerificationScreenContainer.kt @@ -1,36 +1,42 @@ -package com.acon.acon.feature.areaverification.composable +package com.acon.feature.onboarding.area.composable import android.content.Intent import android.net.Uri import android.provider.Settings +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel -import com.acon.acon.core.navigation.route.AreaVerificationRoute -import com.acon.acon.core.ui.permission.checkLocationPermission +import com.acon.acon.core.navigation.LocalNavController import com.acon.acon.core.ui.android.showToast import com.acon.acon.core.ui.compose.LocalRequestLocationPermission +import com.acon.acon.core.ui.permission.checkLocationPermission +import com.acon.feature.onboarding.area.viewmodel.AreaVerificationSideEffect +import com.acon.feature.onboarding.area.viewmodel.AreaVerificationViewModel import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun AreaVerificationScreenContainer( - route: String, - onNextScreen: (Double, Double) -> Unit, - onNavigateToOnboarding: () -> Unit, + onNavigateToVerifyInMap: () -> Unit, + onNavigateToChooseDislikes: () -> Unit, + onNavigateToIntroduce: () -> Unit, onNavigateToSpotList: () -> Unit, + onNavigateBack: () -> Unit, + skippable: Boolean, modifier: Modifier = Modifier, - viewModel: AreaVerificationViewModel = hiltViewModel() + viewModel: AreaVerificationViewModel = hiltViewModel(creationCallback = { factory: AreaVerificationViewModel.Factory -> + factory.create(shouldShowSkipButton = skippable) + }) ) { val context = LocalContext.current - val state by viewModel.collectAsState() val onRequestLocationPermission = LocalRequestLocationPermission.current + val state by viewModel.collectAsState() AreaVerificationScreen( state = state, - route = route, onNextButtonClick = { val hasPermission = context.checkLocationPermission() @@ -41,7 +47,8 @@ fun AreaVerificationScreenContainer( } }, modifier = modifier, - onSkip = viewModel::onSkipButtonClick + onSkipClick = viewModel::onSkipClicked, + onBack = viewModel::onBackClicked ) viewModel.useLiveLocation() @@ -61,16 +68,24 @@ fun AreaVerificationScreenContainer( context.startActivity(intent) } - is AreaVerificationSideEffect.NavigateToNextScreen -> { - onNextScreen(it.latitude, it.longitude) + is AreaVerificationSideEffect.NavigateToVerifyInMap -> { + onNavigateToVerifyInMap() } is AreaVerificationSideEffect.ShowErrorToast -> { context.showToast(it.errorMessage) } - is AreaVerificationSideEffect.NavigateToOnboarding -> onNavigateToOnboarding() + is AreaVerificationSideEffect.NavigateToChooseDislikes -> onNavigateToChooseDislikes() + is AreaVerificationSideEffect.NavigateToIntroduce -> onNavigateToIntroduce() is AreaVerificationSideEffect.NavigateToSpotList -> onNavigateToSpotList() + is AreaVerificationSideEffect.NavigateBack -> onNavigateBack() } } + + val navController = LocalNavController.current + BackHandler { + if (!skippable) + navController.popBackStack() + } } \ No newline at end of file diff --git a/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/VerifyInMapScreen.kt b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/VerifyInMapScreen.kt new file mode 100644 index 000000000..ed08978dc --- /dev/null +++ b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/VerifyInMapScreen.kt @@ -0,0 +1,124 @@ +package com.acon.feature.onboarding.area.composable + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.component.button.v2.AconFilledButton +import com.acon.acon.core.designsystem.component.popup.AconToastPopup +import com.acon.acon.core.designsystem.component.topbar.AconTopBar +import com.acon.acon.core.designsystem.theme.AconTheme +import com.acon.core.map.composable.NaverMapView +import com.acon.feature.onboarding.area.viewmodel.VerifyInMapState + +@Composable +internal fun VerifyInMapScreen( + state: VerifyInMapState, + onCompleteButtonClick: () -> Unit, + onBackIconClick: () -> Unit, + modifier: Modifier = Modifier +) { + + Column( + modifier = modifier + ) { + AconTopBar( + leadingIcon = { + IconButton( + onClick = onBackIconClick + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_topbar_arrow_left), + contentDescription = stringResource(R.string.back), + tint = AconTheme.color.Gray50 + ) + } + }, + content = { + Text( + text = stringResource(R.string.area_verification_topbar), + style = AconTheme.typography.Title4, + color = AconTheme.color.White + ) + }, + modifier = Modifier + .background( + color = AconTheme.color.DimDefault.copy(alpha = 0.8f) + ) + .padding(vertical = 14.dp) + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxSize() + ) { + + AconToastPopup( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 17.dp) + .padding(horizontal = 16.dp), + color = AconTheme.color.DimDefault.copy( + alpha = 0.8f + ), + content = { + Text( + text = stringResource(R.string.area_verification_popup), + color = AconTheme.color.White, + style = AconTheme.typography.Body1, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(vertical = 13.dp) + ) + } + ) + + if (state.latitude != null && state.longitude != null) + NaverMapView( + latitude = state.latitude, + longitude = state.longitude, + modifier = Modifier.fillMaxSize(), + ) + + AconFilledButton( + onClick = onCompleteButtonClick, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .navigationBarsPadding() + .padding(vertical = 24.dp, horizontal = 16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = AconTheme.color.DimDefault.copy( + alpha = 0.8f + ), + contentColor = AconTheme.color.White + ), + content = { + Text( + text = stringResource(R.string.area_verification_btn_content), + style = AconTheme.typography.Title4, + fontWeight = FontWeight.SemiBold + ) + } + ) + } + } +} \ No newline at end of file diff --git a/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/VerifyInMapScreenContainer.kt b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/VerifyInMapScreenContainer.kt new file mode 100644 index 000000000..c23c7e395 --- /dev/null +++ b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/composable/VerifyInMapScreenContainer.kt @@ -0,0 +1,36 @@ +package com.acon.feature.onboarding.area.composable + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import com.acon.feature.onboarding.area.viewmodel.VerifyInMapSideEffect +import com.acon.feature.onboarding.area.viewmodel.VerifyInMapViewModel +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun VerifyInMapScreenContainer( + onNavigateToNextScreen: () -> Unit, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: VerifyInMapViewModel = hiltViewModel() +) { + + val state by viewModel.collectAsState() + + VerifyInMapScreen( + state = state, + onCompleteButtonClick = viewModel::onCompleteButtonClicked, + onBackIconClick = viewModel::onBackIconClicked, + modifier = modifier + ) + + viewModel.useLiveLocation() + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is VerifyInMapSideEffect.NavigateToNextScreen -> onNavigateToNextScreen() + VerifyInMapSideEffect.NavigateBack -> onNavigateBack() + } + } +} \ No newline at end of file diff --git a/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/viewmodel/AreaVerificationViewModel.kt b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/viewmodel/AreaVerificationViewModel.kt new file mode 100644 index 000000000..5346713dc --- /dev/null +++ b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/viewmodel/AreaVerificationViewModel.kt @@ -0,0 +1,79 @@ +package com.acon.feature.onboarding.area.viewmodel + +import com.acon.acon.core.model.type.UserActionType +import com.acon.acon.core.ui.base.BaseContainerHost +import com.acon.acon.domain.repository.OnboardingRepository +import com.acon.acon.domain.repository.TimeRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import org.orbitmvi.orbit.viewmodel.container + +@HiltViewModel(assistedFactory = AreaVerificationViewModel.Factory::class) +class AreaVerificationViewModel @AssistedInject constructor( + private val timeRepository: TimeRepository, + private val onboardingRepository: OnboardingRepository, + @Assisted private val shouldShowSkipButton: Boolean +) : BaseContainerHost() { + + override val container = container( + AreaVerificationState( + shouldShowSkipButton = shouldShowSkipButton + ) + ) { + onboardingRepository.updateShouldVerifyArea(false) + } + + fun onNextButtonClick() = intent { + postSideEffect( + AreaVerificationSideEffect.NavigateToVerifyInMap + ) + } + + fun onSkipClicked() = intent { + timeRepository.saveUserActionTime(UserActionType.SKIP_AREA_VERIFICATION, System.currentTimeMillis()) + + onboardingRepository.getOnboardingPreferences().onSuccess { pref -> + if (pref.shouldChooseDislikes) + postSideEffect(AreaVerificationSideEffect.NavigateToChooseDislikes) + else + postSideEffect(AreaVerificationSideEffect.NavigateToSpotList) + }.onFailure { + postSideEffect(AreaVerificationSideEffect.NavigateToSpotList) + } + } + + fun onBackClicked() = intent { + postSideEffect(AreaVerificationSideEffect.NavigateBack) + } + + @AssistedFactory + interface Factory { + fun create(shouldShowSkipButton: Boolean): AreaVerificationViewModel + } +} + +data class AreaVerificationState( + val shouldShowSkipButton: Boolean +) + +sealed interface AreaVerificationSideEffect { + + data class NavigateToAppLocationSettings( + val packageName: String + ) : AreaVerificationSideEffect + + data class NavigateToSystemLocationSettings( + val packageName: String + ) : AreaVerificationSideEffect + + data object NavigateToVerifyInMap: AreaVerificationSideEffect + + data class ShowErrorToast(val errorMessage: String) : AreaVerificationSideEffect + + data object NavigateToChooseDislikes : AreaVerificationSideEffect + data object NavigateToIntroduce : AreaVerificationSideEffect + data object NavigateToSpotList : AreaVerificationSideEffect + data object NavigateBack : AreaVerificationSideEffect +} \ No newline at end of file diff --git a/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/viewmodel/VerifyInMapViewModel.kt b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/viewmodel/VerifyInMapViewModel.kt new file mode 100644 index 000000000..121670d3c --- /dev/null +++ b/feature/onboarding/src/main/java/com/acon/feature/onboarding/area/viewmodel/VerifyInMapViewModel.kt @@ -0,0 +1,52 @@ +package com.acon.feature.onboarding.area.viewmodel + +import com.acon.acon.core.ui.base.BaseContainerHost +import com.acon.acon.domain.repository.OnboardingRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import org.orbitmvi.orbit.viewmodel.container +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class VerifyInMapViewModel @Inject constructor( + private val onboardingRepository: OnboardingRepository +) : BaseContainerHost() { + + override val container = container(VerifyInMapState()) { + val currentLocation = getCurrentLocation() + val verifyingLatitude = currentLocation.latitude + val verifyingLongitude = currentLocation.longitude + + reduce { + state.copy( + latitude = verifyingLatitude, + longitude = verifyingLongitude + ) + } + } + + fun onCompleteButtonClicked() = intent { + onboardingRepository.verifyArea( + latitude = state.latitude ?: error("latitude is null"), + longitude = state.longitude ?: error("longitude is null") + ).onSuccess { + postSideEffect(VerifyInMapSideEffect.NavigateToNextScreen) + }.onFailure { e -> + Timber.e(e) + } + } + + fun onBackIconClicked() = intent { + postSideEffect(VerifyInMapSideEffect.NavigateBack) + } +} + +data class VerifyInMapState( + val latitude: Double? = null, + val longitude: Double? = null +) + +sealed interface VerifyInMapSideEffect { + data object NavigateToNextScreen : VerifyInMapSideEffect + data object NavigateBack : VerifyInMapSideEffect +} \ No newline at end of file diff --git a/feature/onboarding/src/main/java/com/acon/feature/onboarding/dislikes/composable/ChooseDislikesScreen.kt b/feature/onboarding/src/main/java/com/acon/feature/onboarding/dislikes/composable/ChooseDislikesScreen.kt index 1680247c0..d9f7b56ce 100644 --- a/feature/onboarding/src/main/java/com/acon/feature/onboarding/dislikes/composable/ChooseDislikesScreen.kt +++ b/feature/onboarding/src/main/java/com/acon/feature/onboarding/dislikes/composable/ChooseDislikesScreen.kt @@ -49,7 +49,7 @@ internal fun ChooseDislikesScreen( is ChooseDislikesUiState.Success -> { if (state.showStopModal) { AconTwoActionDialog( - title = stringResource(R.string.stop_onboarding), + title = stringResource(R.string.stop_choose_dislikes), action1 = stringResource(R.string.keep_going), action2 = stringResource(R.string.stop), onAction1 = { onDismissStopModal{} }, diff --git a/feature/onboarding/src/main/java/com/acon/feature/onboarding/dislikes/viewmodel/ChooseDislikesViewModel.kt b/feature/onboarding/src/main/java/com/acon/feature/onboarding/dislikes/viewmodel/ChooseDislikesViewModel.kt index e57b80f48..b0bc1023a 100644 --- a/feature/onboarding/src/main/java/com/acon/feature/onboarding/dislikes/viewmodel/ChooseDislikesViewModel.kt +++ b/feature/onboarding/src/main/java/com/acon/feature/onboarding/dislikes/viewmodel/ChooseDislikesViewModel.kt @@ -18,7 +18,7 @@ class ChooseDislikesViewModel @Inject constructor( ) : BaseContainerHost() { override val container = container(ChooseDislikesUiState.Success()) { - + onboardingRepository.updateShouldChooseDislikes(false) } fun onNoneClicked() = intent { @@ -92,11 +92,11 @@ class ChooseDislikesViewModel @Inject constructor( fun onCompletion() = intent { runOn { - onboardingRepository.submitOnboardingResult(state.selectedDislikes.toList()).onSuccess { - if (onboardingRepository.getDidOnboarding().getOrDefault(true)) - postSideEffect(ChooseDislikesSideEffect.NavigateToHome) - else + onboardingRepository.submitTastePreferenceResult(state.selectedDislikes.toList()).onSuccess { + if (onboardingRepository.getOnboardingPreferences().getOrNull()?.shouldShowIntroduce == true) postSideEffect(ChooseDislikesSideEffect.NavigateToIntroduce) + else + postSideEffect(ChooseDislikesSideEffect.NavigateToHome) }.onFailure { postSideEffect(ChooseDislikesSideEffect.ShowErrorToast) } diff --git a/feature/onboarding/src/main/java/com/acon/feature/onboarding/introduce/composable/IntroduceScreenContainer.kt b/feature/onboarding/src/main/java/com/acon/feature/onboarding/introduce/composable/IntroduceScreenContainer.kt index d0245beef..80ae30567 100644 --- a/feature/onboarding/src/main/java/com/acon/feature/onboarding/introduce/composable/IntroduceScreenContainer.kt +++ b/feature/onboarding/src/main/java/com/acon/feature/onboarding/introduce/composable/IntroduceScreenContainer.kt @@ -16,6 +16,8 @@ import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun IntroduceScreenContainer( onNavigateToHome: () -> Unit, + onNavigateToAreaVerification: () -> Unit, + onNavigateToChooseDislikes: () -> Unit, modifier: Modifier = Modifier, viewModel: IntroduceViewModel = hiltViewModel() ) { @@ -49,7 +51,9 @@ fun IntroduceScreenContainer( viewModel.collectSideEffect { sideEffect -> when (sideEffect) { - IntroduceSideEffect.OnNavigateToHomeScreen -> onNavigateToHome() + IntroduceSideEffect.NavigateToHomeScreen -> onNavigateToHome() + IntroduceSideEffect.NavigateToAreaVerification -> onNavigateToAreaVerification() + IntroduceSideEffect.NavigateToChooseDislikes -> onNavigateToChooseDislikes() } } } diff --git a/feature/onboarding/src/main/java/com/acon/feature/onboarding/introduce/viewmodel/IntroduceViewModel.kt b/feature/onboarding/src/main/java/com/acon/feature/onboarding/introduce/viewmodel/IntroduceViewModel.kt index 5f8051d09..483a279e1 100644 --- a/feature/onboarding/src/main/java/com/acon/feature/onboarding/introduce/viewmodel/IntroduceViewModel.kt +++ b/feature/onboarding/src/main/java/com/acon/feature/onboarding/introduce/viewmodel/IntroduceViewModel.kt @@ -1,20 +1,25 @@ package com.acon.feature.onboarding.introduce.viewmodel import androidx.compose.runtime.Immutable +import com.acon.acon.core.model.type.SignInStatus import com.acon.acon.core.ui.base.BaseContainerHost import com.acon.acon.domain.repository.OnboardingRepository +import com.acon.acon.domain.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import org.orbitmvi.orbit.viewmodel.container import javax.inject.Inject @HiltViewModel class IntroduceViewModel @Inject constructor( - private val onboardingRepository: OnboardingRepository + private val onboardingRepository: OnboardingRepository, + private val userRepository: UserRepository ) : BaseContainerHost() { override val container = container( initialState = IntroduceState() - ) + ) { + onboardingRepository.updateShouldShowIntroduce(false) + } fun onIntroduceLocalReviewScreenDisposed() = intent { reduce { @@ -41,8 +46,22 @@ class IntroduceViewModel @Inject constructor( } fun onStartButtonClicked() = intent { - onboardingRepository.saveDidOnboarding(true) - postSideEffect(IntroduceSideEffect.OnNavigateToHomeScreen) + userRepository.getSignInStatus().collect { signInStatus -> + if(signInStatus == SignInStatus.USER) { + onboardingRepository.getOnboardingPreferences().onSuccess { pref -> + if (pref.shouldVerifyArea) + postSideEffect(IntroduceSideEffect.NavigateToAreaVerification) + else if (pref.shouldChooseDislikes) + postSideEffect(IntroduceSideEffect.NavigateToChooseDislikes) + else + postSideEffect(IntroduceSideEffect.NavigateToHomeScreen) + }.onFailure { + postSideEffect(IntroduceSideEffect.NavigateToHomeScreen) + } + } else { + postSideEffect(IntroduceSideEffect.NavigateToHomeScreen) + } + } } } @@ -54,5 +73,7 @@ data class IntroduceState( ) sealed interface IntroduceSideEffect { - data object OnNavigateToHomeScreen : IntroduceSideEffect + data object NavigateToAreaVerification: IntroduceSideEffect + data object NavigateToChooseDislikes : IntroduceSideEffect + data object NavigateToHomeScreen : IntroduceSideEffect } \ No newline at end of file diff --git a/feature/onboarding/src/main/res/drawable/food_american.png b/feature/onboarding/src/main/res/drawable/food_american.png deleted file mode 100644 index 7b69191fc..000000000 Binary files a/feature/onboarding/src/main/res/drawable/food_american.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/food_asian.png b/feature/onboarding/src/main/res/drawable/food_asian.png deleted file mode 100644 index 91fbd5410..000000000 Binary files a/feature/onboarding/src/main/res/drawable/food_asian.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/food_chinese.png b/feature/onboarding/src/main/res/drawable/food_chinese.png deleted file mode 100644 index 04706acc4..000000000 Binary files a/feature/onboarding/src/main/res/drawable/food_chinese.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/food_img_1.png b/feature/onboarding/src/main/res/drawable/food_img_1.png deleted file mode 100644 index fe04b1436..000000000 Binary files a/feature/onboarding/src/main/res/drawable/food_img_1.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/food_img_2.png b/feature/onboarding/src/main/res/drawable/food_img_2.png deleted file mode 100644 index 87b968463..000000000 Binary files a/feature/onboarding/src/main/res/drawable/food_img_2.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/food_img_3.png b/feature/onboarding/src/main/res/drawable/food_img_3.png deleted file mode 100644 index 460ac0c27..000000000 Binary files a/feature/onboarding/src/main/res/drawable/food_img_3.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/food_img_4.png b/feature/onboarding/src/main/res/drawable/food_img_4.png deleted file mode 100644 index 738961432..000000000 Binary files a/feature/onboarding/src/main/res/drawable/food_img_4.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/food_img_5.png b/feature/onboarding/src/main/res/drawable/food_img_5.png deleted file mode 100644 index 5d9091886..000000000 Binary files a/feature/onboarding/src/main/res/drawable/food_img_5.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/food_japanese.png b/feature/onboarding/src/main/res/drawable/food_japanese.png deleted file mode 100644 index 59d017509..000000000 Binary files a/feature/onboarding/src/main/res/drawable/food_japanese.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/food_korean.png b/feature/onboarding/src/main/res/drawable/food_korean.png deleted file mode 100644 index dfa44fda7..000000000 Binary files a/feature/onboarding/src/main/res/drawable/food_korean.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/food_street.png b/feature/onboarding/src/main/res/drawable/food_street.png deleted file mode 100644 index b5e19d1ef..000000000 Binary files a/feature/onboarding/src/main/res/drawable/food_street.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/ic_1.xml b/feature/onboarding/src/main/res/drawable/ic_1.xml deleted file mode 100644 index 42dc5a978..000000000 --- a/feature/onboarding/src/main/res/drawable/ic_1.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/feature/onboarding/src/main/res/drawable/ic_2.xml b/feature/onboarding/src/main/res/drawable/ic_2.xml deleted file mode 100644 index 0999fb038..000000000 --- a/feature/onboarding/src/main/res/drawable/ic_2.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/feature/onboarding/src/main/res/drawable/ic_3.xml b/feature/onboarding/src/main/res/drawable/ic_3.xml deleted file mode 100644 index c92830d4d..000000000 --- a/feature/onboarding/src/main/res/drawable/ic_3.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/feature/onboarding/src/main/res/drawable/ic_4.xml b/feature/onboarding/src/main/res/drawable/ic_4.xml deleted file mode 100644 index 3e4c96bd7..000000000 --- a/feature/onboarding/src/main/res/drawable/ic_4.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/feature/onboarding/src/main/res/drawable/place_img_1.png b/feature/onboarding/src/main/res/drawable/place_img_1.png deleted file mode 100644 index 9c9f74e95..000000000 Binary files a/feature/onboarding/src/main/res/drawable/place_img_1.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/place_img_2.png b/feature/onboarding/src/main/res/drawable/place_img_2.png deleted file mode 100644 index 2cf8fc328..000000000 Binary files a/feature/onboarding/src/main/res/drawable/place_img_2.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/place_img_3.png b/feature/onboarding/src/main/res/drawable/place_img_3.png deleted file mode 100644 index 298ebdf21..000000000 Binary files a/feature/onboarding/src/main/res/drawable/place_img_3.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/place_img_4.png b/feature/onboarding/src/main/res/drawable/place_img_4.png deleted file mode 100644 index 49c1899dc..000000000 Binary files a/feature/onboarding/src/main/res/drawable/place_img_4.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/placetype_img_1.png b/feature/onboarding/src/main/res/drawable/placetype_img_1.png deleted file mode 100644 index 719520189..000000000 Binary files a/feature/onboarding/src/main/res/drawable/placetype_img_1.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/placetype_img_2.png b/feature/onboarding/src/main/res/drawable/placetype_img_2.png deleted file mode 100644 index 7ddaa35f3..000000000 Binary files a/feature/onboarding/src/main/res/drawable/placetype_img_2.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/placetype_img_3.png b/feature/onboarding/src/main/res/drawable/placetype_img_3.png deleted file mode 100644 index acc9c6aa4..000000000 Binary files a/feature/onboarding/src/main/res/drawable/placetype_img_3.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/drawable/placetype_img_4.png b/feature/onboarding/src/main/res/drawable/placetype_img_4.png deleted file mode 100644 index 7610e81d4..000000000 Binary files a/feature/onboarding/src/main/res/drawable/placetype_img_4.png and /dev/null differ diff --git a/feature/onboarding/src/main/res/raw/loading_complete_lottie.json b/feature/onboarding/src/main/res/raw/loading_complete_lottie.json deleted file mode 100644 index 2fb49e663..000000000 --- a/feature/onboarding/src/main/res/raw/loading_complete_lottie.json +++ /dev/null @@ -1 +0,0 @@ -{"ddd":0,"h":1080,"w":1920,"meta":{"g":"@lottiefiles/toolkit-js 0.57.2-beta.0"},"layers":[{"ty":4,"sr":1,"st":0,"op":156,"ip":0,"ln":"20","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[960,540]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-16,102],[122,-84]]}}},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":20.666666666666668},{"s":[100],"t":25}]},"o":{"a":0,"k":0},"s":{"a":0,"k":0},"m":1},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":50},"c":{"a":0,"k":[1,0.3294,0.0078]}},{"ty":"fl","c":{"a":0,"k":[0.102,0.1059,0.1176]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":1},{"ty":4,"sr":1,"st":0,"op":156,"ip":0,"ln":"19","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[998,550,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-136,4],[-58,88]]}}},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":50},"c":{"a":0,"k":[1,0.3294,0.0078]}},{"ty":"fl","c":{"a":0,"k":[0.102,0.1059,0.1176]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":18.666666666666668},{"s":[100],"t":21.666666666666668}]},"o":{"a":0,"k":0},"s":{"a":0,"k":0},"m":1}],"ind":2},{"ty":4,"sr":1,"st":0,"op":156,"ip":0,"ln":"16","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[1004,524,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"el","d":1,"p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[588,588]}},{"ty":"st","lc":1,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":50},"c":{"a":0,"k":[1,0.3294,0.0078]}},{"ty":"fl","c":{"a":0,"k":[0.102,0.1059,0.1176]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[-38,22]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"s":[100],"t":10.333333333333334}]},"o":{"a":0,"k":0},"s":{"a":0,"k":0},"m":1}],"ind":3},{"ty":4,"sr":1,"st":0,"op":156,"ip":0,"ln":"17","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[960,548,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"s":{"a":0,"k":[1948,1108]}},{"ty":"st","lc":1,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":0},"c":{"a":0,"k":[1,0.3294,0.0078]}},{"ty":"fl","c":{"a":0,"k":[0.102,0.1059,0.1176]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[10,-10]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":4}],"v":"5.7.0","fr":29,"op":31,"ip":0,"assets":[]} \ No newline at end of file diff --git a/feature/onboarding/src/main/res/raw/loading_lottie.json b/feature/onboarding/src/main/res/raw/loading_lottie.json deleted file mode 100644 index e2bf81055..000000000 --- a/feature/onboarding/src/main/res/raw/loading_lottie.json +++ /dev/null @@ -1 +0,0 @@ -{"ddd":0,"h":1080,"w":1920,"meta":{"g":"@lottiefiles/toolkit-js 0.57.2-beta.0"},"layers":[{"ty":2,"sr":1,"st":0,"op":72,"ip":0,"ln":"265","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[540,601.5]},"s":{"a":0,"k":[22.926,22.985,100]},"p":{"s":true,"x":{"a":1,"k":[{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.904},"s":[-140],"t":0},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[570.479],"t":14},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[938.74],"t":22},{"o":{"x":0.167,"y":0.125},"i":{"x":0.833,"y":0.875},"s":[1311],"t":30},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.911},"s":[1651.174],"t":39},{"s":[2093],"t":47}]},"y":{"a":1,"k":[{"o":{"x":0.02,"y":0},"i":{"x":0.923,"y":0.76},"s":[180],"t":0},{"o":{"x":0.101,"y":0.345},"i":{"x":0.354,"y":1},"s":[750],"t":7},{"o":{"x":0.663,"y":0.019},"i":{"x":0.88,"y":0.598},"s":[350.536],"t":14},{"o":{"x":0.137,"y":0.542},"i":{"x":0.446,"y":0.934},"s":[740],"t":22},{"o":{"x":0.45,"y":0.05},"i":{"x":0.922,"y":0.531},"s":[453.216],"t":30},{"o":{"x":0.101,"y":0.51},"i":{"x":0.978,"y":1},"s":[740],"t":39},{"s":[527.279],"t":47}]},"z":{"a":0,"k":0}},"r":{"a":1,"k":[{"o":{"x":0.164,"y":0.154},"i":{"x":0.699,"y":0.719},"s":[0],"t":0},{"s":[510],"t":47}]},"o":{"a":0,"k":100}},"refId":"1","ind":1},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"263","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[99.349,100.928,100]},"p":{"a":0,"k":[954,510.25,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"st","lc":1,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.9451,0.3412,0.1333]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-743.609,305.033],[-738.59,263.241]]}}},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.9451,0.3412,0.1333]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[98.592,103.588]},"p":{"a":0,"k":[-5.011,6.5]},"r":{"a":0,"k":-0.053},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":7},{"s":[100],"t":9}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":9},{"s":[100],"t":11}]},"m":1}],"ind":2},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"262","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[96.177,101.048,100]},"p":{"a":0,"k":[866.5,531.5,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-743.864,304.554],[-779.298,288.413]]}}},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.9451,0.3412,0.1333]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[98.592,103.588]},"p":{"a":0,"k":[-1.011,6.5]},"r":{"a":0,"k":-0.053},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":7},{"s":[100],"t":9}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":9},{"s":[100],"t":11}]},"m":1}],"ind":3},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"261","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[98.909,100,100]},"p":{"a":0,"k":[915.5,517.75,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-743.864,304.554],[-759.43,273.603]]}}},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.9451,0.3412,0.1333]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[98.592,103.588]},"p":{"a":0,"k":[-1.011,6.5]},"r":{"a":0,"k":-0.053},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":7},{"s":[100],"t":9}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":9},{"s":[100],"t":11}]},"m":1}],"ind":4},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"260","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[98.909,100,100]},"p":{"a":0,"k":[977.25,526.5,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-743.864,304.554],[-715.508,281.455]]}}},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[98.592,103.588]},"p":{"a":0,"k":[-1.011,6.5]},"r":{"a":0,"k":-0.053},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":7},{"s":[100],"t":9}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":9},{"s":[100],"t":11}]},"m":1}],"ind":5},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"259","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[98.909,100,100]},"p":{"a":0,"k":[999,550.5,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-750.017,305.031],[-717.579,301.243]]}}},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[1,1,1]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[98.592,103.588]},"p":{"a":0,"k":[-1.011,6.5]},"r":{"a":0,"k":-0.053},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":7},{"s":[100],"t":9}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":9},{"s":[100],"t":11}]},"m":1}],"ind":6},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"258","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[972,486.5,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-33.25,350.25],[-30.5,305.75]]}}},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.102,0.1059,0.1176]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":22},{"s":[100],"t":24}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":24},{"s":[100],"t":26}]},"m":1}],"ind":7},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"257","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[942.5,495.5,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-34,348],[-59.75,314.25]]}}},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.102,0.1059,0.1176]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":22},{"s":[100],"t":24}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":24},{"s":[100],"t":26}]},"m":1}],"ind":8},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"256","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,98.509,100]},"p":{"a":0,"k":[915,520,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-26.5,350.03],[-61.25,337.28]]}}},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.102,0.1059,0.1176]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":22},{"s":[100],"t":24}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":24},{"s":[100],"t":26}]},"m":1}],"ind":9},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"255","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[1003,500.25,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-34,348],[-6.5,317]]}}},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.102,0.1059,0.1176]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":22},{"s":[100],"t":24}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":24},{"s":[100],"t":26}]},"m":1}],"ind":10},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"254","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[1018.75,523.5,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-34,348],[11.75,335.75]]}}},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.102,0.1059,0.1176]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":22},{"s":[100],"t":24}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":24},{"s":[100],"t":26}]},"m":1}],"ind":11},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"253","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[962,525,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[686,313.5],[686,273]]}}},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":40},{"s":[100],"t":42}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":42},{"s":[100],"t":44}]},"m":1},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.1651,0.1735,0.1984]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":40},{"s":[100],"t":42}]},"o":{"a":0,"k":0},"s":{"a":0,"k":0},"m":1}],"ind":12},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"252","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[931.5,531.5,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[686,315.75],[665.5,289]]}}},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":40},{"s":[100],"t":42}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":42},{"s":[100],"t":44}]},"m":1},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.1651,0.1735,0.1984]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":40},{"s":[100],"t":42}]},"o":{"a":0,"k":0},"s":{"a":0,"k":0},"m":1}],"ind":13},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"251","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[923,552,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[682.25,316.5],[643.75,311.25]]}}},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":40},{"s":[100],"t":42}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":42},{"s":[100],"t":44}]},"m":1},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.1651,0.1735,0.1984]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":40},{"s":[100],"t":42}]},"o":{"a":0,"k":0},"s":{"a":0,"k":0},"m":1}],"ind":14},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"250","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[991.5,534.5,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[686,313.75],[705,283]]}}},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":40},{"s":[100],"t":42}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":42},{"s":[100],"t":44}]},"m":1},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.1651,0.1735,0.1984]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":40},{"s":[100],"t":42}]},"o":{"a":0,"k":0},"s":{"a":0,"k":0},"m":1}],"ind":15},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"249","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[1009.5,555,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[686,313.75],[719,310.5]]}}},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":40},{"s":[100],"t":42}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":42},{"s":[100],"t":44}]},"m":1},{"ty":"st","lc":2,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":12},"c":{"a":0,"k":[0.9451,0.3412,0.1333]}},{"ty":"fl","c":{"a":0,"k":[0.1651,0.1735,0.1984]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tm","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[0],"t":40},{"s":[100],"t":42}]},"o":{"a":0,"k":0},"s":{"a":0,"k":0},"m":1}],"ind":16},{"ty":4,"sr":1,"st":0,"op":72,"ip":0,"ln":"248","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[1050.25,612.5,0]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0},"s":{"a":0,"k":[2400,1376]}},{"ty":"st","lc":1,"lj":1,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":2},"c":{"a":0,"k":[1,1,1]}},{"ty":"fl","c":{"a":0,"k":[0.102,0.1059,0.1176]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[97.585,97.063]},"p":{"a":0,"k":[-212.75,-127]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":17}],"v":"5.7.0","fr":24,"op":48,"ip":0,"assets":[{"id":"1","e":1,"w":1080,"h":1203,"p":"data:image/png;base64,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","u":""}]} \ No newline at end of file diff --git a/feature/onboarding/src/main/res/values/strings.xml b/feature/onboarding/src/main/res/values/strings.xml deleted file mode 100644 index b7d2ed4d3..000000000 --- a/feature/onboarding/src/main/res/values/strings.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - 싫어하는 음식을 선택해주세요 - 선호 음식 Top3까지 순위를\n매겨주세요 - 자주 가는 곳이 어디인가요? - 어떤 분위기의 공간이 좋으세요? - 선호하는 맛집 스타일의\n순위를 매겨주세요 - 회원님의 취향을 빠르게\n분석하고 있어요. - 분석이 완료되었어요!\n추천 맛집을 보여드릴게요. - 취향분석을 그만둘까요? - 선호도 조사만이 남아있어요!\n1분 내로 빠르게 끝낼 수 있어요. - 그만두기 - 계속하기 - \ No newline at end of file diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts index c511d978a..60ae74189 100644 --- a/feature/profile/build.gradle.kts +++ b/feature/profile/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.acon.android.library.orbit) alias(libs.plugins.acon.android.library.haze) alias(libs.plugins.acon.android.library.coil) + alias(libs.plugins.acon.feature.test) } val localProperties = Properties().apply { @@ -20,10 +21,20 @@ android { defaultConfig { buildConfigField("String", "BUCKET_URL", "\"${localProperties["BUCKET_URL"]}\"") } + + packaging { + resources { + merges += "META-INF/LICENSE.md" + merges += "META-INF/LICENSE-notice.md" + } + } } dependencies { implementation(libs.google.services.ads) // TODO - admob Plugin 분리? implementation(libs.androidx.paignig.compose) +} +tasks.withType { + useJUnitPlatform() } \ No newline at end of file diff --git a/feature/profile/src/androidTest/AndroidManifest.xml b/feature/profile/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..93feda432 --- /dev/null +++ b/feature/profile/src/androidTest/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/feature/profile/src/androidTest/kotlin/com/acon/feature/profile/info/composable/ProfileInfoScreenTest.kt b/feature/profile/src/androidTest/kotlin/com/acon/feature/profile/info/composable/ProfileInfoScreenTest.kt new file mode 100644 index 000000000..29e4288ef --- /dev/null +++ b/feature/profile/src/androidTest/kotlin/com/acon/feature/profile/info/composable/ProfileInfoScreenTest.kt @@ -0,0 +1,174 @@ +package com.acon.feature.profile.info.composable + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp +import com.acon.acon.core.model.model.profile.BirthDateStatus +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import com.acon.acon.core.model.model.profile.SavedSpot +import com.acon.acon.core.model.model.profile.SpotThumbnailStatus +import com.acon.feature.profile.TestTags +import com.acon.feature.profile.info.viewmodel.ProfileInfoUiState +import io.mockk.mockk +import io.mockk.verify +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class ProfileInfoScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + val dummyProfile = Profile( + nickname = "", + birthDate = BirthDateStatus.NotSpecified, + image = ProfileImageStatus.Default + ) + + @Test + fun `로그인_사용자일_경우_로그인_사용자용_프로필_뷰가_보인다`() { + // Given + val mockActions = mockk(relaxed = true) + composeTestRule.setContent { + ProfileInfoScreen( + state = ProfileInfoUiState.User( + profile = dummyProfile, + savedSpots = emptyList() + ), + actions = mockActions + ) + } + + // When + val userProfileViewNode = composeTestRule.onNodeWithTag(TestTags.USER_PROFILE_VIEW) + + // Then + userProfileViewNode.assertIsDisplayed() + } + + @Test + fun `비로그인_사용자일_경우_비로그인_사용자용_프로필_뷰가_보인다`() { + // Given + val mockActions = mockk(relaxed = true) + composeTestRule.setContent { + ProfileInfoScreen( + state = ProfileInfoUiState.Guest, + actions = mockActions + ) + } + + // When + val guestProfileViewNode = composeTestRule.onNodeWithTag(TestTags.GUEST_PROFILE_VIEW) + + // Then + guestProfileViewNode.assertIsDisplayed() + } + + @Test + fun `설정_아이콘_클릭_시_설정_아이콘_클릭_람다를_실행한다`() { + // Given + val mockActions = mockk(relaxed = true) + composeTestRule.setContent { + ProfileInfoScreen( + state = ProfileInfoUiState.User( + profile = dummyProfile, + savedSpots = emptyList() + ), + actions = mockActions + ) + } + + // When + val settingIconNode = composeTestRule.onNodeWithTag(TestTags.SETTING_ICON) + settingIconNode.performClick() + + // Then + verify(exactly = 1) { mockActions.onSettingIconClick() } + } + + @Test + fun `프로필_수정_아이콘_클릭_시_프로필_수정_아이콘_클릭_람다를_실행한다`() { + // Given + val mockActions = mockk(relaxed = true) + composeTestRule.setContent { + ProfileInfoScreen( + state = ProfileInfoUiState.User( + profile = dummyProfile, + savedSpots = emptyList() + ), + actions = mockActions + ) + } + + // When + val profileUpdateIconNode = composeTestRule.onNodeWithTag(TestTags.PROFILE_UPDATE_ICON) + profileUpdateIconNode.performClick() + + // Then + verify(exactly = 1) { mockActions.onProfileUpdateIconClick() } + } + + @Test + fun `저장한_장소_아이템_클릭_시_해당_장소_ID를_파라미터로_받는_람다를_실행한다`() { + // Given + val sampleSpotId = 123L + val mockActions = mockk(relaxed = true) + + composeTestRule.setContent { + ProfileInfoScreen( + state = ProfileInfoUiState.User( + profile = dummyProfile, + savedSpots = listOf( + SavedSpot( + spotId = sampleSpotId, + spotName = "Dummy name", + spotThumbnail = SpotThumbnailStatus.Empty + ) + ) + ), + actions = mockActions + ) + } + + // When + val savedSpotItemNode = composeTestRule.onNodeWithTag(TestTags.SAVED_SPOT_ITEM + sampleSpotId) + savedSpotItemNode.performClick() + + // Then + verify(exactly = 1) { mockActions.onSavedSpotItemClick(sampleSpotId) } + } + + @Test + fun `화면을_수직_스크롤해도_바텀_바는_움직이지_않는다`() { + // Given + val mockActions = mockk(relaxed = true) + composeTestRule.setContent { + ProfileInfoScreen( + state = ProfileInfoUiState.User( + profile = dummyProfile, + savedSpots = emptyList() + ), + actions = mockActions + ) + } + + val bottomBarNode = composeTestRule.onNodeWithTag(TestTags.BOTTOM_BAR) + val verticalScrollableViewNode = composeTestRule.onNodeWithTag(TestTags.VERTICAL_SCROLLABLE_VIEW) + + // When + val expectedBottomBarTop = bottomBarNode.getBoundsInRoot().top + verticalScrollableViewNode.performTouchInput { swipeUp() } + + val actualBottomBarTop = bottomBarNode.getBoundsInRoot().top + + // Then + assertEquals(expectedBottomBarTop, actualBottomBarTop) + bottomBarNode.assertIsDisplayed() + } +} diff --git a/feature/profile/src/androidTest/kotlin/com/acon/feature/profile/update/composable/BirthDateFieldViewTest.kt b/feature/profile/src/androidTest/kotlin/com/acon/feature/profile/update/composable/BirthDateFieldViewTest.kt new file mode 100644 index 000000000..3d0cae2a6 --- /dev/null +++ b/feature/profile/src/androidTest/kotlin/com/acon/feature/profile/update/composable/BirthDateFieldViewTest.kt @@ -0,0 +1,94 @@ +package com.acon.feature.profile.update.composable + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.text.input.TextFieldValue +import com.acon.acon.core.ui.test.getAlpha +import com.acon.feature.profile.TestTags +import com.acon.feature.profile.update.status.BirthDateValidationStatus +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class BirthDateFieldViewTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `생년월일_유효성_상태가_유효하지않음_이면_유효성_경고_행이_보인다`() { + // Given + val dummyInput = TextFieldValue("") + composeTestRule.setContent { + BirthDateFieldView( + input = dummyInput, + onInputChange = mockk(), + validationStatus = BirthDateValidationStatus.Invalid + ) + } + + // When + val birthDateValidationViewNode = composeTestRule.onNodeWithTag(TestTags.BIRTH_DATE_VALIDATION_RESULT_VIEW) + + // Then + assertEquals(1f, birthDateValidationViewNode.getAlpha()) + } + + @Test + fun `생년월일_유효성_상태가_초기상태_이면_유효성_경고_행이_보이지_않는다`() { + // Given + val dummyInput = TextFieldValue("") + composeTestRule.setContent { + BirthDateFieldView( + input = dummyInput, + onInputChange = mockk(), + validationStatus = BirthDateValidationStatus.Idle + ) + } + + // When + val birthDateValidationViewNode = composeTestRule.onNodeWithTag(TestTags.BIRTH_DATE_VALIDATION_RESULT_VIEW) + + // Then + assertEquals(0f, birthDateValidationViewNode.getAlpha()) + } + + @Test + fun `생년월일_유효성_상태가_입력중_이면_유효성_경고_행이_보이지_않는다`() { + // Given + val dummyInput = TextFieldValue("") + composeTestRule.setContent { + BirthDateFieldView( + input = dummyInput, + onInputChange = mockk(), + validationStatus = BirthDateValidationStatus.Typing + ) + } + + // When + val birthDateValidationViewNode = composeTestRule.onNodeWithTag(TestTags.BIRTH_DATE_VALIDATION_RESULT_VIEW) + + // Then + assertEquals(0f, birthDateValidationViewNode.getAlpha()) + } + + @Test + fun `생년월일_유효성_상태가_유효함_이면_유효성_경고_행이_보이지_않는다`() { + // Given + val dummyInput = TextFieldValue("") + composeTestRule.setContent { + BirthDateFieldView( + input = dummyInput, + onInputChange = mockk(), + validationStatus = BirthDateValidationStatus.Valid + ) + } + + // When + val birthDateValidationViewNode = composeTestRule.onNodeWithTag(TestTags.BIRTH_DATE_VALIDATION_RESULT_VIEW) + + // Then + assertEquals(0f, birthDateValidationViewNode.getAlpha()) + } +} diff --git a/feature/profile/src/androidTest/kotlin/com/acon/feature/profile/update/composable/NicknameFieldViewTest.kt b/feature/profile/src/androidTest/kotlin/com/acon/feature/profile/update/composable/NicknameFieldViewTest.kt new file mode 100644 index 000000000..60ef2de96 --- /dev/null +++ b/feature/profile/src/androidTest/kotlin/com/acon/feature/profile/update/composable/NicknameFieldViewTest.kt @@ -0,0 +1,134 @@ +package com.acon.feature.profile.update.composable + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.text.input.TextFieldValue +import com.acon.acon.core.ui.test.getAlpha +import com.acon.feature.profile.TestTags +import com.acon.feature.profile.update.status.NicknameValidationStatus +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +class NicknameFieldViewTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val dummyInput = TextFieldValue("") + + @Test + fun `닉네임_유효성_상태가_초기상태이면_유효성_결과_뷰가_보이지_않는다`() { + + // Given + composeTestRule.setContent { + NicknameFieldView( + input = dummyInput, + onInputChange = mockk(), + validationStatus = NicknameValidationStatus.Idle + ) + } + + // When + val nicknameValidationResultViewNode = composeTestRule.onNodeWithTag(TestTags.NICKNAME_VALIDATION_RESULT_VIEW) + + // Then + assertEquals(0f, nicknameValidationResultViewNode.getAlpha()) + } + + @Test + fun `닉네임_유효성_상태가_로딩이면_유효성_결과_뷰가_보이지_않는다`() { + + // Given + composeTestRule.setContent { + NicknameFieldView( + input = dummyInput, + onInputChange = mockk(), + validationStatus = NicknameValidationStatus.Loading + ) + } + + // When + val nicknameValidationResultViewNode = composeTestRule.onNodeWithTag(TestTags.NICKNAME_VALIDATION_RESULT_VIEW) + + // Then + assertEquals(0f, nicknameValidationResultViewNode.getAlpha()) + } + + @Test + fun `닉네임_유효성_상태가_사용가능_이면_유효성_결과_뷰가_보인다`() { + + // Given + composeTestRule.setContent { + NicknameFieldView( + input = dummyInput, + onInputChange = mockk(), + validationStatus = NicknameValidationStatus.Available + ) + } + + // When + val nicknameValidationResultViewNode = composeTestRule.onNodeWithTag(TestTags.NICKNAME_VALIDATION_RESULT_VIEW) + + // Then + assertEquals(1f, nicknameValidationResultViewNode.getAlpha()) + } + + @Test + fun `닉네임_유효성_상태가_빈입력_이면_유효성_결과_뷰가_보인다`() { + + // Given + composeTestRule.setContent { + NicknameFieldView( + input = dummyInput, + onInputChange = mockk(), + validationStatus = NicknameValidationStatus.Empty + ) + } + + // When + val nicknameValidationResultViewNode = composeTestRule.onNodeWithTag(TestTags.NICKNAME_VALIDATION_RESULT_VIEW) + + // Then + assertEquals(1f, nicknameValidationResultViewNode.getAlpha()) + } + + @Test + fun `닉네임_유효성_상태가_잘못된형식_이면_유효성_결과_뷰가_보인다`() { + + // Given + composeTestRule.setContent { + NicknameFieldView( + input = dummyInput, + onInputChange = mockk(), + validationStatus = NicknameValidationStatus.InvalidFormat + ) + } + + // When + val nicknameValidationResultViewNode = composeTestRule.onNodeWithTag(TestTags.NICKNAME_VALIDATION_RESULT_VIEW) + + // Then + assertEquals(1f, nicknameValidationResultViewNode.getAlpha()) + } + + @Test + fun `닉네임_유효성_상태가_중복닉네임_이면_유효성_결과_뷰가_보인다`() { + + // Given + composeTestRule.setContent { + NicknameFieldView( + input = dummyInput, + onInputChange = mockk(), + validationStatus = NicknameValidationStatus.AlreadyExist + ) + } + + // When + val nicknameValidationResultViewNode = composeTestRule.onNodeWithTag(TestTags.NICKNAME_VALIDATION_RESULT_VIEW) + + // Then + assertEquals(1f, nicknameValidationResultViewNode.getAlpha()) + } +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/GallerySelectBottomSheet.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/GallerySelectBottomSheet.kt deleted file mode 100644 index 14f398596..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/GallerySelectBottomSheet.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.acon.acon.feature.profile.composable.component - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -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.font.FontWeight -import androidx.compose.ui.unit.dp -import com.acon.acon.core.designsystem.R -import com.acon.acon.core.designsystem.component.bottomsheet.AconBottomSheet -import com.acon.acon.core.designsystem.noRippleClickable -import com.acon.acon.core.designsystem.theme.AconTheme - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun GallerySelectBottomSheet( - isDefault: Boolean, - modifier : Modifier = Modifier, - onDismiss: () -> Unit = {}, - onGallerySelect: () -> Unit = {}, - onDefaultImageSelect: () -> Unit = {} -){ - val padding = if(!isDefault) 75.dp else 115.dp - AconBottomSheet( - onDismissRequest = { onDismiss() } - ) { - Column( - modifier = modifier - .fillMaxWidth() - .padding(bottom = padding) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .noRippleClickable { - onGallerySelect() - onDismiss() - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text( - text = stringResource(R.string.upload_photo_from_album), - color = AconTheme.color.White, - style = AconTheme.typography.Title4, - fontWeight = FontWeight.Normal, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 17.dp) - ) - } - if(!isDefault) { - Row( - modifier = Modifier - .fillMaxWidth() - .noRippleClickable { - onDefaultImageSelect() - onDismiss() - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text( - text = stringResource(R.string.set_default_profile_image), - color = AconTheme.color.White, - style = AconTheme.typography.Title4, - fontWeight = FontWeight.Normal, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 17.dp) - ) - } - } - } - } -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/NicknameValidMessageRow.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/NicknameValidMessageRow.kt deleted file mode 100644 index b476afbde..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/NicknameValidMessageRow.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.acon.acon.feature.profile.composable.component - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -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.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.dp -import com.acon.acon.core.designsystem.theme.AconTheme - -@Composable -fun NicknameValidMessageRow( - @StringRes validMessage: Int, - @DrawableRes iconRes: Int, - @StringRes validContentDescription: Int, - color: Color -){ - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 8.dp) - ) { - Icon( - imageVector = ImageVector.vectorResource(iconRes), - contentDescription = stringResource(validContentDescription), - tint = color, - modifier = Modifier.padding(vertical = 1.dp) - ) - Text( - text = stringResource(validMessage), - style = AconTheme.typography.Body1, - color = color, - modifier = Modifier - .padding(start = 4.dp) - ) - } -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/ProfilePhotoBox.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/ProfilePhotoBox.kt deleted file mode 100644 index 615a98fce..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/ProfilePhotoBox.kt +++ /dev/null @@ -1,85 +0,0 @@ -package com.acon.acon.feature.profile.composable.component - -import android.net.Uri -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -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.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import coil3.compose.AsyncImage -import coil3.compose.rememberAsyncImagePainter -import com.acon.acon.core.designsystem.R -import com.acon.acon.core.designsystem.image.rememberDefaultLoadImageErrorPainter - -@Composable -fun ProfilePhotoBox( - photoUri: String, - modifier: Modifier = Modifier -) { - BoxWithConstraints( - modifier = modifier.fillMaxSize() - ) { - if (photoUri.isNotBlank()) { - val imageWidth = maxWidth - Box( - modifier = Modifier - .fillMaxSize() - .width(imageWidth) - .height(imageWidth) - .clip(CircleShape), - contentAlignment = Alignment.Center - ) { - when { - photoUri.startsWith("content://") -> { - Image( - painter = rememberAsyncImagePainter(Uri.parse(photoUri)), - contentDescription = stringResource(R.string.content_description_settings), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - alignment = Alignment.Center - ) - } - - photoUri.startsWith("https://") -> { - AsyncImage( - model = photoUri, - contentDescription = stringResource(R.string.content_description_settings), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - alignment = Alignment.Center, - error = rememberDefaultLoadImageErrorPainter() - ) - } - - photoUri == "basic_profile_image" -> { - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_default_profile), - contentDescription = stringResource(R.string.content_description_default_profile_image), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - alignment = Alignment.Center - ) - } - } - } - } else { - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_default_profile), - contentDescription = stringResource(R.string.content_description_default_profile_image), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - alignment = Alignment.Center - ) - } - } -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/ProfileTextField.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/ProfileTextField.kt deleted file mode 100644 index aaa169b18..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/component/ProfileTextField.kt +++ /dev/null @@ -1,155 +0,0 @@ -package com.acon.acon.feature.profile.composable.component - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -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.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material3.CircularProgressIndicator -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.focus.FocusManager -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.acon.acon.core.designsystem.R -import com.acon.acon.core.designsystem.noRippleClickable -import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.feature.profile.composable.type.FocusType -import com.acon.acon.feature.profile.composable.type.TextFieldStatus - -@Composable -internal fun ProfileTextField( - status: TextFieldStatus, - focusType: FocusType, - focusRequester: FocusRequester, - modifier: Modifier = Modifier, - value: TextFieldValue = TextFieldValue(), - onValueChange: (TextFieldValue) -> Unit, - placeholder: String? = "", - isTyping: Boolean = false, - onFocusChanged: (Boolean, FocusType) -> Unit = { _, _ -> }, - visualTransformation: VisualTransformation = VisualTransformation.None, - onClick: () -> Unit = {}, -) { - Box( - modifier = modifier - .fillMaxWidth() - .border( - width = 1.dp, - color = AconTheme.color.GlassWhiteDefault, - shape = RoundedCornerShape(8.dp) - ) - .background( - shape = RoundedCornerShape(8.dp), - color = AconTheme.color.Gray900 - ) - .padding(vertical = 12.dp, horizontal = 10.dp) - .noRippleClickable(onClick = onClick), - contentAlignment = Alignment.CenterStart - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - BasicTextField( - value = value, - onValueChange = onValueChange, - maxLines = 1, - cursorBrush = SolidColor(AconTheme.color.Action), - modifier = Modifier - .weight(1f) - .focusRequester(focusRequester) - .onFocusChanged { focusState -> - onFocusChanged(focusState.isFocused, focusType) - }, - textStyle = AconTheme.typography.Body1.copy( - color = AconTheme.color.White - ), - visualTransformation = visualTransformation, - decorationBox = { innerTextField -> - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterStart - ) { - if (value.text.isEmpty() && status != TextFieldStatus.Focused) { - if (placeholder != null) { - Text( - text = placeholder, - style = AconTheme.typography.Body1, - color = AconTheme.color.Gray500 - ) - } - } - innerTextField() - } - } - ) - - if (value.text.isNotEmpty() && value.text.length <= 14) { - if (isTyping) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = AconTheme.color.Gray6 - ) - } else { - Icon( - modifier = Modifier - .clickable { onValueChange(TextFieldValue()) } - .size(18.dp), - imageVector = ImageVector.vectorResource(R.drawable.ic_clear), - contentDescription = stringResource(R.string.clear_search_content_description), - tint = AconTheme.color.Gray50 - ) - } - } else { - Spacer(Modifier.size(18.dp)) - } - } - } -} - -fun Modifier.addFocusCleaner(focusManager: FocusManager, doOnClear: () -> Unit = {}): Modifier { - return this.pointerInput(Unit) { - detectTapGestures( - onTap = { - doOnClear() - focusManager.clearFocus() - } - ) - } -} - -@Preview -@Composable -private fun ProfileTextFieldPreview() { - AconTheme { - ProfileTextField( - status = TextFieldStatus.Inactive, - focusRequester = FocusRequester(), - focusType = FocusType.Nickname, - onValueChange = {}, - placeholder = "" - ) - } -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/MockSavedSpotList.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/MockSavedSpotList.kt deleted file mode 100644 index c3abf1907..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/MockSavedSpotList.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.acon.acon.feature.profile.composable.screen - -import com.acon.acon.core.model.model.profile.SavedSpot -import okhttp3.internal.immutableListOf - -internal val mockSpotList = immutableListOf( - com.acon.acon.core.model.model.profile.SavedSpot( - spotId = 1, - image = "https://acon-bucket.s3.ap-northeast-2.amazonaws.com/members/profile-images/e6547003-b4df-42fe-9275-9d9c5a008e79.jpg", - name = "카페 브리즈" - ), - com.acon.acon.core.model.model.profile.SavedSpot( - spotId = 2, - image = "", - name = "서울 맛집" - ), - com.acon.acon.core.model.model.profile.SavedSpot( - spotId = 3, - image = "https://acon-bucket.s3.ap-northeast-2.amazonaws.com/members/profile-images/e6547003-b4df-42fe-9275-9d9c5a008e79.jpg", - name = "초밥 천국" - ), - com.acon.acon.core.model.model.profile.SavedSpot( - spotId = 4, - image = "https://acon-bucket.s3.ap-northeast-2.amazonaws.com/members/profile-images/e6547003-b4df-42fe-9275-9d9c5a008e79.jpg", - name = "분식당" - ), - com.acon.acon.core.model.model.profile.SavedSpot( - spotId = 5, - image = "", - name = "핫플 베이커리" - ), - com.acon.acon.core.model.model.profile.SavedSpot( - spotId = 6, - image = "https://acon-bucket.s3.ap-northeast-2.amazonaws.com/members/profile-images/e6547003-b4df-42fe-9275-9d9c5a008e79.jpg", - name = "우식당" - ), - com.acon.acon.core.model.model.profile.SavedSpot( - spotId = 7, - image = "", - name = "얼음식당" - ), - com.acon.acon.core.model.model.profile.SavedSpot( - spotId = 8, - image = "https://acon-bucket.s3.ap-northeast-2.amazonaws.com/members/profile-images/e6547003-b4df-42fe-9275-9d9c5a008e79.jpg", - name = "소리식당" - ), - com.acon.acon.core.model.model.profile.SavedSpot( - spotId = 9, - image = "", - name = "얼음 베이커리" - ), -) \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/ProfileViewModel.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/ProfileViewModel.kt deleted file mode 100644 index 8b3c27880..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/ProfileViewModel.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.acon.acon.feature.profile.composable.screen.profile - -import androidx.compose.runtime.Immutable -import androidx.lifecycle.viewModelScope -import com.acon.acon.core.model.model.profile.ProfileInfo -import com.acon.acon.domain.repository.ProfileRepository -import com.acon.acon.core.model.type.UserType -import com.acon.acon.core.ui.base.BaseContainerHost -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import org.orbitmvi.orbit.viewmodel.container -import javax.inject.Inject - -@HiltViewModel -class ProfileViewModel @Inject constructor( - private val profileRepository: ProfileRepository -) : BaseContainerHost() { - - val updateProfileState = profileRepository.getProfileType() - - override val container = - container(ProfileUiState.Success(com.acon.acon.core.model.model.profile.ProfileInfo.Empty)) { - userType.collect { - when(it) { - UserType.GUEST -> reduce { ProfileUiState.Guest } - else -> { - profileRepository.fetchProfile().collect { profileInfoResult -> - profileInfoResult.onSuccess { - reduce { ProfileUiState.Success(profileInfo = it) } - }.onFailure { - postSideEffect(ProfileUiSideEffect.FailedToLoadProfileInfo) - } - } - } - } - } - } - - fun resetUpdateProfileType() { - viewModelScope.launch { - profileRepository.resetProfileType() - } - } - - fun onSpotDetail(spotId: Long) = intent { - postSideEffect(ProfileUiSideEffect.OnNavigateToSpotDetailScreen(spotId)) - } - - fun onBookmark() = intent { - postSideEffect(ProfileUiSideEffect.OnNavigateToBookmarkScreen) - } - - fun onSettings() = intent { - postSideEffect(ProfileUiSideEffect.OnNavigateToSettingsScreen) - } - - fun onEditProfile() = intent { - postSideEffect(ProfileUiSideEffect.OnNavigateToProfileEditScreen) - } -} - -sealed interface ProfileUiState { - @Immutable - data class Success( - val profileInfo: com.acon.acon.core.model.model.profile.ProfileInfo - ) : ProfileUiState - - data object Guest : ProfileUiState -} - -sealed interface ProfileUiSideEffect { - data class OnNavigateToSpotDetailScreen(val spotId: Long) : ProfileUiSideEffect - data object OnNavigateToBookmarkScreen : ProfileUiSideEffect - data object OnNavigateToSpotListScreen : ProfileUiSideEffect - data object OnNavigateToSettingsScreen : ProfileUiSideEffect - data object OnNavigateToProfileEditScreen : ProfileUiSideEffect - - data object FailedToLoadProfileInfo : ProfileUiSideEffect -} diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkItem.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkItem.kt deleted file mode 100644 index 2aea79e0f..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkItem.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.acon.acon.feature.profile.composable.screen.profile.composable - -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.acon.acon.core.designsystem.R -import com.acon.acon.core.designsystem.effect.imageGradientLayer -import com.acon.acon.core.designsystem.effect.imageGradientTopLayer -import com.acon.acon.core.designsystem.image.rememberDefaultLoadImageErrorPainter -import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.core.model.model.profile.SavedSpot - -@Composable -internal fun BookmarkItem( - spot: com.acon.acon.core.model.model.profile.SavedSpot, - onClickSpotItem:() -> Unit, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .clickable { onClickSpotItem() } - ) { - if(spot.image.isNotEmpty()) { - AsyncImage( - model = spot.image, - contentDescription = stringResource(R.string.store_background_image_content_description), - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .imageGradientTopLayer(), - error = rememberDefaultLoadImageErrorPainter() - ) - - Text( - text = if (spot.name.length > 9) spot.name.take(8) + stringResource(R.string.ellipsis) else spot.name, - color = AconTheme.color.White, - style = AconTheme.typography.Title5, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .padding(top = 20.dp) - .padding(horizontal = 20.dp) - ) - } else { - Image( - painter = painterResource(R.drawable.ic_bg_no_store_profile), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .imageGradientLayer() - ) - - Text( - text = if (spot.name.length > 9) spot.name.take(8) + stringResource(R.string.ellipsis) else spot.name, - color = AconTheme.color.White, - style = AconTheme.typography.Title5, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.TopCenter) - .padding(top = 20.dp) - .padding(horizontal = 20.dp) - ) - - Text( - text = stringResource(R.string.no_store_image), - color = AconTheme.color.Gray50, - style = AconTheme.typography.Caption1, - modifier = Modifier - .align(Alignment.Center) - ) - } - } -} - -@Preview -@Composable -private fun BookmarkItemPreview() { - AconTheme { - BookmarkItem( - spot = com.acon.acon.core.model.model.profile.SavedSpot(1, "", ""), - onClickSpotItem = {} - ) - } -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreen.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreen.kt deleted file mode 100644 index 53f175d61..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreen.kt +++ /dev/null @@ -1,348 +0,0 @@ -package com.acon.acon.feature.profile.composable.screen.profile.composable - -import android.annotation.SuppressLint -import androidx.compose.foundation.Image -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.aspectRatio -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 -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.acon.acon.core.designsystem.R -import com.acon.acon.core.designsystem.component.bottombar.AconBottomBar -import com.acon.acon.core.designsystem.component.bottombar.BottomNavType -import com.acon.acon.core.designsystem.component.topbar.AconTopBar -import com.acon.acon.core.designsystem.effect.LocalHazeState -import com.acon.acon.core.designsystem.effect.defaultHazeEffect -import com.acon.acon.core.designsystem.noRippleClickable -import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.core.model.model.profile.ProfileInfo -import com.acon.acon.core.model.type.UserType -import com.acon.acon.feature.profile.composable.screen.profile.ProfileUiState -import com.acon.acon.core.ui.compose.LocalRequestSignIn -import com.acon.acon.core.ui.compose.LocalUserType -import com.acon.acon.core.ui.compose.getScreenHeight -import dev.chrisbanes.haze.hazeSource - -@SuppressLint("ConfigurationScreenWidthHeight") -@Composable -fun ProfileScreen( - state: ProfileUiState, - modifier: Modifier = Modifier, - onSpotDetail: (Long) -> Unit = {}, - onBookmark: () -> Unit = {}, - onSettings: () -> Unit = {}, - onEditProfile: () -> Unit = {}, - onNavigateToSpotListScreen: () -> Unit = {}, - onNavigateToUploadScreen: () -> Unit = {}, -) { - val screenHeightDp = getScreenHeight() - val profileImageHeight = (screenHeightDp * (60f / 740f)) - val admobHeight = (screenHeightDp * (165f / 740f)) - val savedStoreHeight = (screenHeightDp * (200f / 740f)) - - val userType = LocalUserType.current - val onSignInRequired = LocalRequestSignIn.current - - Column(modifier) { - when (state) { - is ProfileUiState.Success -> { - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()) - .hazeSource(LocalHazeState.current) - ) { - AconTopBar( - content = { - Text( - text = stringResource(R.string.profile_topbar), - style = AconTheme.typography.Title4, - fontWeight = FontWeight.SemiBold, - color = AconTheme.color.White - ) - }, - trailingIcon = { - IconButton( - onClick = { onSettings() } - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_setting), - contentDescription = stringResource(R.string.content_description_settings), - tint = AconTheme.color.White - ) - } - }, - modifier = Modifier.padding(vertical = 14.dp) - ) - - Column( - modifier = Modifier - .padding(top = 40.dp) - .padding(horizontal = 16.dp) - ) { - Row { - if (state.profileInfo.image.isEmpty()) { - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_default_profile), - contentDescription = stringResource(R.string.content_description_default_profile_image), - modifier = Modifier - .size(profileImageHeight) - .clip(CircleShape) - ) - } else { - AsyncImage( - model = state.profileInfo.image, - contentDescription = stringResource(R.string.content_description_profile_image), - modifier = Modifier - .size(profileImageHeight) - .clip(CircleShape), - contentScale = ContentScale.Crop, - error = painterResource(R.drawable.ic_default_profile) - ) - } - - Column( - modifier = Modifier - .padding(vertical = 5.dp) - .padding(start = 16.dp) - ) { - Text( - text = state.profileInfo.nickname, - style = AconTheme.typography.Headline4, - color = AconTheme.color.White - ) - - Spacer(Modifier.height(4.dp)) - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.edit_profile), - style = AconTheme.typography.Body1, - color = AconTheme.color.Gray500, - ) - - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_edit), - contentDescription = stringResource(R.string.content_description_edit_profile), - modifier = Modifier - .padding(start = 4.dp) - .noRippleClickable { onEditProfile() } - ) - } - } - } - - // TODO - saveSpot = isEmpty -> 저장한 장소가 없어요. - - Spacer(Modifier.height(42.dp)) - if (state.profileInfo != com.acon.acon.core.model.model.profile.ProfileInfo.Empty) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp) - ) { - Text( - text = stringResource(R.string.saved_store), - color = AconTheme.color.White, - style = AconTheme.typography.Title4, - fontWeight = FontWeight.SemiBold - ) - - Spacer(Modifier.weight(1f)) - if (state.profileInfo.savedSpots.isNotEmpty()) { - Text( - text = stringResource(R.string.show_saved_all_store), - color = AconTheme.color.Action, - style = AconTheme.typography.Body1, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .padding(vertical = 2.dp) - .padding(end = 8.dp) - .noRippleClickable { onBookmark() } - ) - } - } - - Spacer(Modifier.height(8.dp)) - if (state.profileInfo.savedSpots.isNotEmpty()) { - LazyRow( - modifier = Modifier - .fillMaxWidth() - .height(savedStoreHeight), - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - items( - items = state.profileInfo.savedSpots, - key = { it.spotId } - ) { spot -> - BookmarkItem( - spot = spot, - onClickSpotItem = { onSpotDetail(spot.spotId) }, - modifier = Modifier.aspectRatio(150f / 217f) - ) - } - } - } else { - Text( - text = stringResource(R.string.no_saved_spot), - style = AconTheme.typography.Body1, - fontWeight = FontWeight.W400, - color = AconTheme.color.Gray500, - ) - } - } - Spacer(Modifier.height(if (state.profileInfo.savedSpots.isEmpty()) 40.dp else 20.dp)) -// ProfileNativeAd( -// screenHeight = admobHeight, -// modifier = Modifier.padding(bottom = 23.dp) -// ) - } - } - } - - is ProfileUiState.Guest -> { - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()) - .hazeSource(LocalHazeState.current) - ) { - AconTopBar( - content = { - Text( - text = stringResource(R.string.profile_topbar), - style = AconTheme.typography.Title4, - fontWeight = FontWeight.SemiBold, - color = AconTheme.color.White - ) - }, - trailingIcon = { - IconButton( - onClick = { onSettings() } - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_setting), - contentDescription = stringResource(R.string.content_description_settings), - tint = AconTheme.color.White - ) - } - }, - modifier = Modifier.padding(vertical = 14.dp) - ) - - Column( - modifier = Modifier - .weight(1f) - .padding(top = 40.dp) - .padding(horizontal = 16.dp) - - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_default_profile), - contentDescription = stringResource(R.string.content_description_default_profile_image), - modifier = Modifier - .size(profileImageHeight) - .clip(CircleShape) - ) - - Row( - modifier = Modifier - .padding(start = 16.dp) - .noRippleClickable { - onSignInRequired("") - } - ) { - Text( - text = stringResource(R.string.you_need_sign_in), - style = AconTheme.typography.Headline4, - color = AconTheme.color.White, - ) - - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_right_24), - contentDescription = stringResource(R.string.content_description_go_sign_in), - modifier = Modifier.padding(start = 4.dp), - tint = AconTheme.color.Gray50 - ) - } - } - -// ProfileNativeAd( -// screenHeight = admobHeight, -// modifier = Modifier.padding(top = 20.dp) -// ) - } - } - } - } - AconBottomBar( - selectedItem = BottomNavType.PROFILE, - onItemClick = { bottomType -> - when (bottomType) { - BottomNavType.SPOT -> { - onNavigateToSpotListScreen() - } - - BottomNavType.UPLOAD -> { - if (userType == com.acon.acon.core.model.type.UserType.GUEST) { - onSignInRequired("click_upload_guest?") - } else { - onNavigateToUploadScreen() - } - } - - BottomNavType.PROFILE -> Unit - } - }, - modifier = Modifier - .fillMaxWidth() - .defaultHazeEffect( - hazeState = LocalHazeState.current, - tintColor = AconTheme.color.GlassGray900 - ) - .navigationBarsPadding() - ) - } -} - -@Preview -@Composable -private fun ProfileScreenPreview() { - AconTheme { - ProfileScreen( - state = ProfileUiState.Guest - ) - } -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreenContainer.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreenContainer.kt deleted file mode 100644 index a46db6da6..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/ProfileScreenContainer.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.acon.acon.feature.profile.composable.screen.profile.composable - -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel -import com.acon.acon.core.designsystem.R -import com.acon.acon.core.ui.android.showToast -import com.acon.acon.core.model.type.UpdateProfileType -import com.acon.acon.feature.profile.composable.screen.profile.ProfileUiSideEffect -import com.acon.acon.feature.profile.composable.screen.profile.ProfileViewModel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import org.orbitmvi.orbit.compose.collectAsState -import org.orbitmvi.orbit.compose.collectSideEffect - -@Composable -fun ProfileScreenContainer( - snackbarHostState: SnackbarHostState, - modifier: Modifier = Modifier, - onNavigateToSpotDetailScreen: (Long) -> Unit = {}, - onNavigateToBookMarkScreen: () -> Unit = {}, - onNavigateToSpotListScreen: () -> Unit = {}, - onNavigateToSettingsScreen: () -> Unit = {}, - onNavigateToProfileEditScreen: () -> Unit = {}, - onNavigateToUploadScreen: () -> Unit = {}, - viewModel: ProfileViewModel = hiltViewModel() -) { - val context = LocalContext.current - val state by viewModel.collectAsState() - - val coroutineScope = rememberCoroutineScope() - val snackbarMsg = stringResource(R.string.profile_save_success) - - LaunchedEffect(viewModel.updateProfileState) { - viewModel.updateProfileState.collectLatest { - when(it) { - com.acon.acon.core.model.type.UpdateProfileType.IDLE -> {} - com.acon.acon.core.model.type.UpdateProfileType.SUCCESS -> { - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = snackbarMsg, - duration = SnackbarDuration.Short, - ) - } - viewModel.resetUpdateProfileType() - } - com.acon.acon.core.model.type.UpdateProfileType.FAILURE -> {} - } - } - } - - ProfileScreen( - state = state, - modifier = modifier, - onBookmark = viewModel::onBookmark, - onSettings = viewModel::onSettings, - onSpotDetail = viewModel::onSpotDetail, - onEditProfile = viewModel::onEditProfile, - onNavigateToSpotListScreen = onNavigateToSpotListScreen, - onNavigateToUploadScreen = onNavigateToUploadScreen - ) - - viewModel.useUserType() - viewModel.collectSideEffect { - when(it) { - is ProfileUiSideEffect.OnNavigateToSpotDetailScreen -> { onNavigateToSpotDetailScreen(it.spotId) } - is ProfileUiSideEffect.OnNavigateToBookmarkScreen -> { onNavigateToBookMarkScreen() } - is ProfileUiSideEffect.OnNavigateToSpotListScreen -> { onNavigateToSpotListScreen() } - is ProfileUiSideEffect.OnNavigateToSettingsScreen -> { onNavigateToSettingsScreen() } - is ProfileUiSideEffect.OnNavigateToProfileEditScreen -> { onNavigateToProfileEditScreen() } - is ProfileUiSideEffect.FailedToLoadProfileInfo -> { context.showToast(R.string.unknown_error) } - } - } -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/ProfileModViewModel.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/ProfileModViewModel.kt deleted file mode 100644 index d3c048090..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/ProfileModViewModel.kt +++ /dev/null @@ -1,423 +0,0 @@ -package com.acon.acon.feature.profile.composable.screen.profileMod - -import android.app.Application -import android.net.Uri -import androidx.compose.runtime.Immutable -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import com.acon.acon.domain.error.profile.ValidateNicknameError -import com.acon.acon.domain.repository.ProfileRepository -import com.acon.acon.feature.profile.BuildConfig -import com.acon.acon.feature.profile.composable.type.BirthdayValidationStatus -import com.acon.acon.feature.profile.composable.type.FocusType -import com.acon.acon.feature.profile.composable.type.NicknameErrorType -import com.acon.acon.feature.profile.composable.type.NicknameValidationStatus -import com.acon.acon.feature.profile.composable.type.TextFieldStatus -import com.acon.acon.feature.profile.composable.utils.isAllowedChar -import com.acon.acon.feature.profile.composable.utils.limitedNickname -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import org.orbitmvi.orbit.ContainerHost -import org.orbitmvi.orbit.annotation.OrbitExperimental -import org.orbitmvi.orbit.viewmodel.container -import timber.log.Timber -import javax.inject.Inject - -@OptIn(OrbitExperimental::class) -@HiltViewModel -class ProfileModViewModel @Inject constructor( - private val profileRepository: ProfileRepository, - application: Application -) : AndroidViewModel(application), ContainerHost { - - private var nicknameValidationJob: Job? = null - - override val container = - container(ProfileModState.Loading) { - fetchUserProfileInfo() - } - - fun onFocusChanged(isFocused: Boolean, field: FocusType) = intent { - runOn { - reduce { - when (field) { - FocusType.Nickname -> state.copy( - nicknameFieldStatus = if (isFocused) TextFieldStatus.Focused else TextFieldStatus.Inactive - ) - - FocusType.Birthday -> state.copy( - birthdayFieldStatus = if (isFocused) TextFieldStatus.Focused else TextFieldStatus.Inactive - ) - } - } - } - } - - private fun fetchUserProfileInfo() = intent { - profileRepository.fetchProfile().collect { - it.onSuccess { profile -> - reduce { - ProfileModState.Success( - fetchedNickname = profile.nickname, - fetchedBirthday = profile.birthDate?.filter { it.isDigit() } ?: "", - fetchedPhotoUri = profile.image - ) - } - onNicknameChanged(profile.nickname, delayValidation = true) - onBirthdayChanged(profile.birthDate ?: "") - } - } - } - - private suspend fun validateNickname( - nickname: String - ): NicknameErrorType? { - return profileRepository.validateNickname(nickname) - .map { null } - .recover { throwable -> - when (throwable) { - is ValidateNicknameError.UnsatisfiedCondition -> NicknameErrorType.Invalid - is ValidateNicknameError.AlreadyUsedNickname -> NicknameErrorType.Duplicate - else -> null - } - } - .getOrNull() - } - - fun onNicknameChanged(text: String, delayValidation: Boolean = false) = intent { - runOn { - val (limitedText, count) = text.limitedNickname() - - val localErrorType: NicknameErrorType? = when { - text.any { !it.isAllowedChar() } -> NicknameErrorType.Invalid - else -> null - } - - reduce { - state.copy( - nickname = limitedText, - isEdited = (state.isEdited || limitedText != state.fetchedNickname), - nicknameCount = count, - nicknameValidationStatus = when { - text.isEmpty() -> NicknameValidationStatus.Empty - localErrorType != null -> NicknameValidationStatus.Error(localErrorType) - else -> NicknameValidationStatus.Typing - }, - nicknameFieldStatus = if (limitedText.isEmpty()) TextFieldStatus.Empty else state.nicknameFieldStatus - ) - } - - nicknameValidationJob?.cancel() - nicknameValidationJob = viewModelScope.launch { - if (text.length > 14) return@launch - if (delayValidation) delay(1000L) else delay(500L) - - if (localErrorType == NicknameErrorType.Invalid) return@launch - val serverErrorType = validateNickname(limitedText) - - reduce { - state.copy( - nicknameValidationStatus = when { - limitedText.isBlank() -> NicknameValidationStatus.Empty - serverErrorType != null -> NicknameValidationStatus.Error( - serverErrorType - ) - - localErrorType != null -> NicknameValidationStatus.Error(localErrorType) - else -> NicknameValidationStatus.Valid - } - ) - } - } - } - } - - fun onBirthdayChanged(text: String) = intent { - runOn { - val digitsOnly = text.filter { it.isDigit() } - val limitedDigits = digitsOnly.take(8) - - val (validationStatus, fieldStatus) = when { - limitedDigits.isEmpty() -> BirthdayValidationStatus.Empty to TextFieldStatus.Empty - limitedDigits.length < 8 -> BirthdayValidationStatus.Empty to TextFieldStatus.Focused - else -> { - val isValid = validateBirthday(limitedDigits) - (if (isValid) BirthdayValidationStatus.Valid else BirthdayValidationStatus.Invalid) to - (if (isValid) TextFieldStatus.Focused else TextFieldStatus.Error) - } - } - - reduce { - state.copy( - isEdited = state.isEdited || limitedDigits != state.fetchedBirthday, - birthday = limitedDigits, - birthdayValidationStatus = validationStatus, - birthdayFieldStatus = fieldStatus - ) - } - } - } - - private fun validateBirthday(birthday: String): Boolean { - val today = java.time.LocalDate.now() - val year = birthday.substring(0, 4).toIntOrNull() ?: return false - val month = birthday.substring(4, 6).toIntOrNull() ?: return false - val day = birthday.substring(6, 8).toIntOrNull() ?: return false - - val currentYear = java.time.Year.now().value - if (year > currentYear || year <= 1940) return false - - if (month !in 1..12) return false - - val maxDays = java.time.YearMonth.of(year, month).lengthOfMonth() - if (day !in 1..maxDays) return false - - return try { - val inputDate = java.time.LocalDate.of(year, month, day) - !inputDate.isAfter(today) - } catch (e: Exception) { - false - } - } - - fun navigateToBack() = intent { - postSideEffect(ProfileModSideEffect.NavigateBack) - } - - fun onRequestExitDialog() = intent { - runOn { - reduce { - state.copy(showExitDialog = true) - } - } - } - - fun onDisMissExitDialog() = intent { - runOn { - reduce { - state.copy(showExitDialog = false) - } - } - } - - fun updateProfileImage(selectedPhotoUri: String) = intent { - runOn { - reduce { - state.copy(selectedPhotoUri = selectedPhotoUri) - } - } - } - - fun onRequestProfileEditModal() = intent { - runOn { - reduce { - state.copy(showPhotoEditModal = true) - } - } - } - - fun onDisMissProfileEditModal() = intent { - runOn { - reduce { - state.copy(showPhotoEditModal = false) - } - } - } - - fun getPreSignedUrl() = intent { - runOn { - Timber.tag(TAG).d("getPreSignedUrl() 호출됨") - val nickname = state.nickname - val isBirthdayValid = state.birthdayValidationStatus == BirthdayValidationStatus.Valid - val birthday = if (isBirthdayValid) state.birthday else null - - when { - state.selectedPhotoUri.isEmpty() -> { - Timber.tag(TAG).d("selectedPhotoUri가 비어 있음 → 기존 이미지로 updateProfile 호출") - updateProfile( - fileName = state.fetchedPhotoUri, - nickname = nickname, - birthday = birthday, - uri = state.fetchedPhotoUri - ) - } - - state.selectedPhotoUri == "basic_profile_image" -> { - Timber.tag(TAG).d("selectedPhotoUri가 basic_profile_image → 기본 이미지로 updateProfile 호출") - updateProfile( - fileName = state.uploadFileName, - nickname = nickname, - birthday = birthday, - uri = state.selectedPhotoUri - ) - } - - else -> { - Timber.tag(TAG).d("selectedPhotoUri가 커스텀 이미지 → presigned URL 요청") - profileRepository.getPreSignedUrl() - .onSuccess { result -> - reduce { state.copy(uploadFileName = result.fileName) } - putPhotoToPreSignedUrl( - nickname = nickname, - birthday = birthday, - imageUri = Uri.parse(state.selectedPhotoUri), - preSignedUrl = result.preSignedUrl - ) - } - .onFailure { - Timber.tag(TAG).e(it, "presigned URL 획득 실패") - } - } - } - } - } - - private fun putPhotoToPreSignedUrl( - nickname: String, - birthday: String?, - imageUri: Uri, - preSignedUrl: String - ) = intent { - runOn { - val context = getApplication().applicationContext - val client = OkHttpClient() - - try { - val byteArray: ByteArray - val mimeType: String - - if (state.selectedPhotoUri.startsWith("content://")) { - val inputStream = context.contentResolver.openInputStream(imageUri) - byteArray = inputStream?.readBytes() - ?: throw IllegalArgumentException("이미지 읽기 실패") - mimeType = context.contentResolver.getType(imageUri) ?: "image/jpeg" - - } else if (state.selectedPhotoUri.startsWith("http://") || state.selectedPhotoUri.startsWith("https://")) { - Timber.tag(TAG).d("원격 URL에서 이미지 가져오기 시작") - val getRequest = Request.Builder().url(imageUri.toString()).build() - val getResponse = client.newCall(getRequest).execute() - - if (!getResponse.isSuccessful) { - Timber.tag(TAG).e("원격 이미지 가져오기 실패, code: %d", getResponse.code) - throw IllegalArgumentException("원격 이미지 가져오기 실패") - } - - byteArray = getResponse.body?.bytes() - ?: throw IllegalArgumentException("원격 이미지 읽기 실패") - mimeType = getResponse.header("Content-Type") ?: "image/jpeg" - - } else { - Timber.tag(TAG).e("지원하지 않는 URI scheme: %s", state.selectedPhotoUri) - throw IllegalArgumentException("지원하지 않는 URI scheme") - } - - val fileBody = - byteArray.toRequestBody(mimeType.toMediaTypeOrNull(), 0, byteArray.size) - - val request = Request.Builder() - .url(preSignedUrl) - .put(fileBody) - .addHeader("Content-Type", mimeType) - .build() - - val response = client.newCall(request).execute() - val bucketImageUri = "${BuildConfig.BUCKET_URL}${state.uploadFileName}" - - if (response.isSuccessful) { - Timber.tag(TAG).d("이미지 업로드 성공") - if (state.birthdayValidationStatus == BirthdayValidationStatus.Valid) { - updateProfile( - fileName = state.uploadFileName, - nickname = nickname, - birthday = birthday, - uri = bucketImageUri - ) - } else { - updateProfile( - fileName = state.uploadFileName, - nickname = nickname, - birthday = null, - uri = bucketImageUri - ) - } - } else { - Timber.tag(TAG).e("이미지 업로드 실패, code: %d", response.code) - } - } catch (e: Exception) { - Timber.tag(TAG).e(e, "이미지 업로드 과정에서 예외 발생: %s", e.message) - } - } - } - - private fun updateProfile(fileName: String, nickname: String, birthday: String?, uri: String) = - intent { - profileRepository.updateProfile(fileName, nickname, birthday, uri) - .onSuccess { - profileRepository.updateProfileType(com.acon.acon.core.model.type.UpdateProfileType.SUCCESS) - postSideEffect(ProfileModSideEffect.NavigateToProfile) - } - .onFailure { - profileRepository.updateProfileType(com.acon.acon.core.model.type.UpdateProfileType.FAILURE) - postSideEffect(ProfileModSideEffect.NavigateToProfile) - } - } - - companion object { - const val TAG = "ProfileViewModel" - } -} - -sealed interface ProfileModState { - @Immutable - data class Success( - val fetchedNickname: String = "", - val nickname: String = "", - val nicknameFieldStatus: TextFieldStatus = TextFieldStatus.Inactive, - val nicknameValidationStatus: NicknameValidationStatus = NicknameValidationStatus.Empty, - val nicknameCount: Int = 0, - - val fetchedBirthday: String = "", - val birthday: String = "", - val birthdayFieldStatus: TextFieldStatus = TextFieldStatus.Inactive, - val birthdayValidationStatus: BirthdayValidationStatus = BirthdayValidationStatus.Empty, - - val fetchedPhotoUri: String = "", - val selectedPhotoUri: String = "", - val uploadFileName: String = "", - - val isEdited: Boolean = false, - val showExitDialog: Boolean = false, - val showPhotoEditModal: Boolean = false, - ) : ProfileModState { - val isEditButtonEnabled: Boolean - get() { - val isProfileImageChanged = when { - selectedPhotoUri.contains("basic_profile_image") && - fetchedPhotoUri.contains("basic_profile_image") -> false - - selectedPhotoUri.isNotEmpty() && selectedPhotoUri != fetchedPhotoUri -> true - else -> false - } - val isBirthValid = fetchedBirthday.isNotEmpty() && birthday.isEmpty() - val isContentValid = nicknameValidationStatus == NicknameValidationStatus.Valid && - (birthday.isEmpty() || birthdayValidationStatus == BirthdayValidationStatus.Valid) - - return (isProfileImageChanged && isContentValid) || isBirthValid || (isEdited && isContentValid) - } - } - - data object Loading : ProfileModState - data object LoadFailed : ProfileModState -} - -sealed interface ProfileModSideEffect { - data object NavigateBack : ProfileModSideEffect - data class UpdateProfileImage(val imageUri: String?) : ProfileModSideEffect - data object NavigateToProfile : ProfileModSideEffect -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreen.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreen.kt deleted file mode 100644 index fb804d15e..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreen.kt +++ /dev/null @@ -1,449 +0,0 @@ -package com.acon.acon.feature.profile.composable.screen.profileMod.composable - -import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -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.aspectRatio -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 -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -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.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.acon.acon.core.designsystem.R -import com.acon.acon.core.designsystem.component.button.v2.AconFilledButton -import com.acon.acon.core.designsystem.component.dialog.v2.AconTwoActionDialog -import com.acon.acon.core.designsystem.component.topbar.AconTopBar -import com.acon.acon.core.designsystem.effect.LocalHazeState -import com.acon.acon.core.designsystem.noRippleClickable -import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.feature.profile.composable.component.GallerySelectBottomSheet -import com.acon.acon.feature.profile.composable.component.NicknameValidMessageRow -import com.acon.acon.feature.profile.composable.component.ProfilePhotoBox -import com.acon.acon.feature.profile.composable.component.ProfileTextField -import com.acon.acon.feature.profile.composable.component.addFocusCleaner -import com.acon.acon.feature.profile.composable.screen.profileMod.ProfileModState -import com.acon.acon.feature.profile.composable.type.BirthdayValidationStatus -import com.acon.acon.feature.profile.composable.type.FocusType -import com.acon.acon.feature.profile.composable.type.NicknameValidationStatus -import com.acon.acon.feature.profile.composable.type.contentDescriptionResId -import com.acon.acon.feature.profile.composable.type.validMessageResId -import com.acon.acon.feature.profile.composable.utils.BirthdayTransformation -import com.acon.acon.core.ui.compose.getScreenHeight -import com.acon.acon.core.ui.compose.getScreenWidth -import dev.chrisbanes.haze.hazeSource - -@Composable -internal fun ProfileModScreen( - modifier: Modifier = Modifier, - state: ProfileModState, - navigateToBack: () -> Unit, - onNicknameChanged: (String) -> Unit = {}, - onBirthdayChanged: (String) -> Unit = {}, - onFocusChanged: (Boolean, FocusType) -> Unit = { _, _ -> }, - onRequestExitDialog: () -> Unit, - onDisMissExitDialog: () -> Unit, - onRequestProfileEditModal: () -> Unit, - onDisMissProfileEditModal: () -> Unit, - onUpdateProfileImage: (String) -> Unit, - onClickSaveButton: () -> Unit = {}, -) { - val context = LocalContext.current - val screenWidthDp = getScreenWidth() - val screenHeightDp = getScreenHeight() - val dialogWidth = (screenWidthDp * (260f / 360f)) - val profileImageHeight = (screenHeightDp * (80f / 740f)) - - val photoPickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickVisualMedia() - ) { uri -> - uri?.let { - onUpdateProfileImage(uri.toString()) - onDisMissProfileEditModal() - } - } - - val focusManager = LocalFocusManager.current - - val nickNameFocusRequester = remember { FocusRequester() } - val birthDayFocusRequester = remember { FocusRequester() } - - when (state) { - ProfileModState.LoadFailed -> {} - ProfileModState.Loading -> {} - is ProfileModState.Success -> { - - BackHandler(enabled = true) { - if(state.isEdited) { - onRequestExitDialog() - } else { - navigateToBack() - } - } - - var nicknameTextFieldValue by rememberSaveable( - state.fetchedNickname, - stateSaver = TextFieldValue.Saver - ) { - mutableStateOf(TextFieldValue(state.fetchedNickname)) - } - - var birthdayTextFieldValue by rememberSaveable( - state.fetchedBirthday, - stateSaver = TextFieldValue.Saver - ) { - mutableStateOf(TextFieldValue(state.fetchedBirthday)) - } - - if (state.showPhotoEditModal) { - GallerySelectBottomSheet( - isDefault = when { - state.selectedPhotoUri.isNotEmpty() -> { - state.selectedPhotoUri.contains("basic_profile_image") - } - - else -> { - state.fetchedPhotoUri.contains("basic_profile_image") - } - }, - onDismiss = { onDisMissProfileEditModal() }, - onGallerySelect = { - onDisMissProfileEditModal() - photoPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - }, - onDefaultImageSelect = { onUpdateProfileImage("basic_profile_image") }, - ) - } - - if (state.showExitDialog) { - AconTwoActionDialog( - title = stringResource(R.string.profile_mod_exit_title), - action1 = stringResource(R.string.continue_writing), - action2 = stringResource(R.string.exit), - onDismissRequest = { - onRequestExitDialog() - }, - onAction1 = { - onDisMissExitDialog() - }, - onAction2 = { - navigateToBack() - }, - modifier = Modifier.width(dialogWidth) - ) - } - - Column( - modifier = modifier - .fillMaxSize() - .background(color = AconTheme.color.Gray900) - .hazeSource(LocalHazeState.current) - .statusBarsPadding() - .navigationBarsPadding() - .addFocusCleaner(focusManager), - verticalArrangement = Arrangement.Center - ) { - AconTopBar( - leadingIcon = { - IconButton( - onClick = { - if(state.isEdited) { onRequestExitDialog() } - else { navigateToBack() } - } - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_topbar_arrow_left), - contentDescription = stringResource(R.string.back), - tint = AconTheme.color.Gray50 - ) - } - }, - content = { - Text( - text = stringResource(R.string.profile_edit_topbar), - style = AconTheme.typography.Title4, - fontWeight = FontWeight.SemiBold, - color = AconTheme.color.White - ) - }, - modifier = Modifier.padding(vertical = 14.dp) - ) - - Box( - modifier = Modifier.weight(1f) - ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 19.dp), - horizontalArrangement = Arrangement.Center - ) { - Spacer(modifier = Modifier.weight(1f)) - Box( - modifier = Modifier - .size(profileImageHeight) - .aspectRatio(1f) - .noRippleClickable { onRequestProfileEditModal() }, - contentAlignment = Alignment.Center - ) { - ProfilePhotoBox( - modifier = Modifier - .fillMaxSize() - .align(Alignment.Center), - photoUri = state.selectedPhotoUri.ifEmpty { state.fetchedPhotoUri } - ) - Image( - imageVector = ImageVector.vectorResource(R.drawable.ic_profile_img_edit), - contentDescription = stringResource(R.string.content_description_edit_profile), - modifier = Modifier - .align(alignment = Alignment.BottomEnd) - ) - } - Spacer(modifier = Modifier.weight(1f)) - } - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 48.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start - ) { - Text( - text = stringResource(R.string.nickname_textfield_title), - style = AconTheme.typography.Title4, - fontWeight = FontWeight.SemiBold, - color = AconTheme.color.White - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = stringResource(R.string.star), - style = AconTheme.typography.Title4, - fontWeight = FontWeight.SemiBold, - color = AconTheme.color.Gray50 - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - ProfileTextField( - status = state.nicknameFieldStatus, - focusType = FocusType.Nickname, - focusRequester = nickNameFocusRequester, - value = nicknameTextFieldValue, - isTyping = (state.nicknameValidationStatus == NicknameValidationStatus.Typing), - onValueChange = { fieldValue -> - val lowerCaseText = fieldValue.text.lowercase() - if (lowerCaseText.length <= 14) { - nicknameTextFieldValue = - fieldValue.copy(text = lowerCaseText) - onNicknameChanged(lowerCaseText) - } - }, - onFocusChanged = onFocusChanged, - onClick = { - nickNameFocusRequester.requestFocus() - } - ) - - Spacer(modifier = Modifier.height(4.dp)) - Row( - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.Start - ) { - Box( - Modifier.height(24.dp) - ) { - when (state.nicknameValidationStatus) { - is NicknameValidationStatus.Empty -> { - NicknameValidMessageRow( - validMessage = R.string.nickname_error_empty, - iconRes = R.drawable.ic_error, - validContentDescription = R.string.content_description_empty_nickname, - color = AconTheme.color.Danger - ) - } - - is NicknameValidationStatus.Error -> { - val validState = state.nicknameValidationStatus - NicknameValidMessageRow( - validMessage = validState.errorTypes.validMessageResId(), - iconRes = R.drawable.ic_error, - validContentDescription = validState.errorTypes.contentDescriptionResId(), - color = AconTheme.color.Danger - ) - } - - is NicknameValidationStatus.Valid -> { - NicknameValidMessageRow( - validMessage = R.string.nickname_valid, - iconRes = R.drawable.ic_valid, - validContentDescription = R.string.content_description_valid_nickname, - color = AconTheme.color.Success - ) - } - - else -> Unit - } - } - } - - Row( - modifier = Modifier.padding(top = 5.dp, end = 8.dp), - horizontalArrangement = Arrangement.End - ) { - Text( - text = "${state.nicknameCount}", - style = AconTheme.typography.Caption1, - color = AconTheme.color.Gray500 - ) - Text( - text = stringResource(R.string.max_nickname_count), - style = AconTheme.typography.Caption1, - color = AconTheme.color.Gray500 - ) - } - } - } - - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 44.dp) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start - ) { - Text( - text = stringResource(R.string.nickname_birthday_title), - style = AconTheme.typography.Title4, - color = AconTheme.color.White, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.width(4.dp)) - } - Spacer(modifier = Modifier.height(12.dp)) - ProfileTextField( - status = state.birthdayFieldStatus, - focusType = FocusType.Birthday, - focusRequester = birthDayFocusRequester, - value = birthdayTextFieldValue, - placeholder = stringResource(R.string.birthday_placeholder), - onValueChange = { fieldValue -> - if (fieldValue.text.length <= 8) { - birthdayTextFieldValue = fieldValue - onBirthdayChanged(fieldValue.text) - } - }, - onFocusChanged = onFocusChanged, - visualTransformation = BirthdayTransformation(), - onClick = { - birthDayFocusRequester.requestFocus() - } - ) - - Spacer(modifier = Modifier.height(8.dp)) - when (state.birthdayValidationStatus) { - is BirthdayValidationStatus.Valid -> { - Spacer(modifier = Modifier.height(4.dp)) - } - - is BirthdayValidationStatus.Invalid -> { - NicknameValidMessageRow( - validMessage = R.string.birthday_error_invalid, - iconRes = R.drawable.ic_error, - validContentDescription = R.string.content_description_invalid_birthday, - color = AconTheme.color.Danger - ) - } - - else -> Spacer(modifier = Modifier.height(4.dp)) - } - } - - Spacer(Modifier.weight(1f)) - AconFilledButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp), - enabled = state.isEditButtonEnabled, - onClick = { onClickSaveButton() }, - content = { - Text( - text = stringResource(R.string.save), - style = AconTheme.typography.Title4, - fontWeight = FontWeight.SemiBold - ) - } - ) - } - } - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun ProfileModScreenPreview() { - AconTheme { - ProfileModScreen( - modifier = Modifier, - state = ProfileModState.Success(), - navigateToBack = {}, - onNicknameChanged = {}, - onBirthdayChanged = {}, - onFocusChanged = { _, _ -> }, - onRequestExitDialog = {}, - onDisMissExitDialog = {}, - onRequestProfileEditModal = {}, - onDisMissProfileEditModal = {}, - onUpdateProfileImage = {}, - onClickSaveButton = {} - ) - } -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreenContainer.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreenContainer.kt deleted file mode 100644 index ee757913b..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profileMod/composable/ProfileModScreenContainer.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.acon.acon.feature.profile.composable.screen.profileMod.composable - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel -import com.acon.acon.feature.profile.composable.screen.profileMod.ProfileModSideEffect -import com.acon.acon.feature.profile.composable.screen.profileMod.ProfileModViewModel -import org.orbitmvi.orbit.compose.collectAsState -import org.orbitmvi.orbit.compose.collectSideEffect - -@Composable -fun ProfileModScreenContainer( - modifier: Modifier = Modifier, - viewModel: ProfileModViewModel = hiltViewModel(), - selectedPhotoId: String? = null, - onNavigateToBack: () -> Unit = {}, - onClickComplete: () -> Unit = {}, -) { - val state by viewModel.collectAsState() - - LaunchedEffect(selectedPhotoId) { - if (selectedPhotoId?.isNotEmpty() == true) { - viewModel.updateProfileImage(selectedPhotoId) - } - } - - viewModel.collectSideEffect { effect -> - when (effect) { - is ProfileModSideEffect.NavigateBack -> { - onNavigateToBack() - } - - is ProfileModSideEffect.UpdateProfileImage -> { - selectedPhotoId.let { - viewModel.updateProfileImage(selectedPhotoId ?: "") - } - } - - is ProfileModSideEffect.NavigateToProfile -> { - onClickComplete() - } - } - } - - ProfileModScreen( - modifier = modifier, - state = state, - navigateToBack = viewModel::navigateToBack, - onNicknameChanged = viewModel::onNicknameChanged, - onBirthdayChanged = viewModel::onBirthdayChanged, - onFocusChanged = viewModel::onFocusChanged, - onRequestExitDialog = viewModel::onRequestExitDialog, - onDisMissExitDialog = viewModel::onDisMissExitDialog, - onRequestProfileEditModal = viewModel::onRequestProfileEditModal, - onDisMissProfileEditModal = viewModel::onDisMissProfileEditModal, - onUpdateProfileImage = viewModel::updateProfileImage, - onClickSaveButton = viewModel::getPreSignedUrl - ) -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/FocusType.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/FocusType.kt deleted file mode 100644 index ffa803514..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/FocusType.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.acon.acon.feature.profile.composable.type - -enum class FocusType { - Nickname, Birthday -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/NicknameErrorType.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/NicknameErrorType.kt deleted file mode 100644 index 35375e9b8..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/NicknameErrorType.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.acon.acon.feature.profile.composable.type - -import com.acon.acon.core.designsystem.R - -sealed class NicknameErrorType { - data object Invalid : NicknameErrorType() - data object Duplicate : NicknameErrorType() -} - -fun NicknameErrorType.validMessageResId(): Int = when (this) { - NicknameErrorType.Invalid -> R.string.nickname_error_invalid - NicknameErrorType.Duplicate -> R.string.nickname_error_duplicate -} - -fun NicknameErrorType.contentDescriptionResId(): Int = when (this) { - NicknameErrorType.Invalid-> R.string.content_description_empty_nickname - NicknameErrorType.Duplicate -> R.string.content_description_duplicate_nickname -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/ProfileInfoType.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/ProfileInfoType.kt deleted file mode 100644 index cd7eb11c9..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/ProfileInfoType.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.acon.acon.feature.profile.composable.type - -enum class ProfileInfoType { - ACON, - AREA -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/ProfileUpdateResult.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/ProfileUpdateResult.kt deleted file mode 100644 index 36a1c9c7a..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/ProfileUpdateResult.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.acon.acon.feature.profile.composable.type - -enum class ProfileUpdateResult { - SUCCESS, - FAILURE -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/ValidationStatus.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/ValidationStatus.kt deleted file mode 100644 index 4484ee783..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/type/ValidationStatus.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.acon.acon.feature.profile.composable.type - -sealed interface TextFieldStatus { - data object Empty : TextFieldStatus - data object Focused : TextFieldStatus - data object Inactive : TextFieldStatus - data object Error : TextFieldStatus -} - -sealed class NicknameValidationStatus { - data object Empty : NicknameValidationStatus() - data object Typing : NicknameValidationStatus() - data object Valid : NicknameValidationStatus() - data class Error(val errorTypes: NicknameErrorType) : NicknameValidationStatus() -} - -sealed class BirthdayValidationStatus { - data object Empty : BirthdayValidationStatus() - data object Invalid : BirthdayValidationStatus() - data object Valid : BirthdayValidationStatus() -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/utils/BirthdayTransformation.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/utils/BirthdayTransformation.kt deleted file mode 100644 index 8bd88dd1c..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/utils/BirthdayTransformation.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.acon.acon.feature.profile.composable.utils - -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation - -class BirthdayTransformation : VisualTransformation { - override fun filter(text: AnnotatedString): TransformedText { - return birthdayFilter(text) - } -} - -fun birthdayFilter(text: AnnotatedString): TransformedText { - val trimmed = if (text.text.length >= 8) text.text.substring(0..7) else text.text - val builder = StringBuilder() - - for (i in trimmed.indices) { - builder.append(trimmed[i]) - if ((i == 3 || i == 5) && i < trimmed.length - 1) { - builder.append(".") - } - } - - val out = builder.toString() - - val numberOffsetTranslator = object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int { - val clamped = offset.coerceIn(0, trimmed.length) - return when { - clamped <= 3 -> clamped - clamped <= 5 -> (clamped + 1).coerceAtMost(out.length) - clamped <= 7 -> (clamped + 2).coerceAtMost(out.length) - else -> out.length - } - } - - override fun transformedToOriginal(offset: Int): Int { - val clamped = offset.coerceIn(0, out.length) - return when { - clamped <= 4 -> clamped - clamped <= 7 -> clamped - 1 - clamped <= 10 -> clamped - 2 - else -> trimmed.length - } - } - } - - return TransformedText(AnnotatedString(out), numberOffsetTranslator) -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/utils/StringUtils.kt b/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/utils/StringUtils.kt deleted file mode 100644 index cddd1c742..000000000 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/utils/StringUtils.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.acon.acon.feature.profile.composable.utils - -fun Char.isAllowedChar(): Boolean { - return this in 'a'..'z' || - this in 'A'..'Z' || - this in '0'..'9' || - this == '.' || - this == '_' -} - -fun String.limitedNickname( - maxLength: Int = 14 -): Pair { - val limitedNickname = this.take(maxLength) - return limitedNickname to limitedNickname.length -} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/TestTags.kt b/feature/profile/src/main/java/com/acon/feature/profile/TestTags.kt new file mode 100644 index 000000000..91b4af1b8 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/TestTags.kt @@ -0,0 +1,18 @@ +package com.acon.feature.profile + +internal object TestTags { + /** [com.acon.feature.profile.info.composable.ProfileInfoScreen] */ + const val SAVED_SPOT_ITEM = "saved_spot_item" + const val PROFILE_UPDATE_ICON = "profile_update_icon" + const val USER_PROFILE_VIEW = "user_profile_view" + const val GUEST_PROFILE_VIEW = "guest_profile_view" + const val SETTING_ICON = "setting_icon" + const val VERTICAL_SCROLLABLE_VIEW = "vertical_scrollable_view" + const val BOTTOM_BAR = "bottom_bar" + + /** [com.acon.feature.profile.update.composable.BirthDateFieldView] */ + const val BIRTH_DATE_VALIDATION_RESULT_VIEW = "birth_date_validation_result_view" + + /** [com.acon.feature.profile.update.composable.NicknameFieldView] */ + const val NICKNAME_VALIDATION_RESULT_VIEW = "nickname_validation_result_view" +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/info/composable/ProfileInfoScreen.kt b/feature/profile/src/main/java/com/acon/feature/profile/info/composable/ProfileInfoScreen.kt new file mode 100644 index 000000000..4ac155b80 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/info/composable/ProfileInfoScreen.kt @@ -0,0 +1,141 @@ +package com.acon.feature.profile.info.composable + +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.component.bottombar.AconBottomBar +import com.acon.acon.core.designsystem.component.bottombar.BottomNavType +import com.acon.acon.core.designsystem.component.error.NetworkErrorView +import com.acon.acon.core.designsystem.component.topbar.AconTopBar +import com.acon.acon.core.designsystem.effect.LocalHazeState +import com.acon.acon.core.designsystem.effect.defaultHazeEffect +import com.acon.acon.core.designsystem.theme.AconTheme +import com.acon.feature.profile.TestTags +import com.acon.feature.profile.info.viewmodel.ProfileInfoUiState +import dev.chrisbanes.haze.hazeSource +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun ProfileInfoScreen( + state: ProfileInfoUiState, + actions: ProfileInfoScreenActions, + modifier: Modifier = Modifier +) { + + Column(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .hazeSource(LocalHazeState.current) + .testTag(TestTags.VERTICAL_SCROLLABLE_VIEW) + ) { + AconTopBar( + content = { + Text( + text = stringResource(R.string.profile_topbar), + style = AconTheme.typography.Title4, + fontWeight = FontWeight.SemiBold, + color = AconTheme.color.White + ) + }, + trailingIcon = { + IconButton( + modifier = Modifier.testTag(TestTags.SETTING_ICON), + onClick = actions.onSettingIconClick + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_setting), + contentDescription = stringResource(R.string.content_description_settings), + tint = AconTheme.color.White + ) + } + }, + modifier = Modifier.padding(vertical = 14.dp) + ) + + when (state) { + is ProfileInfoUiState.User -> { + UserProfileView( + modifier = Modifier + .testTag(TestTags.USER_PROFILE_VIEW) + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 40.dp), + nickname = state.profile.nickname, + profileImageStatus = state.profile.image, + onProfileUpdateIconClick = actions.onProfileUpdateIconClick + ) + + SavedSpotsView( + savedSpots = state.savedSpots.toImmutableList(), + onSeeAllTextClick = actions.onSeeAllTextClick, + onSavedSpotItemClick = actions.onSavedSpotItemClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 42.dp) + ) + } + + is ProfileInfoUiState.Guest -> { + GuestProfileView( + onRequestSignInTextClick = actions.onRequestSignInTextClick, + modifier = Modifier + .testTag(TestTags.GUEST_PROFILE_VIEW) + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 40.dp), + ) + } + + is ProfileInfoUiState.Loading -> { + + } + + is ProfileInfoUiState.LoadFailed -> { + NetworkErrorView( + onRetry = actions.retryOnError, + modifier = Modifier.fillMaxSize() + ) + } + } + } + + AconBottomBar( + selectedItem = BottomNavType.PROFILE, + onItemClick = { bottomType -> + when (bottomType) { + BottomNavType.SPOT -> actions.onSpotListTabClick() + BottomNavType.UPLOAD -> actions.onUploadTabClick() + BottomNavType.PROFILE -> Unit + } + }, + modifier = Modifier + .fillMaxWidth() + .defaultHazeEffect( + hazeState = LocalHazeState.current, + tintColor = AconTheme.color.GlassGray900 + ) + .navigationBarsPadding() + .testTag(TestTags.BOTTOM_BAR) + ) + } +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/info/composable/ProfileInfoScreenContainer.kt b/feature/profile/src/main/java/com/acon/feature/profile/info/composable/ProfileInfoScreenContainer.kt new file mode 100644 index 000000000..71553927f --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/info/composable/ProfileInfoScreenContainer.kt @@ -0,0 +1,83 @@ +package com.acon.feature.profile.info.composable + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import com.acon.acon.core.model.model.spot.SpotNavigationParameter +import com.acon.feature.profile.info.viewmodel.ProfileInfoSideEffect +import com.acon.feature.profile.info.viewmodel.ProfileInfoViewModel +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun ProfileInfoScreenContainer( + onNavigateToProfileUpdate: () -> Unit, + onNavigateToSpotDetail: (SpotNavigationParameter) -> Unit, + onNavigateToSavedSpots: () -> Unit, + onNavigateToSetting: () -> Unit, + onNavigateToSpotList: () -> Unit, + onNavigateToUpload: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ProfileInfoViewModel = hiltViewModel() +) { + + val state by viewModel.collectAsState() + viewModel.initOnRequestSignIn() + + ProfileInfoScreen( + state = state, + modifier = modifier, + actions = ProfileInfoScreenActions( + onSavedSpotItemClick = viewModel::onSavedSpotClicked, + onProfileUpdateIconClick = viewModel::onProfileUpdateClicked, + onSettingIconClick = viewModel::onSettingClicked, + onSeeAllTextClick = viewModel::onSeeAllSavedSpotsClicked, + onRequestSignInTextClick = viewModel::onRequestSignIn, + onSpotListTabClick = viewModel::onSpotListClicked, + onUploadTabClick = viewModel::onUploadClicked, + retryOnError = { + viewModel.intent { + with(viewModel) { + loadState() + } + } + } + ) + ) + + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is ProfileInfoSideEffect.NavigateToProfileUpdate -> onNavigateToProfileUpdate() + is ProfileInfoSideEffect.NavigateToSpotDetail -> onNavigateToSpotDetail(sideEffect.spotNavigationParam) + is ProfileInfoSideEffect.NavigateToSavedSpots -> onNavigateToSavedSpots() + is ProfileInfoSideEffect.NavigateToSetting -> onNavigateToSetting() + is ProfileInfoSideEffect.NavigateToSpotList -> onNavigateToSpotList() + is ProfileInfoSideEffect.NavigateToUpload -> onNavigateToUpload() + } + } +} + +internal data class ProfileInfoScreenActions( + val onSavedSpotItemClick: (spotId: Long) -> Unit, + val onProfileUpdateIconClick: () -> Unit, + val onSettingIconClick: () -> Unit, + val onSeeAllTextClick: () -> Unit, + val onRequestSignInTextClick: () -> Unit, + val onSpotListTabClick: () -> Unit, + val onUploadTabClick: () -> Unit, + val retryOnError: () -> Unit, +) { + companion object { + val Default = ProfileInfoScreenActions( + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {} + ) + } +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/info/composable/ProfileView.kt b/feature/profile/src/main/java/com/acon/feature/profile/info/composable/ProfileView.kt new file mode 100644 index 000000000..8ed89c55d --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/info/composable/ProfileView.kt @@ -0,0 +1,130 @@ +package com.acon.feature.profile.info.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +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.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.component.image.DefaultProfileImage +import com.acon.acon.core.designsystem.noRippleClickable +import com.acon.acon.core.designsystem.theme.AconTheme +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import com.acon.feature.profile.TestTags + +@Composable +internal fun UserProfileView( + nickname: String, + profileImageStatus: ProfileImageStatus, + onProfileUpdateIconClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + when (profileImageStatus) { + is ProfileImageStatus.Custom -> AsyncImage( + modifier = Modifier + .size(60.dp) + .clip(CircleShape), + model = profileImageStatus.url, + contentDescription = stringResource(R.string.content_description_profile_image), + contentScale = ContentScale.Crop, + error = painterResource(R.drawable.ic_default_profile) + ) + + is ProfileImageStatus.Default -> DefaultProfileImage( + modifier = Modifier + .size(60.dp) + .clip(CircleShape) + ) + } + + Column( + modifier = Modifier.padding(start = 16.dp) + ) { + Text( + text = nickname, + style = AconTheme.typography.Headline4, + color = AconTheme.color.White, + modifier = Modifier + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 2.dp) + ) { + Text( + text = stringResource(R.string.edit_profile), + style = AconTheme.typography.Body1, + color = AconTheme.color.Gray500, + ) + + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_edit), + contentDescription = stringResource(R.string.content_description_edit_profile), + modifier = Modifier + .testTag(TestTags.PROFILE_UPDATE_ICON) + .padding(start = 4.dp) + .noRippleClickable { onProfileUpdateIconClick() } + ) + } + } + } +} + +@Composable +internal fun GuestProfileView( + onRequestSignInTextClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + DefaultProfileImage( + modifier = Modifier + .size(60.dp) + .clip(CircleShape) + ) + + Row( + modifier = Modifier + .padding(start = 16.dp) + .noRippleClickable { + onRequestSignInTextClick() + }, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.you_need_sign_in), + style = AconTheme.typography.Headline4, + color = AconTheme.color.White, + modifier = Modifier + ) + + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_right_24), + contentDescription = stringResource(R.string.content_description_go_sign_in), + modifier = Modifier.padding(start = 4.dp), + tint = AconTheme.color.Gray50 + ) + } + } +} diff --git a/feature/profile/src/main/java/com/acon/feature/profile/info/composable/SavedSpotsView.kt b/feature/profile/src/main/java/com/acon/feature/profile/info/composable/SavedSpotsView.kt new file mode 100644 index 000000000..d4d14665b --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/info/composable/SavedSpotsView.kt @@ -0,0 +1,177 @@ +package com.acon.feature.profile.info.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +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.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.effect.imageGradientLayer +import com.acon.acon.core.designsystem.effect.imageGradientTopLayer +import com.acon.acon.core.designsystem.image.rememberDefaultLoadImageErrorPainter +import com.acon.acon.core.designsystem.noRippleClickable +import com.acon.acon.core.designsystem.theme.AconTheme +import com.acon.acon.core.model.model.profile.SavedSpot +import com.acon.acon.core.model.model.profile.SpotThumbnailStatus +import com.acon.feature.profile.TestTags +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun SavedSpotsView( + savedSpots: ImmutableList, + onSeeAllTextClick: () -> Unit, + onSavedSpotItemClick: (spotId: Long) -> Unit, + modifier: Modifier = Modifier +) { + + Column( + modifier = modifier + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.saved_store), + color = AconTheme.color.White, + style = AconTheme.typography.Title4, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(vertical = 6.dp) + ) + + Spacer(Modifier.weight(1f)) + if (savedSpots.isNotEmpty()) { + Text( + text = stringResource(R.string.show_saved_all_store), + color = AconTheme.color.Action, + style = AconTheme.typography.Body1, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .padding(all = 8.dp) + .noRippleClickable { onSeeAllTextClick() } + ) + } + } + + Spacer(Modifier.height(8.dp)) + if (savedSpots.isNotEmpty()) { + LazyRow( + modifier = Modifier, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + items( + items = savedSpots, + key = { it.spotId } + ) { spot -> + SavedSpotItem( + spot = spot, + onClick = { onSavedSpotItemClick(spot.spotId) }, + modifier = Modifier + .size(width = 150.dp, height = 217.dp) + .testTag(TestTags.SAVED_SPOT_ITEM + spot.spotId) + ) + } + } + } else { + Text( + text = stringResource(R.string.no_saved_spot), + style = AconTheme.typography.Body1, + fontWeight = FontWeight.W400, + color = AconTheme.color.Gray500, + ) + } + } +} + +@Composable +private fun SavedSpotItem( + spot: SavedSpot, + onClick: (spotId: Long) -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onClick(spot.spotId) } + ) { + when (val thumbnailStatus = spot.spotThumbnail) { + is SpotThumbnailStatus.Exist -> { + AsyncImage( + model = thumbnailStatus.url, + contentDescription = stringResource(R.string.store_background_image_content_description), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .imageGradientTopLayer(), + error = rememberDefaultLoadImageErrorPainter() + ) + + Text( + text = if (spot.spotName.length > 9) spot.spotName.take(8) + stringResource(R.string.ellipsis) else spot.spotName, + color = AconTheme.color.White, + style = AconTheme.typography.Title5, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(top = 20.dp) + .padding(horizontal = 20.dp) + ) + } + + else -> { + Image( + painter = painterResource(R.drawable.ic_bg_no_store_profile), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .imageGradientLayer() + ) + + Text( + text = if (spot.spotName.length > 9) spot.spotName.take(8) + stringResource(R.string.ellipsis) else spot.spotName, + color = AconTheme.color.White, + style = AconTheme.typography.Title5, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .padding(top = 20.dp) + .padding(horizontal = 20.dp) + ) + + Text( + text = stringResource(R.string.no_store_image), + color = AconTheme.color.Gray50, + style = AconTheme.typography.Caption1, + modifier = Modifier.align(Alignment.Center) + ) + } + } + } +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/info/viewmodel/ProfileInfoViewModel.kt b/feature/profile/src/main/java/com/acon/feature/profile/info/viewmodel/ProfileInfoViewModel.kt new file mode 100644 index 000000000..67b924eef --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/info/viewmodel/ProfileInfoViewModel.kt @@ -0,0 +1,139 @@ +package com.acon.feature.profile.info.viewmodel + +import androidx.lifecycle.viewModelScope +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.SavedSpot +import com.acon.acon.core.model.model.spot.SpotNavigationParameter +import com.acon.acon.core.model.type.SignInStatus +import com.acon.acon.core.ui.base.BaseContainerHost +import com.acon.acon.domain.repository.ProfileRepository +import com.acon.acon.domain.repository.UserRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.syntax.Syntax +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +class ProfileInfoViewModel @Inject constructor( + private val userRepository: UserRepository, + private val profileRepository: ProfileRepository +) : BaseContainerHost() { + + override val container: Container = + container(ProfileInfoUiState.Loading) { + loadState() + } + + suspend fun Syntax.loadState() { + userRepository.getSignInStatus().collectLatest { userType -> + when (userType) { + SignInStatus.USER -> { + val savedSpotsResultFlowDeferred = viewModelScope.async { + profileRepository.getSavedSpots() + } + val profileResultFlowDeferred = viewModelScope.async { + profileRepository.getProfile() + } + + combine( + savedSpotsResultFlowDeferred.await(), + profileResultFlowDeferred.await() + ) { savedSpotsResult, profileResult -> + when { + savedSpotsResult.isFailure || profileResult.isFailure -> ProfileInfoUiState.LoadFailed + else -> { + val savedSpots = savedSpotsResult.getOrNull()!! + val profile = profileResult.getOrNull()!! + ProfileInfoUiState.User( + profile = profile, + savedSpots = savedSpots + ) + } + } + }.collect { uiState -> + reduce { + uiState + } + } + } + + SignInStatus.GUEST -> { + reduce { + ProfileInfoUiState.Guest + } + } + } + } + } + + + fun onProfileUpdateClicked() = intent { + postSideEffect(ProfileInfoSideEffect.NavigateToProfileUpdate) + } + + fun onSavedSpotClicked(spotId: Long) = intent { + postSideEffect(ProfileInfoSideEffect.NavigateToSpotDetail( + SpotNavigationParameter( + spotId = spotId, + tags = emptyList(), + transportMode = null, + eta = null, + isFromDeepLink = null, + navFromProfile = true + ) + )) + } + + fun onSeeAllSavedSpotsClicked() = intent { + postSideEffect(ProfileInfoSideEffect.NavigateToSavedSpots) + } + + fun onSettingClicked() = intent { + postSideEffect(ProfileInfoSideEffect.NavigateToSetting) + } + + fun onSpotListClicked() = intent { + postSideEffect(ProfileInfoSideEffect.NavigateToSpotList) + } + + fun onUploadClicked() = intent { + if (userRepository.getSignInStatus().first() == SignInStatus.GUEST) { + onRequestSignIn() + } else { + postSideEffect(ProfileInfoSideEffect.NavigateToUpload) + } + } + + fun onRequestSignIn() { + super.onRequestSignIn?.invoke("click_upload_guest?") + } +} + +sealed interface ProfileInfoUiState { + + data object Guest : ProfileInfoUiState + data class User( + val profile: Profile, + val savedSpots: List + ) : ProfileInfoUiState + + data object Loading : ProfileInfoUiState + data object LoadFailed : ProfileInfoUiState +} + +sealed interface ProfileInfoSideEffect { + data object NavigateToProfileUpdate : ProfileInfoSideEffect + data class NavigateToSpotDetail(val spotNavigationParam: SpotNavigationParameter) : ProfileInfoSideEffect + data object NavigateToSavedSpots : ProfileInfoSideEffect + data object NavigateToSetting : ProfileInfoSideEffect + data object NavigateToSpotList : ProfileInfoSideEffect + data object NavigateToUpload : ProfileInfoSideEffect +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkItem.kt b/feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkItem.kt new file mode 100644 index 000000000..b6d65b626 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkItem.kt @@ -0,0 +1,113 @@ +package com.acon.feature.profile.savedspot.composable + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.effect.imageGradientLayer +import com.acon.acon.core.designsystem.effect.imageGradientTopLayer +import com.acon.acon.core.designsystem.image.rememberDefaultLoadImageErrorPainter +import com.acon.acon.core.designsystem.theme.AconTheme +import com.acon.acon.core.model.model.profile.SavedSpot +import com.acon.acon.core.model.model.profile.SpotThumbnailStatus + +@Composable +internal fun BookmarkItem( + spot: SavedSpot, + onClickSpotItem:() -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onClickSpotItem() } + ) { + when(val thumbnailStatus = spot.spotThumbnail) { + is SpotThumbnailStatus.Exist -> { + AsyncImage( + model = thumbnailStatus.url, + contentDescription = stringResource(R.string.store_background_image_content_description), + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .imageGradientTopLayer(), + error = rememberDefaultLoadImageErrorPainter() + ) + + Text( + text = spot.spotName.let { name -> + if (name.length > 9) name.take(8) + "…" else name + }, + color = AconTheme.color.White, + style = AconTheme.typography.Title5, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(top = 20.dp) + .padding(horizontal = 20.dp) + ) + } + is SpotThumbnailStatus.Empty -> { + + Image( + painter = painterResource(R.drawable.ic_bg_no_store_profile), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .imageGradientLayer() + ) + + Text( + text = spot.spotName.let { name -> + if (name.length > 9) name.take(8) + "…" else name + }, + color = AconTheme.color.White, + style = AconTheme.typography.Title5, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .padding(top = 20.dp) + .padding(horizontal = 20.dp) + ) + + Text( + text = stringResource(R.string.no_store_image), + color = AconTheme.color.Gray50, + style = AconTheme.typography.Caption1, + modifier = Modifier + .align(Alignment.Center) + ) + } + } + } +} + +@Preview +@Composable +private fun BookmarkItemPreview() { + AconTheme { + BookmarkItem( + spot = SavedSpot(0, "샘플", SpotThumbnailStatus.Empty), + onClickSpotItem = {} + ) + } +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/composable/BookmarkScreen.kt b/feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkScreen.kt similarity index 93% rename from feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/composable/BookmarkScreen.kt rename to feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkScreen.kt index 50807e9fd..a472e43de 100644 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/composable/BookmarkScreen.kt +++ b/feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkScreen.kt @@ -1,4 +1,4 @@ -package com.acon.acon.feature.profile.composable.screen.bookmark.composable +package com.acon.feature.profile.savedspot.composable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -34,12 +34,9 @@ import com.acon.acon.core.designsystem.component.topbar.AconTopBar import com.acon.acon.core.designsystem.effect.LocalHazeState import com.acon.acon.core.designsystem.effect.defaultHazeEffect import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.feature.profile.composable.screen.bookmark.BookmarkUiState -import com.acon.acon.feature.profile.composable.screen.mockSpotList -import com.acon.acon.feature.profile.composable.screen.profile.composable.BookmarkItem -import com.acon.acon.feature.profile.composable.screen.profile.composable.BookmarkSkeletonItem import com.acon.acon.core.ui.compose.LocalOnRetry import com.acon.acon.core.ui.compose.getScreenHeight +import com.acon.feature.profile.savedspot.viewmodel.BookmarkUiState import dev.chrisbanes.haze.hazeSource @Composable @@ -97,7 +94,7 @@ fun BookmarkScreen( .verticalScroll(rememberScrollState()) .hazeSource(LocalHazeState.current) ) { - mockSpotList.chunked(2).forEach { rowItems -> + (1..9).chunked(2).forEach { rowItems -> Row( modifier = Modifier .fillMaxWidth() @@ -166,7 +163,7 @@ fun BookmarkScreen( .verticalScroll(rememberScrollState()) .hazeSource(LocalHazeState.current) ) { - state.savedSpots?.chunked(2)?.fastForEach { rowItems -> + state.savedSpots.chunked(2).fastForEach { rowItems -> Row( modifier = Modifier .fillMaxWidth() diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/composable/BookmarkScreenContainer.kt b/feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkScreenContainer.kt similarity index 80% rename from feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/composable/BookmarkScreenContainer.kt rename to feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkScreenContainer.kt index 057c7ac73..185c41864 100644 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/composable/BookmarkScreenContainer.kt +++ b/feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkScreenContainer.kt @@ -1,11 +1,11 @@ -package com.acon.acon.feature.profile.composable.screen.bookmark.composable +package com.acon.feature.profile.savedspot.composable import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel -import com.acon.acon.feature.profile.composable.screen.bookmark.BookmarkUiSideEffect -import com.acon.acon.feature.profile.composable.screen.bookmark.BookmarkViewModel +import com.acon.feature.profile.savedspot.viewmodel.BookmarkUiSideEffect +import com.acon.feature.profile.savedspot.viewmodel.BookmarkViewModel import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkSkeletonItem.kt b/feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkSkeletonItem.kt similarity index 93% rename from feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkSkeletonItem.kt rename to feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkSkeletonItem.kt index 1914c168a..4bea33af9 100644 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/profile/composable/BookmarkSkeletonItem.kt +++ b/feature/profile/src/main/java/com/acon/feature/profile/savedspot/composable/BookmarkSkeletonItem.kt @@ -1,4 +1,4 @@ -package com.acon.acon.feature.profile.composable.screen.profile.composable +package com.acon.feature.profile.savedspot.composable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth diff --git a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/BookmarkViewModel.kt b/feature/profile/src/main/java/com/acon/feature/profile/savedspot/viewmodel/BookmarkViewModel.kt similarity index 62% rename from feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/BookmarkViewModel.kt rename to feature/profile/src/main/java/com/acon/feature/profile/savedspot/viewmodel/BookmarkViewModel.kt index 411366f45..84ae2167f 100644 --- a/feature/profile/src/main/java/com/acon/acon/feature/profile/composable/screen/bookmark/BookmarkViewModel.kt +++ b/feature/profile/src/main/java/com/acon/feature/profile/savedspot/viewmodel/BookmarkViewModel.kt @@ -1,8 +1,9 @@ -package com.acon.acon.feature.profile.composable.screen.bookmark +package com.acon.feature.profile.savedspot.viewmodel +import androidx.compose.runtime.Immutable import com.acon.acon.core.model.model.profile.SavedSpot -import com.acon.acon.domain.repository.SpotRepository import com.acon.acon.core.ui.base.BaseContainerHost +import com.acon.acon.domain.repository.ProfileRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import org.orbitmvi.orbit.annotation.OrbitExperimental @@ -12,27 +13,24 @@ import javax.inject.Inject @OptIn(OrbitExperimental::class) @HiltViewModel class BookmarkViewModel @Inject constructor( - private val spotRepository: SpotRepository + private val profileRepository: ProfileRepository ) : BaseContainerHost() { override val container = container(BookmarkUiState.Loading) { - fetchSavedSpotList() - } - - private fun fetchSavedSpotList() = intent { - delay(800) - spotRepository.fetchSavedSpotList().onSuccess { - reduce { - BookmarkUiState.Success(savedSpots = it) - } - }.onFailure { - reduce { - BookmarkUiState.LoadFailed + delay(LOADING_DELAY_MILLIS) + profileRepository.getSavedSpots().collect { result -> + result.onSuccess { + reduce { + BookmarkUiState.Success(savedSpots = it) + } + }.onFailure { + reduce { + BookmarkUiState.LoadFailed + } } } } - fun navigateToBack() = intent { postSideEffect(BookmarkUiSideEffect.OnNavigateToBack) } @@ -42,10 +40,17 @@ class BookmarkViewModel @Inject constructor( postSideEffect(BookmarkUiSideEffect.OnNavigateToSpotDetailScreen(spotId)) } } + + companion object { + private const val LOADING_DELAY_MILLIS = 800L + } } sealed interface BookmarkUiState { - data class Success(val savedSpots: List? = emptyList()) : BookmarkUiState + @Immutable + data class Success( + val savedSpots: List + ) : BookmarkUiState data object Loading : BookmarkUiState data object LoadFailed : BookmarkUiState } diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/composable/BirthDateFieldView.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/composable/BirthDateFieldView.kt new file mode 100644 index 000000000..364f1ff40 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/composable/BirthDateFieldView.kt @@ -0,0 +1,104 @@ +package com.acon.feature.profile.update.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +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.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.component.textfield.v2.AconOutlinedTextField +import com.acon.acon.core.designsystem.theme.AconTheme +import com.acon.acon.core.ui.DateVisualTransformation +import com.acon.acon.core.ui.test.testAlpha +import com.acon.feature.profile.TestTags +import com.acon.feature.profile.update.status.BirthDateValidationStatus + +@Composable +internal fun BirthDateFieldView( + input: TextFieldValue, + onInputChange: (TextFieldValue) -> Unit, + validationStatus: BirthDateValidationStatus, + modifier: Modifier = Modifier, + keyboardOptions: androidx.compose.foundation.text.KeyboardOptions = androidx.compose.foundation.text.KeyboardOptions.Default, + keyboardActions: androidx.compose.foundation.text.KeyboardActions = androidx.compose.foundation.text.KeyboardActions.Default, +) { + + val validationResultAlpha = when(validationStatus) { + BirthDateValidationStatus.Invalid -> 1f + else -> 0f + } + + val validationResultMessageResId = when (validationStatus) { + BirthDateValidationStatus.Idle -> null + BirthDateValidationStatus.Valid -> null + BirthDateValidationStatus.Typing -> null + BirthDateValidationStatus.Invalid -> R.string.birthday_error_invalid + } + val validationResultIcon = ImageVector.vectorResource( + R.drawable.ic_error + ) + + Column( + modifier = modifier + ) { + Text( + text = stringResource(R.string.birthdate_field_title), + style = AconTheme.typography.Title4, + fontWeight = FontWeight.SemiBold, + color = AconTheme.color.White + ) + + AconOutlinedTextField( + value = input, + onValueChange = onInputChange, + visualTransformation = DateVisualTransformation, + keyboardOptions = keyboardOptions.copy( + keyboardType = KeyboardType.Number + ), + keyboardActions = keyboardActions, + modifier = Modifier + .padding(top = 12.dp) + .fillMaxWidth() + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 4.dp) + .alpha(validationResultAlpha).testTag(TestTags.BIRTH_DATE_VALIDATION_RESULT_VIEW).semantics { + this.testAlpha = validationResultAlpha + }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = validationResultIcon, + contentDescription = null, + tint = Color.Unspecified + ) + + if (validationResultMessageResId != null) + Text( + text = stringResource(validationResultMessageResId), + style = AconTheme.typography.Body1, + color = AconTheme.color.Danger, + modifier = Modifier.padding(start = 4.dp) + ) + } + } +} diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/composable/NicknameFieldView.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/composable/NicknameFieldView.kt new file mode 100644 index 000000000..1f27f02b1 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/composable/NicknameFieldView.kt @@ -0,0 +1,180 @@ +package com.acon.feature.profile.update.composable + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +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.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.component.textfield.v2.AconOutlinedTextField +import com.acon.acon.core.designsystem.theme.AconTheme +import com.acon.acon.core.ui.test.testAlpha +import com.acon.acon.domain.usecase.ValidateNicknameUseCase.Companion.MAX_NICKNAME_LENGTH +import com.acon.feature.profile.TestTags +import com.acon.feature.profile.update.status.NicknameValidationStatus + +@Composable +internal fun NicknameFieldView( + input: TextFieldValue, + onInputChange: (TextFieldValue) -> Unit, + validationStatus: NicknameValidationStatus, + modifier: Modifier = Modifier, + keyboardOptions: androidx.compose.foundation.text.KeyboardOptions = androidx.compose.foundation.text.KeyboardOptions.Default, + keyboardActions: androidx.compose.foundation.text.KeyboardActions = androidx.compose.foundation.text.KeyboardActions.Default, +) { + + val validationResultAlpha = when(validationStatus) { + is NicknameValidationStatus.Idle -> 0f + is NicknameValidationStatus.Loading -> 0f + else -> 1f + } + + val validationResultMessageResId = when (validationStatus) { + NicknameValidationStatus.Idle, NicknameValidationStatus.Loading -> null + NicknameValidationStatus.Empty -> R.string.nickname_error_empty + NicknameValidationStatus.AlreadyExist -> R.string.nickname_error_duplicate + NicknameValidationStatus.InvalidFormat -> R.string.nickname_error_invalid + NicknameValidationStatus.Available -> R.string.nickname_valid + } + val validationResultIcon = ImageVector.vectorResource( + when (validationStatus) { + NicknameValidationStatus.Available -> R.drawable.ic_valid + else -> R.drawable.ic_error + } + ) + val validationResultUIColor = when (validationStatus) { + NicknameValidationStatus.Available -> AconTheme.color.Success + else -> AconTheme.color.Danger + } + + Column( + modifier = modifier + ) { + Row( + modifier = Modifier, + horizontalArrangement = Arrangement.Start + ) { + Text( + text = stringResource(R.string.nickname_textfield_title), + style = AconTheme.typography.Title4, + fontWeight = FontWeight.SemiBold, + color = AconTheme.color.White + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.star), + style = AconTheme.typography.Title4, + fontWeight = FontWeight.SemiBold, + color = AconTheme.color.Gray50 + ) + } + + AconOutlinedTextField( + value = input, + onValueChange = onInputChange, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + modifier = Modifier + .padding(top = 12.dp) + .fillMaxWidth() + ) { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically + ) { + innerTextField() + Spacer(modifier = Modifier.weight(1f)) + if (validationStatus is NicknameValidationStatus.Loading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = AconTheme.color.Gray600 + ) + } else { + Icon( + modifier = Modifier + .clickable { onInputChange(TextFieldValue()) } + .size(18.dp) + .alpha( + if (validationStatus is NicknameValidationStatus.Empty) 0f else 1f + ), + imageVector = ImageVector.vectorResource(R.drawable.ic_clear), + contentDescription = stringResource(R.string.clear_search_content_description), + tint = AconTheme.color.Gray50, + ) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.alpha(validationResultAlpha).testTag( + TestTags.NICKNAME_VALIDATION_RESULT_VIEW + ).semantics { + testAlpha = validationResultAlpha + } + ) { + Icon( + imageVector = validationResultIcon, + contentDescription = null, + tint = Color.Unspecified + ) + + if (validationResultMessageResId != null) + Text( + text = stringResource(validationResultMessageResId), + style = AconTheme.typography.Body1, + color = validationResultUIColor, + modifier = Modifier.padding(start = 4.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource( + R.string.nickname_input_count, + input.text.length, + MAX_NICKNAME_LENGTH + ), + style = AconTheme.typography.Caption1, + color = AconTheme.color.Gray500, + ) + } + } +} + +@Preview +@Composable +private fun NicknameFieldViewPreview() { + NicknameFieldView( + input = TextFieldValue("acon123"), + onInputChange = {}, + validationStatus = NicknameValidationStatus.Idle, + ) +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/composable/ProfileUpdateScreen.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/composable/ProfileUpdateScreen.kt new file mode 100644 index 000000000..5f08982d2 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/composable/ProfileUpdateScreen.kt @@ -0,0 +1,205 @@ +package com.acon.feature.profile.update.composable + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.core.net.toUri +import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.component.bottomsheet.AconBottomSheet +import com.acon.acon.core.designsystem.component.button.v2.AconFilledTextButton +import com.acon.acon.core.designsystem.component.dialog.v2.AconTwoActionDialog +import com.acon.acon.core.designsystem.component.topbar.AconTopBar +import com.acon.acon.core.designsystem.theme.AconTheme +import com.acon.feature.profile.update.viewmodel.ProfileUpdateState + +private data class ImageSelectDialogItem( + @StringRes val titleResId: Int, + val onClick: () -> Unit +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ProfileUpdateScreen( + state: ProfileUpdateState, + actions: ProfileUpdateScreenActions, + modifier: Modifier = Modifier +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val birthDateFocusRequester = remember { FocusRequester() } + + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + uri?.let { + actions.onGalleryImageSelect(uri) + } + } + + if (state.showExitModal) { + AconTwoActionDialog( + title = stringResource(R.string.profile_mod_exit_title), + action1 = stringResource(R.string.continue_writing), + action2 = stringResource(R.string.exit), + onDismissRequest = actions.onDismissExitDialog, + onAction1 = actions.onDismissExitDialog, + onAction2 = actions.onBackConfirm, + ) + } + + if (state.showImageSelectModal) { + AconBottomSheet( + onDismissRequest = actions.onDismissImageSelectDialog, + useGlassMorphism = false + ) { + remember { + listOf( + ImageSelectDialogItem(R.string.upload_photo_from_album) { + actions.onDismissImageSelectDialog() + photoPickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + ImageSelectDialogItem(R.string.set_default_profile_image) { + actions.onDismissImageSelectDialog() + actions.onDefaultImageSelect() + }, + ) + }.fastForEach { + Text( + text = stringResource(it.titleResId), + color = AconTheme.color.White, + style = AconTheme.typography.Title4, + fontWeight = FontWeight.Normal, + modifier = Modifier + .fillMaxWidth() + .clickable { + it.onClick() + } + .padding(horizontal = 16.dp, vertical = 17.dp) + ) + } + Spacer(modifier = Modifier.height(75.dp)) + } + } + + Column( + modifier = modifier + .pointerInput(Unit) { + detectTapGestures(onTap = { + focusManager.clearFocus() + keyboardController?.hide() + }) + } + ) { + AconTopBar( + leadingIcon = { + IconButton( + onClick = actions.onBack + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_topbar_arrow_left), + contentDescription = stringResource(R.string.back), + tint = AconTheme.color.Gray50 + ) + } + }, + content = { + Text( + text = stringResource(R.string.profile_edit_topbar), + style = AconTheme.typography.Title4, + fontWeight = FontWeight.SemiBold, + color = AconTheme.color.White + ) + }, + modifier = Modifier.padding(vertical = 14.dp) + ) + + UpdatableProfileImageView( + imageUri = state.profileImageUriInput?.toUri(), + onClick = actions.onProfileImageBoxClick, + modifier = Modifier + .size(80.dp) + .align(Alignment.CenterHorizontally) + ) + + NicknameFieldView( + input = state.nicknameInput, + onInputChange = actions.onNicknameInputChange, + validationStatus = state.nicknameValidationStatus, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions( + onNext = { birthDateFocusRequester.requestFocus() } + ), + modifier = Modifier + .padding(top = 48.dp) + .padding(horizontal = 16.dp) + .fillMaxWidth() + ) + + BirthDateFieldView( + input = state.birthDateInput, + onInputChange = actions.onBirthDateInputChange, + validationStatus = state.birthDateValidationStatus, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + } + ), + modifier = Modifier + .padding(top = 24.dp) + .padding(horizontal = 16.dp) + .fillMaxWidth() + .focusRequester(birthDateFocusRequester) + ) + + Spacer(modifier = Modifier.weight(1f)) + + AconFilledTextButton( + text = stringResource(R.string.save), + onClick = actions.onSaveButtonClick, + enabled = state.isSaveEnabled, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .padding(horizontal = 16.dp) + ) + } +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/composable/ProfileUpdateScreenContainer.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/composable/ProfileUpdateScreenContainer.kt new file mode 100644 index 000000000..670172c92 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/composable/ProfileUpdateScreenContainer.kt @@ -0,0 +1,70 @@ +package com.acon.feature.profile.update.composable + +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.TextFieldValue +import androidx.hilt.navigation.compose.hiltViewModel +import com.acon.acon.core.designsystem.R +import com.acon.acon.core.ui.android.showToast +import com.acon.feature.profile.update.viewmodel.ProfileUpdateSideEffect +import com.acon.feature.profile.update.viewmodel.ProfileUpdateViewModel +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun ProfileUpdateScreenContainer( + modifier: Modifier = Modifier, + onNavigateBack: () -> Unit, + viewModel: ProfileUpdateViewModel = hiltViewModel() +) { + + val context = LocalContext.current + val state by viewModel.collectAsState() + + ProfileUpdateScreen( + state = state, + actions = ProfileUpdateScreenActions( + onBack = viewModel::onBack, + onProfileImageBoxClick = viewModel::onProfileImageClicked, + onNicknameInputChange = viewModel::onNicknameInputChanged, + onBirthDateInputChange = viewModel::onBirthDateInputChanged, + onSaveButtonClick = viewModel::onSave, + onDismissExitDialog = viewModel::onDismissExitModal, + onBackConfirm = viewModel::onBackConfirmed, + onGalleryImageSelect = { + viewModel.onProfileImageSelected(it.toString()) + }, + onDefaultImageSelect = viewModel::onDefaultProfileImageSelected, + onDismissImageSelectDialog = viewModel::onDismissImageSelectModal + ), + modifier = modifier + ) + + BackHandler { + viewModel.onBack() + } + + viewModel.collectSideEffect { sideEffect -> + when(sideEffect) { + is ProfileUpdateSideEffect.NavigateBack -> onNavigateBack() + is ProfileUpdateSideEffect.ShowSaveFailedMessage -> context.showToast(R.string.unknown_error) + } + } +} + +internal data class ProfileUpdateScreenActions( + val onBack: () -> Unit = {}, + val onProfileImageBoxClick: () -> Unit = {}, + val onNicknameInputChange: (TextFieldValue) -> Unit = {}, + val onBirthDateInputChange: (TextFieldValue) -> Unit = {}, + val onSaveButtonClick: () -> Unit = {}, + val onDismissExitDialog: () -> Unit = {}, + val onBackConfirm: () -> Unit = {}, + val onGalleryImageSelect: (Uri) -> Unit = {}, + val onDefaultImageSelect: () -> Unit = {}, + val onDismissImageSelectDialog: () -> Unit = {}, +) \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/composable/UpdatableProfileImageView.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/composable/UpdatableProfileImageView.kt new file mode 100644 index 000000000..c858e11da --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/composable/UpdatableProfileImageView.kt @@ -0,0 +1,57 @@ +package com.acon.feature.profile.update.composable + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +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.draw.clipToBounds +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import coil3.compose.AsyncImage +import com.acon.acon.core.designsystem.R +import com.acon.acon.core.designsystem.component.image.DefaultProfileImage +import com.acon.acon.core.designsystem.noRippleClickable + +@Composable +internal fun UpdatableProfileImageView( + imageUri: Uri?, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + + Box( + modifier = modifier.noRippleClickable { + onClick() + } + ) { + when { + imageUri != null -> AsyncImage( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape), + model = imageUri, + contentDescription = stringResource(R.string.content_description_profile_image), + contentScale = ContentScale.Crop, + error = painterResource(R.drawable.ic_default_profile) + ) + else -> DefaultProfileImage( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + ) + } + Image( + imageVector = ImageVector.vectorResource(R.drawable.ic_profile_img_edit), + contentDescription = stringResource(R.string.content_description_edit_profile), + modifier = Modifier.align(alignment = Alignment.BottomEnd) + ) + } +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/status/BirthDateValidationStatus.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/status/BirthDateValidationStatus.kt new file mode 100644 index 000000000..bc8815ea4 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/status/BirthDateValidationStatus.kt @@ -0,0 +1,8 @@ +package com.acon.feature.profile.update.status + +sealed interface BirthDateValidationStatus { + data object Idle: BirthDateValidationStatus + data object Typing: BirthDateValidationStatus + data object Valid: BirthDateValidationStatus + data object Invalid: BirthDateValidationStatus +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/status/NicknameValidationStatus.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/status/NicknameValidationStatus.kt new file mode 100644 index 000000000..459894479 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/status/NicknameValidationStatus.kt @@ -0,0 +1,10 @@ +package com.acon.feature.profile.update.status + +sealed interface NicknameValidationStatus { + data object Idle: NicknameValidationStatus + data object Available: NicknameValidationStatus + data object AlreadyExist: NicknameValidationStatus + data object Empty: NicknameValidationStatus + data object InvalidFormat: NicknameValidationStatus + data object Loading: NicknameValidationStatus +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/status/ProfileImageInputStatus.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/status/ProfileImageInputStatus.kt new file mode 100644 index 000000000..30345a66d --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/status/ProfileImageInputStatus.kt @@ -0,0 +1,6 @@ +package com.acon.feature.profile.update.status + +sealed interface ProfileImageInputStatus { + data object Changed: ProfileImageInputStatus + data object NotChanged: ProfileImageInputStatus +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModel.kt b/feature/profile/src/main/java/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModel.kt new file mode 100644 index 000000000..6f04b47c7 --- /dev/null +++ b/feature/profile/src/main/java/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModel.kt @@ -0,0 +1,246 @@ +package com.acon.feature.profile.update.viewmodel + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.text.isDigitsOnly +import com.acon.acon.core.common.utils.toLocalDate +import com.acon.acon.core.common.utils.toyyyyMMdd +import com.acon.acon.core.model.model.profile.BirthDateStatus +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import com.acon.acon.core.ui.base.BaseContainerHost +import com.acon.acon.domain.error.profile.ValidateNicknameError +import com.acon.acon.domain.repository.ProfileRepository +import com.acon.acon.domain.usecase.ValidateBirthDateUseCase +import com.acon.acon.domain.usecase.ValidateNicknameUseCase +import com.acon.acon.domain.usecase.ValidateNicknameUseCase.Companion.MAX_NICKNAME_LENGTH +import com.acon.feature.profile.update.status.BirthDateValidationStatus +import com.acon.feature.profile.update.status.NicknameValidationStatus +import com.acon.feature.profile.update.status.ProfileImageInputStatus +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import org.orbitmvi.orbit.Container +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +class ProfileUpdateViewModel @Inject constructor( + private val profileRepository: ProfileRepository, + private val validateNicknameUseCase: ValidateNicknameUseCase, + private val validateBirthDateUseCase: ValidateBirthDateUseCase +) : BaseContainerHost() { + + private var nicknameValidationJob: Job? = null + + override val container: Container = + container(ProfileUpdateState()) { + profileRepository.getProfile().collect { profileResult -> + profileResult.onSuccess { profile -> + reduce { + ProfileUpdateState( + nicknameInput = TextFieldValue(profile.nickname), + birthDateInput = TextFieldValue( + when (val birthDateStatus = profile.birthDate) { + is BirthDateStatus.Specified -> birthDateStatus.date.toyyyyMMdd() + is BirthDateStatus.NotSpecified -> "" + } + ), + profileImageUriInput = when(val imageStatus = profile.image) { + is ProfileImageStatus.Custom -> imageStatus.url + is ProfileImageStatus.Default -> null + } + ) + } + } + } + } + + fun onNicknameInputChanged(input: TextFieldValue): Job? { + if (input.text.length > MAX_NICKNAME_LENGTH) + return null + + nicknameValidationJob?.cancel() + nicknameValidationJob = intent { + + val isJustSelection = input.text == state.nicknameInput.text + if (isJustSelection) { + reduce { state.copy(nicknameInput = input) } + return@intent + } + + reduce { + state.copy( + nicknameInput = input, + shouldShowExitModal = true, + nicknameValidationStatus = NicknameValidationStatus.Loading, + ) + } + + delay(DEBOUNCE_MILLIS) + validateNicknameUseCase(input.text) + .onSuccess { + reduce { state.copy(nicknameValidationStatus = NicknameValidationStatus.Available) } + } + .onFailure { e -> + reduce { + state.copy( + nicknameValidationStatus = when (e) { + is ValidateNicknameError.EmptyInput -> NicknameValidationStatus.Empty + is ValidateNicknameError.AlreadyExist -> NicknameValidationStatus.AlreadyExist + is ValidateNicknameError.InvalidFormat -> NicknameValidationStatus.InvalidFormat + else -> NicknameValidationStatus.Idle + } + ) + } + } + } + return nicknameValidationJob!! + } + + fun onBirthDateInputChanged(input: TextFieldValue) = intent { + if (input.text.length > 8) return@intent + if (input.text.any { it.isDigit().not() }) return@intent + + val isJustSelection = input.text == state.birthDateInput.text + + if (isJustSelection) { + reduce { + state.copy(birthDateInput = input) + } + } + else { + reduce { + state.copy( + birthDateInput = input, + shouldShowExitModal = true, + ) + } + if (input.text.isEmpty()) { + reduce { + state.copy(birthDateValidationStatus = BirthDateValidationStatus.Valid) + } + } else if (input.text.length in 1 until 8) { + reduce { + state.copy(birthDateValidationStatus = BirthDateValidationStatus.Typing) + } + } else if (input.text.length == 8) { + val localDate = input.text.toLocalDate() + if (localDate == null) + reduce { + state.copy(birthDateValidationStatus = BirthDateValidationStatus.Invalid) + } + else { + validateBirthDateUseCase(localDate).onSuccess { + reduce { + state.copy(birthDateValidationStatus = BirthDateValidationStatus.Valid) + } + }.onFailure { + reduce { + state.copy(birthDateValidationStatus = BirthDateValidationStatus.Invalid) + } + } + } + } + } + } + + fun onDefaultProfileImageSelected() = intent { + reduce { + state.copy( + profileImageUriInput = null, + profileImageInputStatus = ProfileImageInputStatus.Changed, + shouldShowExitModal = true + ) + } + } + + fun onProfileImageSelected(imageUri: String) = intent { + reduce { + state.copy( + profileImageUriInput = imageUri, + profileImageInputStatus = ProfileImageInputStatus.Changed, + shouldShowExitModal = true, + ) + } + } + + fun onBack() = intent { + if (state.shouldShowExitModal) { + reduce { + state.copy(showExitModal = true) + } + } else { + postSideEffect(ProfileUpdateSideEffect.NavigateBack) + } + } + + fun onProfileImageClicked() = intent { + reduce { + state.copy(showImageSelectModal = true) + } + } + + fun onDismissImageSelectModal() = intent { + reduce { + state.copy(showImageSelectModal = false) + } + } + + fun onDismissExitModal() = intent { + reduce { + state.copy(showExitModal = false) + } + } + + fun onBackConfirmed() = intent { + reduce { + state.copy(showExitModal = false) + } + postSideEffect(ProfileUpdateSideEffect.NavigateBack) + } + + fun onSave() = intent { + profileRepository.updateProfile(Profile( + nickname = state.nicknameInput.text, + birthDate = state.birthDateInput.text.toLocalDate()?.let { date -> + BirthDateStatus.Specified(date) + } ?: BirthDateStatus.NotSpecified, + image = state.profileImageUriInput?.let { ProfileImageStatus.Custom(it) } ?: ProfileImageStatus.Default + )).onSuccess { + postSideEffect(ProfileUpdateSideEffect.NavigateBack) + }.onFailure { _ -> + postSideEffect(ProfileUpdateSideEffect.ShowSaveFailedMessage) + } + } + + companion object { + private const val DEBOUNCE_MILLIS = 200L + } +} + +@Immutable +data class ProfileUpdateState( + val nicknameInput: TextFieldValue = TextFieldValue(""), + val birthDateInput: TextFieldValue = TextFieldValue(""), + val profileImageUriInput: String? = null, + val nicknameValidationStatus: NicknameValidationStatus = NicknameValidationStatus.Idle, + val birthDateValidationStatus: BirthDateValidationStatus = BirthDateValidationStatus.Idle, + val profileImageInputStatus: ProfileImageInputStatus = ProfileImageInputStatus.NotChanged, + val showImageSelectModal: Boolean = false, + val showExitModal: Boolean = false, + val shouldShowExitModal: Boolean = false, +) { + val isSaveEnabled: Boolean + get() = (profileImageInputStatus is ProfileImageInputStatus.NotChanged && ( + (nicknameValidationStatus is NicknameValidationStatus.Available && (birthDateValidationStatus is BirthDateValidationStatus.Idle || birthDateValidationStatus is BirthDateValidationStatus.Valid)) || + (nicknameValidationStatus is NicknameValidationStatus.Idle && (birthDateValidationStatus is BirthDateValidationStatus.Valid))) || + (profileImageInputStatus is ProfileImageInputStatus.Changed && (nicknameValidationStatus is NicknameValidationStatus.Available || nicknameValidationStatus is NicknameValidationStatus.Idle) && (birthDateValidationStatus is BirthDateValidationStatus.Valid || birthDateValidationStatus is BirthDateValidationStatus.Idle)) + ) +} + + +sealed interface ProfileUpdateSideEffect { + data object NavigateBack: ProfileUpdateSideEffect + data object ShowSaveFailedMessage: ProfileUpdateSideEffect +} \ No newline at end of file diff --git a/feature/profile/src/test/kotlin/com/acon/feature/profile/info/viewmodel/ProfileInfoViewModelTest.kt b/feature/profile/src/test/kotlin/com/acon/feature/profile/info/viewmodel/ProfileInfoViewModelTest.kt new file mode 100644 index 000000000..103e6230d --- /dev/null +++ b/feature/profile/src/test/kotlin/com/acon/feature/profile/info/viewmodel/ProfileInfoViewModelTest.kt @@ -0,0 +1,241 @@ +package com.acon.feature.profile.info.viewmodel + +import com.acon.acon.core.model.model.profile.BirthDateStatus +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import com.acon.acon.core.model.model.profile.SavedSpot +import com.acon.acon.core.model.model.profile.SpotThumbnailStatus +import com.acon.acon.core.model.model.spot.SpotNavigationParameter +import com.acon.acon.core.model.type.SignInStatus +import com.acon.acon.domain.repository.ProfileRepository +import com.acon.acon.domain.repository.UserRepository +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.BehaviorSpec +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.orbitmvi.orbit.test.test + +class ProfileInfoViewModelTest : BehaviorSpec({ + + isolationMode = IsolationMode.InstancePerLeaf + + lateinit var userRepository: UserRepository + lateinit var profileRepository: ProfileRepository + lateinit var viewModel: ProfileInfoViewModel + + context("초기 상태 업데이트 - container onCreate()") { + userRepository = mockk() + profileRepository = mockk() + viewModel = ProfileInfoViewModel(userRepository, profileRepository) + + Given("로그인 유저일 경우") { + + coEvery { userRepository.getSignInStatus() } returns flowOf(SignInStatus.USER) + + val sampleProfile = Profile( + nickname = "Sample Nickname", + birthDate = BirthDateStatus.NotSpecified, + image = ProfileImageStatus.Default + ) + val sampleSavedSpots = listOf( + SavedSpot(1, "Sample Spot1", SpotThumbnailStatus.Empty), + SavedSpot(2, "Sample Spot2", SpotThumbnailStatus.Exist("url1")), + SavedSpot(3, "Sample Spot3", SpotThumbnailStatus.Exist("url2")) + ) + + When("프로필과 저장한 장소를 불러오는데,") { + And("둘 다 성공하면") { + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success(sampleProfile) + ) + coEvery { profileRepository.getSavedSpots() } returns flowOf(Result.success( + sampleSavedSpots + )) + + Then("상태를 두 모델 값이 반영된 성공 상태로 업데이트한다") { + runTest { + viewModel.test(this) { + runOnCreate().join() + + coVerify(exactly = 1) { profileRepository.getProfile() } + coVerify(exactly = 1) { profileRepository.getSavedSpots() } + + expectState { + ProfileInfoUiState.User(sampleProfile, sampleSavedSpots) + } + } + } + } + } + And("프로필 로드만 성공하면") { + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success( + sampleProfile + ) + ) + coEvery { profileRepository.getSavedSpots() } returns flowOf(Result.failure(mockk())) + Then("상태를 실패로 업데이트한다") { + runTest { + viewModel.test(this) { + runOnCreate().join() + + coVerify(exactly = 1) { profileRepository.getProfile() } + coVerify(exactly = 1) { profileRepository.getSavedSpots() } + expectState { ProfileInfoUiState.LoadFailed } + } + } + } + } + And("저장한 장소 로드만 성공하면") { + coEvery { profileRepository.getProfile() } returns flowOf( + Result.failure(mockk()) + ) + coEvery { profileRepository.getSavedSpots() } returns flowOf(Result.success( + sampleSavedSpots + )) + + Then("상태를 실패로 업데이트한다") { + runTest { + viewModel.test(this) { + runOnCreate().join() + + coVerify(exactly = 1) { profileRepository.getProfile() } + coVerify(exactly = 1) { profileRepository.getSavedSpots() } + expectState { ProfileInfoUiState.LoadFailed } + } + } + } + } + } + } + + Given("비로그인 유저일 경우") { + coEvery { userRepository.getSignInStatus() } returns flowOf(SignInStatus.GUEST) + + Then("비로그인 상태로 업데이트한다") { + runTest { + viewModel.test(this) { + runOnCreate().join() + + expectState { ProfileInfoUiState.Guest } + } + } + } + } + } + + context("화면 이동") { + userRepository = mockk() + profileRepository = mockk() + viewModel = ProfileInfoViewModel(userRepository, profileRepository) + + Given("onSpotListClicked()는") { + When("추천 장소 리스트 UI가 눌렸을 때") { + Then("추천 장소 리스트 화면 이동 SideEffect를 보낸다") { + runTest { + viewModel.test(this) { + viewModel.onSpotListClicked() + + expectSideEffect(ProfileInfoSideEffect.NavigateToSpotList) + } + } + } + } + } + + Given("onUploadClicked()는") { + When("업로드 UI가 눌렸을 때") { + And("비로그인일 경우") { + coEvery { userRepository.getSignInStatus() } returns flowOf(SignInStatus.GUEST) + Then("로그인 요청 함수를 호츨한다") { + // TODO + } + } + And("로그인일 경우") { + coEvery { userRepository.getSignInStatus() } returns flowOf(SignInStatus.USER) + Then("업로드 화면 이동 SideEffect를 보낸다") { + runTest { + viewModel.test(this) { + viewModel.onUploadClicked() + + expectSideEffect(ProfileInfoSideEffect.NavigateToUpload) + } + } + } + } + } + } + + Given("onProfileUpdateClicked()는") { + When("프로필 수정 UI가 눌렸을 때") { + Then("프로필 수정 화면 이동 SideEffect를 보낸다") { + runTest { + viewModel.test(this) { + viewModel.onProfileUpdateClicked() + + expectSideEffect(ProfileInfoSideEffect.NavigateToProfileUpdate) + } + } + } + } + } + + Given("onSavedSpotClicked()는") { + When("저장한 장소 하나가 눌렸을 때") { + val sampleSavedSpotId = 123L + val sampleSpotNavigationParam = SpotNavigationParameter( + spotId = sampleSavedSpotId, + tags = emptyList(), + transportMode = null, + eta = null, + isFromDeepLink = null, + navFromProfile = true + ) + Then("해당 저장한 장소 디테일 화면 이동 SideEffect를 보낸다") { + runTest { + viewModel.test(this) { + viewModel.onSavedSpotClicked(sampleSavedSpotId) + + expectSideEffect( + ProfileInfoSideEffect + .NavigateToSpotDetail(sampleSpotNavigationParam) + ) + } + } + } + } + } + + Given("onSeeAllSavedSpotsClicked()는") { + When("전체보기 UI가 눌렸을 때") { + Then("저장한 장소 전체 보기 화면 이동 SideEffect를 보낸다") { + runTest { + viewModel.test(this) { + viewModel.onSeeAllSavedSpotsClicked() + + expectSideEffect(ProfileInfoSideEffect.NavigateToSavedSpots) + } + } + } + } + } + + Given("onSettingClicked()는") { + When("설정 UI가 눌렸을 때") { + Then("설정 화면 이동 SideEffect를 보낸다") { + runTest { + viewModel.test(this) { + viewModel.onSettingClicked() + + expectSideEffect(ProfileInfoSideEffect.NavigateToSetting) + } + } + } + } + } + } +}) \ No newline at end of file diff --git a/feature/profile/src/test/kotlin/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModelTest.kt b/feature/profile/src/test/kotlin/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModelTest.kt new file mode 100644 index 000000000..e6db38c3e --- /dev/null +++ b/feature/profile/src/test/kotlin/com/acon/feature/profile/update/viewmodel/ProfileUpdateViewModelTest.kt @@ -0,0 +1,828 @@ +package com.acon.feature.profile.update.viewmodel + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.acon.acon.core.model.model.profile.BirthDateStatus +import com.acon.acon.core.model.model.profile.Profile +import com.acon.acon.core.model.model.profile.ProfileImageStatus +import com.acon.acon.domain.error.profile.ValidateBirthDateError +import com.acon.acon.domain.error.profile.ValidateNicknameError +import com.acon.acon.domain.repository.ProfileRepository +import com.acon.acon.domain.usecase.ValidateBirthDateUseCase +import com.acon.acon.domain.usecase.ValidateNicknameUseCase +import com.acon.feature.profile.update.status.BirthDateValidationStatus +import com.acon.feature.profile.update.status.NicknameValidationStatus +import com.acon.feature.profile.update.status.ProfileImageInputStatus +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.data.forAll +import io.kotest.data.headers +import io.kotest.data.row +import io.kotest.data.table +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.orbitmvi.orbit.test.test +import java.time.LocalDate + +class ProfileUpdateViewModelTest : BehaviorSpec({ + + isolationMode = IsolationMode.InstancePerLeaf + + lateinit var profileRepository: ProfileRepository + lateinit var validateNicknameUseCase: ValidateNicknameUseCase + lateinit var validateBirthDateUseCase: ValidateBirthDateUseCase + lateinit var viewModel: ProfileUpdateViewModel + + fun ProfileUpdateViewModel.getState() = container.stateFlow.value + + context("초기 상태 업데이트 - container onCreate()") { + profileRepository = mockk() + validateNicknameUseCase = mockk() + validateBirthDateUseCase = mockk() + viewModel = ProfileUpdateViewModel( + profileRepository, + validateNicknameUseCase, + validateBirthDateUseCase + ) + + Given("유저의 프로필 불러오기를") { + When("성공하면") { + Then("입력 상태에 닉네임을 반영한다") { + val sampleProfile = Profile( + nickname = "Sample Nickname", + birthDate = BirthDateStatus.NotSpecified, + image = ProfileImageStatus.Default + ) + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success( + sampleProfile + ) + ) + + val expectedNicknameInput = TextFieldValue(sampleProfile.nickname) + runTest { + viewModel.test(this) { + runOnCreate() + + val state = awaitState() + state.nicknameInput shouldBe expectedNicknameInput + } + } + } + + And("생년월일이 지정되어 있으면") { + val sampleProfile = Profile( + nickname = "Sample Nickname", + birthDate = BirthDateStatus.Specified(LocalDate.of(1999, 12, 25)), + image = ProfileImageStatus.Default + ) + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success( + sampleProfile + ) + ) + Then(" 생년월일 입력 상태를 8자리 문자열로 설정한다") { + val expectedBirthDateInput = TextFieldValue("19991225") + runTest { + viewModel.test(this) { + runOnCreate() + + val state = awaitState() + state.birthDateInput shouldBe expectedBirthDateInput + } + } + } + } + And("생년월일이 지정되어 있지 않으면") { + val sampleProfile = Profile( + nickname = "Sample Nickname", + birthDate = BirthDateStatus.NotSpecified, + image = ProfileImageStatus.Default + ) + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success( + sampleProfile + ) + ) + Then("생년월일 입력 상태를 빈 문자열로 설정한다") { + val expectedBirthDateInput = TextFieldValue("") + runTest { + viewModel.test(this) { + runOnCreate() + + val state = awaitState() + state.birthDateInput shouldBe expectedBirthDateInput + } + } + } + } + + And("프로필 이미지가 지정되어 있으면") { + val sampleProfile = Profile( + nickname = "Sample Nickname", + birthDate = BirthDateStatus.NotSpecified, + image = ProfileImageStatus.Custom("Sample Url") + ) + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success( + sampleProfile + ) + ) + val expectedProfileImageUriInput = "Sample Url" + Then("프로필 이미지 입력 상태를 uri 형식의 문자열로 설정한다") { + runTest { + viewModel.test(this) { + runOnCreate() + + val state = awaitState() + state.profileImageUriInput shouldBe expectedProfileImageUriInput + } + } + } + } + And("프로필 이미지가 기본 이미지면") { + val sampleProfile = Profile( + nickname = "Sample Nickname", + birthDate = BirthDateStatus.NotSpecified, + image = ProfileImageStatus.Default + ) + coEvery { profileRepository.getProfile() } returns flowOf( + Result.success( + sampleProfile + ) + ) + val expectedProfileImageUriInput = null + Then("프로필 이미지 입력 상태를 null로 설정한다") { + runTest { + viewModel.test(this) { + runOnCreate() + + val state = awaitState() + state.profileImageUriInput shouldBe expectedProfileImageUriInput + } + } + } + } + } + } + } + + Given("State의 isSaveEnabled는") { + viewModel = ProfileUpdateViewModel(mockk(), mockk(), mockk()) + When("닉네임, 생년월일의 유효성 상태와 프로필 이미지 입력 상태가 주어졌을 때") { + table( + headers("테스트 설명", "닉네임 유효성 상태", "생년월일 유효성 상태", "프로필 이미지 입력 상태", "기대 결과"), + row( + "모두 초기상태", + NicknameValidationStatus.Idle, + BirthDateValidationStatus.Idle, + ProfileImageInputStatus.NotChanged, + false + ), + row( + "닉네임과 생년월일 초기 상태, 프로필 이미지 '변경됨'", + NicknameValidationStatus.Idle, + BirthDateValidationStatus.Idle, + ProfileImageInputStatus.Changed, + true + ), + row( + "닉네임과 프로필 이미지 초기 상태, 생일 '유효함'", + NicknameValidationStatus.Idle, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.NotChanged, + true + ), + row( + "생년월일과 프로필 이미지 초기 상태, 닉네임 '사용 가능'", + NicknameValidationStatus.Available, + BirthDateValidationStatus.Idle, + ProfileImageInputStatus.NotChanged, + true + ), + row( + "닉네임 '사용 가능', 생년월일 '유효', 생일 입력 '변경 안 됨'", + NicknameValidationStatus.Available, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.NotChanged, + true + ), + row( + "닉네임 초기 상태, 생년월일 유효, 프로필 이미지 '변경됨'", + NicknameValidationStatus.Idle, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + true + ), + row( + "닉네임 '사용 가능', 생년월일 '유효', 프로필 이미지 '변경됨'", + NicknameValidationStatus.Available, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + true + ), + row( + "생년월일 '입력 중'", + NicknameValidationStatus.Available, + BirthDateValidationStatus.Typing, + ProfileImageInputStatus.Changed, + false + ), + row( + "생년월일 '유효하지 않음'", + NicknameValidationStatus.Available, + BirthDateValidationStatus.Invalid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임 로딩 중", + NicknameValidationStatus.Loading, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임 형식 오류", + NicknameValidationStatus.InvalidFormat, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임 중복", + NicknameValidationStatus.AlreadyExist, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임 '비어있음'", + NicknameValidationStatus.Empty, + BirthDateValidationStatus.Valid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임, 생년월일 '유효하지 않음', 프로필 이미지 '변경됨'", + NicknameValidationStatus.Empty, + BirthDateValidationStatus.Invalid, + ProfileImageInputStatus.Changed, + false + ), + row( + "닉네임, 생년월일 '유효하지 않음', 프로필 이미지 '변경 안 됨'", + NicknameValidationStatus.Empty, + BirthDateValidationStatus.Invalid, + ProfileImageInputStatus.NotChanged, + false + ) + ).forAll { testDescription, nicknameStatus, birthDateStatus, imageInputState, expected -> + Then("$testDescription -> 저장 버튼 활성화 상태는 $expected") { + runTest { + viewModel.test( + this, ProfileUpdateState( + nicknameValidationStatus = nicknameStatus, + birthDateValidationStatus = birthDateStatus, + profileImageInputStatus = imageInputState + ) + ) { + viewModel.getState().isSaveEnabled shouldBe expected + } + } + } + } + } + } + + context("입력 처리") { + profileRepository = mockk() + validateNicknameUseCase = mockk() + validateBirthDateUseCase = mockk() + viewModel = ProfileUpdateViewModel( + profileRepository, + validateNicknameUseCase, + validateBirthDateUseCase + ) + + Given("onNicknameInputChanged()는") { + When("Text Value 변화로 발생한 호출일 경우") { + + val sampleNewTextFieldValue = TextFieldValue("acon123") + And("닉네임 유효성 검사 수행 전") { + Then("프로필 상태에 새 닉네임 입력을 반영한다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + nicknameInput = TextFieldValue("acon12") + ) + ) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue) + + val state = awaitState() + state.nicknameInput shouldBe sampleNewTextFieldValue + } + } + } + + Then("닉네임 유효성 상태는 '로딩'이 된다") { + val sampleTextFieldValue = TextFieldValue("acon123") + + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleTextFieldValue) + + val state = awaitState() + + state.nicknameValidationStatus shouldBe NicknameValidationStatus.Loading + } + } + } + + And("입력이 14글자를 초과하면") { + val sampleNewTextFieldValue = TextFieldValue("이것은14글자를초과하는닉네임") + val expectedProfileUpdateState = viewModel.getState() + Then("해당 입력 전체를 무시한다") { + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue)?.join() + + viewModel.getState() shouldBe expectedProfileUpdateState + } + } + } + } + } + + And("닉네임 유효성 검사를 수행하여") { + And("통과하면") { + coEvery { validateNicknameUseCase(any()) } returns Result.success(Unit) + + Then("닉네임 유효성 상태를 '사용 가능'으로 설정한다") { + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue)?.join() + + val state = viewModel.getState() + + coVerify(exactly = 1) { + validateNicknameUseCase( + sampleNewTextFieldValue.text + ) + } + state.nicknameValidationStatus shouldBe NicknameValidationStatus.Available + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("빈 입력 에러를 반환받으면") { + coEvery { validateNicknameUseCase(any()) } returns Result.failure( + ValidateNicknameError.EmptyInput() + ) + + Then("닉네임 유효성 상태를 `빈 입력`으로 설정한다") { + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue)?.join() + + val state = viewModel.getState() + + coVerify(exactly = 1) { + validateNicknameUseCase( + sampleNewTextFieldValue.text + ) + } + state.nicknameValidationStatus shouldBe NicknameValidationStatus.Empty + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("중복된 닉네임 에러를 반환받으면") { + coEvery { validateNicknameUseCase(any()) } returns Result.failure( + ValidateNicknameError.AlreadyExist() + ) + + Then("닉네임 유효성 상태를 `중복`으로 설정한다") { + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue)?.join() + + val state = viewModel.getState() + + coVerify(exactly = 1) { + validateNicknameUseCase( + sampleNewTextFieldValue.text + ) + } + state.nicknameValidationStatus shouldBe NicknameValidationStatus.AlreadyExist + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("잘못된 닉네임 형식 에러를 반환받으면") { + coEvery { validateNicknameUseCase(any()) } returns Result.failure( + ValidateNicknameError.InvalidFormat() + ) + + Then("닉네임 유효성 상태를 `잘못된 형식`으로 설정한다") { + runTest { + viewModel.test(this) { + viewModel.onNicknameInputChanged(sampleNewTextFieldValue)?.join() + + val state = viewModel.getState() + + coVerify(exactly = 1) { + validateNicknameUseCase( + sampleNewTextFieldValue.text + ) + } + state.nicknameValidationStatus shouldBe NicknameValidationStatus.InvalidFormat + + cancelAndIgnoreRemainingItems() + } + } + + } + } + } + } + + When("Text Selection으로 발생한 호출일 경우") { + val originalNicknameTextFieldValue = TextFieldValue("acon123") + val selectedTextRange = TextRange(4, 4) + val expectedNicknameTextFieldValue = + originalNicknameTextFieldValue.copy(selection = selectedTextRange) + + Then("nicknameInput 상태만 업데이트하고 그 외에는 수행하지 않는다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + nicknameInput = originalNicknameTextFieldValue + ) + ) { + viewModel.onNicknameInputChanged(expectedNicknameTextFieldValue)?.join() + coVerify(exactly = 0) { validateNicknameUseCase(any()) } + + expectState { + ProfileUpdateState( + nicknameInput = expectedNicknameTextFieldValue + ) + } + } + } + } + } + } + + Given("onBirthDateInputChanged()는") { + And("입력에 숫자가 아닌 것이 포함되어 있으면") { + val sampleNewTextFieldValue = TextFieldValue("2025.") + val expectedProfileUpdateState = viewModel.getState() + Then("해당 입력 전체를 무시한다") { + runTest { + viewModel.test(this) { + viewModel.onBirthDateInputChanged(sampleNewTextFieldValue).join() + + viewModel.getState() shouldBe expectedProfileUpdateState + } + } + } + } + When("Text Value 변화로 발생한 호출일 경우") { + Then("생년월일 입력 상태를 업데이트한다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = TextFieldValue("19990429") + ) + ) { + val sampleBirthDateInput = TextFieldValue("1999042") + viewModel.onBirthDateInputChanged(sampleBirthDateInput) + + val state = awaitState() + + state.birthDateInput shouldBe sampleBirthDateInput + + cancelAndIgnoreRemainingItems() + } + } + } + And("입력이 8자리로 완성되지 않았다면") { + Then("생년월일 입력 유효성 상태는 '입력 중'이다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = TextFieldValue("1999") + ) + ) { + val sampleBirthDateInput = TextFieldValue("19990") + viewModel.onBirthDateInputChanged(sampleBirthDateInput).join() + + val state = viewModel.getState() + + state.birthDateValidationStatus shouldBe BirthDateValidationStatus.Typing + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("올바른 생년월일로 입력이 완료되면") { + coEvery { validateBirthDateUseCase(any()) } returns Result.success(Unit) + Then("생년월일 입력 유효성 상태는 '유효함'이다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = TextFieldValue("1999042") + ) + ) { + val sampleBirthDateInput = TextFieldValue("19990429") + viewModel.onBirthDateInputChanged(sampleBirthDateInput).join() + + val state = viewModel.getState() + + state.birthDateValidationStatus shouldBe BirthDateValidationStatus.Valid + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("올바르지 않은 생년월일로 입력이 완료되면") { + Then("생년월일 입력 유효성 상태는 '유효하지 않음'이다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = TextFieldValue("1999043") + ) + ) { + val sampleBirthDateInput = TextFieldValue("19990439") + viewModel.onBirthDateInputChanged(sampleBirthDateInput).join() + + val state = viewModel.getState() + + state.birthDateValidationStatus shouldBe BirthDateValidationStatus.Invalid + + cancelAndIgnoreRemainingItems() + } + } + } + } + And("이번 입력으로 인해 Text가 비어졌으면") { + Then("생년월일 입력 유효성 상태는 '유효함'이다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = TextFieldValue("1") + ) + ) { + val sampleBirthDateInput = TextFieldValue("") + viewModel.onBirthDateInputChanged(sampleBirthDateInput).join() + + val state = viewModel.getState() + + state.birthDateValidationStatus shouldBe BirthDateValidationStatus.Valid + + cancelAndIgnoreRemainingItems() + } + } + } + } + } + When("Text Selection으로 발생한 호출일 경우") { + val originalBirthDateInput = TextFieldValue("19990429") + val selectedTextRange = TextRange(4, 4) + val expectedBirthDateInput = + originalBirthDateInput.copy(selection = selectedTextRange) + + Then("birthDateInput 상태만 업데이트하고 그 외에는 수행하지 않는다") { + runTest { + viewModel.test( + this, ProfileUpdateState( + birthDateInput = originalBirthDateInput + ) + ) { + viewModel.onBirthDateInputChanged(expectedBirthDateInput).join() + coVerify(exactly = 0) { validateBirthDateUseCase(any()) } + + val state = viewModel.getState() + state shouldBe ProfileUpdateState(birthDateInput = expectedBirthDateInput) + + cancelAndIgnoreRemainingItems() + } + } + } + } + } + + Given("onDefaultProfileImageSelected()는") { + runTest { + viewModel.test(this) { + viewModel.onDefaultProfileImageSelected() + + Then("선택된 프로필 이미지 uri를 null로 설정한다") { + val state = awaitState() + + state.profileImageUriInput shouldBe null + } + Then("프로필 이미지 입력 상태를 '변경됨'으로 설정한다") { + val state = awaitState() + + state.profileImageInputStatus shouldBe ProfileImageInputStatus.Changed + } + } + } + } + + Given("onProfileImageSelected()는") { + runTest { + viewModel.test(this) { + val sampleUri = "content://..." + viewModel.onProfileImageSelected(sampleUri) + + Then("선택된 프로필 이미지 uri를 넘겨받은 매개변수로 설정한다") { + val state = awaitState() + + state.profileImageUriInput shouldBe sampleUri + } + Then("프로필 이미지 입력 상태를 '변경됨'으로 설정한다") { + val state = awaitState() + + state.profileImageInputStatus shouldBe ProfileImageInputStatus.Changed + } + } + } + } + } + + context("프로필 저장") { + profileRepository = mockk() + validateNicknameUseCase = mockk() + validateBirthDateUseCase = mockk() + viewModel = ProfileUpdateViewModel( + profileRepository, + validateNicknameUseCase, + validateBirthDateUseCase + ) + + Given("onSave()는") { + When("프로필 저장 API를 호출할 때") { + val profileSlot = slot() + Then("상태에 저장된 입력 값을 바탕으로 API를 호출한다") { + coEvery { profileRepository.updateProfile(capture(profileSlot)) } returns Result.success( + Unit + ) + runTest { + viewModel.test(this, ProfileUpdateState( + nicknameInput = TextFieldValue("acon123"), + birthDateInput = TextFieldValue("20021225"), + profileImageUriInput = null + )) { + viewModel.onSave().join() + + val capturedProfile = profileSlot.captured + capturedProfile.nickname shouldBe "acon123" + capturedProfile.birthDate shouldBe + BirthDateStatus.Specified(LocalDate.of(2002,12,25)) + capturedProfile.image shouldBe ProfileImageStatus.Default + + coVerify(exactly = 1) { profileRepository.updateProfile(any()) } + + cancelAndIgnoreRemainingItems() + } + } + } + And("성공하면") { + coEvery { profileRepository.updateProfile(any()) } returns Result.success( + Unit + ) + + Then("이전 화면으로 전환 SideEffect를 보낸다") { + runTest { + viewModel.test(this) { + viewModel.onSave() + + expectSideEffect(ProfileUpdateSideEffect.NavigateBack) + } + } + } + } + And("실패하면") { + coEvery { profileRepository.updateProfile(any()) } returns Result.failure( + Exception("") + ) + Then("저장 실패 메시지 출력 SideEffect를 보낸다") { + runTest { + viewModel.test(this) { + viewModel.onSave() + + expectSideEffect(ProfileUpdateSideEffect.ShowSaveFailedMessage) + } + } + } + } + } + } + } + + context("뒤로 가기") { + profileRepository = mockk() + validateNicknameUseCase = mockk(relaxed = true) + validateBirthDateUseCase = mockk(relaxed = true) + viewModel = ProfileUpdateViewModel( + profileRepository, + validateNicknameUseCase, + validateBirthDateUseCase + ) + runTest { + viewModel.test(this) { + Given("닉네임 입력(Text Value 변화)이 발생한 경우") { + val sampleTextFieldValue = TextFieldValue("acon123") + When("나중에 뒤로가기 할 때") { + Then("모달을 보여줘야 하는 상태로 설정한다") { + viewModel.onNicknameInputChanged(sampleTextFieldValue)?.join() + + val state = viewModel.getState() + + state.shouldShowExitModal shouldBe true + } + } + } + Given("생일 입력(Text Value 변화)이 발생한 경우") { + val sampleTextFieldValue = TextFieldValue("20") + When("나중에 뒤로가기 할 때") { + Then("모달을 보여줘야 하는 상태로 설정한다") { + viewModel.onBirthDateInputChanged(sampleTextFieldValue).join() + + val state = viewModel.getState() + + state.shouldShowExitModal shouldBe true + } + } + } + Given("프로필 이미지 변경이 발생한 경우") { + When("나중에 뒤로가기 할 때") { + Then("모달을 보여줘야 하는 상태로 설정한다") { + viewModel.onDefaultProfileImageSelected().join() + + viewModel.getState().shouldShowExitModal shouldBe true + + viewModel.intent { + reduce { state.copy(shouldShowExitModal = false) } + }.join() + awaitState() + + viewModel.onProfileImageSelected("").join() + + viewModel.getState().shouldShowExitModal shouldBe true + } + } + } + + Given("뒤로가기 이벤트 발생 시") { + When("모달을 보여줘야 하는 상태이면") { + viewModel.intent { + reduce { + state.copy(shouldShowExitModal = true) + } + }.join() + awaitState() + Then("모달 출력 상태를 true로 설정한다") { + viewModel.onBack() + + expectState { + ProfileUpdateState( + showExitModal = true, + shouldShowExitModal = true, + ) + } + } + } + When("모달을 보여줘야 하는 상태가 아니면") { + viewModel.intent { + reduce { + state.copy(shouldShowExitModal = false) + } + }.join() + Then("뒤로가기 SideEffect를 보낸다") { + viewModel.onBack() + + expectSideEffect(ProfileUpdateSideEffect.NavigateBack) + } + } + } + } + } + } +}) \ No newline at end of file diff --git a/feature/settings/src/main/java/com/acon/acon/feature/settings/screen/SettingsViewModel.kt b/feature/settings/src/main/java/com/acon/acon/feature/settings/screen/SettingsViewModel.kt index 0761f315c..7ac9798ed 100644 --- a/feature/settings/src/main/java/com/acon/acon/feature/settings/screen/SettingsViewModel.kt +++ b/feature/settings/src/main/java/com/acon/acon/feature/settings/screen/SettingsViewModel.kt @@ -1,5 +1,6 @@ package com.acon.acon.feature.settings.screen +import com.acon.acon.core.model.type.SignInStatus import com.acon.acon.domain.repository.UserRepository import com.acon.acon.core.ui.base.BaseContainerHost import dagger.hilt.android.lifecycle.HiltViewModel @@ -16,11 +17,10 @@ class SettingsViewModel @Inject constructor( override val container = container(SettingsUiState.Guest) { - userRepository.getUserType().collectLatest { userType -> - when (userType) { - com.acon.acon.core.model.type.UserType.GUEST -> reduce { SettingsUiState.Guest } - com.acon.acon.core.model.type.UserType.USER -> reduce { SettingsUiState.User() } - com.acon.acon.core.model.type.UserType.ADMIN -> reduce { SettingsUiState.User() } + userRepository.getSignInStatus().collectLatest { signInStatus -> + when (signInStatus) { + SignInStatus.GUEST -> reduce { SettingsUiState.Guest } + SignInStatus.USER -> reduce { SettingsUiState.User() } } } } @@ -62,8 +62,8 @@ class SettingsViewModel @Inject constructor( postSideEffect(SettingsSideEffect.NavigateToOnboarding) } - fun onNavigateToLocalVerification() = intent { - postSideEffect(SettingsSideEffect.NavigateToLocalVerification) + fun onNavigateToUserVerifiedAreas() = intent { + postSideEffect(SettingsSideEffect.NavigateToUserVerifiedAreas) } fun onDeleteAccount() = intent { @@ -87,6 +87,6 @@ sealed interface SettingsSideEffect { data object OpenTermOfUse : SettingsSideEffect data object OpenPrivatePolicy : SettingsSideEffect data object NavigateToOnboarding : SettingsSideEffect - data object NavigateToLocalVerification : SettingsSideEffect + data object NavigateToUserVerifiedAreas : SettingsSideEffect data object NavigateToDeleteAccount : SettingsSideEffect } \ No newline at end of file diff --git a/feature/settings/src/main/java/com/acon/acon/feature/settings/screen/composable/SettingsScreenContainer.kt b/feature/settings/src/main/java/com/acon/acon/feature/settings/screen/composable/SettingsScreenContainer.kt index 47adbf8ab..bf7db5aa6 100644 --- a/feature/settings/src/main/java/com/acon/acon/feature/settings/screen/composable/SettingsScreenContainer.kt +++ b/feature/settings/src/main/java/com/acon/acon/feature/settings/screen/composable/SettingsScreenContainer.kt @@ -23,7 +23,7 @@ fun SettingsScreenContainer( onNavigateToSignInScreen: () -> Unit = {}, onNavigateToProfileScreen: () -> Unit = {}, onNavigateToOnboardingScreen: () -> Unit = {}, - onNavigateLocalVerificationScreen: () -> Unit = {}, + onNavigateUserVerifiedAreasScreen: () -> Unit = {}, onNavigateToDeleteAccountScreen: () -> Unit = {}, viewModel: SettingsViewModel = hiltViewModel() ) { @@ -42,7 +42,7 @@ fun SettingsScreenContainer( onTermOfUse = viewModel::onTermOfUse, onPrivatePolicy = viewModel::onPrivatePolicy, onRetryOnBoarding = viewModel::onRetryOnBoarding, - onAreaVerification = viewModel::onNavigateToLocalVerification, + onAreaVerification = viewModel::onNavigateToUserVerifiedAreas, onUpdateVersion = viewModel::onUpdateVersion, onSignOut = viewModel::onSignOut, onDeleteAccountScreen = viewModel::onDeleteAccount, @@ -67,7 +67,7 @@ fun SettingsScreenContainer( context.startActivity(intent) } is SettingsSideEffect.NavigateToOnboarding -> onNavigateToOnboardingScreen() - is SettingsSideEffect.NavigateToLocalVerification -> onNavigateLocalVerificationScreen() + is SettingsSideEffect.NavigateToUserVerifiedAreas -> onNavigateUserVerifiedAreasScreen() is SettingsSideEffect.NavigateToDeleteAccount -> onNavigateToDeleteAccountScreen() } } diff --git a/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/LocalVerificationViewModel.kt b/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/UserVerifiedAreasViewModel.kt similarity index 63% rename from feature/settings/src/main/java/com/acon/acon/feature/verification/screen/LocalVerificationViewModel.kt rename to feature/settings/src/main/java/com/acon/acon/feature/verification/screen/UserVerifiedAreasViewModel.kt index 4f27250e8..1822bf0e8 100644 --- a/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/LocalVerificationViewModel.kt +++ b/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/UserVerifiedAreasViewModel.kt @@ -4,6 +4,7 @@ import com.acon.acon.core.ui.base.BaseContainerHost import com.acon.acon.domain.error.area.DeleteVerifiedAreaError import com.acon.acon.domain.repository.ProfileRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import org.orbitmvi.orbit.Container import org.orbitmvi.orbit.annotation.OrbitExperimental import org.orbitmvi.orbit.viewmodel.container @@ -12,34 +13,39 @@ import javax.inject.Inject @OptIn(OrbitExperimental::class) @HiltViewModel -class LocalVerificationViewModel @Inject constructor( +class UserVerifiedAreasViewModel @Inject constructor( private val profileRepository: ProfileRepository -) : BaseContainerHost() { +) : BaseContainerHost() { - override val container: Container = - container(LocalVerificationUiState.Loading) { - fetchVerifiedAreaList() + private var loadVerifiedAreasJob: Job? = null + + override val container: Container = + container(UserVerifiedAreasUiState.Loading) { + loadVerifiedAreasJob = loadVerifiedAreas() } - private fun fetchVerifiedAreaList() = intent { - profileRepository.fetchVerifiedAreaList() - .onSuccess { + private fun loadVerifiedAreas() = intent { + profileRepository.getVerifiedAreas().collect { result -> + result.onSuccess { areas -> reduce { - LocalVerificationUiState.Success(verificationAreaList = it) + UserVerifiedAreasUiState.Success(verificationAreaList = areas) + } + }.onFailure { + reduce { + UserVerifiedAreasUiState.LoadFailed } } - .onFailure { - LocalVerificationUiState.LoadFailed - } + } } fun retry() = intent { - reduce { LocalVerificationUiState.Loading } - fetchVerifiedAreaList() + reduce { UserVerifiedAreasUiState.Loading } + loadVerifiedAreasJob?.cancel() + loadVerifiedAreas() } private fun showAreaDeleteFailDialog() = intent { - runOn { + runOn { reduce { state.copy(showAreaDeleteFailDialog = true) } @@ -47,7 +53,7 @@ class LocalVerificationViewModel @Inject constructor( } fun dismissAreaDeleteFailDialog() = intent { - runOn { + runOn { reduce { state.copy(showAreaDeleteFailDialog = false) } @@ -55,7 +61,7 @@ class LocalVerificationViewModel @Inject constructor( } fun showEditAreaDialog() = intent { - runOn { + runOn { reduce { state.copy(showEditAreaDialog = true) } @@ -63,7 +69,7 @@ class LocalVerificationViewModel @Inject constructor( } fun dismissEditAreaDialog() = intent { - runOn { + runOn { reduce { state.copy(showEditAreaDialog = false) } @@ -72,14 +78,11 @@ class LocalVerificationViewModel @Inject constructor( fun deleteVerifiedArea(verifiedAreaId: Long) = intent { profileRepository.deleteVerifiedArea(verifiedAreaId) - .onSuccess { - fetchVerifiedAreaList() - } .onFailure { error -> when (error) { is DeleteVerifiedAreaError.InvalidVerifiedArea -> { Timber.e(TAG, "유효하지 않은 인증 지역입니다.") - postSideEffect(LocalVerificationSideEffect.ShowUnKnownErrorToast) + postSideEffect(UserVerifiedAreasSideEffect.ShowUnKnownErrorToast) } is DeleteVerifiedAreaError.VerifiedAreaLimitViolation -> { @@ -94,23 +97,23 @@ class LocalVerificationViewModel @Inject constructor( is DeleteVerifiedAreaError.VerifiedAreaNotFound -> { Timber.e(TAG, "존재하지 않는 인증 동네입니다.") - postSideEffect(LocalVerificationSideEffect.ShowUnKnownErrorToast) + postSideEffect(UserVerifiedAreasSideEffect.ShowUnKnownErrorToast) } else -> { Timber.e(TAG, error.message) - postSideEffect(LocalVerificationSideEffect.ShowUnKnownErrorToast) + postSideEffect(UserVerifiedAreasSideEffect.ShowUnKnownErrorToast) } } } } fun onNavigateToSettingsScreen() = intent { - postSideEffect(LocalVerificationSideEffect.NavigateToSettingsScreen) + postSideEffect(UserVerifiedAreasSideEffect.NavigateToSettingsScreen) } fun onNavigateToAreaVerification(verifiedAreaId: Long) = intent { - postSideEffect(LocalVerificationSideEffect.NavigateToAreaVerification(verifiedAreaId)) + postSideEffect(UserVerifiedAreasSideEffect.NavigateToAreaVerification(verifiedAreaId)) } companion object { @@ -118,20 +121,20 @@ class LocalVerificationViewModel @Inject constructor( } } -sealed interface LocalVerificationUiState { +sealed interface UserVerifiedAreasUiState { data class Success( val selectedAreaId: Long? = null, val verificationAreaList: List, val showAreaDeleteFailDialog: Boolean = false, val showEditAreaDialog: Boolean = false - ) : LocalVerificationUiState + ) : UserVerifiedAreasUiState - data object Loading : LocalVerificationUiState - data object LoadFailed : LocalVerificationUiState + data object Loading : UserVerifiedAreasUiState + data object LoadFailed : UserVerifiedAreasUiState } -sealed interface LocalVerificationSideEffect { - data object ShowUnKnownErrorToast : LocalVerificationSideEffect - data object NavigateToSettingsScreen : LocalVerificationSideEffect - data class NavigateToAreaVerification(val verifiedAreaId: Long) : LocalVerificationSideEffect +sealed interface UserVerifiedAreasSideEffect { + data object ShowUnKnownErrorToast : UserVerifiedAreasSideEffect + data object NavigateToSettingsScreen : UserVerifiedAreasSideEffect + data class NavigateToAreaVerification(val verifiedAreaId: Long) : UserVerifiedAreasSideEffect } \ No newline at end of file diff --git a/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/LocalVerificationScreen.kt b/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/UserVerifiedAreasScreen.kt similarity index 94% rename from feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/LocalVerificationScreen.kt rename to feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/UserVerifiedAreasScreen.kt index 06d8aaae1..3eeb7fdaf 100644 --- a/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/LocalVerificationScreen.kt +++ b/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/UserVerifiedAreasScreen.kt @@ -30,12 +30,12 @@ import com.acon.acon.core.designsystem.component.error.NetworkErrorView import com.acon.acon.core.designsystem.component.topbar.AconTopBar import com.acon.acon.core.designsystem.theme.AconTheme import com.acon.acon.feature.verification.component.VerifiedAreaChipRow -import com.acon.acon.feature.verification.screen.LocalVerificationUiState +import com.acon.acon.feature.verification.screen.UserVerifiedAreasUiState import com.acon.acon.core.ui.compose.LocalOnRetry @Composable -fun LocalVerificationScreen( - state: LocalVerificationUiState, +fun UserVerifiedAreasScreen( + state: UserVerifiedAreasUiState, onNavigateBack: () -> Unit, onclickAreaVerification: (Long) -> Unit, onDeleteVerifiedAreaChip: (Long) -> Unit, @@ -45,7 +45,7 @@ fun LocalVerificationScreen( modifier: Modifier = Modifier ) { when (state) { - is LocalVerificationUiState.Success -> { + is UserVerifiedAreasUiState.Success -> { if (state.showAreaDeleteFailDialog) { AconDefaultDialog( title = stringResource(R.string.delete_area_dialog_fail_title), @@ -63,7 +63,6 @@ fun LocalVerificationScreen( ) } - if (state.showEditAreaDialog) { AconTwoActionDialog( title = stringResource(R.string.delete_area_dialog_fail_title), @@ -164,8 +163,8 @@ fun LocalVerificationScreen( } } - is LocalVerificationUiState.Loading -> {} - is LocalVerificationUiState.LoadFailed -> { + is UserVerifiedAreasUiState.Loading -> {} + is UserVerifiedAreasUiState.LoadFailed -> { NetworkErrorView( onRetry = LocalOnRetry.current, modifier = Modifier.fillMaxSize() @@ -176,10 +175,10 @@ fun LocalVerificationScreen( @Preview @Composable -fun LocalVerificationScreenPreview() { +fun UserVerifiedAreasScreenPreview() { AconTheme { - LocalVerificationScreen( - state = LocalVerificationUiState.Success( + UserVerifiedAreasScreen( + state = UserVerifiedAreasUiState.Success( verificationAreaList = emptyList() ), onNavigateBack = {}, diff --git a/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/LocalVerificationScreenContainer.kt b/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/UserVerifiedAreasScreenContainer.kt similarity index 77% rename from feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/LocalVerificationScreenContainer.kt rename to feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/UserVerifiedAreasScreenContainer.kt index 19ed2e5e1..b0bde6d82 100644 --- a/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/LocalVerificationScreenContainer.kt +++ b/feature/settings/src/main/java/com/acon/acon/feature/verification/screen/composable/UserVerifiedAreasScreenContainer.kt @@ -8,24 +8,24 @@ import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import com.acon.acon.core.designsystem.R import com.acon.acon.core.ui.android.showToast -import com.acon.acon.feature.verification.screen.LocalVerificationSideEffect -import com.acon.acon.feature.verification.screen.LocalVerificationViewModel +import com.acon.acon.feature.verification.screen.UserVerifiedAreasSideEffect +import com.acon.acon.feature.verification.screen.UserVerifiedAreasViewModel import com.acon.acon.core.ui.compose.LocalOnRetry import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @Composable -fun LocalVerificationScreenContainer( +fun UserVerifiedAreasScreenContainer( modifier: Modifier = Modifier, navigateToSettingsScreen: () -> Unit = {}, navigateToAreaVerification: (Long) -> Unit = {}, - viewModel: LocalVerificationViewModel = hiltViewModel() + viewModel: UserVerifiedAreasViewModel = hiltViewModel() ) { val context = LocalContext.current val state by viewModel.collectAsState() CompositionLocalProvider(LocalOnRetry provides viewModel::retry) { - LocalVerificationScreen( + UserVerifiedAreasScreen( state = state, modifier = modifier, onNavigateBack = viewModel::onNavigateToSettingsScreen, @@ -39,12 +39,12 @@ fun LocalVerificationScreenContainer( viewModel.collectSideEffect { when (it) { - is LocalVerificationSideEffect.ShowUnKnownErrorToast -> { + is UserVerifiedAreasSideEffect.ShowUnKnownErrorToast -> { context.showToast(R.string.unknown_error) } - is LocalVerificationSideEffect.NavigateToSettingsScreen -> navigateToSettingsScreen() - is LocalVerificationSideEffect.NavigateToAreaVerification-> navigateToAreaVerification( + is UserVerifiedAreasSideEffect.NavigateToSettingsScreen -> navigateToSettingsScreen() + is UserVerifiedAreasSideEffect.NavigateToAreaVerification-> navigateToAreaVerification( it.verifiedAreaId ) } diff --git a/feature/signin/build.gradle.kts b/feature/signin/build.gradle.kts index a2b70a434..721bc9a9a 100644 --- a/feature/signin/build.gradle.kts +++ b/feature/signin/build.gradle.kts @@ -12,6 +12,8 @@ android { } dependencies { + + implementation(projects.core.social) implementation(libs.lottie.compose) } \ No newline at end of file diff --git a/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInScreen.kt b/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInScreen.kt index 09fd84ec6..52cdfc53b 100644 --- a/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInScreen.kt +++ b/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -40,23 +39,20 @@ import com.acon.acon.core.designsystem.R import com.acon.acon.core.designsystem.component.button.AconGoogleSignInButton import com.acon.acon.core.designsystem.noRippleClickable import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.feature.signin.screen.component.SignInTopBar -import com.acon.acon.feature.signin.utils.SplashAudioManager -import com.acon.acon.core.analytics.amplitude.AconAmplitude -import com.acon.acon.core.analytics.constants.EventNames -import com.acon.acon.core.analytics.constants.PropertyKeys -import com.acon.acon.core.model.model.user.VerificationStatus import com.acon.acon.core.ui.compose.LocalDeepLinkHandler -import com.acon.acon.core.ui.compose.LocalUserType +import com.acon.acon.core.ui.compose.LocalSignInStatus import com.acon.acon.core.ui.compose.getScreenHeight import com.acon.acon.core.ui.compose.getScreenWidth -import com.acon.acon.feature.signin.utils.rememberSocialRepository +import com.acon.acon.core.ui.rememberActivityComponentEntryPoint +import com.acon.acon.feature.signin.screen.component.SignInTopBar +import com.acon.acon.feature.signin.utils.SplashAudioManager +import com.acon.core.social.client.SocialAuthClient +import com.acon.core.social.di.AuthClientEntryPoint import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition import kotlinx.coroutines.delay -import kotlinx.coroutines.launch @Composable fun SignInScreen( @@ -65,12 +61,12 @@ fun SignInScreen( navigateToSpotListView: () -> Unit, onClickTermsOfUse: () -> Unit, onClickPrivacyPolicy: () -> Unit, - onSignInComplete: (VerificationStatus) -> Unit, + onSignInButtonClick: (SocialAuthClient) -> Unit, onAnimationEnd:() -> Unit, onSkipButtonClick: () -> Unit ) { - val scope = rememberCoroutineScope() - val socialRepository = rememberSocialRepository() + + val authClientEntryPoint = rememberActivityComponentEntryPoint() val context = LocalContext.current val activity = context as? Activity @@ -85,14 +81,14 @@ fun SignInScreen( ) val logoAnimationState = animateLottieCompositionAsState(composition = composition) - val userType = LocalUserType.current + val userType = LocalSignInStatus.current val deepLinkHandler = LocalDeepLinkHandler.current LaunchedEffect(Unit) { snapshotFlow { logoAnimationState.value } .collect { animationValue -> if (animationValue == 1f) { - if(deepLinkHandler.hasDeepLink.value && userType == com.acon.acon.core.model.type.UserType.GUEST) { + if(deepLinkHandler.hasDeepLink.value && userType == com.acon.acon.core.model.type.SignInStatus.GUEST) { navigateToSpotListView() } else { onAnimationEnd() @@ -179,13 +175,7 @@ fun SignInScreen( .alpha(alpha), onClick = { if (alpha >= 0.75f) { - scope.launch { - socialRepository.googleSignIn() - .onSuccess { - onSignInComplete(it) - }.onFailure { - } - } + onSignInButtonClick(authClientEntryPoint.googleAuthClient()) } } ) @@ -257,7 +247,7 @@ private fun PreviewSignInScreen() { onClickPrivacyPolicy = {}, onAnimationEnd = {}, onSkipButtonClick = {}, - onSignInComplete = {} + onSignInButtonClick = {} ) } } \ No newline at end of file diff --git a/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInScreenContainer.kt b/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInScreenContainer.kt index 7596cefcf..004c63ddf 100644 --- a/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInScreenContainer.kt +++ b/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInScreenContainer.kt @@ -30,14 +30,14 @@ fun SignInScreenContainer( state = state, modifier = modifier.fillMaxSize(), navigateToSpotListView = viewModel::navigateToSpotListView, - onSignInComplete = viewModel::onSignInComplete, + onSignInButtonClick = viewModel::onSignInButtonClicked, onClickTermsOfUse = viewModel::onClickTermsOfUse, onClickPrivacyPolicy = viewModel::onClickPrivacyPolicy, onAnimationEnd = viewModel::signIn, onSkipButtonClick = viewModel::onSkipButtonClicked ) - viewModel.useUserType() + viewModel.useSignInStatus() viewModel.collectSideEffect { sideEffect -> when(sideEffect) { is SignInSideEffect.ShowToastMessage -> { context.showToast(R.string.sign_in_failed_toast) } @@ -53,7 +53,7 @@ fun SignInScreenContainer( val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) context.startActivity(intent) } - is SignInSideEffect.NavigateToOnboarding -> navigateToOnboarding() + is SignInSideEffect.NavigateToChooseDislikes -> navigateToOnboarding() is SignInSideEffect.NavigateToIntroduce -> navigateToIntroduce() } } diff --git a/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInViewModel.kt b/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInViewModel.kt index 7595c6c63..56c0ee563 100644 --- a/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInViewModel.kt +++ b/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/SignInViewModel.kt @@ -3,12 +3,12 @@ package com.acon.acon.feature.signin.screen import com.acon.acon.core.analytics.amplitude.AconAmplitude import com.acon.acon.core.analytics.constants.EventNames import com.acon.acon.core.analytics.constants.PropertyKeys -import com.acon.acon.core.model.model.user.VerificationStatus -import com.acon.acon.core.model.type.UserType +import com.acon.acon.core.model.model.user.ExternalUUID +import com.acon.acon.core.model.type.SignInStatus import com.acon.acon.core.ui.base.BaseContainerHost import com.acon.acon.domain.repository.OnboardingRepository -import com.acon.acon.domain.repository.ProfileRepository import com.acon.acon.domain.repository.UserRepository +import com.acon.core.social.client.SocialAuthClient import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.collectLatest import org.orbitmvi.orbit.Container @@ -17,7 +17,6 @@ import javax.inject.Inject @HiltViewModel class SignInViewModel @Inject constructor( - private val profileRepository: ProfileRepository, private val onboardingRepository: OnboardingRepository, private val userRepository: UserRepository ) : BaseContainerHost() { @@ -26,24 +25,25 @@ class SignInViewModel @Inject constructor( container(initialState = SignInUiState.SignIn()) fun signIn() = intent { - if (userType.value == UserType.GUEST) { + if (signInStatus.value == SignInStatus.GUEST) { reduce { SignInUiState.SignIn(showSignInInfo = true) } } else { - userRepository.getDidOnboarding().onSuccess { did -> - if (!did) - postSideEffect(SignInSideEffect.NavigateToOnboarding) - else { - if (onboardingRepository.getDidOnboarding().getOrDefault(true)) - postSideEffect(SignInSideEffect.NavigateToSpotListView) - else - postSideEffect(SignInSideEffect.NavigateToIntroduce) - } + onboardingRepository.getOnboardingPreferences().onSuccess { + if (it.shouldShowIntroduce) + postSideEffect(SignInSideEffect.NavigateToIntroduce) + else if (it.shouldVerifyArea) + postSideEffect(SignInSideEffect.NavigateToAreaVerification) + else if (it.shouldChooseDislikes) + postSideEffect(SignInSideEffect.NavigateToChooseDislikes) + else + postSideEffect(SignInSideEffect.NavigateToSpotListView) + } } - userType.collectLatest { - if (it == UserType.GUEST) { + signInStatus.collectLatest { + if (it == SignInStatus.GUEST) { reduce { SignInUiState.SignIn(showSignInInfo = true) } @@ -52,30 +52,45 @@ class SignInViewModel @Inject constructor( } fun onSkipButtonClicked() = intent { - if (onboardingRepository.getDidOnboarding().getOrDefault(true)) { - postSideEffect(SignInSideEffect.NavigateToSpotListView) - } else { + if (onboardingRepository.getOnboardingPreferences().getOrNull()?.shouldShowIntroduce == true) { postSideEffect(SignInSideEffect.NavigateToIntroduce) + } else { + postSideEffect(SignInSideEffect.NavigateToSpotListView) } } - fun onSignInComplete(verificationStatus: VerificationStatus) = intent { + fun onSignInButtonClicked(socialAuthClient: SocialAuthClient) = intent { + val platform = socialAuthClient.platform + val code = socialAuthClient.getCredentialCode() + + userRepository.signIn(platform, code ?: return@intent).onSuccess { externalUUID -> + onSignInComplete(externalUUID) + }.onFailure { + postSideEffect(SignInSideEffect.ShowToastMessage) + } + } + + private fun onSignInComplete(externalUUID: ExternalUUID) = intent { AconAmplitude.trackEvent( eventName = EventNames.SIGN_IN, properties = mapOf( PropertyKeys.SIGN_IN_OR_NOT to true ) ) - if (verificationStatus.hasVerifiedArea.not()) { - postSideEffect(SignInSideEffect.NavigateToAreaVerification) - } else if (verificationStatus.hasPreference.not()) { - postSideEffect(SignInSideEffect.NavigateToOnboarding) - } else if (onboardingRepository.getDidOnboarding().getOrDefault(true)) { + onboardingRepository.getOnboardingPreferences().onSuccess { pref -> + if (pref.shouldShowIntroduce) { + postSideEffect(SignInSideEffect.NavigateToIntroduce) + } else if (pref.shouldVerifyArea) { + postSideEffect(SignInSideEffect.NavigateToAreaVerification) + } else if (pref.shouldChooseDislikes) { + postSideEffect(SignInSideEffect.NavigateToChooseDislikes) + } else { + postSideEffect(SignInSideEffect.NavigateToSpotListView) + } + }.onFailure { postSideEffect(SignInSideEffect.NavigateToSpotListView) - } else { - postSideEffect(SignInSideEffect.NavigateToIntroduce) } - AconAmplitude.setUserId(verificationStatus.externalUUID) + AconAmplitude.setUserId(externalUUID.value) } fun onClickTermsOfUse() = intent { @@ -105,7 +120,7 @@ sealed interface SignInSideEffect { data object ShowToastMessage : SignInSideEffect data object NavigateToSpotListView : SignInSideEffect data object NavigateToAreaVerification : SignInSideEffect - data object NavigateToOnboarding : SignInSideEffect + data object NavigateToChooseDislikes : SignInSideEffect data object OnClickTermsOfUse : SignInSideEffect data object OnClickPrivacyPolicy : SignInSideEffect data object NavigateToIntroduce : SignInSideEffect diff --git a/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/component/SignInTopBar.kt b/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/component/SignInTopBar.kt index df3a0cb3c..2149f374e 100644 --- a/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/component/SignInTopBar.kt +++ b/feature/signin/src/main/java/com/acon/acon/feature/signin/screen/component/SignInTopBar.kt @@ -31,7 +31,7 @@ fun SignInTopBar( Text( text = stringResource(R.string.signin_topbar_text), style = AconTheme.typography.Body1, - color = AconTheme.color.White, + color = AconTheme.color.Gray500, modifier = Modifier .padding(8.dp) .noRippleClickable { onClickText() } diff --git a/feature/signin/src/main/java/com/acon/acon/feature/signin/utils/ComposableEntryPoints.kt b/feature/signin/src/main/java/com/acon/acon/feature/signin/utils/ComposableEntryPoints.kt deleted file mode 100644 index 546ab9319..000000000 --- a/feature/signin/src/main/java/com/acon/acon/feature/signin/utils/ComposableEntryPoints.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.acon.acon.feature.signin.utils - -import com.acon.acon.domain.repository.SocialRepository -import com.acon.acon.domain.repository.UserRepository -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.components.SingletonComponent - -@EntryPoint -@InstallIn(SingletonComponent::class) -interface ComposableEntryPoint { - fun userRepository(): UserRepository -} - -@EntryPoint -@InstallIn(ActivityComponent::class) -interface ComposableActivityEntryPoint { - fun socialRepository(): SocialRepository -} \ No newline at end of file diff --git a/feature/signin/src/main/java/com/acon/acon/feature/signin/utils/RememberInstances.kt b/feature/signin/src/main/java/com/acon/acon/feature/signin/utils/RememberInstances.kt deleted file mode 100644 index a2da008b2..000000000 --- a/feature/signin/src/main/java/com/acon/acon/feature/signin/utils/RememberInstances.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.acon.acon.feature.signin.utils - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import com.acon.acon.domain.repository.SocialRepository -import com.acon.acon.domain.repository.UserRepository -import com.acon.acon.core.ui.android.findActivity -import dagger.hilt.android.EntryPointAccessors - -@Composable -fun rememberUserRepository(): UserRepository { - val context = LocalContext.current - val entryPoint = EntryPointAccessors.fromApplication( - context.applicationContext, - ComposableEntryPoint::class.java - ) - - return remember { - entryPoint.userRepository() - } -} - -@Composable -fun rememberSocialRepository(): SocialRepository { - val context = LocalContext.current - val entryPoint = EntryPointAccessors.fromActivity( - context.findActivity(), - ComposableActivityEntryPoint::class.java - ) - - return remember { - entryPoint.socialRepository() - } -} \ No newline at end of file diff --git a/feature/spot/build.gradle.kts b/feature/spot/build.gradle.kts index 9d4647713..5b68a7cf8 100644 --- a/feature/spot/build.gradle.kts +++ b/feature/spot/build.gradle.kts @@ -15,7 +15,7 @@ android { dependencies { implementation(projects.core.map) - implementation(projects.core.adsApi) + implementation(projects.core.ads) implementation(libs.branch.io) implementation(libs.pulltorefresh) diff --git a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SignatureMenu.kt b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SignatureMenu.kt index baaae94ab..fd933e7b4 100644 --- a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SignatureMenu.kt +++ b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SignatureMenu.kt @@ -6,8 +6,8 @@ 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.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -23,7 +23,7 @@ import com.acon.acon.feature.spot.toPriceString @Composable internal fun SignatureMenu( - signatureMenuList: List + signatureMenuList: List ) { Column( verticalArrangement = Arrangement.spacedBy(4.dp) @@ -43,16 +43,22 @@ internal fun SignatureMenuItem( menuPrice: String ) { Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = menuName, - color = AconTheme.color.White, - style = AconTheme.typography.Body1, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(min = 100.dp, max = 160.dp) - ) + Box( + modifier = Modifier.width(160.dp) + ) { + Text( + text = menuName, + color = AconTheme.color.White, + style = AconTheme.typography.Body1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.CenterStart) + ) + } Spacer(Modifier.width(8.dp)) Text( diff --git a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailScreen.kt b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailScreen.kt index 0c883bb89..f85cd0635 100644 --- a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailScreen.kt +++ b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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.graphics.Brush @@ -42,6 +43,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.acon.acon.core.analytics.amplitude.AconAmplitude +import com.acon.acon.core.analytics.constants.EventNames +import com.acon.acon.core.analytics.constants.PropertyKeys import com.acon.acon.core.common.UrlConstants import com.acon.acon.core.designsystem.R import com.acon.acon.core.designsystem.component.button.v2.AconFilledButton @@ -52,17 +56,14 @@ import com.acon.acon.core.designsystem.effect.imageGradientLayer import com.acon.acon.core.designsystem.image.rememberDefaultLoadImageErrorPainter import com.acon.acon.core.designsystem.noRippleClickable import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.feature.spot.screen.component.OperationDot -import com.acon.acon.feature.spot.screen.spotdetail.createBranchDeepLink -import com.acon.acon.feature.spot.screen.spotlist.composable.SpotDetailLoadingView -import com.acon.acon.core.analytics.amplitude.AconAmplitude -import com.acon.acon.core.analytics.constants.EventNames -import com.acon.acon.core.analytics.constants.PropertyKeys import com.acon.acon.core.ui.compose.LocalDeepLinkHandler import com.acon.acon.core.ui.compose.LocalOnRetry import com.acon.acon.core.ui.compose.LocalRequestSignIn -import com.acon.acon.core.ui.compose.LocalUserType +import com.acon.acon.core.ui.compose.LocalSignInStatus import com.acon.acon.core.ui.compose.getTextSizeDp +import com.acon.acon.feature.spot.screen.component.OperationDot +import com.acon.acon.feature.spot.screen.spotdetail.createBranchDeepLink +import com.acon.acon.feature.spot.screen.spotlist.composable.SpotDetailLoadingView import dev.chrisbanes.haze.hazeSource import okhttp3.internal.immutableListOf @@ -90,7 +91,7 @@ internal fun SpotDetailScreen( stringResource(R.string.no_store_image_mystery) ) - val userType = LocalUserType.current + val userType = LocalSignInStatus.current val deepLinkHandler = LocalDeepLinkHandler.current val onSignInRequired = LocalRequestSignIn.current @@ -122,11 +123,13 @@ internal fun SpotDetailScreen( } is SpotDetailUiState.Success -> { + val rememberedNoStoreText = remember { noStoreText.random() } + BackHandler { if (state.isAreaVerified) { deepLinkHandler.clear() onNavigateToBack() - } else if (deepLinkHandler.hasDeepLink.value && userType == com.acon.acon.core.model.type.UserType.USER) { + } else if (deepLinkHandler.hasDeepLink.value && userType == com.acon.acon.core.model.type.SignInStatus.USER) { deepLinkHandler.clear() onBackToAreaVerification() } else { @@ -232,7 +235,7 @@ internal fun SpotDetailScreen( Spacer(Modifier.height(12.dp)) Text( - text = noStoreText.random(), + text = rememberedNoStoreText, color = AconTheme.color.Gray200, style = AconTheme.typography.Body1, fontWeight = FontWeight.SemiBold, @@ -255,7 +258,7 @@ internal fun SpotDetailScreen( if (state.isAreaVerified) { deepLinkHandler.clear() onNavigateToBack() - } else if (deepLinkHandler.hasDeepLink.value && userType == com.acon.acon.core.model.type.UserType.USER) { + } else if (deepLinkHandler.hasDeepLink.value && userType == com.acon.acon.core.model.type.SignInStatus.USER) { deepLinkHandler.clear() onBackToAreaVerification() } else { @@ -345,7 +348,7 @@ internal fun SpotDetailScreen( Text( text = if (isStoreOpen) state.spotDetail.closingTime else state.spotDetail.nextOpening, - color = AconTheme.color.Gray200, + color = if (isStoreOpen) AconTheme.color.White else AconTheme.color.Gray200, style = AconTheme.typography.Body1, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 12.dp) @@ -353,7 +356,7 @@ internal fun SpotDetailScreen( Text( text = stringResource(R.string.store_closed), - color = AconTheme.color.Gray200, + color = if (isStoreOpen) AconTheme.color.White else AconTheme.color.Gray200, style = AconTheme.typography.Body1, fontWeight = FontWeight.Normal, modifier = Modifier.padding(start = 4.dp) @@ -410,14 +413,14 @@ internal fun SpotDetailScreen( } }, onClickBookmark = { - if (userType == com.acon.acon.core.model.type.UserType.GUEST) { + if (userType == com.acon.acon.core.model.type.SignInStatus.GUEST) { onSignInRequired("") deepLinkHandler.clear() } else { onClickBookmark() } }, - isBookmarkSelected = if (userType == com.acon.acon.core.model.type.UserType.GUEST) false else state.spotDetail.isSaved, + isBookmarkSelected = if (userType == com.acon.acon.core.model.type.SignInStatus.GUEST) false else state.spotDetail.isSaved, isMenuBoardEnabled = state.spotDetail.hasMenuboardImage ) } diff --git a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailScreenContainer.kt b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailScreenContainer.kt index 61f31ba3e..27baa6756 100644 --- a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailScreenContainer.kt +++ b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailScreenContainer.kt @@ -7,10 +7,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import com.acon.acon.core.designsystem.R -import com.acon.acon.core.map.onLocationReady +import com.acon.acon.core.model.type.SignInStatus +import com.acon.core.map.onLocationReady import com.acon.acon.core.ui.android.showToast import com.acon.acon.core.ui.compose.LocalOnRetry import com.acon.acon.core.ui.android.openNaverMapNavigationWithMode +import com.acon.acon.core.ui.compose.LocalRequestSignIn +import com.acon.acon.core.ui.compose.LocalSignInStatus import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @@ -23,6 +26,8 @@ fun SpotDetailScreenContainer( ) { val context = LocalContext.current val state by viewModel.collectAsState() + val signInStatus = LocalSignInStatus.current + val onRequestSignIn = LocalRequestSignIn.current CompositionLocalProvider(LocalOnRetry provides viewModel::retry) { SpotDetailScreen( @@ -30,7 +35,13 @@ fun SpotDetailScreenContainer( modifier = modifier, onNavigateToBack = viewModel::navigateToBack, onBackToAreaVerification = onBackToAreaVerification, - onClickBookmark = viewModel::toggleBookmark, + onClickBookmark = { + if (signInStatus == SignInStatus.GUEST) { + onRequestSignIn("") + } else { + viewModel.toggleBookmark() + } + }, onClickRequestMenuBoard = viewModel::fetchMenuBoardList, onDismissMenuBoard = viewModel::onDismissMenuBoard, onRequestErrorReportModal = viewModel::onRequestReportErrorModal, @@ -41,7 +52,7 @@ fun SpotDetailScreenContainer( ) } - viewModel.useUserType() + viewModel.useSignInStatus() viewModel.collectSideEffect { sideEffect -> when (sideEffect) { is SpotDetailSideEffect.NavigateToBack -> { diff --git a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailViewModel.kt b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailViewModel.kt index 968e0de2a..0e5a9f6b7 100644 --- a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailViewModel.kt +++ b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/SpotDetailViewModel.kt @@ -7,6 +7,7 @@ import androidx.navigation.toRoute import com.acon.acon.core.analytics.amplitude.AconAmplitude import com.acon.acon.core.analytics.constants.EventNames import com.acon.acon.core.analytics.constants.PropertyKeys +import com.acon.acon.core.model.type.SignInStatus import com.acon.acon.core.navigation.route.SpotRoute import com.acon.acon.core.navigation.type.spotNavigationParameterNavType import com.acon.acon.core.ui.base.BaseContainerHost @@ -15,6 +16,7 @@ import com.acon.acon.domain.repository.SpotRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.firstOrNull import org.orbitmvi.orbit.annotation.OrbitExperimental import org.orbitmvi.orbit.viewmodel.container import javax.annotation.concurrent.Immutable @@ -43,21 +45,7 @@ class SpotDetailViewModel @Inject constructor( override val container = container(SpotDetailUiState.Loading) { - userType.collect { - when (it) { - com.acon.acon.core.model.type.UserType.GUEST -> { - if (spotNavData.isFromDeepLink == true) { - fetchedSpotDetail() - } else { - reduce { SpotDetailUiState.LoadFailed() } - } - } - - else -> { - fetchedSpotDetail() - } - } - } + fetchedSpotDetail() } private fun fetchedSpotDetail() = intent { @@ -68,22 +56,22 @@ class SpotDetailViewModel @Inject constructor( val isDeepLink = spotNavData.isFromDeepLink == true val spotDetailDeferred = viewModelScope.async { - if (isDeepLink) { + if (signInStatus.value == SignInStatus.GUEST) { spotRepository.fetchSpotDetail( spotId = spotNavData.spotId, - isDeepLink = true + isDeepLink = isDeepLink ) } else { spotRepository.fetchSpotDetailFromUser( - spotId = spotNavData.spotId + spotId = spotNavData.spotId, ) } } // GUEST 인 경우 빈 리스트 val verifiedAreaListDeferred = viewModelScope.async { - if (userType.value != com.acon.acon.core.model.type.UserType.GUEST) { - profileRepository.fetchVerifiedAreaList() + if (signInStatus.value != SignInStatus.GUEST) { + profileRepository.getVerifiedAreas().firstOrNull() } else { Result.success(emptyList()) } @@ -94,7 +82,7 @@ class SpotDetailViewModel @Inject constructor( reduce { val isAreaVerified = verifiedAreaListResult - .getOrNull() + ?.getOrNull() .orEmpty() .isNotEmpty() diff --git a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/StoreFloatingButtonSet.kt b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/StoreFloatingButtonSet.kt index df0949943..8901628e6 100644 --- a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/StoreFloatingButtonSet.kt +++ b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotdetail/composable/StoreFloatingButtonSet.kt @@ -115,7 +115,7 @@ private fun StoreDetailButton( ) { var isLongPressed by remember { mutableStateOf(false) } var lastClickTime by remember { mutableLongStateOf(0L) } - val throttleTime = 1000L + val throttleTime = 3000L val density = LocalDensity.current val blurPx = with(density) { 4.dp.toPx() } diff --git a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/SpotListViewModel.kt b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/SpotListViewModel.kt index 4b24110df..17b068b04 100644 --- a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/SpotListViewModel.kt +++ b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/SpotListViewModel.kt @@ -4,7 +4,6 @@ import android.content.Context import android.location.Location import androidx.compose.runtime.Immutable import androidx.compose.ui.util.fastForEach -import androidx.lifecycle.viewModelScope import com.acon.acon.core.analytics.amplitude.AconAmplitude import com.acon.acon.core.analytics.constants.EventNames import com.acon.acon.core.analytics.constants.PropertyKeys @@ -19,19 +18,18 @@ import com.acon.acon.core.model.type.SpotType import com.acon.acon.core.model.type.TagType import com.acon.acon.core.model.type.TransportMode import com.acon.acon.core.model.type.UserActionType -import com.acon.acon.core.model.type.UserType +import com.acon.acon.core.model.type.SignInStatus import com.acon.acon.core.ui.android.NavigationAppHandler import com.acon.acon.core.ui.android.isInKorea import com.acon.acon.core.ui.base.BaseContainerHost import com.acon.acon.domain.error.spot.FetchSpotListError -import com.acon.acon.domain.repository.ProfileRepository +import com.acon.acon.domain.repository.OnboardingRepository import com.acon.acon.domain.repository.SpotRepository import com.acon.acon.domain.repository.TimeRepository import com.acon.acon.domain.usecase.IsCooldownExpiredUseCase import com.acon.acon.domain.usecase.IsDistanceExceededUseCase import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.async import kotlinx.coroutines.delay import org.orbitmvi.orbit.annotation.OrbitExperimental import org.orbitmvi.orbit.viewmodel.container @@ -43,7 +41,7 @@ import kotlin.reflect.KClass class SpotListViewModel @Inject constructor( @ApplicationContext private val context: Context, private val spotRepository: SpotRepository, - private val profileRepository: ProfileRepository, + private val onboardingRepository: OnboardingRepository, private val timeRepository: TimeRepository, private val isDistanceExceededUseCase: IsDistanceExceededUseCase, private val isCooldownExpiredUseCase: IsCooldownExpiredUseCase @@ -77,9 +75,10 @@ class SpotListViewModel @Inject constructor( initialLocation = location if (location.isInKorea(context)) { var showAreaVerificationModal = false - if (isCooldownExpiredUseCase(UserActionType.SKIP_AREA_VERIFICATION, 24 * 60 * 60) && userType.value != UserType.GUEST) - showAreaVerificationModal = - profileRepository.fetchVerifiedAreaList().takeIf { it.isSuccess }?.getOrNull()?.isEmpty() == true + if (isCooldownExpiredUseCase(UserActionType.SKIP_AREA_VERIFICATION, 24 * 60 * 60) && signInStatus.value != SignInStatus.GUEST) { + showAreaVerificationModal = onboardingRepository.getOnboardingPreferences() + .getOrNull()?.shouldVerifyArea == true + } fetchSpotList(location, Condition( state.selectedSpotType, @@ -351,6 +350,10 @@ class SpotListViewModel @Inject constructor( } } } + + fun onRegisterNewSpot() = intent { + postSideEffect(SpotListSideEffectV2.NavigateToUploadPlace) + } } sealed interface SpotListUiStateV2 { @@ -400,6 +403,7 @@ sealed interface SpotListSideEffectV2 { data object ShowToastMessage : SpotListSideEffectV2 data class NavigateToExternalMap(val handler: NavigationAppHandler) : SpotListSideEffectV2 data class NavigateToSpotDetailScreen(val spot: Spot, val transportMode: TransportMode) : SpotListSideEffectV2 + data object NavigateToUploadPlace : SpotListSideEffectV2 } internal typealias FilterDetailKey = KClass> diff --git a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotEmptyView.kt b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotEmptyView.kt index 7aae9b2f9..ac7577cee 100644 --- a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotEmptyView.kt +++ b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotEmptyView.kt @@ -13,27 +13,23 @@ import androidx.compose.runtime.mutableFloatStateOf 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.platform.LocalDensity -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed -import com.acon.acon.core.common.UrlConstants import com.acon.acon.core.designsystem.R import com.acon.acon.core.designsystem.noRippleClickable import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.core.ui.android.showToast import com.acon.acon.core.model.model.spot.Spot +import com.acon.acon.core.model.type.SignInStatus import com.acon.acon.core.model.type.TransportMode -import com.acon.acon.core.model.type.UserType -import com.acon.acon.feature.spot.mock.spotListUiStateRestaurantMock import com.acon.acon.core.ui.compose.LocalRequestSignIn import com.acon.acon.core.ui.compose.getScreenHeight import com.acon.acon.core.ui.compose.toDp +import com.acon.acon.feature.spot.mock.spotListUiStateRestaurantMock import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -41,10 +37,11 @@ private const val MAX_GUEST_AVAILABLE_COUNT = 5 @Composable internal fun SpotEmptyView( - userType: com.acon.acon.core.model.type.UserType, - otherSpots: ImmutableList, - onSpotClick: (com.acon.acon.core.model.model.spot.Spot, rank: Int) -> Unit, - onTryFindWay: (com.acon.acon.core.model.model.spot.Spot) -> Unit, + signInStatus: SignInStatus, + otherSpots: ImmutableList, + onSpotClick: (Spot, rank: Int) -> Unit, + onTryFindWay: (Spot) -> Unit, + onRegisterNewSpotClick: () -> Unit, modifier: Modifier = Modifier, ) { val screenHeightDp = getScreenHeight() @@ -89,7 +86,7 @@ internal fun SpotEmptyView( fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 60.dp, bottom = 24.dp) ) - if (index >= MAX_GUEST_AVAILABLE_COUNT && userType == com.acon.acon.core.model.type.UserType.GUEST) { + if (index >= MAX_GUEST_AVAILABLE_COUNT && signInStatus == SignInStatus.GUEST) { SpotGuestItem( spot = spot, modifier = Modifier @@ -101,7 +98,7 @@ internal fun SpotEmptyView( } else { SpotItem( spot = spot, - transportMode = com.acon.acon.core.model.type.TransportMode.BIKING, + transportMode = TransportMode.BIKING, onItemClick = { onSpotClick(spot, index + 1) }, onFindWayButtonClick = onTryFindWay, modifier = Modifier @@ -112,9 +109,6 @@ internal fun SpotEmptyView( } } } else { - val uriHandler = LocalUriHandler.current - val context = LocalContext.current - Text( text = stringResource(R.string.no_other_spots), style = AconTheme.typography.Title4, @@ -129,11 +123,7 @@ internal fun SpotEmptyView( color = AconTheme.color.Action, fontWeight = FontWeight.SemiBold, modifier = Modifier.noRippleClickable { - try { - uriHandler.openUri(UrlConstants.REQUEST_NEW_SPOT) - } catch (e: Exception) { - context.showToast("웹사이트 접속에 실패했어요") - } + onRegisterNewSpotClick() } ) } @@ -145,10 +135,11 @@ internal fun SpotEmptyView( @Composable private fun SpotListEmptyView1Preview() { SpotEmptyView( - userType = com.acon.acon.core.model.type.UserType.GUEST, + signInStatus = SignInStatus.GUEST, otherSpots = spotListUiStateRestaurantMock.spotList.toImmutableList(), onSpotClick = { _, _ -> }, onTryFindWay = {}, + onRegisterNewSpotClick = {}, modifier = Modifier.fillMaxSize() ) } @@ -157,10 +148,11 @@ private fun SpotListEmptyView1Preview() { @Composable private fun SpotListEmptyView2Preview() { SpotEmptyView( - userType = com.acon.acon.core.model.type.UserType.GUEST, - otherSpots = listOf().toImmutableList(), + signInStatus = SignInStatus.GUEST, + otherSpots = listOf().toImmutableList(), onSpotClick = { _, _ -> }, onTryFindWay = {}, + onRegisterNewSpotClick = {}, modifier = Modifier.fillMaxSize() ) } \ No newline at end of file diff --git a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotItem.kt b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotItem.kt index d7fcab071..db76ecdb2 100644 --- a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotItem.kt +++ b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotItem.kt @@ -53,14 +53,10 @@ import com.acon.acon.core.designsystem.effect.imageGradientBottomLayer import com.acon.acon.core.designsystem.effect.imageGradientTopLayer import com.acon.acon.core.designsystem.image.rememberDefaultLoadImageErrorPainter import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.core.model.model.spot.Spot -import com.acon.acon.core.model.type.TagType -import com.acon.acon.core.model.type.TransportMode -import com.acon.acon.core.model.type.UserType import com.acon.acon.feature.spot.mock.spotListUiStateRestaurantMock import com.acon.acon.feature.spot.screen.component.OperationDot import com.acon.acon.core.ui.compose.LocalRequestSignIn -import com.acon.acon.core.ui.compose.LocalUserType +import com.acon.acon.core.ui.compose.LocalSignInStatus @Composable internal fun SpotItem( @@ -119,7 +115,7 @@ private fun SpotInfo( ) { val onSignInRequired = LocalRequestSignIn.current - val userType = LocalUserType.current + val userType = LocalSignInStatus.current Column( modifier = modifier @@ -205,7 +201,7 @@ private fun SpotInfo( AconFilledButton( modifier = Modifier.align(Alignment.CenterHorizontally), onClick = { - if (userType == com.acon.acon.core.model.type.UserType.GUEST) + if (userType == com.acon.acon.core.model.type.SignInStatus.GUEST) onSignInRequired("click_home_navigation_guest?") else onFindWayButtonClick(spot) diff --git a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListScreen.kt b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListScreen.kt index e28e8af92..ae569863d 100644 --- a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListScreen.kt +++ b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListScreen.kt @@ -44,14 +44,14 @@ import com.acon.acon.core.model.model.spot.Spot import com.acon.acon.core.model.type.CafeFilterType import com.acon.acon.core.model.type.RestaurantFilterType import com.acon.acon.core.model.type.SpotType -import com.acon.acon.core.model.type.UserType +import com.acon.acon.core.model.type.SignInStatus import com.acon.acon.feature.spot.mock.spotListUiStateRestaurantMock import com.acon.acon.feature.spot.screen.component.SpotTypeToggle import com.acon.acon.feature.spot.screen.spotlist.FilterDetailKey import com.acon.acon.feature.spot.screen.spotlist.SpotListUiStateV2 import com.acon.acon.core.ui.compose.LocalOnRetry import com.acon.acon.core.ui.compose.LocalRequestSignIn -import com.acon.acon.core.ui.compose.LocalUserType +import com.acon.acon.core.ui.compose.LocalSignInStatus import com.acon.acon.core.ui.compose.getScreenHeight import com.acon.acon.core.ui.android.NavigationAppHandler import dev.chrisbanes.haze.hazeSource @@ -74,7 +74,8 @@ internal fun SpotListScreen( onNavigateToUploadScreen: () -> Unit = {}, onNavigateToProfileScreen: () -> Unit = {}, onDismissAreaVerificationModalRequest: () -> Unit = {}, - onNavigateToAreaVerificationScreen: () -> Unit = {} + onNavigateToAreaVerificationScreen: () -> Unit = {}, + onRegisterNewSpotClick: () -> Unit = {} ) { val screenHeightDp = getScreenHeight() val screenHeightPx = with(LocalDensity.current) { @@ -88,7 +89,7 @@ internal fun SpotListScreen( var pagerState = rememberPagerState { 0 } val scope = rememberCoroutineScope() - val userType = LocalUserType.current + val userType = LocalSignInStatus.current val onSignInRequired = LocalRequestSignIn.current if (state.showAreaVerificationModal) { @@ -119,7 +120,7 @@ internal fun SpotListScreen( SpotTypeToggle( selectedType = state.selectedSpotType, onSwitched = { - if (userType == UserType.GUEST) + if (userType == SignInStatus.GUEST) onSignInRequired("click_toggle_guest?") else onSpotTypeChanged(it) @@ -135,7 +136,7 @@ internal fun SpotListScreen( .align(Alignment.CenterEnd) .padding(end = 16.dp) .noRippleClickable { - if (userType == com.acon.acon.core.model.type.UserType.GUEST) + if (userType == com.acon.acon.core.model.type.SignInStatus.GUEST) onSignInRequired("") else onFilterButtonClick() @@ -190,13 +191,14 @@ internal fun SpotListScreen( SpotListSuccessView( pagerState = pagerState, state = state, - userType = userType, + signInStatus = userType, onSpotClick = onSpotClick, onTryFindWay = onTryFindWay, itemHeightPx = itemHeightPx, modifier = Modifier.fillMaxSize(), onNavigationAppChoose = onNavigationAppChoose, - onChooseNavigationAppModalDismiss = onChooseNavigationAppModalDismiss + onChooseNavigationAppModalDismiss = onChooseNavigationAppModalDismiss, + onRegisterNewSpotClick = onRegisterNewSpotClick ) } @@ -221,13 +223,14 @@ internal fun SpotListScreen( SpotListSuccessView( pagerState = pagerState, state = state, - userType = userType, + signInStatus = userType, onSpotClick = onSpotClick, onTryFindWay = onTryFindWay, itemHeightPx = itemHeightPx, modifier = Modifier.fillMaxSize(), onNavigationAppChoose = onNavigationAppChoose, - onChooseNavigationAppModalDismiss = onChooseNavigationAppModalDismiss + onChooseNavigationAppModalDismiss = onChooseNavigationAppModalDismiss, + onRegisterNewSpotClick = onRegisterNewSpotClick ) } } @@ -284,7 +287,7 @@ internal fun SpotListScreen( } } BottomNavType.UPLOAD -> { - if (userType == com.acon.acon.core.model.type.UserType.GUEST) { + if (userType == com.acon.acon.core.model.type.SignInStatus.GUEST) { onSignInRequired("click_upload_guest?") } else { onNavigateToUploadScreen() diff --git a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListScreenContainer.kt b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListScreenContainer.kt index e1dfd1b08..2b8dca673 100644 --- a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListScreenContainer.kt +++ b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListScreenContainer.kt @@ -11,13 +11,13 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.acon.acon.core.model.model.spot.SpotNavigationParameter import com.acon.acon.core.model.model.spot.Spot import com.acon.acon.core.model.type.TransportMode -import com.acon.acon.core.model.type.UserType +import com.acon.acon.core.model.type.SignInStatus import com.acon.acon.feature.spot.screen.spotlist.SpotListSideEffectV2 import com.acon.acon.feature.spot.screen.spotlist.SpotListViewModel import com.acon.acon.core.ui.compose.LocalDeepLinkHandler import com.acon.acon.core.ui.compose.LocalOnRetry import com.acon.acon.core.ui.compose.LocalRequestSignIn -import com.acon.acon.core.ui.compose.LocalUserType +import com.acon.acon.core.ui.compose.LocalSignInStatus import com.acon.acon.feature.spot.screen.spotlist.SpotListUiStateV2 import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter @@ -32,13 +32,14 @@ fun SpotListScreenContainer( onNavigateToAreaVerificationScreen: (latitude: Double, longitude: Double) -> Unit, modifier: Modifier = Modifier, onNavigateToDeeplinkSpotDetailScreen: (spotNav: SpotNavigationParameter) -> Unit = {}, + onNavigateToUploadPlace: () -> Unit = {}, viewModel: SpotListViewModel = hiltViewModel() ) { val state by viewModel.collectAsState() val deepLinkHandler = LocalDeepLinkHandler.current val context = LocalContext.current - val userType = LocalUserType.current + val userType = LocalSignInStatus.current val onSignInRequired = LocalRequestSignIn.current LaunchedEffect(Unit) { @@ -63,12 +64,7 @@ fun SpotListScreenContainer( SpotListScreen( state = state, onSpotTypeChanged = viewModel::onSpotTypeClicked, - onSpotClick = { spot, rank -> - if (userType == UserType.GUEST) - onSignInRequired("click_detail_guest?") - else - viewModel.onSpotClicked(spot, rank) - }, + onSpotClick = viewModel::onSpotClicked, onTryFindWay = viewModel::onTryFindWay, onNavigationAppChoose = viewModel::onNavigationAppChosen, onChooseNavigationAppModalDismiss = viewModel::onChooseNavigationAppModalDismissed, @@ -84,12 +80,18 @@ fun SpotListScreenContainer( val lat = (state as? SpotListUiStateV2.Success)?.currentLocation?.latitude ?: 0.0 val lon = (state as? SpotListUiStateV2.Success)?.currentLocation?.longitude ?: 0.0 onNavigateToAreaVerificationScreen(lat, lon) + }, + onRegisterNewSpotClick = { + if (userType == SignInStatus.GUEST) + onSignInRequired("") + else + viewModel.onRegisterNewSpot() } ) } viewModel.requestLocationPermission() - viewModel.useUserType() + viewModel.useSignInStatus() viewModel.useLiveLocation() viewModel.collectSideEffect { when (it) { @@ -104,6 +106,10 @@ fun SpotListScreenContainer( is SpotListSideEffectV2.NavigateToExternalMap -> { it.handler.startNavigationApp(context) } + + is SpotListSideEffectV2.NavigateToUploadPlace -> { + onNavigateToUploadPlace() + } } } } \ No newline at end of file diff --git a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListSuccessView.kt b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListSuccessView.kt index 042dfeb34..af349970d 100644 --- a/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListSuccessView.kt +++ b/feature/spot/src/main/java/com/acon/acon/feature/spot/screen/spotlist/composable/SpotListSuccessView.kt @@ -41,17 +41,20 @@ import androidx.compose.ui.zIndex import com.acon.acon.core.designsystem.R import com.acon.acon.core.designsystem.component.bottomsheet.AconBottomSheet import com.acon.acon.core.designsystem.effect.LocalHazeState -import com.acon.acon.core.designsystem.effect.effect.shadowLayerBackground import com.acon.acon.core.designsystem.effect.effect.getOverlayColor +import com.acon.acon.core.designsystem.effect.effect.shadowLayerBackground import com.acon.acon.core.designsystem.noRippleClickable import com.acon.acon.core.designsystem.theme.AconTheme -import com.acon.acon.feature.spot.screen.spotlist.SpotListUiStateV2 -import com.acon.acon.core.ads_api.LocalSpotListAdProvider -import com.acon.acon.core.ui.compose.LocalRequestSignIn -import com.acon.acon.core.ui.compose.toDp +import com.acon.acon.core.model.model.spot.Spot +import com.acon.acon.core.model.type.SignInStatus +import com.acon.acon.core.model.type.TransportMode import com.acon.acon.core.ui.android.KakaoNavigationAppHandler import com.acon.acon.core.ui.android.NaverNavigationAppHandler import com.acon.acon.core.ui.android.NavigationAppHandler +import com.acon.acon.core.ui.compose.LocalRequestSignIn +import com.acon.acon.core.ui.compose.toDp +import com.acon.acon.feature.spot.screen.spotlist.SpotListUiStateV2 +import com.acon.core.ads.SpotListNativeAd import dev.chrisbanes.haze.hazeSource import kotlinx.collections.immutable.toImmutableList import kotlin.math.absoluteValue @@ -63,17 +66,18 @@ private const val MAX_GUEST_AVAILABLE_COUNT = 5 internal fun SpotListSuccessView( pagerState: PagerState, state: SpotListUiStateV2.Success, - userType: com.acon.acon.core.model.type.UserType, - onSpotClick: (com.acon.acon.core.model.model.spot.Spot, rank: Int) -> Unit, - onTryFindWay: (com.acon.acon.core.model.model.spot.Spot) -> Unit, + signInStatus: SignInStatus, + onSpotClick: (Spot, rank: Int) -> Unit, + onTryFindWay: (Spot) -> Unit, itemHeightPx: Float, modifier: Modifier = Modifier, onNavigationAppChoose: (NavigationAppHandler) -> Unit = {}, onChooseNavigationAppModalDismiss: () -> Unit = {}, + onRegisterNewSpotClick: () -> Unit = {} ) { val adInsertedSpot = remember(state.spotList) { - val list: MutableList = state.spotList.toMutableList() + val list: MutableList = state.spotList.toMutableList() if (list.size >= 11) { list.add(11, null) @@ -88,12 +92,13 @@ internal fun SpotListSuccessView( val context = LocalContext.current val onSignInRequired = LocalRequestSignIn.current - if (state.transportMode == com.acon.acon.core.model.type.TransportMode.BIKING) { + if (state.transportMode == TransportMode.BIKING) { SpotEmptyView( - userType = userType, + signInStatus = signInStatus, otherSpots = state.spotList.toImmutableList(), onSpotClick = onSpotClick, onTryFindWay = onTryFindWay, + onRegisterNewSpotClick = onRegisterNewSpotClick, modifier = modifier .verticalScroll(rememberScrollState()) .hazeSource(LocalHazeState.current) @@ -235,7 +240,7 @@ internal fun SpotListSuccessView( ) } if (spot != null) { - if (page >= MAX_GUEST_AVAILABLE_COUNT && userType == com.acon.acon.core.model.type.UserType.GUEST) { + if (page >= MAX_GUEST_AVAILABLE_COUNT && signInStatus == SignInStatus.GUEST) { SpotGuestItem( spot = spot, onItemClick = { onSignInRequired("click_locked_detail_guest?") }, @@ -264,7 +269,7 @@ internal fun SpotListSuccessView( ) } } else { - LocalSpotListAdProvider.current.NativeAd( + SpotListNativeAd( modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(20.dp)) diff --git a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/UploadPlaceViewModel.kt b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/UploadPlaceViewModel.kt index e728bdd76..d17f42965 100644 --- a/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/UploadPlaceViewModel.kt +++ b/feature/upload/src/main/java/com/acon/acon/feature/upload/screen/UploadPlaceViewModel.kt @@ -9,12 +9,13 @@ import com.acon.acon.core.model.model.upload.Feature import com.acon.acon.core.model.model.upload.SearchedSpotByMap import com.acon.acon.core.model.type.CafeFeatureType import com.acon.acon.core.model.type.CategoryType +import com.acon.acon.core.model.type.ImageType import com.acon.acon.core.model.type.PriceFeatureType import com.acon.acon.core.model.type.RestaurantFeatureType import com.acon.acon.core.model.type.SpotType +import com.acon.acon.domain.repository.AconAppRepository import com.acon.acon.domain.repository.MapSearchRepository import com.acon.acon.domain.repository.UploadRepository -import com.acon.acon.feature.upload.BuildConfig import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -26,23 +27,16 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.ConnectionPool -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.internal.toImmutableList import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container -import timber.log.Timber -import java.util.concurrent.TimeUnit import javax.inject.Inject @OptIn(FlowPreview::class) @HiltViewModel class UploadPlaceViewModel @Inject constructor( private val mapSearchRepository: MapSearchRepository, + private val aconAppRepository: AconAppRepository, private val uploadRepository: UploadRepository, application: Application ) : AndroidViewModel(application), ContainerHost { @@ -346,9 +340,10 @@ class UploadPlaceViewModel @Inject constructor( onSuccess:() -> Unit, imageList: List = emptyList() ) = intent { + // 지번 주소가 없으면 도로명 주소, 도로명 주소도 없으면 서버에 빈 문자열 전송 uploadRepository.submitUploadPlace( spotName = state.selectedSpotByMap?.title ?: "", - address = state.selectedSpotByMap?.address ?: "", + address = state.selectedSpotByMap?.address ?: state.selectedSpotByMap?.roadAddress ?: "", spotType = state.selectedSpotType ?: SpotType.CAFE, featureList = state.featureList ?: emptyList(), recommendedMenu = state.recommendMenu ?: "", @@ -372,9 +367,9 @@ class UploadPlaceViewModel @Inject constructor( val presignedResults = runCatching { coroutineScope { - (0 until uris.size).map { + uris.map { uri -> async(Dispatchers.IO) { - uploadRepository.getUploadPlacePreSignedUrl().getOrThrow() + aconAppRepository.uploadImage(ImageType.SPOT, uri.toString()).getOrThrow() } }.awaitAll() } @@ -383,82 +378,11 @@ class UploadPlaceViewModel @Inject constructor( return@intent }.getOrThrow() - val uploadSuccessful = runCatching { - coroutineScope { - uris.zip(presignedResults).map { (imageUri, presignedResult) -> - async(Dispatchers.IO) { - putPlaceImageToPreSignedUrlOptimized(imageUri, presignedResult.preSignedUrl) - } - }.awaitAll().all { it } - } - }.onFailure { - postSideEffect(UploadPlaceSideEffect.ShowToastUploadImageFailed) - return@intent - }.getOrThrow() - - if (!uploadSuccessful) { - postSideEffect(UploadPlaceSideEffect.ShowToastUploadImageFailed) - return@intent - } - - val bucketUrls = presignedResults.map { "${BuildConfig.BUCKET_URL}${it.fileName}" } + val bucketUrls = presignedResults.map { it } submitUploadPlace(onSuccess = onSuccess, imageList = bucketUrls) } - private suspend fun putPlaceImageToPreSignedUrlOptimized( - imageUri: Uri, - preSignedUrl: String - ): Boolean = withContext(Dispatchers.IO) { - val context = getApplication().applicationContext - - return@withContext try { - val byteArray: ByteArray - val mimeType: String - - if (imageUri.scheme == "content") { - context.contentResolver.openInputStream(imageUri).use { inputStream -> - byteArray = inputStream?.readBytes() - ?: throw IllegalArgumentException("이미지 읽기 실패") - } - mimeType = context.contentResolver.getType(imageUri) ?: "image/jpeg" - } else { - Timber.tag(TAG).e("지원하지 않는 URI scheme: %s", imageUri.toString()) - throw IllegalArgumentException("지원하지 않는 URI scheme") - } - - val fileBody = byteArray.toRequestBody(mimeType.toMediaTypeOrNull(), 0, byteArray.size) - val request = Request.Builder() - .url(preSignedUrl) - .put(fileBody) - .addHeader("Content-Type", mimeType) - .build() - - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - Timber.tag(TAG).d("이미지 업로드 성공") - true - } else { - Timber.tag(TAG).e("이미지 업로드 실패, code: ${response.code}") - false - } - } - } catch (e: Exception) { - Timber.tag(TAG).e(e, "이미지 업로드 과정에서 예외 발생: ${e.message}") - false - } - } - companion object { - private val client: OkHttpClient by lazy { - OkHttpClient.Builder() - .connectionPool(ConnectionPool(20, 5, TimeUnit.MINUTES)) - .connectTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .retryOnConnectionFailure(true) - .build() - } - const val TAG = "UploadPlaceViewModel" } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 39cc5e09b..dcd6ff9ce 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,11 +4,11 @@ projectApplicationId = "com.acon.acon" projectCompileSdk = "35" projectTargetSdk = "35" projectMinSdk = "28" -projectVersionCode = "20010010" -projectVersionName = "2.1.1" +projectVersionCode = "20010020" +projectVersionName = "2.1.2" ####################################### -composeCompilerVersion = "1.5.1" +composeCompilerVersion = "1.5.15" agp = "8.6.0" androidTools = "31.9.1" coilCompose = "3.0.4" @@ -18,13 +18,13 @@ haze = "1.2.1" credentials = "1.3.0" googleid = "1.1.1" kotlin = "2.0.21" +ksp = "2.0.21-1.0.27" coreKtx = "1.16.0" lifecycleRuntimeKtx = "2.8.7" composeBom = "2025.05.01" appcompat = "1.7.0" mapSdk = "3.21.0" material = "1.12.0" -jetbrainsKotlinJvm = "2.0.21" navigationCompose = "2.8.2" kotlinxSerializationJson = "1.7.3" okhttp = "4.12.0" @@ -32,13 +32,11 @@ pulltorefresh = "0.2.0" retrofitKotlinSerializationConverter = "1.0.0" retrofit = "2.11.0" securityCryptoKtx = "1.1.0-alpha06" -ksp = "2.0.21-1.0.27" hilt = "2.52" hiltCompose = "1.2.0" javaxInject = "1" -orbit = "9.0.0" +orbit = "10.0.0" coroutine = "1.7.3" -dataStoreVersion = "1.1.0" paging-compose = "3.3.6" amplitudeVersion = "1.20.1" @@ -48,6 +46,10 @@ firebaseCrashlytics = "3.0.3" googleServices = "4.4.2" ads = "24.3.0" +# DataStore +dataStoreVersion = "1.1.7" +protobufKotlinLite = "4.32.0" + ## Naver Map naverMapCompose = "1.8.0" naverMapLocation = "21.0.2" @@ -78,6 +80,7 @@ junit5 = "5.10.2" junitAndroid = "1.2.1" mockk = "1.13.10" turbine = "1.2.1" +kotest = "5.9.1" # Android Test junitComposeVersion = "1.8.2" @@ -130,12 +133,18 @@ kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotl kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } coroutine-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutine" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine"} +orbit-test = { module = "org.orbit-mvi:orbit-test", version.ref = "orbit"} +kotest-runner = { group = "io.kotest", name = "kotest-runner-junit5", version.ref = "kotest" } +kotest-assertions = { group = "io.kotest", name = "kotest-assertions-core", version.ref = "kotest" } +kotest-property = { group = "io.kotest", name = "kotest-property", version.ref = "kotest" } # Android Testing androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitAndroid" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "junitComposeVersion" } +androidx-ui-test = { group = "androidx.compose.ui", name = "ui-test" } # Paging androidx-paignig-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging-compose" } @@ -202,6 +211,8 @@ firebase-crashlytics-sdk = { group = "com.google.firebase", name = "firebase-cra # DataStore preferences-datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "dataStoreVersion" } +proto-datastore = { module = "androidx.datastore:datastore", version.ref = "dataStoreVersion" } +protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobufKotlinLite" } # Palette palette = { module = "androidx.palette:palette", version.ref = "paletteVersion" } @@ -229,8 +240,9 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlytics" } -jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrainsKotlinJvm" } +jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +protobuf = { id = "com.google.protobuf", version = "0.9.5" } # build-logic plugins acon-non-android-library = { id = "com.acon.non.android.library", version = "unspecified" } @@ -245,6 +257,8 @@ acon-android-library-haze = { id = "com.acon.android.library.haze", version = "u acon-android-library-coil = { id = "com.acon.android.library.coil", version = "unspecified" } acon-android-library-naver-map = { id = "com.acon.android.library.naver.map", version = "unspecified" } acon-firebase = { id = "com.acon.firebase", version = "unspecified" } +acon-feature-test = { id = "com.acon.feature.test", version = "unspecified"} +acon-common-unit-test = { id = "com.acon.common.unit.test", version = "unspecified"} [bundles] googleSignIn = ["androidx-credentials", "androidx-credentials-play-services-auth", "googleid"] @@ -258,6 +272,10 @@ coil = ["coil-compose", "coil-network-okhttp"] naver-map = ["naver-map-compose", "naver-map-location", "naver-map-sdk"] firebase = ["firebase-analytics-sdk", "firebase-crashlytics-sdk"] play-app-update = ["play-core", "play-core-ktx"] -non-android-test = ["junit4", "kotlin-test", "mockk", "coroutine-test", "turbine", "junit5-api", "junit5-params"] -junit5-runtime = ["junit5-runtime-engine", "junit5-runtime-vintage-engine"] -android-test = ["androidx-junit", "androidx-espresso-core", "androidx-ui-test-junit4"] \ No newline at end of file + +android-test = ["androidx-junit", "androidx-espresso-core", "androidx-ui-test-junit4"] +orbit-test = ["orbit-test"] +kotest = ["kotest-runner", "kotest-property", "kotest-assertions"] +test-coroutine = ["coroutine-test", "turbine"] +test-unit = ["junit4", "kotlin-test", "junit5-api", "junit5-params"] +test-runtime = ["junit5-runtime-engine", "junit5-runtime-vintage-engine"] diff --git a/provider/ads-impl/.gitignore b/provider/ads-impl/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/provider/ads-impl/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 1550dfbd1..6012b2544 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,11 +21,11 @@ dependencyResolutionManagement { } } -rootProject.name = "Acon" +rootProject.name = "ACON-Android" enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") includeBuild("build-logic") -include(":app") +include(":acon") include( ":core:designsystem", @@ -34,21 +34,20 @@ include( ) include( - ":feature:areaverification", ":feature:spot", ":feature:signin", ":feature:upload", ":feature:onboarding" ) -include(":data") include(":domain") include(":feature:profile") include(":feature:settings") include(":core:analytics") -include(":core:ads-api") +include(":core:ads") include(":core:ui") include(":core:model") include(":core:navigation") -include(":provider:ads-impl") include(":core:launcher") +include(":core:social") +include(":core:data")