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/java/com/goalpanzi/mission_mate/core/designsystem/component/TextField.kt b/core/designsystem/src/main/java/com/goalpanzi/mission_mate/core/designsystem/component/TextField.kt
index 24df52cb..06dd8d4c 100644
--- a/core/designsystem/src/main/java/com/goalpanzi/mission_mate/core/designsystem/component/TextField.kt
+++ b/core/designsystem/src/main/java/com/goalpanzi/mission_mate/core/designsystem/component/TextField.kt
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -45,26 +47,32 @@ fun MissionMateTextField(
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
@StringRes hintId : Int? = null,
+ @StringRes guidanceId : Int? = null,
isError : Boolean = false,
+ useMaxLength : Boolean = false,
textStyle: TextStyle = MissionMateTypography.body_lg_regular,
hintStyle: TextStyle = MissionMateTypography.body_lg_regular,
textColor: Color = ColorGray1_FF404249,
hintColor: Color = ColorGray3_FF727484,
+ guidanceColor : Color = Color(0xFF4F505C),
+ errorColor : Color = Color(0xFFFF6464),
containerColor: Color = ColorWhite_FFFFFFFF,
unfocusedHintColor: Color = ColorGray5_80F5F6F9,
borderStroke: BorderStroke = BorderStroke(1.dp, ColorGray4_FFE5E5E5),
focusedBorderStroke: BorderStroke = BorderStroke(1.dp, ColorGray4_FFE5E5E5),
errorBorderStroke: BorderStroke = BorderStroke(2.dp, ColorRed_FFFF5858),
shape: Shape = RoundedCornerShape(12.dp),
+ maxLength : Int = Int.MAX_VALUE,
isSingleLine: Boolean = true,
visualTransformation: VisualTransformation = VisualTransformation.None,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
textAlign : Alignment = Alignment.CenterStart,
contentPadding : PaddingValues = PaddingValues(horizontal = 16.dp)
) {
var isFocused by remember { mutableStateOf(false) }
BasicTextField(
modifier = modifier
- .heightIn(min = 60.dp)
.onFocusChanged {
isFocused = it.isFocused
},
@@ -74,34 +82,48 @@ fun MissionMateTextField(
color = textColor
),
visualTransformation = visualTransformation,
+ keyboardActions = keyboardActions,
+ keyboardOptions = keyboardOptions,
onValueChange = onValueChange,
decorationBox = { innerTextField ->
- Box(
- modifier = Modifier
- .clip(shape)
- .border(
- border = if (isError) errorBorderStroke
- else if (isFocused) focusedBorderStroke
- else borderStroke,
- shape = shape
- )
- .background(
- if (!isFocused && text.isEmpty()) unfocusedHintColor
- else containerColor
- )
- .padding(contentPadding),
- contentAlignment = textAlign
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- if(text.isBlank()){
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 60.dp)
+ .clip(shape)
+ .border(
+ border = if (isError) errorBorderStroke
+ else if (isFocused) focusedBorderStroke
+ else borderStroke,
+ shape = shape
+ )
+ .background(
+ if (!isFocused && text.isEmpty()) unfocusedHintColor
+ else containerColor
+ )
+ .padding(contentPadding),
+ contentAlignment = textAlign
+ ) {
+ if(text.isBlank()){
+ Text(
+ text = hintId?.let { stringResource(id = it) } ?: "",
+ style = hintStyle,
+ color = hintColor
+ )
+ }
+ innerTextField()
+ }
+ if(guidanceId != null){
Text(
- text = hintId?.let { stringResource(id = it) } ?: "",
- style = hintStyle,
- color = hintColor
+ text = stringResource(id = guidanceId) + if(useMaxLength) "(${text.length}/$maxLength)" else "",
+ style = MissionMateTypography.body_md_regular,
+ color = if(isError) errorColor else guidanceColor
)
}
- innerTextField()
}
-
}
)
}
@@ -119,7 +141,9 @@ fun MissionMateTextFieldGroup(
isError : Boolean = false,
titleColor : Color = Color(0xFF4F505C),
guidanceColor : Color = Color(0xFF4F505C),
- errorColor : Color = Color(0xFFFF6464)
+ errorColor : Color = Color(0xFFFF6464),
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
){
Column(
modifier = modifier,
@@ -127,7 +151,6 @@ fun MissionMateTextFieldGroup(
) {
if(titleId != null){
Text(
- modifier = Modifier.padding(bottom = 4.dp),
text = stringResource(id = titleId),
style = MissionMateTypography.body_md_bold,
color = titleColor
@@ -138,15 +161,15 @@ fun MissionMateTextFieldGroup(
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
hintId = hintId,
- isError = isError
+ isError = isError,
+ useMaxLength = useMaxLength,
+ guidanceId = guidanceId,
+ maxLength = maxLength,
+ guidanceColor = guidanceColor,
+ errorColor = errorColor,
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions
)
- if(guidanceId != null){
- Text(
- text = stringResource(id = guidanceId) + if(useMaxLength) "(${text.length}/$maxLength)" else "",
- style = MissionMateTypography.body_md_regular,
- color = if(isError) errorColor else guidanceColor
- )
- }
}
}
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 6708b8bd..708d6765 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/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 83fa85a6..2f501796 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
@@ -13,6 +13,8 @@ import com.goalpanzi.mission_mate.feature.onboarding.boardSetupNavGraph
import com.goalpanzi.mission_mate.feature.onboarding.boardSetupSuccessNavGraph
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(
@@ -30,7 +32,7 @@ internal fun MainNavHost(
startDestination = navigator.startDestination
) {
loginNavGraph(
- onBackClick = { navigator.popBackStack() }
+ onLoginSuccess = { if (it) navigator.navigationToOnboarding() else navigator.navigateToProfileCreate() }
)
onboardingNavGraph(
onClickBoardSetup = { navigator.navigationToBoardSetup() },
@@ -55,6 +57,9 @@ internal fun MainNavHost(
navigator.popBackStack()
}
)
+ 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 8b24a29e..5cf696d3 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
@@ -10,6 +10,7 @@ import com.goalpanzi.mission_mate.feature.onboarding.navigateToBoardSetup
import com.goalpanzi.mission_mate.feature.onboarding.navigateToBoardSetupSuccess
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
@@ -26,6 +27,10 @@ class MainNavigator(
navController.navigateToLogin()
}
+ fun navigateToProfileCreate() {
+ navController.navigateToProfileCreate()
+ }
+
fun navigationToOnboarding() {
navController.navigateToOnboarding()
}
diff --git a/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/OnboardingScreen.kt b/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/OnboardingScreen.kt
index 7e2a2b85..c9b0b561 100644
--- a/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/OnboardingScreen.kt
+++ b/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/OnboardingScreen.kt
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.wrapContentHeight
@@ -65,7 +66,7 @@ fun OnboardingScreen(
contentScale = ContentScale.FillWidth
)
Column(
- modifier = modifier.statusBarsPadding(),
+ modifier = modifier.statusBarsPadding().navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
IconButton(
diff --git a/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/boardsetup/BoardSetupMission.kt b/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/boardsetup/BoardSetupMission.kt
index 10642630..e8770a80 100644
--- a/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/boardsetup/BoardSetupMission.kt
+++ b/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/boardsetup/BoardSetupMission.kt
@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -13,13 +15,15 @@ import com.goalpanzi.mission_mate.feature.onboarding.R
@Composable
fun BoardSetupMission(
- missionTitle : String,
- onTitleChange : (String) -> Unit,
+ missionTitle: String,
+ onTitleChange: (String) -> Unit,
modifier: Modifier = Modifier
-){
+) {
+ val scrollState = rememberScrollState()
Column(
modifier = modifier
.fillMaxSize()
+ .verticalScroll(scrollState)
.padding(horizontal = 24.dp)
) {
BoardSetupDescription(
diff --git a/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/boardsetup/BoardSetupScreen.kt b/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/boardsetup/BoardSetupScreen.kt
index 0b9ddc4e..b5cd31de 100644
--- a/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/boardsetup/BoardSetupScreen.kt
+++ b/feature/onboarding/src/main/java/com/goalpanzi/mission_mate/feature/onboarding/screen/boardsetup/BoardSetupScreen.kt
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.wrapContentHeight
@@ -145,7 +146,6 @@ fun BoardSetupRoute(
)
}
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BoardSetupScreen(
currentStep: BoardSetupStep,
@@ -172,6 +172,7 @@ fun BoardSetupScreen(
.fillMaxSize()
.background(ColorWhite_FFFFFFFF)
.statusBarsPadding()
+ .navigationBarsPadding()
.imePadding()
) {
BoardSetupNavigationBar(
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..2d33e144
--- /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")