diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 78ad2798..75d9c149 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.coil.compose) implementation(libs.foundation) + implementation(libs.androidx.datastore.preferences) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/java/com/texthip/thip/MainActivity.kt b/app/src/main/java/com/texthip/thip/MainActivity.kt index d527d5dd..f5da8ad4 100644 --- a/app/src/main/java/com/texthip/thip/MainActivity.kt +++ b/app/src/main/java/com/texthip/thip/MainActivity.kt @@ -9,13 +9,18 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import com.texthip.thip.data.manager.TokenManager import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var tokenManager: TokenManager + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() diff --git a/app/src/main/java/com/texthip/thip/data/manager/TokenManager.kt b/app/src/main/java/com/texthip/thip/data/manager/TokenManager.kt new file mode 100644 index 00000000..168bd8b0 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/manager/TokenManager.kt @@ -0,0 +1,64 @@ +package com.texthip.thip.data.manager + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + + +private val Context.dataStore: DataStore by preferencesDataStore(name = "thip_tokens") + +@Singleton +class TokenManager @Inject constructor( + @ApplicationContext private val context: Context +) { + // 저장할 데이터의 Key 정의 + companion object { + private val TEMP_TOKEN_KEY = stringPreferencesKey("temp_token") + private val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") + private val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token") + } + + // 임시 토큰 저장 + suspend fun saveTempToken(token: String) { + context.dataStore.edit { prefs -> + prefs[TEMP_TOKEN_KEY] = token + } + } + + // 임시 토큰 읽기 + suspend fun getTempToken(): String? { + return context.dataStore.data.map { prefs -> + prefs[TEMP_TOKEN_KEY] + }.first() // Flow에서 첫 번째 값을 한번만 읽어옴 + } + + // 정식 토큰들(Access, Refresh) 저장 + suspend fun saveAccessTokens(accessToken: String, refreshToken: String) { + context.dataStore.edit { prefs -> + prefs[ACCESS_TOKEN_KEY] = accessToken + prefs[REFRESH_TOKEN_KEY] = refreshToken + } + } + + // Access Token 읽기 (Flow로 제공하여 토큰 변화를 감지할 수 있게 함) + fun getAccessToken(): kotlinx.coroutines.flow.Flow { + return context.dataStore.data.map { prefs -> + prefs[ACCESS_TOKEN_KEY] + } + } + + // 모든 토큰 삭제 (로그아웃 시) + suspend fun clearTokens() { + context.dataStore.edit { prefs -> + prefs.clear() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/users/request/ProfileUpdateRequest.kt b/app/src/main/java/com/texthip/thip/data/model/users/request/ProfileUpdateRequest.kt new file mode 100644 index 00000000..28f62012 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/users/request/ProfileUpdateRequest.kt @@ -0,0 +1,9 @@ +package com.texthip.thip.data.model.users.request + +import kotlinx.serialization.Serializable + +@Serializable +data class ProfileUpdateRequest( + val nickname: String?, + val aliasName: String +) diff --git a/app/src/main/java/com/texthip/thip/data/model/users/request/SignupRequest.kt b/app/src/main/java/com/texthip/thip/data/model/users/request/SignupRequest.kt new file mode 100644 index 00000000..934acab0 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/users/request/SignupRequest.kt @@ -0,0 +1,9 @@ +package com.texthip.thip.data.model.users.request + +import kotlinx.serialization.Serializable + +@Serializable +data class SignupRequest( + val nickname: String, + val aliasName: String +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/users/response/MyFollowingsResponse.kt b/app/src/main/java/com/texthip/thip/data/model/users/response/MyFollowingsResponse.kt index aa96cb6e..eb9a3de8 100644 --- a/app/src/main/java/com/texthip/thip/data/model/users/response/MyFollowingsResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/users/response/MyFollowingsResponse.kt @@ -19,4 +19,16 @@ data class FollowingList( @SerializedName("aliasName") val aliasName: String, @SerializedName("aliasColor") val aliasColor: String, @SerializedName("isFollowing") val isFollowing: Boolean +) + +@Serializable +data class MyRecentFollowingsResponse( + @SerializedName("myFollowingUsers") val myFollowingUsers: List +) + +@Serializable +data class RecentWriterList( + @SerializedName("userId") val userId: Long, + @SerializedName("nickname") val nickname: String, + @SerializedName("profileImageUrl") val profileImageUrl: String? ) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/users/response/OthersFollowersResponse.kt b/app/src/main/java/com/texthip/thip/data/model/users/response/OthersFollowersResponse.kt index 73229f26..71728031 100644 --- a/app/src/main/java/com/texthip/thip/data/model/users/response/OthersFollowersResponse.kt +++ b/app/src/main/java/com/texthip/thip/data/model/users/response/OthersFollowersResponse.kt @@ -1,22 +1,22 @@ package com.texthip.thip.data.model.users.response +import com.google.gson.annotations.SerializedName import kotlinx.serialization.Serializable @Serializable data class OthersFollowersResponse( - val followers: List, - val totalFollowerCount: Int, - val nextCursor: String?, - val isLast: Boolean + @SerializedName("followers") val followers: List, + @SerializedName("totalFollowerCount") val totalFollowerCount: Int, + @SerializedName("nextCursor") val nextCursor: String?, + @SerializedName("isLast") val isLast: Boolean ) @Serializable data class FollowerList( - val userId: Long, - val nickname: String, - val profileImageUrl: String?, - val aliasName: String, - val aliasColor: String, - val followerCount: Int, -) - + @SerializedName("userId") val userId: Long, + @SerializedName("nickname") val nickname: String, + @SerializedName("profileImageUrl") val profileImageUrl: String?, + @SerializedName("aliasName") val aliasName: String, + @SerializedName("aliasColor") val aliasColor: String, + @SerializedName("followerCount") val followerCount: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/users/response/SignupResponse.kt b/app/src/main/java/com/texthip/thip/data/model/users/response/SignupResponse.kt new file mode 100644 index 00000000..9d0c63d6 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/users/response/SignupResponse.kt @@ -0,0 +1,10 @@ +package com.texthip.thip.data.model.users.response + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class SignupResponse( + @SerializedName("accessToken") val accessToken: String, + @SerializedName("userId") val userId: Long +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/model/users/response/UserSearchResponse.kt b/app/src/main/java/com/texthip/thip/data/model/users/response/UserSearchResponse.kt new file mode 100644 index 00000000..9267f7d7 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/data/model/users/response/UserSearchResponse.kt @@ -0,0 +1,18 @@ +package com.texthip.thip.data.model.users.response + +import com.google.gson.annotations.SerializedName +import kotlinx.serialization.Serializable + +@Serializable +data class UserSearchResponse( + val userList: List +) +@Serializable +data class UserItem( + @SerializedName("userId") val userId: Int, + @SerializedName("nickname") val nickname: String, + @SerializedName("profileImageUrl") val profileImageUrl: String?, + @SerializedName("aliasName") val aliasName: String, + @SerializedName("aliasColor") val aliasColor: String, + @SerializedName("followerCount") val followerCount: Int +) \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt b/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt index 425c76c1..922d96af 100644 --- a/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt +++ b/app/src/main/java/com/texthip/thip/data/repository/UserRepository.kt @@ -1,21 +1,27 @@ package com.texthip.thip.data.repository +import com.texthip.thip.data.manager.TokenManager import com.texthip.thip.data.model.base.handleBaseResponse import com.texthip.thip.data.model.users.request.FollowRequest import com.texthip.thip.data.model.users.request.NicknameRequest +import com.texthip.thip.data.model.users.request.ProfileUpdateRequest +import com.texthip.thip.data.model.users.request.SignupRequest import com.texthip.thip.data.model.users.response.AliasChoiceResponse import com.texthip.thip.data.model.users.response.FollowResponse import com.texthip.thip.data.model.users.response.MyFollowingsResponse import com.texthip.thip.data.model.users.response.MyPageInfoResponse import com.texthip.thip.data.model.users.response.NicknameResponse import com.texthip.thip.data.model.users.response.OthersFollowersResponse +import com.texthip.thip.data.model.users.response.SignupResponse +import com.texthip.thip.data.model.users.response.UserSearchResponse import com.texthip.thip.data.service.UserService import javax.inject.Inject import javax.inject.Singleton @Singleton class UserRepository @Inject constructor( - private val userService: UserService + private val userService: UserService, + private val tokenManager: TokenManager ) { //내 팔로잉 목록 조회 suspend fun getMyFollowings( @@ -72,4 +78,32 @@ class UserRepository @Inject constructor( .handleBaseResponse() .getOrThrow() } + + suspend fun updateProfile(request: ProfileUpdateRequest): Result = runCatching { + userService.updateProfile(request) + .handleBaseResponse() + .getOrThrow() + } + + suspend fun signup(request: SignupRequest): Result { + val tempToken = tokenManager.getTempToken() + + if (tempToken.isNullOrBlank()) { + return Result.failure(Exception("임시 토큰이 없습니다. 로그인을 다시 시도해주세요.")) + } + return runCatching { + userService.signup("Bearer $tempToken", request) + .handleBaseResponse() + .getOrThrow() + } + } + + suspend fun searchUsers( + keyword: String, + isFinalized: Boolean + ): Result = runCatching { + userService.searchUsers(isFinalized = isFinalized, keyword = keyword) + .handleBaseResponse() + .getOrThrow() + } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/data/service/UserService.kt b/app/src/main/java/com/texthip/thip/data/service/UserService.kt index 36f261b7..a8149f6c 100644 --- a/app/src/main/java/com/texthip/thip/data/service/UserService.kt +++ b/app/src/main/java/com/texthip/thip/data/service/UserService.kt @@ -3,15 +3,21 @@ package com.texthip.thip.data.service import com.texthip.thip.data.model.base.BaseResponse import com.texthip.thip.data.model.users.request.FollowRequest import com.texthip.thip.data.model.users.request.NicknameRequest +import com.texthip.thip.data.model.users.request.ProfileUpdateRequest +import com.texthip.thip.data.model.users.request.SignupRequest import com.texthip.thip.data.model.users.response.AliasChoiceResponse import com.texthip.thip.data.model.users.response.FollowResponse import com.texthip.thip.data.model.users.response.MyFollowingsResponse import com.texthip.thip.data.model.users.response.MyPageInfoResponse import com.texthip.thip.data.model.users.response.NicknameResponse import com.texthip.thip.data.model.users.response.OthersFollowersResponse +import com.texthip.thip.data.model.users.response.SignupResponse +import com.texthip.thip.data.model.users.response.UserSearchResponse import com.texthip.thip.data.model.users.response.UsersMyFollowingsRecentFeedsResponse import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query @@ -49,4 +55,23 @@ interface UserService { @Path("followingUserId") followingUserId: Long, @Body request: FollowRequest ): BaseResponse + + @PATCH("users") + suspend fun updateProfile( + @Body request: ProfileUpdateRequest + ): BaseResponse + + @POST("users/signup") + suspend fun signup( + @Header("Authorization") tempToken: String, + @Body request: SignupRequest + ): BaseResponse + + @GET("users") + suspend fun searchUsers( + @Query("isFinalized") isFinalized: Boolean, + @Query("keyword") keyword: String, + @Query("size") size: Int = 30 + ): BaseResponse + } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/mock/MySubscriptionData.kt b/app/src/main/java/com/texthip/thip/ui/feed/mock/MySubscriptionData.kt index 0b222adb..51c565b9 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/mock/MySubscriptionData.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/mock/MySubscriptionData.kt @@ -2,6 +2,8 @@ package com.texthip.thip.ui.feed.mock import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import com.texthip.thip.data.model.users.response.UserItem +import com.texthip.thip.utils.color.hexToColor data class MySubscriptionData( val profileImageUrl: String? = null, @@ -11,3 +13,14 @@ data class MySubscriptionData( val subscriberCount: Int = 0, var isSubscribed: Boolean = true ) + +fun UserItem.toMySubscriptionData(): MySubscriptionData { + return MySubscriptionData( + profileImageUrl = this.profileImageUrl, + nickname = this.nickname, + role = this.aliasName, + roleColor = hexToColor(this.aliasColor), + subscriberCount = this.followerCount, + isSubscribed = false // API 응답에 구독 여부 정보가 없음 + ) +} diff --git a/app/src/main/java/com/texthip/thip/ui/feed/screen/SearchPeopleScreen.kt b/app/src/main/java/com/texthip/thip/ui/feed/screen/SearchPeopleScreen.kt index 17a011f3..8a6d09dd 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/screen/SearchPeopleScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/screen/SearchPeopleScreen.kt @@ -1,7 +1,6 @@ package com.texthip.thip.ui.feed.screen import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,20 +11,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.texthip.thip.R import com.texthip.thip.ui.common.forms.SearchBookTextField import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar @@ -33,163 +27,191 @@ import com.texthip.thip.ui.feed.component.PeopleRecentSearch import com.texthip.thip.ui.feed.component.SearchPeopleEmptyResult import com.texthip.thip.ui.feed.component.SearchPeopleResult import com.texthip.thip.ui.feed.mock.MySubscriptionData +import com.texthip.thip.ui.feed.viewmodel.SearchPeopleUiState +import com.texthip.thip.ui.feed.viewmodel.SearchPeopleViewModel import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun SearchPeopleScreen( - modifier: Modifier = Modifier, - allPeople: List + viewModel: SearchPeopleViewModel = hiltViewModel() ) { - var recentSearches by rememberSaveable { - mutableStateOf(listOf("메롱", "메메롱", "메메메롱", "메메메", "메메루메루메루")) - } - var searchText by rememberSaveable { mutableStateOf("") } - var isSearched by rememberSaveable { mutableStateOf(false) } - val focusRequester = remember { FocusRequester() } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val focusManager = LocalFocusManager.current - val liveSearchResults by remember(searchText, allPeople) { - derivedStateOf { - if (searchText.isBlank()) emptyList() - else allPeople.filter { person -> - person.nickname.contains(searchText, ignoreCase = true) - } + LaunchedEffect(uiState.isSearched) { + if (uiState.isSearched) { + focusManager.clearFocus() } } - val finalSearchResults by remember(searchText, isSearched, allPeople) { - derivedStateOf { - if (isSearched) { - if (searchText.isNotBlank()) { - allPeople.filter { person -> - person.nickname.contains(searchText, ignoreCase = true) - } - } else { - emptyList() - } - } else { - emptyList() - } - } - } + SearchPeopleContent( + uiState = uiState, + onSearchTextChanged = viewModel::onSearchTextChanged, + onFinalSearch = viewModel::onFinalSearch, + onRecentSearchClick = { keyword -> viewModel.onFinalSearch(keyword) }, + onRecentSearchRemove = viewModel::removeRecentSearch + ) +} - LaunchedEffect(isSearched) { - if (isSearched) { - focusManager.clearFocus() - } - } +@Composable +fun SearchPeopleContent( + uiState: SearchPeopleUiState, + onSearchTextChanged: (String) -> Unit, + onFinalSearch: (String) -> Unit, + onRecentSearchClick: (String) -> Unit, + onRecentSearchRemove: (String) -> Unit + +) { - Box( - modifier = modifier.fillMaxSize() + Column( + modifier = Modifier.fillMaxSize() ) { - Column( - modifier = Modifier.fillMaxSize() - ) { - DefaultTopAppBar( - title = stringResource(R.string.search_user), - onLeftClick = {}, - ) - Column( - modifier = Modifier - .fillMaxSize() - ) { - Spacer(modifier = Modifier.height(16.dp)) - - SearchBookTextField( + DefaultTopAppBar( + title = stringResource(R.string.search_user), + onLeftClick = {}, + ) + Spacer(modifier = Modifier.height(16.dp)) + + SearchBookTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + hint = stringResource(R.string.search_user_you_look_for), + text = uiState.searchText, + onValueChange = onSearchTextChanged, + onSearch = onFinalSearch + ) + Spacer(modifier = Modifier.height(16.dp)) + + when { + uiState.isSearched && uiState.searchResults.isNotEmpty() -> { //검색했는데 결과 있음 + Row( modifier = Modifier .fillMaxWidth() - .focusRequester(focusRequester) .padding(horizontal = 20.dp), - hint = stringResource(R.string.search_user_you_look_for), - text = searchText, - onValueChange = { - searchText = it - isSearched = false - }, - onSearch = { query -> - if (query.isNotBlank() && !recentSearches.contains(query)) { - recentSearches = (listOf(query) + recentSearches).take(10) - } - isSearched = true - } - ) - Spacer(modifier = Modifier.height(16.dp)) - - when { - isSearched && finalSearchResults.isNotEmpty() -> { //검색했는데 결과 있음 - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.group_searched_room_size, finalSearchResults.size), - color = colors.Grey, - style = typography.menu_m500_s14_h24 - ) - } - Spacer( - modifier = Modifier - .padding(top = 4.dp, bottom = 16.dp) - .padding(horizontal = 20.dp) - .fillMaxWidth() - .height(1.dp) - .background(colors.DarkGrey02) - ) - SearchPeopleResult( - modifier = Modifier.weight(1f), - peopleList = finalSearchResults, - onThipNumClick = { person -> /*프로필로 이동*/ } - ) - } - isSearched && finalSearchResults.isEmpty() -> { //검색했는데 결과 없음 - SearchPeopleEmptyResult( - modifier = Modifier.padding(horizontal = 20.dp), - mainText = stringResource(R.string.no_user_you_look_for) - ) - } - searchText.isNotBlank() && !isSearched -> { //검색중 - SearchPeopleResult( - modifier = Modifier.weight(1f), - peopleList = liveSearchResults, - onThipNumClick = { person -> /* 프로필 화면으로 이동 */ } - ) - } - searchText.isBlank() && !isSearched -> { //최근검색어 보여주기 - PeopleRecentSearch( - modifier = Modifier.padding(horizontal = 20.dp), - recentSearches = recentSearches, - onSearchClick = { keyword -> - searchText = keyword - isSearched = true - }, - onRemove = { keyword -> - recentSearches = recentSearches.filterNot { it == keyword } - } - ) - } + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource( + R.string.group_searched_room_size, + uiState.searchResults.size + ), + color = colors.Grey, + style = typography.menu_m500_s14_h24 + ) } + Spacer( + modifier = Modifier + .padding(top = 4.dp, bottom = 16.dp) + .padding(horizontal = 20.dp) + .fillMaxWidth() + .height(1.dp) + .background(colors.DarkGrey02) + ) + SearchPeopleResult(peopleList = uiState.searchResults) + } + + uiState.isSearched && uiState.searchResults.isEmpty() -> { //검색했는데 결과 없음 + SearchPeopleEmptyResult( + modifier = Modifier.padding(horizontal = 20.dp), + mainText = stringResource(R.string.no_user_you_look_for) + ) + } + + uiState.searchText.isNotBlank() && !uiState.isSearched -> { //검색중 + SearchPeopleResult(peopleList = uiState.searchResults) + } + + else -> { //최근검색어 보여주기 + PeopleRecentSearch( + modifier = Modifier.padding(horizontal = 20.dp), + recentSearches = uiState.recentSearches, + onSearchClick = onRecentSearchClick, + onRemove = onRecentSearchRemove + ) } } + + + } +} + + +@Preview +@Composable +private fun SearchPeopleContentPreview_Recent() { + ThipTheme { + SearchPeopleContent( + uiState = SearchPeopleUiState( + recentSearches = listOf("메롱", "메메롱", "메메메롱") + ), + onSearchTextChanged = {}, + onFinalSearch = {}, + onRecentSearchClick = {}, + onRecentSearchRemove = {} + ) } } +@Preview +@Composable +private fun SearchPeopleContentPreview_Typing() { + val dummyResults = listOf( + MySubscriptionData(null, "메롱이", "인플루언서", colors.NeonGreen, 12, false), + MySubscriptionData(null, "메메롱이", "칭호", colors.NeonGreen, 1, false), + ) + ThipTheme { + SearchPeopleContent( + uiState = SearchPeopleUiState( + searchText = "메롱", + searchResults = dummyResults + ), + onSearchTextChanged = {}, + onFinalSearch = {}, + onRecentSearchClick = {}, + onRecentSearchRemove = {} + ) + } +} + +@Preview +@Composable +private fun SearchPeopleContentPreview_Result() { + val dummyResults = listOf( + MySubscriptionData(null, "Thip_Official", "인플루언서", colors.NeonGreen, 111, false), + MySubscriptionData(null, "thip01", "작가", colors.NeonGreen, 0, false) + ) + ThipTheme { + SearchPeopleContent( + uiState = SearchPeopleUiState( + searchText = "thip", + isSearched = true, + searchResults = dummyResults + ), + onSearchTextChanged = {}, + onFinalSearch = {}, + onRecentSearchClick = {}, + onRecentSearchRemove = {} + ) + } +} @Preview @Composable -fun PreviewGroupSearchScreen() { +private fun SearchPeopleContentPreview_Empty() { ThipTheme { - SearchPeopleScreen( - allPeople = listOf( - MySubscriptionData(null, "메롱이", "인플루언서", colors.NeonGreen, 12, false), - MySubscriptionData(null, "메메롱이", "칭호", colors.NeonGreen, 1, false), - MySubscriptionData(null, "thip", "칭호칭호", colors.NeonGreen, 11, false), - MySubscriptionData(null, "Thip", "인플루언서", colors.NeonGreen, 111, false), - MySubscriptionData(null, "thip01", "작가", colors.NeonGreen, 0, false) - ) + SearchPeopleContent( + uiState = SearchPeopleUiState( + searchText = "없는사용자", + isSearched = true, + searchResults = emptyList() + ), + onSearchTextChanged = {}, + onFinalSearch = {}, + onRecentSearchClick = {}, + onRecentSearchRemove = {} ) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt index 40298576..3994e22a 100644 --- a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/FeedViewModel.kt @@ -51,6 +51,7 @@ class FeedViewModel @Inject constructor( private val _uiState = MutableStateFlow(FeedUiState()) val uiState = _uiState.asStateFlow() + fun fetchRecentWriters() { private var allFeedsNextCursor: String? = null private var myFeedsNextCursor: String? = null private var isLoadingAllFeeds = false diff --git a/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/SearchPeopleViewModel.kt b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/SearchPeopleViewModel.kt new file mode 100644 index 00000000..eac8a8ac --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/feed/viewmodel/SearchPeopleViewModel.kt @@ -0,0 +1,99 @@ +package com.texthip.thip.ui.feed.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.texthip.thip.data.repository.UserRepository +import com.texthip.thip.ui.feed.mock.MySubscriptionData +import com.texthip.thip.ui.feed.mock.toMySubscriptionData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.onSuccess + +data class SearchPeopleUiState( + val searchText: String = "", + val isSearched: Boolean = false, // 최종 검색 완료 여부 + val searchResults: List = emptyList(), + val recentSearches: List = listOf("메롱", "메메롱"), // TODO: 실제 최근 검색어 로딩 + val isLoading: Boolean = false, + val errorMessage: String? = null +) + +@HiltViewModel +class SearchPeopleViewModel @Inject constructor( + private val userRepository: UserRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(SearchPeopleUiState()) + val uiState = _uiState.asStateFlow() + + private var searchJob: Job? = null + + // 사용자가 텍스트를 입력할 때 호출 + fun onSearchTextChanged(text: String) { + _uiState.update { it.copy(searchText = text, isSearched = false) } + + // 이전 검색 요청이 있다면 취소 + searchJob?.cancel() + + if (text.isNotBlank()) { + searchJob = viewModelScope.launch { + delay(500L) + searchUsers(keyword = text, isFinalized = false) + } + } else { + // 입력창이 비워지면 검색 결과도 비움 + _uiState.update { it.copy(searchResults = emptyList()) } + } + } + + // 키보드의 '검색' 버튼이나 아이콘을 눌렀을 때 호출 + fun onFinalSearch(query: String) { + searchJob?.cancel() + _uiState.update { it.copy(isSearched = true) } + + if (query.isNotBlank()) { + addRecentSearch(query) + searchUsers(keyword = query, isFinalized = true) + } else { + _uiState.update { it.copy(searchResults = emptyList()) } + } + } + + // 실제 API를 호출하는 private 함수 + private fun searchUsers(keyword: String, isFinalized: Boolean) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + userRepository.searchUsers(keyword, isFinalized) + .onSuccess { response -> + val userList = + response?.userList?.map { it.toMySubscriptionData() } ?: emptyList() + _uiState.update { it.copy(isLoading = false, searchResults = userList) } + } + .onFailure { exception -> + _uiState.update { it.copy(isLoading = false, errorMessage = exception.message) } + } + } + } + + // 최근 검색어 관련 로직 + fun addRecentSearch(keyword: String) { + _uiState.update { currentState -> + val updatedSearches = (listOf(keyword) + currentState.recentSearches) + .distinct().take(10) + currentState.copy(recentSearches = updatedSearches) + } + } + + fun removeRecentSearch(keyword: String) { + _uiState.update { currentState -> + val updatedSearches = currentState.recentSearches.filterNot { it == keyword } + currentState.copy(recentSearches = updatedSearches) + } + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageCustomerServiceScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageCustomerServiceScreen.kt new file mode 100644 index 00000000..e01ada44 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageCustomerServiceScreen.kt @@ -0,0 +1,71 @@ +package com.texthip.thip.ui.mypage.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.texthip.thip.R +import com.texthip.thip.ui.common.topappbar.DefaultTopAppBar +import com.texthip.thip.ui.theme.ThipTheme +import com.texthip.thip.ui.theme.ThipTheme.colors +import com.texthip.thip.ui.theme.ThipTheme.typography + +@Composable +fun MypageCustomerServiceScreen( + modifier: Modifier = Modifier, + onNavigateBack: () -> Unit = {} +) { + Column( + modifier = modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally + ) { + DefaultTopAppBar( + title = stringResource(R.string.customer_service), + onLeftClick = onNavigateBack, + ) + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.customer_center_email), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White + ) + Spacer(modifier = Modifier.padding(top = 8.dp)) + + Text( + text = stringResource(R.string.customer_center_description), + style = typography.copy_r400_s14, + color = colors.White + ) + Text( + text = stringResource(R.string.customer_center_description_2), + style = typography.copy_r400_s14, + color = colors.White + ) + } + } +} + +@Preview +@Composable +private fun MypageCustomerServiceScreenPrev() { + ThipTheme { + MypageCustomerServiceScreen( + onNavigateBack = {} + ) + } +} diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageEditScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageEditScreen.kt index 97e661a4..140c7ba2 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageEditScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageEditScreen.kt @@ -1,7 +1,13 @@ package com.texthip.thip.ui.mypage.screen +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -13,148 +19,195 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed 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.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.texthip.thip.R -import com.texthip.thip.ui.common.forms.FormTextFieldDefault +import com.texthip.thip.ui.common.forms.WarningTextField +import com.texthip.thip.ui.common.modal.ToastWithDate import com.texthip.thip.ui.common.topappbar.InputTopAppBar import com.texthip.thip.ui.mypage.component.RoleCard import com.texthip.thip.ui.mypage.mock.RoleItem +import com.texthip.thip.ui.mypage.viewmodel.EditProfileUiState +import com.texthip.thip.ui.mypage.viewmodel.EditProfileViewModel import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography +import kotlinx.coroutines.delay @Composable -fun EditProfileScreen() { - var selectedIndex by rememberSaveable { mutableStateOf(-1) } - val roleCards = listOf( - RoleItem( - stringResource(R.string.literature), - stringResource(R.string.literary_person), - "https://photos/1111", - "#FF6B6B" - ), - RoleItem( - stringResource(R.string.science_it), - stringResource(R.string.scientist), - "https://photos/1111", - "#FF6B6B" - ), - RoleItem( - stringResource(R.string.social_science), - stringResource(R.string.sociologist), - "https://photos/1111", - "#FF6B6B" - ), - RoleItem( - stringResource(R.string.art), - stringResource(R.string.artist), - "https://photos/1111", - "#FF6B6B" - ), - RoleItem( - stringResource(R.string.humanities), - stringResource(R.string.philosopher), - "https://photos/1111", - "#FF6B6B" - ) +fun EditProfileScreen( + onNavigateBack: () -> Unit, + viewModel: EditProfileViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(uiState.isSaveSuccess) { + if (uiState.isSaveSuccess) { + delay(2500L) + onNavigateBack() + viewModel.onSaveComplete() + } + } + + + EditProfileContent( + uiState = uiState, + onNicknameChange = viewModel::onNicknameChange, + onCardSelected = viewModel::selectCard, + onSaveClick = viewModel::saveProfile, + onNavigateBack = onNavigateBack ) - Column( +} + + +@Composable +fun EditProfileContent( + uiState: EditProfileUiState, + onNicknameChange: (String) -> Unit, + onCardSelected: (Int) -> Unit, + onSaveClick: () -> Unit, + onNavigateBack: () -> Unit +) { + val isChanged = + uiState.nickname != uiState.initialNickname || uiState.selectedIndex != uiState.initialSelectedIndex + val isRightButtonEnabled = isChanged && uiState.nickname.isNotBlank() && !uiState.isLoading + + Box( Modifier .fillMaxSize() ) { - InputTopAppBar( - title = stringResource(R.string.edit_profile), - isRightButtonEnabled = true, - onLeftClick = {}, - onRightClick = {} - ) Column( - modifier = Modifier - .padding(horizontal = 20.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + Modifier + .fillMaxSize() ) { - Spacer(modifier = Modifier.height(40.dp)) - Text( - text = stringResource(R.string.change_nickname), - style = typography.smalltitle_sb600_s18_h24, - color = colors.White, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp) + InputTopAppBar( + title = stringResource(R.string.edit_profile), + isRightButtonEnabled = isRightButtonEnabled, + onLeftClick = onNavigateBack, + onRightClick = onSaveClick ) - //TODO 컴포넌트 수정 필요 -> text count 추가, boolean 값으로 icon, limit 설정가능하도록 - FormTextFieldDefault( - modifier = Modifier.fillMaxWidth(), - showLimit = true, - limit = 10, - showIcon = false, - containerColor = colors.DarkGrey02, - hint = stringResource(R.string.change_nickname) - ) - Spacer(modifier = Modifier.height(40.dp)) - Text( - text = stringResource(R.string.edit_role), - style = typography.smalltitle_sb600_s18_h24, - color = colors.White, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - ) - Text( - text = stringResource(R.string.role_description), - style = typography.copy_r400_s14, - color = colors.White, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp) - ) - Text( - text = stringResource(R.string.choice_one), - style = typography.info_r400_s12, - color = colors.NeonGreen, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp) - ) - - - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 152.dp), // 카드 최소 크기 + Column( modifier = Modifier + .padding(horizontal = 20.dp) .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - userScrollEnabled = false, + horizontalAlignment = Alignment.CenterHorizontally ) { - itemsIndexed(roleCards) { index, roleItem -> - RoleCard( - genre = roleItem.genre, - role = roleItem.role, - imageUrl = roleItem.imageUrl, - roleColor = roleItem.roleColor, - selected = selectedIndex == index, - onClick = { selectedIndex = index } - ) + Spacer(modifier = Modifier.height(40.dp)) + Text( + text = stringResource(R.string.change_nickname), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) + WarningTextField( + containerColor = colors.DarkGrey02, + value = uiState.nickname, + onValueChange = onNicknameChange, + //hint = stringResource(R.string.nickname_condition), + hint = uiState.initialNickname.takeIf { it.isNotBlank() } ?: stringResource(R.string.nickname_condition), + showWarning = uiState.nicknameWarningMessageResId != null, + showIcon = false, + showLimit = true, + maxLength = 10, + warningMessage = uiState.nicknameWarningMessageResId?.let { stringResource(it) } + ?: "" + ) + Spacer(modifier = Modifier.height(40.dp)) + Text( + text = stringResource(R.string.edit_role), + style = typography.smalltitle_sb600_s18_h24, + color = colors.White, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + Text( + text = stringResource(R.string.role_description), + style = typography.copy_r400_s14, + color = colors.White, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 20.dp) + ) + + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 152.dp), // 카드 최소 크기 + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + userScrollEnabled = false, + ) { + itemsIndexed(uiState.roleCards) { index, roleItem -> + RoleCard( + genre = roleItem.genre, + role = roleItem.role, + imageUrl = roleItem.imageUrl, + roleColor = roleItem.roleColor, + selected = uiState.selectedIndex == index, + onClick = { onCardSelected(index) } + ) + } } - } + } + } + AnimatedVisibility( + visible = uiState.isSaveSuccess, + enter = slideInVertically( + initialOffsetY = { -it }, + animationSpec = tween(durationMillis = 2000) + ), + exit = slideOutVertically( + targetOffsetY = { -it }, + animationSpec = tween(durationMillis = 2000) + ), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(horizontal = 20.dp, vertical = 16.dp) + .zIndex(3f) + ) { + ToastWithDate( + message = stringResource(R.string.profile_edit_completely) + ) } } } + @Preview @Composable private fun EditProfileScreenPrev() { + val previewRoleCards = listOf( + RoleItem("문학", "문학가", "", "#FFFFFF"), + RoleItem("과학/IT", "과학자", "", "#FFFFFF") + ) + val previewUiState = EditProfileUiState( + isLoading = false, + roleCards = previewRoleCards, + selectedIndex = 0, + nickname = "기존닉네임" + ) ThipTheme { - EditProfileScreen() + EditProfileContent( + uiState = previewUiState, + onNicknameChange = {}, + onCardSelected = {}, + onSaveClick = {}, + onNavigateBack = {} + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageLeavethipScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageLeavethipScreen.kt index 31b6f7bb..7b6d8172 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageLeavethipScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageLeavethipScreen.kt @@ -39,7 +39,9 @@ import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @Composable -fun DeleteAccountScreen() { +fun DeleteAccountScreen( + onNavigateBack: () -> Unit +) { var isChecked by rememberSaveable { mutableStateOf(false) } val backgroundColor = if (isChecked) colors.Purple else colors.Grey02 var isDialogVisible by rememberSaveable { mutableStateOf(false) } @@ -51,7 +53,7 @@ fun DeleteAccountScreen() { ) { DefaultTopAppBar( title = stringResource(R.string.delete_account), - onLeftClick = {}, + onLeftClick = onNavigateBack, ) Spacer(modifier = Modifier.height(40.dp)) Column( @@ -169,5 +171,7 @@ fun DeleteAccountScreen() { @Preview @Composable private fun DeleteAccountScreenPrev() { - DeleteAccountScreen() + DeleteAccountScreen( + onNavigateBack = {} + ) } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt index af5b4fe8..137f8cb6 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageNotificationEditScreen.kt @@ -32,7 +32,9 @@ import com.texthip.thip.ui.theme.ThipTheme.typography import kotlinx.coroutines.delay @Composable -fun NotificationScreen() { +fun NotificationScreen( + onNavigateBack: () -> Unit +) { var isChecked by rememberSaveable { mutableStateOf(true) } var toastMessage by rememberSaveable { mutableStateOf(null) } @@ -68,7 +70,7 @@ fun NotificationScreen() { ) { DefaultTopAppBar( title = stringResource(R.string.notification_settings), - onLeftClick = {}, + onLeftClick = onNavigateBack, ) Spacer(modifier = Modifier.height(40.dp)) Column( @@ -114,5 +116,7 @@ fun NotificationScreen() { @Preview @Composable private fun NotificationScreenPrev() { - NotificationScreen() + NotificationScreen( + onNavigateBack = {} + ) } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt index 8885b79d..e3b64629 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageSaveScreen.kt @@ -45,6 +45,7 @@ import com.texthip.thip.ui.theme.White @Composable fun SavedScreen( + onNavigateBack: () -> Unit, feedViewModel: SavedFeedViewModel = viewModel(), bookViewModel: SavedBookViewModel = viewModel() @@ -61,7 +62,7 @@ fun SavedScreen( ) { DefaultTopAppBar( title = stringResource(R.string.saved), - onLeftClick = {}, + onLeftClick = onNavigateBack, ) Column( modifier = Modifier @@ -124,6 +125,7 @@ fun SavedScreen( @Composable private fun SavedScreenPrev() { SavedScreen( + onNavigateBack = {}, feedViewModel = SavedFeedViewModel(), bookViewModel = SavedBookViewModel() ) @@ -135,6 +137,7 @@ private fun SavedScreenPrev() { private fun SavedScreenWithoutFeedPrev() { ThipTheme { SavedScreen( + onNavigateBack = {}, feedViewModel = EmptySavedFeedViewModel(), bookViewModel = EmptySavedBookViewModel() ) diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageScreen.kt b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageScreen.kt index fb152afc..c214883c 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/screen/MypageScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -51,16 +52,20 @@ fun MyPageScreen( viewModel: MyPageViewModel = hiltViewModel(), onNavigateToEditProfile: () -> Unit, onNavigateToSavedFeeds: () -> Unit, + onCustomerService: () -> Unit, onNavigateToNotificationSettings: () -> Unit, onDeleteAccount: () -> Unit ) { val uiState by viewModel.uiState.collectAsState() - + LaunchedEffect(Unit) { + viewModel.fetchMyPageInfo() + } MyPageContent( uiState = uiState, onEditProfileClick = onNavigateToEditProfile, onSavedFeedsClick = onNavigateToSavedFeeds, onNotificationSettingsClick = onNavigateToNotificationSettings, + onCustomerServiceClick = onCustomerService, onLogoutClick = { viewModel.onLogoutClick() }, onDismissLogoutDialog = { viewModel.onDismissLogoutDialog() }, onConfirmLogout = { viewModel.confirmLogout() }, @@ -73,6 +78,7 @@ fun MyPageContent( onEditProfileClick: () -> Unit, onSavedFeedsClick: () -> Unit, onNotificationSettingsClick: () -> Unit, + onCustomerServiceClick: () -> Unit, onLogoutClick: () -> Unit, onDismissLogoutDialog: () -> Unit, onConfirmLogout: () -> Unit, @@ -169,6 +175,7 @@ fun MyPageContent( hasRightIcon = true, modifier = Modifier.fillMaxWidth(), onClick = { + onCustomerServiceClick val intent = Intent(Intent.ACTION_VIEW, URL_CUSTOMER_SERVICE.toUri()) context.startActivity(intent) @@ -284,6 +291,7 @@ private fun MyPagePrev() { onEditProfileClick = {}, onSavedFeedsClick = {}, onNotificationSettingsClick = {}, + onCustomerServiceClick = {}, onDismissLogoutDialog = {}, onConfirmLogout = {}, onDeleteAccount = {} diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/MyPageEditViewModel.kt b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/MyPageEditViewModel.kt new file mode 100644 index 00000000..7cb0777e --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/MyPageEditViewModel.kt @@ -0,0 +1,145 @@ +package com.texthip.thip.ui.mypage.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.texthip.thip.R +import com.texthip.thip.data.model.base.ThipApiFailureException +import com.texthip.thip.data.model.users.request.ProfileUpdateRequest +import com.texthip.thip.data.repository.UserRepository +import com.texthip.thip.ui.mypage.mock.RoleItem +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class EditProfileUiState( + val isLoading: Boolean = true, + val nickname: String = "", + val roleCards: List = emptyList(), + val selectedIndex: Int = -1, + val initialNickname: String = "", + val initialSelectedIndex: Int = -1, + val nicknameWarningMessageResId: Int? = null, + val errorMessage: String? = null, + val isSaveSuccess: Boolean = false +) + +@HiltViewModel +class EditProfileViewModel @Inject constructor( + private val userRepository: UserRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(EditProfileUiState()) + val uiState = _uiState.asStateFlow() + + init { + loadInitialProfile() + } + + private fun loadInitialProfile() { + viewModelScope.launch { + val profileDeferred = async { userRepository.getMyPageInfo() } + val aliasDeferred = async { userRepository.getAliasChoices() } + + val profileResult = profileDeferred.await() + val aliasResult = aliasDeferred.await() + + if (profileResult.isSuccess && aliasResult.isSuccess) { + val profileData = profileResult.getOrNull() + val aliasResponse = aliasResult.getOrNull() + + val roleCards = aliasResponse?.aliasChoices?.map { + RoleItem(it.aliasName, it.categoryName, it.imageUrl, it.aliasColor) + } ?: emptyList() + + // 서버에서 받아온 현재 닉네임과 역할을 설정 + val currentNickname = profileData?.nickname ?: "" + val currentAliasName = profileData?.aliasName ?: "" + val currentSelectedIndex = roleCards.indexOfFirst { it.genre == currentAliasName } + + _uiState.update { + it.copy( + isLoading = false, + nickname = currentNickname, + roleCards = roleCards, + selectedIndex = currentSelectedIndex, + initialNickname = currentNickname, + initialSelectedIndex = currentSelectedIndex + ) + } + } else { + // 둘 중 하나라도 실패하면 에러 메시지 표시 + val error = profileResult.exceptionOrNull() ?: aliasResult.exceptionOrNull() + _uiState.update { it.copy(isLoading = false, errorMessage = error?.message) } + } + } + } + + fun onNicknameChange(newNickname: String) { + _uiState.update { + it.copy( + nickname = newNickname, + nicknameWarningMessageResId = null + ) + } + } + + fun selectCard(index: Int) { + _uiState.update { it.copy(selectedIndex = index) } + } + + fun saveProfile() { + viewModelScope.launch { + val currentState = _uiState.value + + val selectedRole = currentState.roleCards.getOrNull(currentState.selectedIndex) + if (selectedRole == null) { + _uiState.update { it.copy(errorMessage = "역할을 선택해주세요.") } + return@launch + } + + val nicknameToSend: String? = + if (currentState.nickname != currentState.initialNickname) { + currentState.nickname + } else { + null + } + + val request = ProfileUpdateRequest( + nickname = nicknameToSend, + aliasName = selectedRole.genre + ) + + userRepository.updateProfile(request) + .onSuccess { + _uiState.update { it.copy(isSaveSuccess = true) } + } + .onFailure { exception -> + if (exception is ThipApiFailureException) { + when (exception.code) { + 70004, 70005, 70006 -> { + val messageResId = when (exception.code) { + 70004 -> R.string.nickname_error_same + 70005 -> R.string.nickname_error_cooldown + else -> R.string.nickname_error_duplicate + } + _uiState.update { it.copy(nicknameWarningMessageResId = messageResId) } + } + + else -> { + _uiState.update { it.copy(errorMessage = exception.message) } + } + } + } else { + _uiState.update { it.copy(errorMessage = "네트워크 오류가 발생했습니다.") } + } + } + } + } + fun onSaveComplete() { + _uiState.update { it.copy(isSaveSuccess = false) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/MyPageViewModel.kt b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/MyPageViewModel.kt index bf569aec..40e91a8d 100644 --- a/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/MyPageViewModel.kt +++ b/app/src/main/java/com/texthip/thip/ui/mypage/viewmodel/MyPageViewModel.kt @@ -27,9 +27,6 @@ class MyPageViewModel @Inject constructor( private val _uiState = MutableStateFlow(MyPageUiState()) val uiState = _uiState.asStateFlow() - init { - fetchMyPageInfo() - } fun fetchMyPageInfo() { viewModelScope.launch { diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/MyPageNavigationExtensions.kt b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/MyPageNavigationExtensions.kt index 153d958e..e02c3928 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/extensions/MyPageNavigationExtensions.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/extensions/MyPageNavigationExtensions.kt @@ -24,4 +24,8 @@ fun NavHostController.navigateToNotificationSettings() { fun NavHostController.navigateToLeaveThipScreen() { navigate(MyPageRoutes.LeaveThip) +} + +fun NavHostController.navigateToCustomerService() { + navigate(MyPageRoutes.CustomerService) } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/MyPageNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/MyPageNavigation.kt index f318dfb6..a43b7a4d 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/MyPageNavigation.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/MyPageNavigation.kt @@ -7,8 +7,10 @@ import androidx.navigation.compose.composable import com.texthip.thip.ui.mypage.screen.DeleteAccountScreen import com.texthip.thip.ui.mypage.screen.EditProfileScreen import com.texthip.thip.ui.mypage.screen.MyPageScreen +import com.texthip.thip.ui.mypage.screen.MypageCustomerServiceScreen import com.texthip.thip.ui.mypage.screen.NotificationScreen import com.texthip.thip.ui.mypage.screen.SavedScreen +import com.texthip.thip.ui.navigator.extensions.navigateToCustomerService import com.texthip.thip.ui.navigator.extensions.navigateToEditProfile import com.texthip.thip.ui.navigator.extensions.navigateToLeaveThipScreen import com.texthip.thip.ui.navigator.extensions.navigateToNotificationSettings @@ -24,20 +26,34 @@ fun NavGraphBuilder.myPageNavigation(navController: NavHostController) { onNavigateToEditProfile = { navController.navigateToEditProfile() }, onNavigateToSavedFeeds = { navController.navigateToSavedFeeds() }, onNavigateToNotificationSettings = { navController.navigateToNotificationSettings() }, + onCustomerService = {navController.navigateToCustomerService()}, onDeleteAccount = { navController.navigateToLeaveThipScreen() } ) } composable { - EditProfileScreen() + EditProfileScreen( + onNavigateBack = { navController.popBackStack() } + ) } composable { - SavedScreen() + SavedScreen( + onNavigateBack = { navController.popBackStack() } + ) } composable { - NotificationScreen() + NotificationScreen( + onNavigateBack = { navController.popBackStack() } + ) } composable { - DeleteAccountScreen() + DeleteAccountScreen( + onNavigateBack = { navController.popBackStack() } + ) + } + composable { + MypageCustomerServiceScreen ( + onNavigateBack = { navController.popBackStack() } + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/navigations/signupNavigation.kt b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/signupNavigation.kt new file mode 100644 index 00000000..cdfaa516 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/navigator/navigations/signupNavigation.kt @@ -0,0 +1,49 @@ +package com.texthip.thip.ui.navigator.navigations + +import androidx.compose.runtime.remember +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import com.texthip.thip.ui.navigator.routes.MainTabRoutes +import com.texthip.thip.ui.signin.screen.SignupGenreScreen +import com.texthip.thip.ui.signin.screen.SignupNicknameScreen +import com.texthip.thip.ui.signin.viewmodel.SignupViewModel + +fun NavGraphBuilder.signupNavigation(navController: NavHostController) { + navigation( + startDestination = "signup_nickname", + route = "signup_flow" + ) { + composable("signup_nickname") { navBackStackEntry -> + val parentEntry = remember(navBackStackEntry) { + navController.getBackStackEntry("signup_flow") + } + val viewModel: SignupViewModel = hiltViewModel(parentEntry) + + SignupNicknameScreen( + viewModel = viewModel, + onNavigateToGenre = { + navController.navigate("signup_genre") + } + ) + } + + composable("signup_genre"){ navBackStackEntry -> + val parentEntry = remember(navBackStackEntry) { + navController.getBackStackEntry("signup_flow") + } + val viewModel: SignupViewModel = hiltViewModel(parentEntry) + + SignupGenreScreen( + viewModel = viewModel, + onSignupSuccess = { + navController.navigate(MainTabRoutes.Feed) { + popUpTo("signup_flow") { inclusive = true } + } + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/navigator/routes/MyPageRoutes.kt b/app/src/main/java/com/texthip/thip/ui/navigator/routes/MyPageRoutes.kt index 3c0beed9..37959f5c 100644 --- a/app/src/main/java/com/texthip/thip/ui/navigator/routes/MyPageRoutes.kt +++ b/app/src/main/java/com/texthip/thip/ui/navigator/routes/MyPageRoutes.kt @@ -9,4 +9,5 @@ sealed class MyPageRoutes : Routes() { @Serializable data object Reaction : MyPageRoutes() @Serializable data object NotificationEdit : MyPageRoutes() @Serializable data object LeaveThip : MyPageRoutes() + @Serializable data object CustomerService : MyPageRoutes() } \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupGenreScreen.kt b/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupGenreScreen.kt index 30f06578..b221b598 100644 --- a/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupGenreScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupGenreScreen.kt @@ -12,44 +12,55 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.texthip.thip.R import com.texthip.thip.ui.common.topappbar.InputTopAppBar import com.texthip.thip.ui.mypage.component.RoleCard import com.texthip.thip.ui.mypage.mock.RoleItem -import com.texthip.thip.ui.signin.viewmodel.SignupAliasUiState -import com.texthip.thip.ui.signin.viewmodel.SignupAliasViewModel +import com.texthip.thip.ui.signin.viewmodel.SignupUiState +import com.texthip.thip.ui.signin.viewmodel.SignupViewModel import com.texthip.thip.ui.theme.ThipTheme import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun SignupGenreScreen( - onNavigateToNext: (RoleItem) -> Unit, // 선택된 아이템 정보를 다음 화면으로 넘겨주기 - viewModel: SignupAliasViewModel = hiltViewModel() + viewModel: SignupViewModel, + onSignupSuccess: () -> Unit ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + viewModel.fetchAliasChoices() + } + LaunchedEffect(uiState.isSignupSuccess) { + if (uiState.isSignupSuccess) { + onSignupSuccess() + } + } + SignupGenreContent( uiState = uiState, - onCardSelected = { index -> viewModel.selectCard(index) }, + /*onCardSelected = { index -> viewModel.selectCard(index) }, onNextClick = { // 선택된 아이템이 있을 경우에만 다음 화면으로 이동 uiState.roleCards.getOrNull(uiState.selectedIndex)?.let { selectedRoleItem -> onNavigateToNext(selectedRoleItem) } - } + }*/ + onCardSelected = viewModel::selectCard, + onNextClick = viewModel::signup ) } @Composable fun SignupGenreContent( - uiState: SignupAliasUiState, + uiState: SignupUiState, onCardSelected: (Int) -> Unit, onNextClick: () -> Unit ) { @@ -125,7 +136,7 @@ private fun SignupGenreScreenPrev() { RoleItem("예술", "예술가", "", "#FFFFFF"), RoleItem("인문", "철학자", "", "#FFFFFF") ) - val previewUiState = SignupAliasUiState( + val previewUiState = SignupUiState( roleCards = previewRoleCards, selectedIndex = 1 // 1번 아이템이 선택된 상태로 프리뷰 ) diff --git a/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupNicknameScreen.kt b/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupNicknameScreen.kt index a844de6f..500ef73e 100644 --- a/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupNicknameScreen.kt +++ b/app/src/main/java/com/texthip/thip/ui/signin/screen/SignupNicknameScreen.kt @@ -20,31 +20,31 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.texthip.thip.R import com.texthip.thip.ui.common.forms.WarningTextField import com.texthip.thip.ui.common.topappbar.InputTopAppBar -import com.texthip.thip.ui.signin.viewmodel.NicknameViewModel +import com.texthip.thip.ui.signin.viewmodel.SignupViewModel import com.texthip.thip.ui.theme.ThipTheme.colors import com.texthip.thip.ui.theme.ThipTheme.typography @Composable fun SignupNicknameScreen( - viewModel: NicknameViewModel = hiltViewModel(), - onNavigateToNext: () -> Unit + viewModel: SignupViewModel, + onNavigateToGenre: () -> Unit ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - - LaunchedEffect(uiState.navigateToNext, uiState.errorMessage) { - if (uiState.navigateToNext) { - onNavigateToNext() - viewModel.onNavigated() + LaunchedEffect(uiState.navigateToGenreScreen) { + if (uiState.navigateToGenreScreen) { + onNavigateToGenre() + viewModel.onNavigatedToGenre() } + } + + LaunchedEffect(uiState.errorMessage) { uiState.errorMessage?.let { message -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - viewModel.onNavigated() } } @@ -53,7 +53,7 @@ fun SignupNicknameScreen( onNicknameChange = viewModel::onNicknameChange, onNextClick = viewModel::checkNickname, isLoading = uiState.isLoading, - warningMessageResId = uiState.warningMessageResId + warningMessageResId = uiState.nicknameWarningMessageResId ) } @Composable diff --git a/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/NicknameViewModel.kt b/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/NicknameViewModel.kt deleted file mode 100644 index fd9b1a90..00000000 --- a/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/NicknameViewModel.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.texthip.thip.ui.signin.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.texthip.thip.R -import com.texthip.thip.data.repository.UserRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - - -data class NicknameUiState( - val isLoading: Boolean = false, - val nickname: String = "", - val isVerified: Boolean? = null, - val warningMessageResId: Int? = null, - val errorMessage: String? = null, - val navigateToNext: Boolean = false -) - -@HiltViewModel -class NicknameViewModel @Inject constructor( - private val userRepository: UserRepository -) : ViewModel() { - private val _uiState = MutableStateFlow(NicknameUiState()) - val uiState = _uiState.asStateFlow() - - fun onNicknameChange(nickname: String) { - // 닉네임 입력 시, 경고 메시지 초기화 - _uiState.update { - it.copy( - nickname = nickname, - warningMessageResId = null - ) - } - } - - fun checkNickname() { - if (_uiState.value.isLoading) return - if (_uiState.value.nickname.isBlank()) return - - viewModelScope.launch { - _uiState.update { - it.copy( - isLoading = true, - warningMessageResId = null, - errorMessage = null - ) - } - - userRepository.checkNickname(_uiState.value.nickname) - .onSuccess { response -> - if (response?.isVerified == true) { - _uiState.update { it.copy(isLoading = false, navigateToNext = true) } - } else { - // 중복된 닉네임 - _uiState.update { - it.copy( - isLoading = false, - warningMessageResId = R.string.nickname_warning - ) - } - } - } - .onFailure { exception -> - _uiState.update { - it.copy( - isLoading = false, - errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다." - ) - } - } - } - } - fun onNavigated() { - _uiState.update { it.copy(navigateToNext = false, errorMessage = null) } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/SignupAliasViewModel.kt b/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/SignupAliasViewModel.kt deleted file mode 100644 index 0f0b7ab5..00000000 --- a/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/SignupAliasViewModel.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.texthip.thip.ui.signin.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.texthip.thip.data.repository.UserRepository -import com.texthip.thip.ui.mypage.mock.RoleItem -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject -import kotlin.onSuccess - -data class SignupAliasUiState( - val isLoading: Boolean = false, - val roleCards: List = emptyList(), - val selectedIndex: Int = -1, - val errorMessage: String? = null -) - -@HiltViewModel -class SignupAliasViewModel @Inject constructor( - private val userRepository: UserRepository -) : ViewModel() { - - private val _uiState = MutableStateFlow(SignupAliasUiState()) - val uiState = _uiState.asStateFlow() - - init { - fetchAliasChoices() - } - - fun fetchAliasChoices() { - viewModelScope.launch { - _uiState.update { it.copy(isLoading = true) } - userRepository.getAliasChoices() - .onSuccess { response -> - val roleCards = response?.aliasChoices?.map { aliasChoice -> - RoleItem( - genre = aliasChoice.aliasName, - role = aliasChoice.categoryName, - imageUrl = aliasChoice.imageUrl, - roleColor = aliasChoice.aliasColor - ) - } ?: emptyList() - _uiState.update { it.copy(isLoading = false, roleCards = roleCards) } - } - .onFailure { exception -> - _uiState.update { it.copy(isLoading = false, errorMessage = exception.message) } - } - } - } - - fun selectCard(index: Int) { - _uiState.update { it.copy(selectedIndex = index) } - } -} - diff --git a/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/SignupViewModel.kt b/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/SignupViewModel.kt new file mode 100644 index 00000000..06c67444 --- /dev/null +++ b/app/src/main/java/com/texthip/thip/ui/signin/viewmodel/SignupViewModel.kt @@ -0,0 +1,121 @@ +package com.texthip.thip.ui.signin.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.texthip.thip.R +import com.texthip.thip.data.model.base.ThipApiFailureException +import com.texthip.thip.data.model.users.request.SignupRequest +import com.texthip.thip.data.repository.UserRepository +import com.texthip.thip.ui.mypage.mock.RoleItem +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class SignupUiState( + val isLoading: Boolean = false, + val nickname: String = "", + val isNicknameVerified: Boolean = false, + val nicknameWarningMessageResId: Int? = null, + val roleCards: List = emptyList(), + val selectedIndex: Int = -1, + val errorMessage: String? = null, + val navigateToGenreScreen: Boolean = false, + val isSignupSuccess: Boolean = false +) + +@HiltViewModel +class SignupViewModel @Inject constructor( + private val userRepository: UserRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SignupUiState()) + val uiState = _uiState.asStateFlow() + + fun onNicknameChange(nickname: String) { + _uiState.update { + it.copy( + nickname = nickname, + isNicknameVerified = false, // 닉네임이 바뀌면 인증 상태 초기화 + nicknameWarningMessageResId = null, + navigateToGenreScreen = false + ) + } + } + + fun checkNickname() { + if (_uiState.value.isLoading || _uiState.value.nickname.isBlank()) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, nicknameWarningMessageResId = null) } + userRepository.checkNickname(_uiState.value.nickname) + .onSuccess { response -> + if (response?.isVerified == true) { + _uiState.update { it.copy(isLoading = false, isNicknameVerified = true, navigateToGenreScreen = true) } + } else { + _uiState.update { it.copy(isLoading = false, nicknameWarningMessageResId = R.string.nickname_warning) } + } + } + .onFailure { _uiState.update { it.copy(isLoading = false, nicknameWarningMessageResId = R.string.error_unknown) } } + } + } + + fun onNavigatedToGenre() { + _uiState.update { it.copy(navigateToGenreScreen = false) } + } + + fun fetchAliasChoices() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + userRepository.getAliasChoices() + .onSuccess { response -> + val roleCards = response?.aliasChoices?.map { RoleItem(it.aliasName, it.categoryName, it.imageUrl, it.aliasColor) } ?: emptyList() + _uiState.update { it.copy(isLoading = false, roleCards = roleCards) } + } + .onFailure { exception -> _uiState.update { it.copy(isLoading = false, errorMessage = exception.message) } } + } + } + + fun selectCard(index: Int) { + _uiState.update { it.copy(selectedIndex = index) } + } + + fun signup() { + viewModelScope.launch { + val currentState = _uiState.value + val selectedRole = currentState.roleCards.getOrNull(currentState.selectedIndex) + + if (selectedRole == null || currentState.nickname.isBlank() || !currentState.isNicknameVerified) { + _uiState.update { it.copy(errorMessage = "닉네임과 역할을 모두 선택해주세요.") } + return@launch + } + + val request = SignupRequest( + nickname = currentState.nickname, + aliasName = selectedRole.genre + ) + + _uiState.update { it.copy(isLoading = true) } + userRepository.signup(request) + .onSuccess { authResponse -> + // TODO: 성공 시 받은 토큰(authResponse) 저장 및 메인 화면으로 이동 + _uiState.update { it.copy(isLoading = false, isSignupSuccess = true) } + } + .onFailure { exception -> + val errorMsg = if (exception is ThipApiFailureException) exception.message else "회원가입에 실패했습니다." + _uiState.update { it.copy(isLoading = false, errorMessage = errorMsg) } + } + } + } + // 소셜로그인과 연동 후 삭제 예정 + fun setInitialDataForTest(nickname: String) { + _uiState.update { + it.copy( + nickname = nickname, + isNicknameVerified = true // 닉네임이 검증되었다고 가정 + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 275098a2..933f5014 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,7 +124,8 @@ 푸시 알림이 설정되었어요. texthip2025@gmail.com - 이메일로 닉네임과 문의사항을 보내주시면\n빠른 시일 내로 해결해 드릴게요! + 이메일로 닉네임과 문의사항을 보내주시면 + 빠른 시일 내로 해결해 드릴게요! 아직 저장한 책이 없어요 아직 저장한 피드가 없어요 @@ -134,6 +135,14 @@ 아직 저장한 반응이 없어요 첫 번째 반응을 저장해 보세요! + 닉네임은 6개월에 한 번 변경할 수 있어요 + 중복된 닉네임이에요 + 현재 닉네임과 같아요 + 알 수 없는 오류가 발생했어요 + + 프로필 설정을 성공적으로 변경했어요. + + 진행중 모집중 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 821d4cca..832f4cbf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ okhttp = "5.1.0" retrofit = "3.0.0" retrofitKotlinSerializationConverter = "1.0.0" androidxComposeNavigation = "2.8.2" +datastorePreferences = "1.1.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -58,6 +59,7 @@ logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", ver okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-kotlin-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinSerializationConverter" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }