From 9e80ec44ddfaabc8b3b110cdd953543c811791db Mon Sep 17 00:00:00 2001 From: Changyeop Lee Date: Fri, 2 Aug 2024 20:04:58 +0900 Subject: [PATCH] Implement profile create. --- .idea/kotlinc.xml | 6 - core/data/build.gradle.kts | 1 + .../mission_mate/core/data/di/DataModule.kt | 5 + .../data/repository/ProfileRepositoryImpl.kt | 16 + .../datasource/AuthDataSourceImpl.kt | 1 + .../src/main/res/values/colors.xml | 7 + core/domain/build.gradle.kts | 2 + .../core/domain/di/ResourceProvider.kt | 30 ++ .../domain/repository/ProfileRepository.kt | 8 + .../core/domain/usecase/LoginUseCase.kt | 12 +- .../core/domain/usecase/ProfileUseCase.kt | 10 + .../luckyoct/core/model/base/NetworkResult.kt | 7 + .../core/model/request/SaveProfileRequest.kt | 20 ++ .../core/navigation/RouteModel.kt | 8 + core/network/build.gradle.kts | 1 + .../core/network/ResultHandler.kt | 28 ++ .../core/network/TokenInterceptor.kt | 40 +++ .../core/network/di/NetworkModule.kt | 6 +- .../core/network/di/ServiceModule.kt | 7 + .../core/network/service/ProfileService.kt | 13 + .../mission_mate/feature/login/LoginEvent.kt | 6 + .../feature/login/LoginNavigation.kt | 4 +- .../mission_mate/feature/login/LoginScreen.kt | 13 +- .../feature/login/LoginUiState.kt | 6 - .../feature/login/LoginViewModel.kt | 8 +- feature/main/build.gradle.kts | 3 +- feature/main/src/main/AndroidManifest.xml | 3 +- .../core/main/component/MainNavHost.kt | 13 +- .../core/main/component/MainNavigator.kt | 5 + feature/profile/.gitignore | 1 + feature/profile/build.gradle.kts | 74 +++++ feature/profile/proguard-rules.pro | 21 ++ .../profile/ExampleInstrumentedTest.kt | 24 ++ feature/profile/src/main/AndroidManifest.xml | 4 + .../feature/profile/ProfileNavigation.kt | 35 +++ .../luckyoct/feature/profile/ProfileScreen.kt | 284 ++++++++++++++++++ .../feature/profile/ProfileViewModel.kt | 121 ++++++++ .../ProfileViewModelFactoryProvider.kt | 12 + .../profile/model/CharacterListItem.kt | 12 + .../feature/profile/model/ProfileUiState.kt | 8 + .../profile/src/main/res/values/arrays.xml | 29 ++ .../profile/src/main/res/values/strings.xml | 14 + .../feature/profile/ExampleUnitTest.kt | 17 ++ settings.gradle.kts | 1 + 44 files changed, 920 insertions(+), 26 deletions(-) delete mode 100644 .idea/kotlinc.xml create mode 100644 core/data/src/main/java/com/goalpanzi/mission_mate/core/data/repository/ProfileRepositoryImpl.kt create mode 100644 core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/di/ResourceProvider.kt create mode 100644 core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/repository/ProfileRepository.kt create mode 100644 core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/usecase/ProfileUseCase.kt create mode 100644 core/model/src/main/java/com/luckyoct/core/model/base/NetworkResult.kt create mode 100644 core/model/src/main/java/com/luckyoct/core/model/request/SaveProfileRequest.kt create mode 100644 core/network/src/main/java/com/goalpanzi/mission_mate/core/network/ResultHandler.kt create mode 100644 core/network/src/main/java/com/goalpanzi/mission_mate/core/network/TokenInterceptor.kt create mode 100644 core/network/src/main/java/com/goalpanzi/mission_mate/core/network/service/ProfileService.kt create mode 100644 feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginEvent.kt delete mode 100644 feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginUiState.kt create mode 100644 feature/profile/.gitignore create mode 100644 feature/profile/build.gradle.kts create mode 100644 feature/profile/proguard-rules.pro create mode 100644 feature/profile/src/androidTest/java/com/luckyoct/feature/profile/ExampleInstrumentedTest.kt create mode 100644 feature/profile/src/main/AndroidManifest.xml create mode 100644 feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileNavigation.kt create mode 100644 feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileScreen.kt create mode 100644 feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileViewModel.kt create mode 100644 feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileViewModelFactoryProvider.kt create mode 100644 feature/profile/src/main/java/com/luckyoct/feature/profile/model/CharacterListItem.kt create mode 100644 feature/profile/src/main/java/com/luckyoct/feature/profile/model/ProfileUiState.kt create mode 100644 feature/profile/src/main/res/values/arrays.xml create mode 100644 feature/profile/src/main/res/values/strings.xml create mode 100644 feature/profile/src/test/java/com/luckyoct/feature/profile/ExampleUnitTest.kt diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml deleted file mode 100644 index 6d0ee1c2..00000000 --- a/.idea/kotlinc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index beb4b359..e7be6c14 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation(libs.bundles.test) implementation(libs.bundles.coroutines) + implementation(libs.retrofit) ksp(libs.hilt.compiler) implementation(libs.hilt.android) diff --git a/core/data/src/main/java/com/goalpanzi/mission_mate/core/data/di/DataModule.kt b/core/data/src/main/java/com/goalpanzi/mission_mate/core/data/di/DataModule.kt index 21696f28..cd69468a 100644 --- a/core/data/src/main/java/com/goalpanzi/mission_mate/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/goalpanzi/mission_mate/core/data/di/DataModule.kt @@ -1,7 +1,9 @@ package com.goalpanzi.mission_mate.core.data.di import com.goalpanzi.mission_mate.core.data.repository.LoginRepositoryImpl +import com.goalpanzi.mission_mate.core.data.repository.ProfileRepositoryImpl import com.goalpanzi.mission_mate.core.domain.repository.LoginRepository +import com.goalpanzi.mission_mate.core.domain.repository.ProfileRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -13,4 +15,7 @@ internal abstract class DataModule { @Binds abstract fun bindLoginRepository(impl: LoginRepositoryImpl): LoginRepository + + @Binds + abstract fun bindProfileRepository(impl: ProfileRepositoryImpl): ProfileRepository } \ No newline at end of file diff --git a/core/data/src/main/java/com/goalpanzi/mission_mate/core/data/repository/ProfileRepositoryImpl.kt b/core/data/src/main/java/com/goalpanzi/mission_mate/core/data/repository/ProfileRepositoryImpl.kt new file mode 100644 index 00000000..e99e9d20 --- /dev/null +++ b/core/data/src/main/java/com/goalpanzi/mission_mate/core/data/repository/ProfileRepositoryImpl.kt @@ -0,0 +1,16 @@ +package com.goalpanzi.mission_mate.core.data.repository + +import com.goalpanzi.mission_mate.core.domain.repository.ProfileRepository +import com.goalpanzi.mission_mate.core.network.service.ProfileService +import com.luckyoct.core.model.base.NetworkResult +import com.luckyoct.core.model.request.SaveProfileRequest +import javax.inject.Inject + +class ProfileRepositoryImpl @Inject constructor( + private val profileService: ProfileService +): ProfileRepository { + override suspend fun saveProfile(nickname: String, index: Int): NetworkResult = handleResult { + val request = SaveProfileRequest.createRequest(nickname, index) + profileService.saveProfile(request) + } +} \ No newline at end of file diff --git a/core/datastore/src/main/java/com/goalpanzi/mission_mate/core/datastore/datasource/AuthDataSourceImpl.kt b/core/datastore/src/main/java/com/goalpanzi/mission_mate/core/datastore/datasource/AuthDataSourceImpl.kt index ddb789b5..c7ecc417 100644 --- a/core/datastore/src/main/java/com/goalpanzi/mission_mate/core/datastore/datasource/AuthDataSourceImpl.kt +++ b/core/datastore/src/main/java/com/goalpanzi/mission_mate/core/datastore/datasource/AuthDataSourceImpl.kt @@ -1,5 +1,6 @@ package com.goalpanzi.mission_mate.core.datastore.datasource +import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit diff --git a/core/designsystem/src/main/res/values/colors.xml b/core/designsystem/src/main/res/values/colors.xml index f8c6127d..400b3520 100644 --- a/core/designsystem/src/main/res/values/colors.xml +++ b/core/designsystem/src/main/res/values/colors.xml @@ -7,4 +7,11 @@ #FF018786 #FF000000 #FFFFFFFF + + #FFFFE4E4 + #FFBFD7FF + #FFFFE59A + #FFC2E792 + #FFF7D8B3 + #FFBCE7FF \ No newline at end of file diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 60147db4..b3882872 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -45,4 +45,6 @@ dependencies { implementation(libs.hilt.android) implementation(project(":core:model")) + implementation(project(":core:datastore")) + implementation(project(":core:network")) } \ No newline at end of file diff --git a/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/di/ResourceProvider.kt b/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/di/ResourceProvider.kt new file mode 100644 index 00000000..fe1d04c1 --- /dev/null +++ b/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/di/ResourceProvider.kt @@ -0,0 +1,30 @@ +package com.goalpanzi.mission_mate.core.domain.di + +import android.content.Context +import android.content.res.TypedArray +import androidx.annotation.ArrayRes +import androidx.annotation.StringRes +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ResourceProvider @Inject constructor( + @ApplicationContext private val context: Context +) { + fun getString(@StringRes stringResId: Int): String { + return context.getString(stringResId) + } + + fun getIntArray(@ArrayRes arrayResId: Int): Array { + return context.resources.getIntArray(arrayResId).toTypedArray() + } + + fun getDrawableArray(@ArrayRes arrayResId: Int): TypedArray { + return context.resources.obtainTypedArray(arrayResId) + } + + fun getStringArray(@ArrayRes arrayResId: Int): Array { + return context.resources.getStringArray(arrayResId) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/repository/ProfileRepository.kt b/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/repository/ProfileRepository.kt new file mode 100644 index 00000000..55400ac6 --- /dev/null +++ b/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/repository/ProfileRepository.kt @@ -0,0 +1,8 @@ +package com.goalpanzi.mission_mate.core.domain.repository + +import com.goalpanzi.mission_mate.core.network.ResultHandler +import com.luckyoct.core.model.base.NetworkResult + +interface ProfileRepository: ResultHandler { + suspend fun saveProfile(nickname: String, index: Int): NetworkResult +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/usecase/LoginUseCase.kt b/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/usecase/LoginUseCase.kt index 63aea819..250dfc21 100644 --- a/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/usecase/LoginUseCase.kt +++ b/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/usecase/LoginUseCase.kt @@ -1,13 +1,21 @@ package com.goalpanzi.mission_mate.core.domain.usecase +import com.goalpanzi.mission_mate.core.datastore.datasource.AuthDataSource import com.goalpanzi.mission_mate.core.domain.repository.LoginRepository import com.luckyoct.core.model.GoogleLogin +import kotlinx.coroutines.flow.first import javax.inject.Inject class LoginUseCase @Inject constructor( - private val loginRepository: LoginRepository + private val loginRepository: LoginRepository, + private val authDataSource: AuthDataSource ) { - suspend fun requestGoogleLogin(token: String, email: String): GoogleLogin = loginRepository.requestGoogleLogin(token, email) + suspend fun requestGoogleLogin(token: String, email: String): GoogleLogin { + val response = loginRepository.requestGoogleLogin(token, email) + authDataSource.setAccessToken(response.accessToken).first() + authDataSource.setRefreshToken(response.refreshToken).first() + return response + } } \ No newline at end of file diff --git a/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/usecase/ProfileUseCase.kt b/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/usecase/ProfileUseCase.kt new file mode 100644 index 00000000..89c23033 --- /dev/null +++ b/core/domain/src/main/java/com/goalpanzi/mission_mate/core/domain/usecase/ProfileUseCase.kt @@ -0,0 +1,10 @@ +package com.goalpanzi.mission_mate.core.domain.usecase + +import com.goalpanzi.mission_mate.core.domain.repository.ProfileRepository +import javax.inject.Inject + +class ProfileUseCase @Inject constructor( + private val profileRepository: ProfileRepository +) { + suspend fun saveProfile(nickname: String, index: Int) = profileRepository.saveProfile(nickname, index) +} \ No newline at end of file diff --git a/core/model/src/main/java/com/luckyoct/core/model/base/NetworkResult.kt b/core/model/src/main/java/com/luckyoct/core/model/base/NetworkResult.kt new file mode 100644 index 00000000..2d549dba --- /dev/null +++ b/core/model/src/main/java/com/luckyoct/core/model/base/NetworkResult.kt @@ -0,0 +1,7 @@ +package com.luckyoct.core.model.base + +sealed interface NetworkResult { + data class Success(val data: T) : NetworkResult + data class Error(val code: Int? = null, val message: String? = null) : NetworkResult + data class Exception(val error: Throwable) : NetworkResult +} \ No newline at end of file diff --git a/core/model/src/main/java/com/luckyoct/core/model/request/SaveProfileRequest.kt b/core/model/src/main/java/com/luckyoct/core/model/request/SaveProfileRequest.kt new file mode 100644 index 00000000..5b961ede --- /dev/null +++ b/core/model/src/main/java/com/luckyoct/core/model/request/SaveProfileRequest.kt @@ -0,0 +1,20 @@ +package com.luckyoct.core.model.request + +import kotlinx.serialization.Serializable + +enum class CharacterType { + RABBIT, CAT, DOG, PANDA, BEAR, BIRD +} + +@Serializable +data class SaveProfileRequest( + val nickname: String, + val characterType: String, +) { + companion object { + fun createRequest(nickname: String, index: Int) = SaveProfileRequest( + nickname = nickname, + characterType = CharacterType.entries[index].name.uppercase() + ) + } +} diff --git a/core/navigation/src/main/java/com/goalpanzi/mission_mate/core/navigation/RouteModel.kt b/core/navigation/src/main/java/com/goalpanzi/mission_mate/core/navigation/RouteModel.kt index 7b36c68e..e2829a25 100644 --- a/core/navigation/src/main/java/com/goalpanzi/mission_mate/core/navigation/RouteModel.kt +++ b/core/navigation/src/main/java/com/goalpanzi/mission_mate/core/navigation/RouteModel.kt @@ -8,6 +8,14 @@ sealed interface RouteModel { @Serializable data object Onboarding : RouteModel + + @Serializable + sealed interface Profile: RouteModel { + @Serializable + data object Create : Profile + @Serializable + data object Change : Profile + } } sealed interface OnboardingRouteModel { diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 311c2922..ea1e4c2d 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { implementation(libs.hilt.android) implementation(project(":core:model")) + implementation(project(":core:datastore")) } fun getMissionMateBaseUrl(): String { diff --git a/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/ResultHandler.kt b/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/ResultHandler.kt new file mode 100644 index 00000000..ba86944e --- /dev/null +++ b/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/ResultHandler.kt @@ -0,0 +1,28 @@ +package com.goalpanzi.mission_mate.core.network + +import com.luckyoct.core.model.base.NetworkResult +import retrofit2.HttpException +import retrofit2.Response + +interface ResultHandler { + + suspend fun handleResult(execute: suspend () -> Response): NetworkResult { + return try { + val response = execute() + if (response.isSuccessful) { + val body = response.body() + if (body != null) { + NetworkResult.Success(body) + } else { + NetworkResult.Error(response.code(), "Response body is null") + } + } else { + NetworkResult.Error(response.code(), response.errorBody()?.string()) + } + } catch (e: HttpException) { + NetworkResult.Error(e.code(), e.message()) + } catch (e: Throwable) { + NetworkResult.Exception(e) + } + } +} diff --git a/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/TokenInterceptor.kt b/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/TokenInterceptor.kt new file mode 100644 index 00000000..7afd645d --- /dev/null +++ b/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/TokenInterceptor.kt @@ -0,0 +1,40 @@ +package com.goalpanzi.mission_mate.core.network + +import com.goalpanzi.mission_mate.core.datastore.datasource.AuthDataSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TokenInterceptor @Inject constructor( + private val authDataSource: AuthDataSource +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val newRequest = chain.request().newBuilder().apply { + runBlocking { + val token = authDataSource.getAccessToken().first() + token?.let { + addHeader("Authorization", "Bearer $it") + } + } + } + + val response = chain.proceed(newRequest.build()) + if (response.code == 200) { + val newAccessToken: String = response.header("Authorization", null) ?: return response + CoroutineScope(Dispatchers.IO).launch { + val existedAccessToken = authDataSource.getAccessToken().first() + if (existedAccessToken != newAccessToken) { + authDataSource.setAccessToken(newAccessToken) + } + } + } + return response + } +} \ No newline at end of file diff --git a/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/di/NetworkModule.kt b/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/di/NetworkModule.kt index fa3284ae..a8f19c7c 100644 --- a/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/di/NetworkModule.kt +++ b/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/di/NetworkModule.kt @@ -1,11 +1,13 @@ package com.goalpanzi.mission_mate.core.network.di import com.goalpanzi.mission_mate.core.network.BuildConfig +import com.goalpanzi.mission_mate.core.network.TokenInterceptor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json +import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -31,7 +33,8 @@ internal object NetworkModule { @Provides @Singleton fun provideOkhttpClient( - httpLoggingInterceptor: HttpLoggingInterceptor + httpLoggingInterceptor: HttpLoggingInterceptor, + tokenInterceptor: TokenInterceptor ): OkHttpClient { // TLS 대응 val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) @@ -41,6 +44,7 @@ internal object NetworkModule { sslContext.init(null, arrayOf(trustManager), java.security.SecureRandom()) return OkHttpClient.Builder() + .addInterceptor(tokenInterceptor) .sslSocketFactory(sslContext.socketFactory, trustManager) .addInterceptor(httpLoggingInterceptor) .build() diff --git a/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/di/ServiceModule.kt b/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/di/ServiceModule.kt index 0d248b31..a6153a67 100644 --- a/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/di/ServiceModule.kt +++ b/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/di/ServiceModule.kt @@ -1,6 +1,7 @@ package com.goalpanzi.mission_mate.core.network.di import com.goalpanzi.mission_mate.core.network.service.LoginService +import com.goalpanzi.mission_mate.core.network.service.ProfileService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -17,4 +18,10 @@ object ServiceModule { fun provideLoginService(retrofit: Retrofit): LoginService { return retrofit.create(LoginService::class.java) } + + @Provides + @Singleton + fun provideProfileService(retrofit: Retrofit): ProfileService { + return retrofit.create(ProfileService::class.java) + } } \ No newline at end of file diff --git a/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/service/ProfileService.kt b/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/service/ProfileService.kt new file mode 100644 index 00000000..0e44a627 --- /dev/null +++ b/core/network/src/main/java/com/goalpanzi/mission_mate/core/network/service/ProfileService.kt @@ -0,0 +1,13 @@ +package com.goalpanzi.mission_mate.core.network.service + +import com.luckyoct.core.model.request.SaveProfileRequest +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.PATCH + +interface ProfileService { + @PATCH("/api/member/profile") + suspend fun saveProfile( + @Body request: SaveProfileRequest + ): Response +} \ No newline at end of file diff --git a/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginEvent.kt b/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginEvent.kt new file mode 100644 index 00000000..d447417d --- /dev/null +++ b/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginEvent.kt @@ -0,0 +1,6 @@ +package com.goalpanzi.mission_mate.feature.login + +sealed interface LoginEvent { + data object Error : LoginEvent + data class Success(val isAlreadyMember: Boolean) : LoginEvent +} \ No newline at end of file diff --git a/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginNavigation.kt b/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginNavigation.kt index dd559be8..4653bdfd 100644 --- a/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginNavigation.kt +++ b/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginNavigation.kt @@ -10,11 +10,11 @@ fun NavController.navigateToLogin() { } fun NavGraphBuilder.loginNavGraph( - onBackClick: () -> Unit + onLoginSuccess: (isProfileSet: Boolean) -> Unit ) { composable { LoginRoute( - onBackClick = onBackClick + onLoginSuccess = onLoginSuccess ) } } \ No newline at end of file diff --git a/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginScreen.kt b/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginScreen.kt index 59b6d3bb..dd504e50 100644 --- a/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginScreen.kt +++ b/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -28,15 +29,25 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.goalpanzi.mission_mate.core.designsystem.theme.ColorFFF5EDEA import com.goalpanzi.mission_mate.core.designsystem.theme.MissionMateTypography +import kotlinx.coroutines.flow.collectLatest @Composable fun LoginRoute( - onBackClick: () -> Unit, + onLoginSuccess: (Boolean) -> Unit, modifier: Modifier = Modifier, viewModel: LoginViewModel = hiltViewModel() ) { val context = LocalContext.current + LaunchedEffect(true) { + viewModel.eventFlow.collectLatest { + when (it) { + LoginEvent.Error -> Unit + is LoginEvent.Success -> onLoginSuccess(it.isAlreadyMember) + } + } + } + LoginScreen( modifier = modifier, onGoogleLoginClick = { viewModel.request(context) } diff --git a/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginUiState.kt b/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginUiState.kt deleted file mode 100644 index dac6ead8..00000000 --- a/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginUiState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.goalpanzi.mission_mate.feature.login - -sealed interface LoginUiState { - data object Loading : LoginUiState - data class Success(val isAlreadyMember: Boolean) : LoginUiState -} \ No newline at end of file diff --git a/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginViewModel.kt b/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginViewModel.kt index 83074eea..36e4d4c4 100644 --- a/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginViewModel.kt +++ b/feature/login/src/main/java/com/goalpanzi/mission_mate/feature/login/LoginViewModel.kt @@ -13,6 +13,8 @@ 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.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -21,9 +23,11 @@ class LoginViewModel @Inject constructor( private val loginUseCase: LoginUseCase ) : ViewModel() { + private val _eventFlow = MutableSharedFlow() + val eventFlow = _eventFlow.asSharedFlow() + fun request(context: Context) { viewModelScope.launch { - val credentialManager = CredentialManager.create(context) val signInWithGoogleOption: GetSignInWithGoogleOption = GetSignInWithGoogleOption.Builder(BuildConfig.CREDENTIAL_WEB_CLIENT_ID) @@ -54,7 +58,7 @@ class LoginViewModel @Inject constructor( token = compressedToken, email = googleIdTokenCredential.id ) - // TODO : success event + _eventFlow.emit(LoginEvent.Success(result.isProfileSet)) } catch (e: GoogleIdTokenParsingException) { e.printStackTrace() } diff --git a/feature/main/build.gradle.kts b/feature/main/build.gradle.kts index a6a0df44..a6b29731 100644 --- a/feature/main/build.gradle.kts +++ b/feature/main/build.gradle.kts @@ -71,4 +71,5 @@ dependencies { implementation(project(":core:domain")) implementation(project(":feature:login")) implementation(project(":feature:onboarding")) -} \ No newline at end of file + implementation(project(":feature:profile")) +} diff --git a/feature/main/src/main/AndroidManifest.xml b/feature/main/src/main/AndroidManifest.xml index 703653c4..5d913de9 100644 --- a/feature/main/src/main/AndroidManifest.xml +++ b/feature/main/src/main/AndroidManifest.xml @@ -4,7 +4,8 @@ + android:theme="@style/Theme.Missionmate" + android:windowSoftInputMode="adjustResize"> diff --git a/feature/main/src/main/java/com/goalpanzi/mission_mate/core/main/component/MainNavHost.kt b/feature/main/src/main/java/com/goalpanzi/mission_mate/core/main/component/MainNavHost.kt index fc0cb642..ee9198ee 100644 --- a/feature/main/src/main/java/com/goalpanzi/mission_mate/core/main/component/MainNavHost.kt +++ b/feature/main/src/main/java/com/goalpanzi/mission_mate/core/main/component/MainNavHost.kt @@ -12,6 +12,8 @@ import com.goalpanzi.mission_mate.feature.login.loginNavGraph import com.goalpanzi.mission_mate.feature.onboarding.boardSetupNavGraph import com.goalpanzi.mission_mate.feature.onboarding.invitationCodeNavGraph import com.goalpanzi.mission_mate.feature.onboarding.onboardingNavGraph +import com.luckyoct.feature.profile.ProfileSettingType +import com.luckyoct.feature.profile.profileNavGraph @Composable internal fun MainNavHost( @@ -29,15 +31,18 @@ internal fun MainNavHost( startDestination = navigator.startDestination ) { loginNavGraph( - onBackClick = { navigator.popBackStack() } + onLoginSuccess = { if (it) navigator.navigationToOnboarding() else navigator.navigateToProfileCreate() } ) onboardingNavGraph( - onClickBoardSetup = { }, - onClickInvitationCode = { }, - onClickSetting = { } + onClickBoardSetup = { }, + onClickInvitationCode = { }, + onClickSetting = { } ) boardSetupNavGraph() invitationCodeNavGraph() + profileNavGraph( + onSaveSuccess = { navigator.navigationToOnboarding() } + ) } } } \ No newline at end of file diff --git a/feature/main/src/main/java/com/goalpanzi/mission_mate/core/main/component/MainNavigator.kt b/feature/main/src/main/java/com/goalpanzi/mission_mate/core/main/component/MainNavigator.kt index 7e4ca198..5bc25bae 100644 --- a/feature/main/src/main/java/com/goalpanzi/mission_mate/core/main/component/MainNavigator.kt +++ b/feature/main/src/main/java/com/goalpanzi/mission_mate/core/main/component/MainNavigator.kt @@ -9,6 +9,7 @@ import com.goalpanzi.mission_mate.feature.login.navigateToLogin import com.goalpanzi.mission_mate.feature.onboarding.navigateToBoardSetup import com.goalpanzi.mission_mate.feature.onboarding.navigateToInvitationCode import com.goalpanzi.mission_mate.feature.onboarding.navigateToOnboarding +import com.luckyoct.feature.profile.navigateToProfileCreate class MainNavigator( val navController: NavHostController @@ -25,6 +26,10 @@ class MainNavigator( navController.navigateToLogin() } + fun navigateToProfileCreate() { + navController.navigateToProfileCreate() + } + fun navigationToOnboarding() { navController.navigateToOnboarding() } diff --git a/feature/profile/.gitignore b/feature/profile/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/profile/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts new file mode 100644 index 00000000..e0abf1ad --- /dev/null +++ b/feature/profile/build.gradle.kts @@ -0,0 +1,74 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.ksp) + alias(libs.plugins.hilt.android) +} + +android { + namespace = "com.luckyoct.feature.profile" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + buildFeatures { + compose = true + } + composeCompiler { + enableStrongSkippingMode = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.bundles.lifecycle) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.compose) + implementation(libs.androidx.activity.compose) + implementation(libs.bundles.coroutines) + + testImplementation(libs.bundles.test) + androidTestImplementation(libs.bundles.android.test) + androidTestImplementation(platform(libs.androidx.compose.bom)) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + implementation(project(":core:designsystem")) + implementation(project(":core:navigation")) + implementation(project(":core:domain")) + implementation(project(":core:model")) +} \ No newline at end of file diff --git a/feature/profile/proguard-rules.pro b/feature/profile/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/profile/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/feature/profile/src/androidTest/java/com/luckyoct/feature/profile/ExampleInstrumentedTest.kt b/feature/profile/src/androidTest/java/com/luckyoct/feature/profile/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..eecad5fd --- /dev/null +++ b/feature/profile/src/androidTest/java/com/luckyoct/feature/profile/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.luckyoct.feature.profile + +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.luckyoct.feature.profile.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/profile/src/main/AndroidManifest.xml b/feature/profile/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/profile/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileNavigation.kt b/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileNavigation.kt new file mode 100644 index 00000000..75c2520f --- /dev/null +++ b/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileNavigation.kt @@ -0,0 +1,35 @@ +package com.luckyoct.feature.profile + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.goalpanzi.mission_mate.core.navigation.RouteModel + +enum class ProfileSettingType { + CREATE, CHANGE +} + +fun NavController.navigateToProfileCreate() { + this.navigate(RouteModel.Profile.Create) +} + +fun NavController.navigateToProfileChange() { + this.navigate(RouteModel.Profile.Change) +} + +fun NavGraphBuilder.profileNavGraph( + onSaveSuccess: () -> Unit +) { + composable { + ProfileRoute( + profileSettingType = ProfileSettingType.CREATE, + onSaveSuccess = { onSaveSuccess() } + ) + } + composable { + ProfileRoute( + profileSettingType = ProfileSettingType.CHANGE, + onSaveSuccess = { onSaveSuccess() } + ) + } +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileScreen.kt b/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileScreen.kt new file mode 100644 index 00000000..4313f08e --- /dev/null +++ b/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileScreen.kt @@ -0,0 +1,284 @@ +package com.luckyoct.feature.profile + +import android.app.Activity +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +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.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.goalpanzi.mission_mate.core.designsystem.component.MissionMateTextButton +import com.goalpanzi.mission_mate.core.designsystem.component.MissionMateTextFieldGroup +import com.goalpanzi.mission_mate.core.designsystem.theme.ColorGray1_FF404249 +import com.goalpanzi.mission_mate.core.designsystem.theme.ColorGray5_FFF5F6F9 +import com.goalpanzi.mission_mate.core.designsystem.theme.ColorPink_FFFFE4E4 +import com.goalpanzi.mission_mate.core.designsystem.theme.ColorWhite_FFFFFFFF +import com.goalpanzi.mission_mate.core.designsystem.theme.MissionMateTypography +import com.luckyoct.feature.profile.model.CharacterListItem +import com.luckyoct.feature.profile.model.ProfileEvent +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.flow.collectLatest + +@Composable +fun profileViewModel(profileSettingType: ProfileSettingType): ProfileViewModel { + val factory = EntryPointAccessors.fromActivity( + LocalContext.current as Activity, + ProfileViewModelFactoryProvider::class.java + ).profileViewModelFactory() + + return viewModel(factory = ProfileViewModel.provideFactory(factory, profileSettingType)) +} + +@Composable +fun ProfileRoute( + modifier: Modifier = Modifier, + profileSettingType: ProfileSettingType, + onSaveSuccess: () -> Unit +) { + val viewModel = profileViewModel(profileSettingType = profileSettingType) + val characters = viewModel.characters.collectAsStateWithLifecycle() + + LaunchedEffect(true) { + viewModel.event.collectLatest { + when (it) { + ProfileEvent.Success -> onSaveSuccess() + else -> return@collectLatest + } + } + } + + ProfileScreen( + profileSettingType = profileSettingType, + characters = characters.value, + onclickCharacter = { + viewModel.selectCharacter(it) + }, + onClickSave = { + viewModel.saveProfile(it) + } + ) +} + +@Composable +fun ProfileScreen( + modifier: Modifier = Modifier, + profileSettingType: ProfileSettingType, + characters: List, + onclickCharacter: (CharacterListItem) -> Unit, + onClickSave: (String) -> Unit +) { + + var nicknameInput by remember { mutableStateOf("") } + val scrollState = rememberScrollState() + val regex = Regex("^[가-힣a-zA-Z0-9]{1,6}$") + + Column( + modifier = modifier + .fillMaxSize() + .background(color = ColorWhite_FFFFFFFF) + .imePadding() + ) { + Column( + modifier = modifier + .padding(bottom = 18.dp) + .fillMaxWidth() + .weight(1f) + .verticalScroll(scrollState) + ) { + Text( + text = stringResource(id = R.string.profile_create), + modifier = modifier + .align(Alignment.CenterHorizontally) + .padding(top = 48.dp), + style = MissionMateTypography.heading_sm_bold, + ) + + characters.find { it.isSelected }?.let { + Box( + modifier = modifier + .padding(top = 32.dp) + .size(220.dp) + .background(color = it.backgroundColor, shape = CircleShape) + .align(Alignment.CenterHorizontally) + ) { + CharacterLargeImage(imageResId = it.imageResId) + } + } + CharacterRow( + characters = characters, + onClick = onclickCharacter + ) + + MissionMateTextFieldGroup( + modifier = modifier + .padding(top = 38.dp, start = 24.dp, end = 24.dp) + .fillMaxWidth() + .wrapContentHeight(), + text = nicknameInput, + onValueChange = { if (regex.matches(it) || it.isEmpty()) nicknameInput = it }, + hintId = R.string.nickname_hint, + guidanceId = R.string.nickname_input_guide + ) + } + + MissionMateTextButton( + modifier = modifier + .padding(bottom = 36.dp, start = 24.dp, end = 24.dp) + .fillMaxWidth(), + textId = R.string.save, + onClick = { onClickSave(nicknameInput) } + ) + } +} + +@Composable +fun CharacterLargeImage( + modifier: Modifier = Modifier, + @DrawableRes imageResId: Int, +) { + Image( + modifier = modifier + .padding(10.dp) + .fillMaxSize(), + painter = painterResource(id = imageResId), + contentDescription = "" + ) +} + +@Composable +fun CharacterRow( + modifier: Modifier = Modifier, + characters: List, + onClick: (CharacterListItem) -> Unit +) { + LazyRow( + modifier = modifier + .padding(top = 18.dp), + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), + contentPadding = PaddingValues(horizontal = 24.dp) + ) { + items(items = characters, key = { it.imageResId }) { + CharacterElement( + character = it, + onClick = onClick + ) + } + } +} + +@Composable +fun CharacterElement( + modifier: Modifier = Modifier, + character: CharacterListItem, + onClick: (CharacterListItem) -> Unit = {} +) { + Box( + modifier = modifier + .size(width = 100.dp, height = 124.dp) + .alpha(if (character.isSelected) 1f else 0.3f) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { onClick(character) } + ) + ) { + Image( + painter = painterResource(id = character.imageResId), + contentDescription = null, + ) + + Text( + text = stringResource(id = character.nameResId), + style = MissionMateTypography.body_md_bold, + modifier = modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(color = ColorGray5_FFF5F6F9, shape = RoundedCornerShape(10.dp)), + color = ColorGray1_FF404249, + textAlign = TextAlign.Center + ) + } +} + +@Preview +@Composable +fun ProfileScreenPreview() { + ProfileScreen( + profileSettingType = ProfileSettingType.CREATE, + characters = listOf( + CharacterListItem( + imageResId = com.goalpanzi.mission_mate.core.designsystem.R.drawable.img_rabbit_selected, + nameResId = R.string.rabbit_name, + isSelected = true, + backgroundColor = ColorPink_FFFFE4E4 + ), + CharacterListItem( + imageResId = com.goalpanzi.mission_mate.core.designsystem.R.drawable.img_cat_selected, + nameResId = R.string.cat_name, + isSelected = false, + backgroundColor = ColorPink_FFFFE4E4 + ), + CharacterListItem( + imageResId = com.goalpanzi.mission_mate.core.designsystem.R.drawable.img_dog_selected, + nameResId = R.string.dog_name, + isSelected = false, + backgroundColor = ColorPink_FFFFE4E4 + ), + CharacterListItem( + imageResId = com.goalpanzi.mission_mate.core.designsystem.R.drawable.img_panda_selected, + nameResId = R.string.panda_name, + isSelected = false, + backgroundColor = ColorPink_FFFFE4E4 + ), + ), + onclickCharacter = {}, + onClickSave = {} + ) +} + +@Preview +@Composable +fun CharacterElementPreview() { + CharacterElement( + character = CharacterListItem( + imageResId = com.goalpanzi.mission_mate.core.designsystem.R.drawable.img_cat_selected, + nameResId = R.string.cat_name, + isSelected = false, + backgroundColor = ColorPink_FFFFE4E4 + ) + ) +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileViewModel.kt b/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileViewModel.kt new file mode 100644 index 00000000..cf8f0f7e --- /dev/null +++ b/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileViewModel.kt @@ -0,0 +1,121 @@ +package com.luckyoct.feature.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.goalpanzi.mission_mate.core.designsystem.theme.ColorBlue_FFBFD7FF +import com.goalpanzi.mission_mate.core.designsystem.theme.ColorLightBlue_FFBCE7FF +import com.goalpanzi.mission_mate.core.designsystem.theme.ColorLightBrown_FFF7D8B3 +import com.goalpanzi.mission_mate.core.designsystem.theme.ColorLightGreen_FFC2E792 +import com.goalpanzi.mission_mate.core.designsystem.theme.ColorLightYellow_FFFFE59A +import com.goalpanzi.mission_mate.core.designsystem.theme.ColorPink_FFFFE4E4 +import com.goalpanzi.mission_mate.core.domain.usecase.ProfileUseCase +import com.luckyoct.core.model.base.NetworkResult +import com.luckyoct.feature.profile.model.CharacterListItem +import com.luckyoct.feature.profile.model.ProfileEvent +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ProfileViewModel @AssistedInject constructor( + @Assisted private val profileSettingType: ProfileSettingType, + private val profileUseCase: ProfileUseCase +): ViewModel() { + + @AssistedFactory + interface Factory { + fun create(profileSettingType: ProfileSettingType): ProfileViewModel + } + + companion object { + @Suppress("UNCHECKED_CAST") + fun provideFactory( + assistedFactory: Factory, + profileSettingType: ProfileSettingType + ): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return assistedFactory.create(profileSettingType) as T + } + } + } + + private val defaultImageIds = listOf( + com.goalpanzi.mission_mate.core.designsystem.R.drawable.img_rabbit_selected, + com.goalpanzi.mission_mate.core.designsystem.R.drawable.img_cat_selected, + com.goalpanzi.mission_mate.core.designsystem.R.drawable.img_dog_selected, + com.goalpanzi.mission_mate.core.designsystem.R.drawable.img_panda_selected, + com.goalpanzi.mission_mate.core.designsystem.R.drawable.img_bear_selected, + com.goalpanzi.mission_mate.core.designsystem.R.drawable.img_bird_selected + ) + private val defaultNameIds = listOf( + R.string.rabbit_name, + R.string.cat_name, + R.string.dog_name, + R.string.panda_name, + R.string.bear_name, + R.string.bird_name + ) + private val defaultColors = listOf( + ColorPink_FFFFE4E4, + ColorBlue_FFBFD7FF, + ColorLightYellow_FFFFE59A, + ColorLightGreen_FFC2E792, + ColorLightBrown_FFF7D8B3, + ColorLightBlue_FFBCE7FF + ) + + private val _event = MutableSharedFlow() + val event = _event.asSharedFlow() + + private val _characters = MutableStateFlow( + when (profileSettingType) { + ProfileSettingType.CREATE -> { + defaultImageIds.indices.map { + CharacterListItem( + imageResId = defaultImageIds[it], + nameResId = defaultNameIds[it], + isSelected = it == 0, + backgroundColor = defaultColors[it] + ) + } + } + // TODO : set my character selected + ProfileSettingType.CHANGE -> { + defaultImageIds.indices.map { + CharacterListItem( + imageResId = defaultImageIds[it], + nameResId = defaultNameIds[it], + isSelected = false, + backgroundColor = defaultColors[it] + ) + } + } + } + ) + val characters = _characters.asStateFlow() + + fun selectCharacter(character: CharacterListItem) { + _characters.value = _characters.value.map { + it.copy(isSelected = it == character) + } + } + + fun saveProfile(nickname: String) { + if (nickname.isEmpty()) return + + viewModelScope.launch { + val response = profileUseCase.saveProfile(nickname, characters.value.indexOfFirst { it.isSelected }) + when (response) { + is NetworkResult.Success -> { + _event.emit(ProfileEvent.Success) + } + else -> return@launch + } + } + } +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileViewModelFactoryProvider.kt b/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileViewModelFactoryProvider.kt new file mode 100644 index 00000000..24a4eb64 --- /dev/null +++ b/feature/profile/src/main/java/com/luckyoct/feature/profile/ProfileViewModelFactoryProvider.kt @@ -0,0 +1,12 @@ +package com.luckyoct.feature.profile + +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent + +@EntryPoint +@InstallIn(ActivityComponent::class) +interface ProfileViewModelFactoryProvider { + + fun profileViewModelFactory(): ProfileViewModel.Factory +} \ No newline at end of file diff --git a/feature/profile/src/main/java/com/luckyoct/feature/profile/model/CharacterListItem.kt b/feature/profile/src/main/java/com/luckyoct/feature/profile/model/CharacterListItem.kt new file mode 100644 index 00000000..824e9390 --- /dev/null +++ b/feature/profile/src/main/java/com/luckyoct/feature/profile/model/CharacterListItem.kt @@ -0,0 +1,12 @@ +package com.luckyoct.feature.profile.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.ui.graphics.Color + +data class CharacterListItem( + @DrawableRes val imageResId: Int, + @StringRes val nameResId: Int, + val isSelected: Boolean, + val backgroundColor: Color +) diff --git a/feature/profile/src/main/java/com/luckyoct/feature/profile/model/ProfileUiState.kt b/feature/profile/src/main/java/com/luckyoct/feature/profile/model/ProfileUiState.kt new file mode 100644 index 00000000..03f1b26a --- /dev/null +++ b/feature/profile/src/main/java/com/luckyoct/feature/profile/model/ProfileUiState.kt @@ -0,0 +1,8 @@ +package com.luckyoct.feature.profile.model + +sealed interface ProfileEvent { + + data object Loading : ProfileEvent + + data object Success : ProfileEvent +} \ No newline at end of file diff --git a/feature/profile/src/main/res/values/arrays.xml b/feature/profile/src/main/res/values/arrays.xml new file mode 100644 index 00000000..8e6024ca --- /dev/null +++ b/feature/profile/src/main/res/values/arrays.xml @@ -0,0 +1,29 @@ + + + + @drawable/img_rabbit_selected + @drawable/img_cat_selected + @drawable/img_dog_selected + @drawable/img_panda_selected + @drawable/img_bear_selected + @drawable/img_bird_selected + + + + @string/rabbit_name + @string/cat_name + @string/dog_name + @string/panda_name + @string/bear_name + @string/bird_name + + + + @color/rabbit_color + @color/cat_color + @color/dog_color + @color/panda_color + @color/bear_color + @color/bird_color + + \ No newline at end of file diff --git a/feature/profile/src/main/res/values/strings.xml b/feature/profile/src/main/res/values/strings.xml new file mode 100644 index 00000000..b420ebdb --- /dev/null +++ b/feature/profile/src/main/res/values/strings.xml @@ -0,0 +1,14 @@ + + + 프로필 만들기 + 닉네임 입력 + 1~6자, 한글, 영문 또는 숫자를 입력하세요. + 저장하기 + + 뚝심토끼 + 포기란없다냥 + 끝까지해볼개 + 하나만팬다 + 할건끝내곰 + 할때까지해뱁새 + \ No newline at end of file diff --git a/feature/profile/src/test/java/com/luckyoct/feature/profile/ExampleUnitTest.kt b/feature/profile/src/test/java/com/luckyoct/feature/profile/ExampleUnitTest.kt new file mode 100644 index 00000000..5dcebd32 --- /dev/null +++ b/feature/profile/src/test/java/com/luckyoct/feature/profile/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.luckyoct.feature.profile + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index a6c442f9..e887b188 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,3 +32,4 @@ include(":feature:main") include(":feature:board") include(":core:model") include(":feature:onboarding") +include(":feature:profile")