diff --git a/.github/workflows/android-pull-request-ci.yml b/.github/workflows/android-pull-request-ci.yml index af693824..f4bdd649 100644 --- a/.github/workflows/android-pull-request-ci.yml +++ b/.github/workflows/android-pull-request-ci.yml @@ -3,7 +3,7 @@ name: Android Pull Request CI on: pull_request: branches: [ "main", "develop", "dev" ] - + jobs: build: runs-on: ubuntu-latest @@ -49,6 +49,16 @@ jobs: run: | echo "KAKAO_NATIVE_APP_KEY=\"$KAKAO_NATIVE_APP_KEY\"" >> local.properties + - name: Access Near PRIVACY URLS + env: + SERVICE_AGREED_TERMS_URL: ${{ secrets.SERVICE_AGREED_TERMS_URL }} + PERSONAL_INFO_TERMS_URL: ${{ secrets.PERSONAL_INFO_TERMS_URL }} + PRIVACY_POLICY_TERMS_URL: ${{ secrets.PRIVACY_POLICY_TERMS_URL }} + run: | + echo "SERVICE_AGREED_TERMS_URL=\"$SERVICE_AGREED_TERMS_URL\"" >> local.properties + echo "PERSONAL_INFO_TERMS_URL=\"$PERSONAL_INFO_TERMS_URL\"" >> local.properties + echo "PRIVACY_POLICY_TERMS_URL=\"$PRIVACY_POLICY_TERMS_URL\"" >> local.properties + - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/Near/app/build.gradle.kts b/Near/app/build.gradle.kts index 0284dce0..702769ce 100644 --- a/Near/app/build.gradle.kts +++ b/Near/app/build.gradle.kts @@ -34,12 +34,18 @@ android { buildConfigField("String", "NEAR_URL", getProperty("NEAR_PROD_URL")) buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요 buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getProperty("KAKAO_NATIVE_APP_KEY")) + buildConfigField("String", "SERVICE_AGREED_TERMS_URL", getProperty("SERVICE_AGREED_TERMS_URL")) + buildConfigField("String", "PERSONAL_INFO_TERMS_URL", getProperty("PERSONAL_INFO_TERMS_URL")) + buildConfigField("String", "PRIVACY_POLICY_TERMS_URL", getProperty("PRIVACY_POLICY_TERMS_URL")) manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "") } debug { buildConfigField("String", "NEAR_URL", getProperty("NEAR_DEV_URL")) buildConfigField("String", "TEMP_TOKEN", getProperty("TEMP_TOKEN")) // TODO 추후 삭제 필요 buildConfigField("String", "KAKAO_NATIVE_APP_KEY", getProperty("KAKAO_NATIVE_APP_KEY")) + buildConfigField("String", "SERVICE_AGREED_TERMS_URL", getProperty("SERVICE_AGREED_TERMS_URL")) + buildConfigField("String", "PERSONAL_INFO_TERMS_URL", getProperty("PERSONAL_INFO_TERMS_URL")) + buildConfigField("String", "PRIVACY_POLICY_TERMS_URL", getProperty("PRIVACY_POLICY_TERMS_URL")) manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "") } } @@ -81,9 +87,8 @@ dependencies { implementation(libs.retrofit) implementation(libs.retrofit.kotlin.serialization.converter) implementation(libs.logging.interceptor) - // Glide - implementation(libs.glide) - kapt(libs.glide.compiler) + // Coil + implementation(libs.coil.compose) // Room implementation(libs.room.runtime) implementation(libs.room.ktx) diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt index 5f73bfb6..12c320b2 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt @@ -9,17 +9,35 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier import javax.inject.Singleton +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthDataStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class OnboardingDataStore + // DataStore 확장 프로퍼티 -private val Context.dataStore: DataStore by preferencesDataStore(name = "auth_preferences") +private val Context.authDataStore: DataStore by preferencesDataStore(name = "auth_preferences") +private val Context.onboardingDataStore: DataStore by preferencesDataStore(name = "onboarding_preferences") @Module @InstallIn(SingletonComponent::class) object DataStoreModule { @Provides @Singleton - fun provideDataStore( + @AuthDataStore + fun provideAuthDataStore( + @ApplicationContext context: Context, + ): DataStore = context.authDataStore + + @Provides + @Singleton + @OnboardingDataStore + fun provideOnboardingDataStore( @ApplicationContext context: Context, - ): DataStore = context.dataStore + ): DataStore = context.onboardingDataStore } diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt index 5d73c37f..5480d655 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt @@ -6,6 +6,10 @@ import com.alarmy.near.data.repository.DefaultFriendRepository import com.alarmy.near.data.repository.ExampleRepository import com.alarmy.near.data.repository.ExampleRepositoryImpl import com.alarmy.near.data.repository.FriendRepository +import com.alarmy.near.data.repository.OnBoardingRepository +import com.alarmy.near.data.repository.OnBoardingRepositoryImpl +import com.alarmy.near.data.repository.MemberRepository +import com.alarmy.near.data.repository.MemberRepositoryImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -26,4 +30,12 @@ interface RepositoryModule { @Binds @Singleton abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository + + @Binds + @Singleton + abstract fun bindOnBoardingRepository(onBoardingRepositoryImpl: OnBoardingRepositoryImpl): OnBoardingRepository + + @Binds + @Singleton + abstract fun bindMemberRepository(memberRepositoryImpl: MemberRepositoryImpl): MemberRepository } diff --git a/Near/app/src/main/java/com/alarmy/near/data/entity/MemberInfoEntity.kt b/Near/app/src/main/java/com/alarmy/near/data/entity/MemberInfoEntity.kt new file mode 100644 index 00000000..fe1e9f25 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/entity/MemberInfoEntity.kt @@ -0,0 +1,17 @@ +package com.alarmy.near.data.entity + +import kotlinx.serialization.Serializable + +/** + * 회원 정보 Data Layer 엔티티 + * API 응답 데이터를 나타내는 모델 + */ +@Serializable +data class MemberInfoEntity( + val memberId: String, + val username: String, + val nickname: String, + val imageUrl: String?, + val notificationAgreedAt: String?, + val providerType: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/data/entity/WithdrawRequestEntity.kt b/Near/app/src/main/java/com/alarmy/near/data/entity/WithdrawRequestEntity.kt new file mode 100644 index 00000000..ff267ddb --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/entity/WithdrawRequestEntity.kt @@ -0,0 +1,13 @@ +package com.alarmy.near.data.entity + +import kotlinx.serialization.Serializable + +/** + * 회원 탈퇴 요청 Data Layer 엔티티 + * Network Layer의 WithdrawRequest와 동일한 구조 + */ +@Serializable +data class WithdrawRequestEntity( + val reasonType: String, + val customReason: String? = null, +) diff --git a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/OnboardingPreferences.kt b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/OnboardingPreferences.kt new file mode 100644 index 00000000..fe7f5841 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/OnboardingPreferences.kt @@ -0,0 +1,48 @@ +package com.alarmy.near.data.local.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.alarmy.near.data.di.OnboardingDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 온보딩 완료 상태를 관리하는 DataStore + * 최초 접속 여부를 확인하여 온보딩 화면 표시 여부를 결정 + */ +@Singleton +class OnboardingPreferences @Inject constructor( + @OnboardingDataStore private val dataStore: DataStore, +) { + companion object { + private val ONBOARDING_COMPLETED_KEY = booleanPreferencesKey("onboarding_completed") + } + + /** + * 온보딩 완료 여부를 확인하는 Flow + * true: 온보딩 완료됨, false: 온보딩 미완료 + */ + val isOnboardingCompleted: Flow = dataStore.data.map { preferences -> + preferences[ONBOARDING_COMPLETED_KEY] ?: false + } + + /** + * 온보딩 완료 상태를 저장 + */ + suspend fun setOnboardingCompleted(completed: Boolean) { + dataStore.edit { preferences -> + preferences[ONBOARDING_COMPLETED_KEY] = completed + } + } + + /** + * 온보딩 완료 상태를 true로 설정 + */ + suspend fun markOnboardingAsCompleted() { + setOnboardingCompleted(true) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt index 13f5eaa0..81c5362b 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt @@ -5,9 +5,9 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import com.alarmy.near.data.di.AuthDataStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map - import javax.inject.Inject import javax.inject.Singleton @@ -16,11 +16,9 @@ import javax.inject.Singleton * 액세스 토큰과 리프레시 토큰의 저장, 조회, 삭제를 담당 */ @Singleton -class TokenPreferences - @Inject - constructor( - private val dataStore: DataStore, - ) { +class TokenPreferences @Inject constructor( + @AuthDataStore private val dataStore: DataStore, +) { private val accessTokenKey = stringPreferencesKey("access_token") private val refreshTokenKey = stringPreferencesKey("refresh_token") private val expiresAtKey = longPreferencesKey("expires_at") diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/MemberMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/MemberMapper.kt new file mode 100644 index 00000000..c497935f --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/MemberMapper.kt @@ -0,0 +1,70 @@ +package com.alarmy.near.data.mapper + +import com.alarmy.near.data.entity.MemberInfoEntity +import com.alarmy.near.data.entity.WithdrawRequestEntity +import com.alarmy.near.model.member.MemberInfo +import com.alarmy.near.network.request.WithdrawRequest +import com.alarmy.near.presentation.feature.myprofile.model.LoginType +import com.alarmy.near.presentation.feature.myprofile.model.MyProfileInfoUIModel +import com.alarmy.near.presentation.feature.myprofile.model.WithdrawReason + +/** + * Data Layer Entity를 Model Layer로 변환 + */ +fun MemberInfoEntity.toModel(): MemberInfo = + MemberInfo( + memberId = memberId, + username = username, + nickname = nickname, + imageUrl = imageUrl, + notificationAgreedAt = notificationAgreedAt, + providerType = providerType, + ) + +/** + * Model Layer를 Data Layer Entity로 변환 + */ +fun MemberInfo.toEntity(): MemberInfoEntity = + MemberInfoEntity( + memberId = memberId, + username = username, + nickname = nickname, + imageUrl = imageUrl, + notificationAgreedAt = notificationAgreedAt, + providerType = providerType, + ) + +/** + * WithdrawReason을 Network Layer로 변환 + */ +fun WithdrawReason.toRequest(customReason: String? = null): WithdrawRequest = + WithdrawRequest( + reasonType = this.name, + customReason = customReason, + ) + +fun WithdrawRequest.toEntity(): WithdrawRequestEntity = + WithdrawRequestEntity( + reasonType = reasonType, + customReason = customReason, + ) + +/** + * Model 계층 모델을 UI 계층 모델로 변환 + */ +fun MemberInfo.toMyProfileInfoUIModel(): MyProfileInfoUIModel = + MyProfileInfoUIModel( + nickname = nickname, + imageUrl = imageUrl, + notificationAgreedAt = notificationAgreedAt, + providerType = mapProviderType(providerType), + ) + +/** + * ProviderType 문자열을 LoginType enum으로 변환 + */ +private fun mapProviderType(providerType: String): LoginType = + when (providerType.uppercase()) { + "KAKAO" -> LoginType.KAKAO + else -> LoginType.ETC + } diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/MemberRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/MemberRepository.kt new file mode 100644 index 00000000..583a1086 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/MemberRepository.kt @@ -0,0 +1,13 @@ +package com.alarmy.near.data.repository + +import com.alarmy.near.model.member.MemberInfo +import com.alarmy.near.presentation.feature.myprofile.model.WithdrawReason +import kotlinx.coroutines.flow.Flow + +interface MemberRepository { + // 현재 로그인한 회원의 정보를 조회 + fun getMyInfo(): Flow + + // 회원 탈퇴 + fun withdraw(reason: WithdrawReason, customReason: String? = null): Flow +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/MemberRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/MemberRepositoryImpl.kt new file mode 100644 index 00000000..f5725c0b --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/MemberRepositoryImpl.kt @@ -0,0 +1,38 @@ +package com.alarmy.near.data.repository + +import com.alarmy.near.data.mapper.toEntity +import com.alarmy.near.data.mapper.toModel +import com.alarmy.near.data.mapper.toRequest +import com.alarmy.near.model.member.MemberInfo +import com.alarmy.near.network.service.MemberApiService +import com.alarmy.near.presentation.feature.myprofile.model.WithdrawReason +import com.alarmy.near.utils.extensions.apiCallFlow +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 회원 정보 Repository 구현체 + */ +@Singleton +class MemberRepositoryImpl + @Inject + constructor( + private val memberApiService: MemberApiService, + ) : MemberRepository { + // 현재 로그인한 회원의 정보를 조회 + override fun getMyInfo(): Flow = + apiCallFlow { + memberApiService.getMyInfo().toModel() + } + + // 회원 탈퇴 + override fun withdraw( + reason: WithdrawReason, + customReason: String?, + ): Flow = + apiCallFlow { + val request = reason.toRequest(customReason) + memberApiService.withdraw(request.toEntity()) + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/OnBoardingRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/OnBoardingRepository.kt new file mode 100644 index 00000000..c71f8c37 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/OnBoardingRepository.kt @@ -0,0 +1,37 @@ +package com.alarmy.near.data.repository + +import kotlinx.coroutines.flow.Flow + +/** + * 온보딩 관련 데이터를 관리하는 Repository 인터페이스 + * 온보딩 완료 상태의 저장, 조회, 확인 기능을 제공 + */ +interface OnBoardingRepository { + + /** + * 온보딩 완료 여부를 확인하는 Flow + * true: 온보딩 완료됨, false: 온보딩 미완료 + */ + fun observeOnboardingStatus(): Flow + + /** + * 온보딩 완료 상태를 저장 + * completed: 온보딩 완료 여부 + */ + suspend fun setOnboardingCompleted(completed: Boolean) + + /** + * 온보딩을 완료로 표시 + */ + suspend fun markOnboardingAsCompleted() + + /** + * 온보딩 완료 상태를 확인 + */ + suspend fun isOnboardingCompleted(): Boolean + + /** + * 온보딩 상태를 초기화 (테스트용 또는 재온보딩용) + */ + suspend fun resetOnboardingStatus() +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/OnBoardingRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/OnBoardingRepositoryImpl.kt new file mode 100644 index 00000000..02ae619e --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/OnBoardingRepositoryImpl.kt @@ -0,0 +1,37 @@ +package com.alarmy.near.data.repository + +import com.alarmy.near.data.local.datastore.OnboardingPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +/** + * OnBoardingRepository의 구현체 + * OnboardingPreferences를 사용하여 온보딩 상태를 관리 + */ +@Singleton +class OnBoardingRepositoryImpl @Inject constructor( + private val onboardingPreferences: OnboardingPreferences, +) : OnBoardingRepository { + + override fun observeOnboardingStatus(): Flow { + return onboardingPreferences.isOnboardingCompleted + } + + override suspend fun setOnboardingCompleted(completed: Boolean) { + onboardingPreferences.setOnboardingCompleted(completed) + } + + override suspend fun markOnboardingAsCompleted() { + onboardingPreferences.markOnboardingAsCompleted() + } + + override suspend fun isOnboardingCompleted(): Boolean { + return onboardingPreferences.isOnboardingCompleted.first() + } + + override suspend fun resetOnboardingStatus() { + onboardingPreferences.setOnboardingCompleted(false) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/model/member/MemberInfo.kt b/Near/app/src/main/java/com/alarmy/near/model/member/MemberInfo.kt new file mode 100644 index 00000000..8b68b7b1 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/model/member/MemberInfo.kt @@ -0,0 +1,16 @@ +package com.alarmy.near.model.member + +import kotlinx.serialization.Serializable + +/** + * 회원 정보 응답 모델 + */ +@Serializable +data class MemberInfo( + val memberId: String, + val username: String, + val nickname: String, + val imageUrl: String?, + val notificationAgreedAt: String?, + val providerType: String, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt b/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt index 6324fd18..642023e2 100644 --- a/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/network/di/ServiceModule.kt @@ -2,6 +2,7 @@ package com.alarmy.near.network.di import com.alarmy.near.network.service.AuthService import com.alarmy.near.network.service.FriendService +import com.alarmy.near.network.service.MemberApiService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -19,4 +20,8 @@ object ServiceModule { @Provides @Singleton fun provideFriendService(retrofit: Retrofit): FriendService = retrofit.create(FriendService::class.java) + + @Provides + @Singleton + fun provideMemberApiService(retrofit: Retrofit): MemberApiService = retrofit.create(MemberApiService::class.java) } diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/WithdrawRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/WithdrawRequest.kt new file mode 100644 index 00000000..5ea1ee87 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/request/WithdrawRequest.kt @@ -0,0 +1,13 @@ +package com.alarmy.near.network.request + +import kotlinx.serialization.Serializable + +/** + * 회원 탈퇴 API 요청 데이터 모델 + * Network Layer에서 사용하는 요청 데이터 + */ +@Serializable +data class WithdrawRequest( + val reasonType: String, + val customReason: String? = null +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/service/MemberApiService.kt b/Near/app/src/main/java/com/alarmy/near/network/service/MemberApiService.kt new file mode 100644 index 00000000..f7d3954b --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/service/MemberApiService.kt @@ -0,0 +1,22 @@ +package com.alarmy.near.network.service + +import com.alarmy.near.data.entity.MemberInfoEntity +import com.alarmy.near.data.entity.WithdrawRequestEntity +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.HTTP + +/** + * 회원 관련 API 서비스 + */ +interface MemberApiService { + // 현재 로그인한 회원의 정보를 조회 + @GET("member/me") + suspend fun getMyInfo(): MemberInfoEntity + + // 회원 탈퇴 + @HTTP(method = "DELETE", path = "member/withdraw", hasBody = true) + suspend fun withdraw( + @Body request: WithdrawRequestEntity, + ) +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt index 92fd46ef..c79559a9 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt @@ -30,7 +30,7 @@ class MainActivity : ComponentActivity() { val uiState by mainViewModel.uiState.collectAsStateWithLifecycle() if (!uiState.isLoading) { NearApp( - isLoggedIn = uiState.isLoggedIn + startDestination = uiState.startDestination, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainViewModel.kt index 3c97b6fb..37a23776 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainViewModel.kt @@ -3,6 +3,10 @@ package com.alarmy.near.presentation.feature.main import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.alarmy.near.data.repository.AuthRepository +import com.alarmy.near.data.repository.OnBoardingRepository +import com.alarmy.near.presentation.feature.home.navigation.RouteHome +import com.alarmy.near.presentation.feature.login.navigation.RouteLogin +import com.alarmy.near.presentation.feature.onboarding.navigation.RouteOnboarding import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,6 +17,7 @@ import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( private val authRepository: AuthRepository, + private val onBoardingRepository: OnBoardingRepository, ) : ViewModel() { // UI 상태 관리 @@ -20,26 +25,44 @@ class MainViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() init { - checkLoginStatus() + checkAppStatus() } /** - * 로그인 상태를 확인하고 스플래시 스크린을 제어합니다. - * API 스플래시가 표시되는 동안 백그라운드에서 검증을 수행합니다. + * 앱 상태를 확인하고 스플래시 스크린을 제어합니다. + * 온보딩 완료 여부와 로그인 상태를 확인하여 적절한 화면으로 이동합니다. */ - private fun checkLoginStatus() { + private fun checkAppStatus() { viewModelScope.launch { - - // 로그인 상태 검증 - val isLoggedIn = runCatching { - authRepository.isLoggedIn() - }.getOrElse { false } - - // UI 상태 업데이트 - _uiState.value = _uiState.value.copy( - isLoading = false, - isLoggedIn = isLoggedIn - ) + runCatching { + // 온보딩 완료 여부 확인 + val isOnboardingCompleted = onBoardingRepository.isOnboardingCompleted() + // 로그인 상태 검증 + val isLoggedIn = authRepository.isLoggedIn() + + // 시작 화면 결정 + val startDestination = when { + !isOnboardingCompleted -> RouteOnboarding + isLoggedIn -> RouteHome + else -> RouteLogin + } + + // UI 상태 업데이트 + _uiState.value = _uiState.value.copy( + isLoading = false, + isOnboardingCompleted = isOnboardingCompleted, + isLoggedIn = isLoggedIn, + startDestination = startDestination + ) + }.onFailure { + // 에러 발생 시 기본값으로 설정 + _uiState.value = _uiState.value.copy( + isLoading = false, + isOnboardingCompleted = false, + isLoggedIn = false, + startDestination = RouteOnboarding + ) + } } } } @@ -49,5 +72,7 @@ class MainViewModel @Inject constructor( */ data class MainUiState( val isLoading: Boolean = true, + val isOnboardingCompleted: Boolean = false, val isLoggedIn: Boolean = false, + val startDestination: Any = RouteOnboarding, ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt index 3e739250..1b7be78c 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.launch internal fun NearApp( modifier: Modifier = Modifier, navController: NavHostController = rememberNavController(), - isLoggedIn: Boolean = false, + startDestination: Any, ) { val snackBarState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -48,7 +48,7 @@ internal fun NearApp( NearNavHost( modifier = Modifier.consumeWindowInsets(innerPadding), // 하위 뷰에 Padding을 소비한 것으로 알립니다. navController = navController, - isLoggedIn = isLoggedIn, + startDestination = startDestination, onShowSnackbar = { scope.launch { snackBarState.showSnackbar( diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt index 8c0cdb19..858d92bd 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt @@ -13,11 +13,17 @@ import com.alarmy.near.presentation.feature.friendprofile.navigation.navigateToF import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.FRIEND_PROFILE_EDIT_COMPLETE_KEY import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.friendProfileEditorNavGraph import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.navigateToFriendProfileEditor -import com.alarmy.near.presentation.feature.home.navigation.RouteHome import com.alarmy.near.presentation.feature.home.navigation.homeNavGraph import com.alarmy.near.presentation.feature.home.navigation.navigateToHome import com.alarmy.near.presentation.feature.login.navigation.RouteLogin import com.alarmy.near.presentation.feature.login.navigation.loginNavGraph +import com.alarmy.near.presentation.feature.login.navigation.navigateToLogin +import com.alarmy.near.presentation.feature.myprofile.navigation.myProfileNavGraph +import com.alarmy.near.presentation.feature.myprofile.navigation.navigateToMyProfile +import com.alarmy.near.presentation.feature.myprofile.navigation.navigateToWebView +import com.alarmy.near.presentation.feature.myprofile.navigation.navigateToWithdraw +import com.alarmy.near.presentation.feature.onboarding.navigation.RouteOnboarding +import com.alarmy.near.presentation.feature.onboarding.navigation.onboardingNavGraph import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -25,20 +31,78 @@ import java.nio.charset.StandardCharsets internal fun NearNavHost( modifier: Modifier = Modifier, navController: NavHostController, - isLoggedIn: Boolean = false, + startDestination: Any, // 나중에 모든 루트를 sealed로 구성하면 sealed 타입으로 변경 onShowSnackbar: (Throwable?) -> Unit = { _ -> }, ) { val context = LocalContext.current /* * 화면 이동 및 구성을 위한 컴포저블 함수입니다. - * 로그인 상태에 따라 즉시 적절한 화면으로 시작합니다. + * startDestination 파라미터로 받은 시작 화면으로 이동합니다. * */ NavHost( modifier = modifier, navController = navController, - startDestination = if (isLoggedIn) RouteHome else RouteLogin, + startDestination = startDestination, ) { + // 온보딩 화면 NavGraph + onboardingNavGraph( + onNavigateToLogin = { + navController.navigateToLogin( + navOptions = + navOptions { + popUpTo(RouteOnboarding) { inclusive = true } + }, + ) + }, + ) + + // 로그인 화면 NavGraph + loginNavGraph( + onShowErrorSnackBar = onShowSnackbar, + onNavigateToHome = { + navController.navigateToHome( + navOptions = + navOptions { + popUpTo(RouteLogin) { inclusive = true } + }, + ) + }, + ) + + // 홈 화면 NavGraph + homeNavGraph( + onShowErrorSnackBar = onShowSnackbar, + onContactClick = { contactId -> + navController.navigateToFriendProfile(friendId = contactId) + }, + onMyPageClick = { navController.navigateToMyProfile() }, + onAlarmClick = {}, + onAddContactClick = {}, + ) + + myProfileNavGraph( + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToLogin = { + navController.navigateToLogin( + navOptions = + navOptions { + popUpTo(0) { inclusive = true } + }, + ) + }, + onNavigateToWithdraw = { nickname -> + navController.navigateToWithdraw(nickname) + }, + onNavigateToTerms = { title, url -> + navController.navigateToWebView(title, url) + }, + onShowErrorSnackBar = onShowSnackbar, + ) + + // 친구 프로필 화면 NavGraph friendProfileNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { navController.popBackStack() }, onClickCallButton = { phoneNumber -> @@ -67,6 +131,8 @@ internal fun NearNavHost( ), ) }) + + // 친구 프로필 편집 화면 NavGraph friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { navController.popBackStack() }, onSuccessEdit = { @@ -77,17 +143,17 @@ internal fun NearNavHost( navController.popBackStack() }) - // 로그인 화면 NavGraph loginNavGraph( onShowErrorSnackBar = onShowSnackbar, onNavigateToHome = { navController.navigateToHome( - navOptions = navOptions { - popUpTo(RouteLogin) { inclusive = true } - } + navOptions = + navOptions { + popUpTo(RouteLogin) { inclusive = true } + }, ) - } + }, ) // 홈 화면 NavGraph @@ -100,21 +166,5 @@ internal fun NearNavHost( onAlarmClick = {}, onAddContactClick = {}, ) - - // 친구 프로필 화면 NavGraph - friendProfileNavGraph( - onShowErrorSnackBar = onShowSnackbar, - onClickBackButton = { - navController.popBackStack() - } - ) - - // 친구 프로필 편집 화면 NavGraph - friendProfileEditorNavGraph( - onShowErrorSnackBar = onShowSnackbar, - onClickBackButton = { - navController.popBackStack() - } - ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/MyProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/MyProfileScreen.kt new file mode 100644 index 00000000..37f0092f --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/MyProfileScreen.kt @@ -0,0 +1,302 @@ +package com.alarmy.near.presentation.feature.myprofile + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.alarmy.near.R +import com.alarmy.near.presentation.feature.myprofile.components.NearLogoutButton +import com.alarmy.near.presentation.feature.myprofile.components.NearServiceInfoRow +import com.alarmy.near.presentation.feature.myprofile.components.NearSocialLoginBadge +import com.alarmy.near.presentation.feature.myprofile.components.NearSwitch +import com.alarmy.near.presentation.feature.myprofile.model.LoginType +import com.alarmy.near.presentation.feature.myprofile.model.MyProfileInfoUIModel +import com.alarmy.near.presentation.feature.myprofile.model.TermsType +import com.alarmy.near.presentation.ui.component.NearFrame +import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar +import com.alarmy.near.presentation.ui.extension.ImageLoader +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +internal fun MyProfileRoute( + viewModel: MyProfileViewModel = hiltViewModel(), + onNavigateBack: () -> Unit, + onNavigateToLogin: () -> Unit, + onNavigateToWithdraw: (nickname: String) -> Unit, + onNavigateToTerms: (title: String, url: String) -> Unit, + onShowErrorSnackBar: (throwable: Throwable?) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val termsDetailFormat = stringResource(R.string.my_profile_terms_detail) + + val termsTitles = + mapOf( + TermsType.SERVICE_AGREED_TERMS to stringResource(TermsType.SERVICE_AGREED_TERMS.titleRes), + TermsType.PERSONAL_INFO_TERMS to stringResource(TermsType.PERSONAL_INFO_TERMS.titleRes), + TermsType.PRIVACY_POLICY_TERMS to stringResource(TermsType.PRIVACY_POLICY_TERMS.titleRes), + ) + + // 에러 이벤트 처리 + LaunchedEffect(viewModel.errorEvent) { + viewModel.errorEvent.collect { throwable -> + throwable?.let { onShowErrorSnackBar(it) } + } + } + + // UI 이벤트 처리 + LaunchedEffect(viewModel.uiEvent) { + viewModel.uiEvent.collect { event -> + when (event) { + is MyProfileUiEvent.NavigateBack -> { + onNavigateBack() + } + + is MyProfileUiEvent.ShowError -> { + onShowErrorSnackBar(event.throwable) + } + + is MyProfileUiEvent.Logout -> { + onNavigateToLogin() + } + + is MyProfileUiEvent.NavigateToWithdraw -> { + onNavigateToWithdraw(event.nickname) + } + + is MyProfileUiEvent.NavigateToTerms -> { + val title = termsTitles[event.termsType] ?: "" + onNavigateToTerms(termsDetailFormat.format(title), event.termsType.url) + } + } + } + } + + // 로딩 상태 처리 + if (uiState.isLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = NearTheme.colors.BLUE01_5AA2E9, + ) + } + } else { + MyProfileScreen( + uiState = uiState, + onNavigateBack = { viewModel.onNavigateBack() }, + onLogout = { viewModel.onLogout() }, + onWithdraw = { viewModel.onWithdraw() }, + onTermsClick = { termsType -> viewModel.onTermsClick(termsType) }, + ) + } +} + +@Composable +fun MyProfileScreen( + uiState: MyProfileUiState, + onNavigateBack: () -> Unit = {}, + onLogout: () -> Unit = {}, + onWithdraw: () -> Unit = {}, + onTermsClick: (TermsType) -> Unit = {}, +) { + NearFrame { + // 앱바 + NearTopAppbar( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.my_profile_title), + onClickBackButton = onNavigateBack, + ) + + MyProfileInfoSection(uiState) + + // 일반 정보 섹션 + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + MyProfileGeneralSection(uiState) + MyProfileServiceInfoSection(onLogout, onWithdraw, onTermsClick) + } + } +} + +@Composable +private fun ColumnScope.MyProfileInfoSection(uiState: MyProfileUiState) { + Spacer(modifier = Modifier.size(16.dp)) + + ImageLoader( + uri = uiState.memberInfo.imageUrl, + contentScale = ContentScale.Crop, + contentDescription = stringResource(R.string.my_profile_image_description), + modifier = + Modifier + .padding(horizontal = 24.dp) + .align(Alignment.CenterHorizontally), + ) + + Spacer(modifier = Modifier.size(16.dp)) + + // 프로필 네임 - 가운데 정렬 + Text( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = uiState.memberInfo.nickname, + style = NearTheme.typography.B1_16_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.size(40.dp)) +} + +@Composable +private fun MyProfileGeneralSection(uiState: MyProfileUiState) { + Text( + text = stringResource(R.string.my_profile_general_section), + style = NearTheme.typography.B1_16_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + + Spacer(modifier = Modifier.size(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.my_profile_connected_account), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + + // 로그인 타입에 따른 뱃지 + NearSocialLoginBadge( + loginType = uiState.memberInfo.providerType, + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + color = NearTheme.colors.GRAY03_EBEBEB, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.my_profile_notification_settings), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + + NearSwitch { } + } + + Spacer(modifier = Modifier.size(64.dp)) +} + +@Composable +private fun ColumnScope.MyProfileServiceInfoSection( + onLogout: () -> Unit, + onWithdraw: () -> Unit, + onTermsClick: (TermsType) -> Unit, +) { + Text( + text = stringResource(R.string.my_profile_service_info_section), + style = NearTheme.typography.B1_16_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + ) + + Spacer(modifier = Modifier.size(32.dp)) + + // 약관 및 정책 목록 + TermsType.entries.forEachIndexed { index, termsType -> + NearServiceInfoRow( + label = stringResource(termsType.titleRes), + onClick = { onTermsClick(termsType) }, + showDivider = index < TermsType.entries.size - 1, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + NearLogoutButton( + modifier = Modifier.fillMaxWidth(), + onClick = onLogout, + ) + + Spacer(modifier = Modifier.size(24.dp)) + + Text( + modifier = Modifier.onNoRippleClick(onClick = onWithdraw), + text = stringResource(R.string.my_profile_withdraw), + textDecoration = TextDecoration.Underline, + style = + NearTheme.typography.H1_24_REGULAR.copy( + fontSize = 14.sp, + color = NearTheme.colors.GRAY01_888888, + ), + ) + + Spacer(modifier = Modifier.weight(1f)) +} + +@Preview(showBackground = true) +@Composable +fun ProfileScreenPreview() { + NearTheme { + MyProfileScreen( + uiState = + MyProfileUiState( + isLoading = false, + memberInfo = + MyProfileInfoUIModel( + nickname = "테스트유저", + imageUrl = null, + notificationAgreedAt = null, + providerType = LoginType.KAKAO, + ), + ), + onNavigateBack = {}, + onLogout = {}, + onWithdraw = {}, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/MyProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/MyProfileViewModel.kt new file mode 100644 index 00000000..cd1496b8 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/MyProfileViewModel.kt @@ -0,0 +1,129 @@ +package com.alarmy.near.presentation.feature.myprofile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alarmy.near.data.mapper.toMyProfileInfoUIModel +import com.alarmy.near.data.repository.AuthRepository +import com.alarmy.near.data.repository.MemberRepository +import com.alarmy.near.presentation.feature.myprofile.model.LoginType +import com.alarmy.near.presentation.feature.myprofile.model.MyProfileInfoUIModel +import com.alarmy.near.presentation.feature.myprofile.model.TermsType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MyProfileViewModel + @Inject + constructor( + private val memberRepository: MemberRepository, + private val authRepository: AuthRepository, + ) : ViewModel() { + // 에러 이벤트 관리 + private val _errorEvent = Channel() + val errorEvent = _errorEvent.receiveAsFlow() + + // UI 이벤트 관리 + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + // UI 상태 관리 + val uiState: StateFlow = + memberRepository + .getMyInfo() + .catch { throwable -> + _errorEvent.send(throwable) + }.map { memberInfo -> + MyProfileUiState( + isLoading = false, + memberInfo = memberInfo.toMyProfileInfoUIModel(), + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = + MyProfileUiState( + isLoading = true, + memberInfo = + MyProfileInfoUIModel( + nickname = "", + imageUrl = null, + notificationAgreedAt = null, + providerType = LoginType.KAKAO, + ), + ), + ) + + /** + * 백 네비게이션 이벤트 발생 + */ + fun onNavigateBack() { + _uiEvent.trySend(MyProfileUiEvent.NavigateBack) + } + + /** + * 로그아웃 이벤트 발생 + */ + fun onLogout() { + viewModelScope.launch { + runCatching { + authRepository.logout() + }.onSuccess { + _uiEvent.trySend(MyProfileUiEvent.Logout) + }.onFailure { exception -> + _errorEvent.send(exception) + } + } + } + + /** + * 탈퇴하기 이벤트 발생 + */ + fun onWithdraw() { + val currentState = uiState.value + _uiEvent.trySend(MyProfileUiEvent.NavigateToWithdraw(currentState.memberInfo.nickname)) + } + + /** + * 약관 및 정책 클릭 이벤트 발생 + */ + fun onTermsClick(termsType: TermsType) { + _uiEvent.trySend(MyProfileUiEvent.NavigateToTerms(termsType)) + } + } + +/** + * MyProfile UI 상태 + */ +data class MyProfileUiState( + val isLoading: Boolean = false, + val memberInfo: MyProfileInfoUIModel, +) + +/** + * MyProfile UI 이벤트 + */ +sealed class MyProfileUiEvent { + data class ShowError( + val throwable: Throwable, + ) : MyProfileUiEvent() + + object NavigateBack : MyProfileUiEvent() + + object Logout : MyProfileUiEvent() + + data class NavigateToWithdraw( + val nickname: String, + ) : MyProfileUiEvent() + + data class NavigateToTerms( + val termsType: TermsType, + ) : MyProfileUiEvent() +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/WithdrawScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/WithdrawScreen.kt new file mode 100644 index 00000000..ad57b6ce --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/WithdrawScreen.kt @@ -0,0 +1,227 @@ +package com.alarmy.near.presentation.feature.myprofile + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +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.alarmy.near.R +import com.alarmy.near.presentation.feature.myprofile.model.WithdrawReason +import com.alarmy.near.presentation.ui.component.NearFrame +import com.alarmy.near.presentation.ui.component.appbar.NearCancelTopAppBar +import com.alarmy.near.presentation.ui.component.button.NearBasicButton +import com.alarmy.near.presentation.ui.component.button.NearLineTypeButton +import com.alarmy.near.presentation.ui.component.radiobutton.NearLargeRadioButton +import com.alarmy.near.presentation.ui.component.textfield.NearOutlinedTextField +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme +import kotlinx.coroutines.delay + +@Composable +fun WithdrawRoute( + viewModel: WithdrawViewModel = hiltViewModel(), + onNavigateBack: () -> Unit = {}, + onNavigateToLogin: () -> Unit = {}, + onShowErrorSnackBar: (Throwable?) -> Unit = {}, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // 통합된 이벤트 처리 + LaunchedEffect(viewModel.uiEvent) { + viewModel.uiEvent.collect { event -> + when (event) { + is WithdrawUiEvent.NavigateBack -> { + onNavigateBack() + } + is WithdrawUiEvent.NavigateToLogin -> { + onNavigateToLogin() + } + is WithdrawUiEvent.ShowError -> { + onShowErrorSnackBar(event.throwable) + } + } + } + } + + WithdrawScreen( + uiState = uiState, + onSelectReason = viewModel::selectReason, + onUpdateOtherReasonText = viewModel::updateOtherReasonText, + onSubmitWithdrawRequest = viewModel::submitWithdrawRequest, + onNavigateBack = viewModel::onNavigateBack, + ) +} + +@Composable +fun WithdrawScreen( + uiState: WithdrawUiState, + onSelectReason: (WithdrawReason) -> Unit, + onUpdateOtherReasonText: (String) -> Unit, + onSubmitWithdrawRequest: () -> Unit, + onNavigateBack: () -> Unit, +) { + // 4개의 탈퇴 사유 리스트 생성 + val withdrawReasons = remember { WithdrawReason.entries } + val textFieldFocusRequester = remember { FocusRequester() } + + // 기타 사유 선택 시 먼저 키보드를 올리고, 키보드가 완전히 올라온 후에 에러 상태 생성 + LaunchedEffect(uiState.isOtherReasonSelected) { + if (uiState.isOtherReasonSelected) { + textFieldFocusRequester.requestFocus() + delay(300) + onUpdateOtherReasonText("") + } + } + + NearFrame( + modifier = + Modifier + .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF) + .padding(horizontal = 24.dp), + ) { + NearCancelTopAppBar( + title = stringResource(R.string.withdraw_title), + onCancelClick = onNavigateBack, + ) + + Spacer(modifier = Modifier.size(48.dp)) + + Text( + text = stringResource(R.string.withdraw_greeting, uiState.nickname), + style = NearTheme.typography.H1_24_MEDIUM, + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Text( + text = stringResource(R.string.withdraw_description), + style = + NearTheme.typography.B1_16_MEDIUM.copy( + color = NearTheme.colors.GRAY01_888888, + ), + ) + + Spacer(modifier = Modifier.size(48.dp)) + + // 탈퇴 사유 버튼들을 표시 + withdrawReasons.forEach { reason -> + WithdrawReasonButtonAndLabel( + label = stringResource(reason.displayTextRes), + isSelected = uiState.selectedReason == reason, + onClick = { + onSelectReason(reason) + }, + ) + + // 마지막 항목이 아닌 경우에만 Spacer 추가 + if (reason != withdrawReasons.last()) { + Spacer(modifier = Modifier.size(32.dp)) + } + } + + Spacer(modifier = Modifier.size(16.dp)) + + NearOutlinedTextField( + value = uiState.otherReasonText, + onValueChange = onUpdateOtherReasonText, + placeholder = stringResource(R.string.withdraw_other_reason_placeholder), + enabled = uiState.isOtherReasonTextFieldEnabled, + isError = !uiState.isOtherReasonTextValid, + focusRequester = textFieldFocusRequester, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + NearBasicButton( + modifier = Modifier.weight(1f), + onClick = onNavigateBack, + contentPadding = PaddingValues(16.dp), + ) { + Text( + text = stringResource(R.string.withdraw_cancel_button), + style = NearTheme.typography.B1_16_BOLD, + ) + } + + Spacer(modifier = Modifier.size(7.dp)) + + // 탈퇴하기 버튼 + NearLineTypeButton( + modifier = Modifier.weight(1f), + enabled = uiState.isWithdrawButtonEnabled, + onClick = { + onSubmitWithdrawRequest() + }, + text = stringResource(R.string.withdraw_confirm_button), + contentPadding = PaddingValues(vertical = 16.dp), + ) + } + Spacer(modifier = Modifier.size(24.dp)) + } +} + +@Composable +private fun WithdrawReasonButtonAndLabel( + modifier: Modifier = Modifier, + label: String, + isSelected: Boolean = false, + onClick: () -> Unit = { }, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .onNoRippleClick(onClick = onClick), + ) { + NearLargeRadioButton( + selected = isSelected, + onClick = { newState -> + if (newState) { + onClick() + } + }, + ) + + Spacer(modifier = Modifier.size(8.dp)) + + Text( + text = label, + style = NearTheme.typography.B1_16_MEDIUM, + ) + } +} + +@Preview +@Composable +fun WithdrawScreenPreview() { + NearTheme { + WithdrawScreen( + uiState = WithdrawUiState(), + onSelectReason = {}, + onUpdateOtherReasonText = {}, + onSubmitWithdrawRequest = {}, + onNavigateBack = {}, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/WithdrawViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/WithdrawViewModel.kt new file mode 100644 index 00000000..99b4303f --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/WithdrawViewModel.kt @@ -0,0 +1,170 @@ +package com.alarmy.near.presentation.feature.myprofile + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.alarmy.near.data.repository.AuthRepository +import com.alarmy.near.data.repository.MemberRepository +import com.alarmy.near.presentation.feature.myprofile.model.WithdrawReason +import com.alarmy.near.presentation.feature.myprofile.navigation.RouteWithdraw +import com.alarmy.near.utils.logger.NearLog +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class WithdrawViewModel + @Inject + constructor( + private val memberRepository: MemberRepository, + private val authRepository: AuthRepository, + savedStateHandle: SavedStateHandle, + ) : ViewModel() { + private val nickname: String = savedStateHandle.toRoute().nickname + + // UI 상태 관리 + private val _uiState = MutableStateFlow(WithdrawUiState(nickname = nickname)) + val uiState: StateFlow = _uiState.asStateFlow() + + // UI 이벤트 관리 + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + /** + * 탈퇴 사유를 선택하는 함수 + */ + fun selectReason(reason: WithdrawReason) { + val currentState = _uiState.value + _uiState.value = + currentState.copy( + selectedReason = reason, + ) + } + + /** + * 기타 사유 텍스트를 업데이트하는 함수 + */ + fun updateOtherReasonText(text: String) { + val currentState = _uiState.value + _uiState.value = + currentState.copy( + otherReasonText = text, + ) + } + + /** + * 탈퇴 요청을 처리하는 함수 + */ + fun submitWithdrawRequest() { + val currentState = _uiState.value + val reason = currentState.selectedReason + + if (reason == null) { + _uiEvent.trySend(WithdrawUiEvent.ShowError(Exception("탈퇴 사유를 선택해주세요."))) + return + } + + // 로딩 상태 시작 + _uiState.value = + currentState.copy( + isLoading = true, + ) + + viewModelScope.launch { + val customReason = if (currentState.isOtherReasonSelected) { + currentState.otherReasonText.takeIf { it.isNotEmpty() } + } else { + null + } + + memberRepository + .withdraw(reason, customReason) + .catch { error -> + // 실패 시 에러 처리 + NearLog.d(error.message.toString()) + onWithdrawFailure(error) + }.collect { + // 성공 시 로그아웃 로직 실행 + onWithdrawSuccess() + } + } + } + + /** + * 탈퇴 성공 시 호출되는 함수 + */ + private fun onWithdrawSuccess() { + viewModelScope.launch { + runCatching { + authRepository.logout() + }.onSuccess { + _uiEvent.trySend(WithdrawUiEvent.NavigateToLogin) + }.onFailure { exception -> + NearLog.d(exception.message.toString()) + _uiState.value = _uiState.value.copy(isLoading = false) + _uiEvent.trySend(WithdrawUiEvent.ShowError(exception)) + } + } + } + + /** + * 탈퇴 실패 시 호출되는 함수 + */ + private fun onWithdrawFailure(exception: Throwable) { + _uiState.value = _uiState.value.copy(isLoading = false) + _uiEvent.trySend(WithdrawUiEvent.ShowError(exception)) + } + + /** + * 백 네비게이션 이벤트 발생 + */ + fun onNavigateBack() { + _uiEvent.trySend(WithdrawUiEvent.NavigateBack) + } + } + +/** + * 탈퇴 화면의 UI 상태 + */ +data class WithdrawUiState( + val nickname: String = "", + val selectedReason: WithdrawReason? = null, + val otherReasonText: String = "", + val isLoading: Boolean = false, +) { + // 기타 사유가 선택되었는지 확인 + val isOtherReasonSelected: Boolean + get() = selectedReason == WithdrawReason.REASON_OTHER + + // 기타 사유 텍스트 필드가 활성화되어야 하는지 확인 + val isOtherReasonTextFieldEnabled: Boolean + get() = isOtherReasonSelected + + // 기타 사유 텍스트가 유효한지 확인 + val isOtherReasonTextValid: Boolean + get() = !isOtherReasonSelected || otherReasonText.isNotEmpty() + + // 탈퇴하기 버튼이 활성화되어야 하는지 확인 + val isWithdrawButtonEnabled: Boolean + get() = selectedReason != null && isOtherReasonTextValid && !isLoading +} + +/** + * 탈퇴 화면의 UI 이벤트 + */ +sealed class WithdrawUiEvent { + object NavigateBack : WithdrawUiEvent() + + object NavigateToLogin : WithdrawUiEvent() + + data class ShowError( + val throwable: Throwable?, + ) : WithdrawUiEvent() +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearLogoutButton.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearLogoutButton.kt new file mode 100644 index 00000000..77dabd55 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearLogoutButton.kt @@ -0,0 +1,35 @@ +package com.alarmy.near.presentation.feature.myprofile.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.alarmy.near.R +import com.alarmy.near.presentation.ui.component.button.NearLineTypeButton +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun NearLogoutButton( + onClick: () -> Unit = {}, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = PaddingValues(vertical = 16.dp), +) { + NearLineTypeButton( + modifier = modifier, + enabled = enabled, + onClick = onClick, + text = stringResource(R.string.my_profile_logout), + contentPadding = contentPadding, + ) +} + +@Preview +@Composable +fun NearLogoutButtonPreview() { + NearTheme { + NearLogoutButton() + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearServiceInfoRow.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearServiceInfoRow.kt new file mode 100644 index 00000000..9c26b3b2 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearServiceInfoRow.kt @@ -0,0 +1,56 @@ +package com.alarmy.near.presentation.feature.myprofile.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun NearServiceInfoRow( + modifier: Modifier = Modifier, + label: String, + onClick: () -> Unit = {}, + showDivider: Boolean = true, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .onNoRippleClick(onClick) + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // 정보 라벨 + Text( + text = label, + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + + // 화살표 아이콘 + Icon( + painter = painterResource(R.drawable.ic_front_24_gray), + tint = NearTheme.colors.GRAY01_888888, + contentDescription = null, + ) + } + + // 구분선 (선택사항) + if (showDivider) { + HorizontalDivider( + color = NearTheme.colors.GRAY03_EBEBEB, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearSocialLoginBadge.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearSocialLoginBadge.kt new file mode 100644 index 00000000..e79575f9 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearSocialLoginBadge.kt @@ -0,0 +1,48 @@ +package com.alarmy.near.presentation.feature.myprofile.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.presentation.feature.myprofile.model.LoginType +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun NearSocialLoginBadge(loginType: LoginType) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // 소셜 로그인 텍스트 + Text( + text = stringResource(loginType.typeTitleRes), + style = NearTheme.typography.FC_12_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + + // 소셜 로그인 아이콘 + loginType.loginTypeImage?.let { logo -> + Image( + painter = painterResource(id = logo), + contentDescription = stringResource(R.string.login_social_icon_description), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun NearSocialLoginBadgePreview() { + NearTheme { + NearSocialLoginBadge( + loginType = LoginType.KAKAO, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearSwitch.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearSwitch.kt new file mode 100644 index 00000000..cab251e8 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/components/NearSwitch.kt @@ -0,0 +1,69 @@ +package com.alarmy.near.presentation.feature.myprofile.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun NearSwitch( + modifier: Modifier = Modifier, + checked: Boolean = false, + onCheckedChange: (Boolean) -> Unit = {}, +) { + Switch( + modifier = modifier.size(width = 48.dp, height = 26.dp), + colors = + SwitchDefaults.colors( + checkedTrackColor = NearTheme.colors.BLUE01_5AA2E9, + checkedThumbColor = NearTheme.colors.WHITE_FFFFFF, + uncheckedTrackColor = NearTheme.colors.GRAY03_EBEBEB, + uncheckedThumbColor = NearTheme.colors.WHITE_FFFFFF, + checkedBorderColor = Color.Transparent, + uncheckedBorderColor = Color.Transparent, + ), + checked = checked, + onCheckedChange = onCheckedChange, + thumbContent = { + Box( + modifier = + Modifier + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { } + }, + ) +} + +@Preview(showBackground = true) +@Composable +fun NearSwitchPreview() { + NearTheme { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // 선택된 상태 + NearSwitch( + checked = true, + onCheckedChange = {}, + ) + + // 선택 안된 상태 + NearSwitch( + checked = false, + onCheckedChange = {}, + ) + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/LoginType.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/LoginType.kt new file mode 100644 index 00000000..25323841 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/LoginType.kt @@ -0,0 +1,12 @@ +package com.alarmy.near.presentation.feature.myprofile.model + +import androidx.annotation.StringRes +import com.alarmy.near.R + +enum class LoginType( + @StringRes val typeTitleRes: Int, + @StringRes val loginTypeImage: Int? = null, +) { + KAKAO(R.string.login_type_kakao, R.drawable.ic_kakao_badge_32), + ETC(R.string.login_type_etc), +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/MyProfileInfoUIModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/MyProfileInfoUIModel.kt new file mode 100644 index 00000000..0ce0e5b4 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/MyProfileInfoUIModel.kt @@ -0,0 +1,8 @@ +package com.alarmy.near.presentation.feature.myprofile.model + +data class MyProfileInfoUIModel( + val nickname: String, + val imageUrl: String?, + val notificationAgreedAt: String?, + val providerType: LoginType, +) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/TermsType.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/TermsType.kt new file mode 100644 index 00000000..97086c34 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/TermsType.kt @@ -0,0 +1,26 @@ +package com.alarmy.near.presentation.feature.myprofile.model + +import androidx.annotation.StringRes +import com.alarmy.near.BuildConfig +import com.alarmy.near.R + +/** + * 약관 및 정책 타입 + */ +enum class TermsType( + @StringRes val titleRes: Int, + val url: String, +) { + SERVICE_AGREED_TERMS( + titleRes = R.string.terms_service_agreed, + url = BuildConfig.SERVICE_AGREED_TERMS_URL, + ), + PERSONAL_INFO_TERMS( + titleRes = R.string.terms_personal_info, + url = BuildConfig.PERSONAL_INFO_TERMS_URL, + ), + PRIVACY_POLICY_TERMS( + titleRes = R.string.terms_privacy_policy, + url = BuildConfig.PRIVACY_POLICY_TERMS_URL, + ), +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/WithdrawReason.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/WithdrawReason.kt new file mode 100644 index 00000000..4a882c35 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/model/WithdrawReason.kt @@ -0,0 +1,15 @@ +package com.alarmy.near.presentation.feature.myprofile.model + +import androidx.annotation.StringRes +import com.alarmy.near.R + +/** + * 탈퇴 사유를 나타내는 enum + */ +enum class WithdrawReason(@StringRes val displayTextRes: Int) { + REASON_DONT_USE_OFTEN(R.string.withdraw_reason_not_often), + REASON_NEW_ACCOUNT(R.string.withdraw_reason_new_account), + REASON_WORRIED_INFORMATION(R.string.withdraw_reason_worried_info), + REASON_INCONVENIENT_SERVICE(R.string.withdraw_reason_inconvenient), + REASON_OTHER(R.string.withdraw_reason_other), +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/navigation/NavigationMyProfile.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/navigation/NavigationMyProfile.kt new file mode 100644 index 00000000..24bc4a7c --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/myprofile/navigation/NavigationMyProfile.kt @@ -0,0 +1,75 @@ +package com.alarmy.near.presentation.feature.myprofile.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import com.alarmy.near.presentation.feature.myprofile.MyProfileRoute +import com.alarmy.near.presentation.feature.myprofile.WithdrawRoute +import com.alarmy.near.presentation.ui.component.WebViewFrame +import kotlinx.serialization.Serializable + +@Serializable +object RouteMyProfile + +@Serializable +data class RouteWithdraw( + val nickname: String, +) + +@Serializable +data class RouteWebView( + val title: String, + val url: String, +) + +fun NavController.navigateToMyProfile() { + navigate(RouteMyProfile) +} + +fun NavController.navigateToWithdraw(nickname: String) { + navigate(RouteWithdraw(nickname)) +} + +fun NavController.navigateToWebView( + title: String, + url: String, +) { + navigate(RouteWebView(title, url)) +} + +fun NavGraphBuilder.myProfileNavGraph( + onNavigateBack: () -> Unit, + onNavigateToLogin: () -> Unit, + onNavigateToWithdraw: (nickname: String) -> Unit, + onNavigateToTerms: (title: String, url: String) -> Unit, + onShowErrorSnackBar: (throwable: Throwable?) -> Unit, +) { + composable { + MyProfileRoute( + onNavigateBack = onNavigateBack, + onNavigateToLogin = onNavigateToLogin, + onNavigateToWithdraw = onNavigateToWithdraw, + onNavigateToTerms = onNavigateToTerms, + onShowErrorSnackBar = onShowErrorSnackBar, + ) + } + + composable { + WithdrawRoute( + onNavigateBack = onNavigateBack, + onNavigateToLogin = onNavigateToLogin, + onShowErrorSnackBar = onShowErrorSnackBar, + ) + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + WebViewFrame( + onNavigateBack = onNavigateBack, + title = route.title, + url = route.url, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt new file mode 100644 index 00000000..48928afc --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt @@ -0,0 +1,234 @@ +package com.alarmy.near.presentation.feature.onboarding + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.alarmy.near.R +import com.alarmy.near.presentation.feature.onboarding.components.BackgroundArea +import com.alarmy.near.presentation.feature.onboarding.components.OnboardingButton +import com.alarmy.near.presentation.feature.onboarding.components.PageIndicator +import com.alarmy.near.presentation.feature.onboarding.model.OnboardingPage +import com.alarmy.near.presentation.ui.theme.NearTheme +import kotlinx.coroutines.launch + +/** + * 온보딩 화면 메인 컴포넌트 + * 5페이지로 구성된 뷰페이저 형태의 온보딩 화면 + */ +@Composable +fun OnboardingScreen( + onNavigateToLogin: () -> Unit, + viewModel: OnboardingViewModel = hiltViewModel(), +) { + // UI 상태 관찰 + val uiState by viewModel.uiState.collectAsState() + + // 사이드 이펙트 처리 + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is OnboardingEffect.NavigateToLogin -> { + onNavigateToLogin() + } + } + } + } + + // 온보딩 페이지 데이터 - remember로 성능 최적화 + val pages = remember { + listOf( + OnboardingPage( + titleResId = R.string.first_onboarding_title, + image = R.drawable.img_onboarding_page_first, + ), + OnboardingPage( + titleResId = R.string.second_onboarding_title, + image = R.drawable.img_onboarding_page_second, + ), + OnboardingPage( + titleResId = R.string.third_onboarding_title, + image = R.drawable.img_onboarding_page_third, + ), + OnboardingPage( + titleResId = R.string.fourth_onboarding_title, + image = R.drawable.img_onboarding_page_forth, + ), + OnboardingPage( + titleResId = R.string.fifth_onboarding_title, + image = R.drawable.img_onboarding_page_fifth, + ), + ) + } + + // 상태바와 네비게이션 바 높이 계산 + val density = LocalDensity.current + val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + + // 페이저 상태 관리 + val pagerState = rememberPagerState(pageCount = { pages.size }) + val scope = rememberCoroutineScope() + + Box( + modifier = Modifier.fillMaxSize(), + ) { + BackgroundArea() + Column( + modifier = Modifier + .padding(top = statusBarHeightDp, bottom = navigationBarHeightDp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 뷰페이저 + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f), + ) { page -> + OnboardingPageContent( + page = pages[page], + modifier = Modifier.fillMaxSize(), + ) + } + + Spacer(modifier = Modifier.size(25.dp)) + + // 페이지 인디케이터 + PageIndicator( + pageCount = pages.size, + currentPage = pagerState.currentPage, + ) + + Spacer(modifier = Modifier.size(32.dp)) + + // 다음/완료 버튼 + OnboardingButton( + currentPage = pagerState.currentPage, + totalPages = pages.size, + isLoading = uiState.isLoading, + onNextClick = { + if (pagerState.currentPage < pages.size - 1) { + scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } else { + // 온보딩 완료 시 DataStore에 저장 + viewModel.completeOnboarding() + } + }, + ) + Spacer(modifier = Modifier.size(24.dp)) + } + } +} + +/** + * 온보딩 페이지 콘텐츠 컴포넌트 + * 각 페이지의 제목과 설명을 표시 + */ +@Composable +private fun OnboardingPageContent( + page: OnboardingPage, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.size(45.dp)) + + // 각 온보딩 페이지 타이틀 + Text( + text = createAnnotatedText(stringResource(page.titleResId)), + textAlign = TextAlign.Center, + style = + NearTheme.typography.H1_24_BOLD.copy( + fontSize = 20.sp, + lineHeight = 30.sp, + ), + ) + + Spacer(modifier = Modifier.size(24.dp)) + + Image( + modifier = Modifier.weight(1f), + painter = painterResource(page.image), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + + } +} + +/** + * \n 이후 텍스트에 다른 색상을 적용하는 AnnotatedString 생성 + * 현 페이지에서 \n 이후 텍스트 색상이 다른 규칙이 있습니다. + */ +@Composable +private fun createAnnotatedText( + text: String, + defaultColor: Color = NearTheme.colors.BLACK_1A1A1A, + highlightColor: Color = NearTheme.colors.BLUE01_5AA2E9, +): AnnotatedString = + buildAnnotatedString { + val newLineIndex = text.indexOf("\n") + + if (newLineIndex != -1) { + // \n 이전 텍스트 (기본 색상) + appendStyledText(text.substring(0, newLineIndex), defaultColor) + // \n 이후 텍스트 (강조 색상) + appendStyledText(text.substring(newLineIndex), highlightColor) + } else { + // 텍스트에 \n가 없는 경우 + appendStyledText(text, defaultColor) + } + } + +/** + * 지정된 색상으로 텍스트를 추가하는 함수 + */ +private fun AnnotatedString.Builder.appendStyledText(text: String, color: Color) { + withStyle(style = SpanStyle(color = color)) { + append(text) + } +} + +@Preview(showBackground = true) +@Composable +fun OnboardingScreenPreview() { + NearTheme { + OnboardingScreen( + onNavigateToLogin = {}, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingViewModel.kt new file mode 100644 index 00000000..d5c65e72 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingViewModel.kt @@ -0,0 +1,63 @@ +package com.alarmy.near.presentation.feature.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alarmy.near.data.repository.OnBoardingRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 온보딩 화면의 상태와 로직을 관리하는 ViewModel + */ +@HiltViewModel +class OnboardingViewModel @Inject constructor( + private val onBoardingRepository: OnBoardingRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(OnboardingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effect = MutableSharedFlow() + val effect = _effect.asSharedFlow() + + /** + * 온보딩 완료 처리 + */ + fun completeOnboarding() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + + runCatching { + onBoardingRepository.markOnboardingAsCompleted() + }.onSuccess { + _effect.emit(OnboardingEffect.NavigateToLogin) + }.onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = exception.message + ) + } + } + } + +} + +/** + * 온보딩 UI 상태 + */ +data class OnboardingUiState( + val isLoading: Boolean = false, + val error: String? = null, +) + +/** + * 온보딩 사이드 이펙트 + */ +sealed class OnboardingEffect { + object NavigateToLogin : OnboardingEffect() +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/BackgroundArea.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/BackgroundArea.kt new file mode 100644 index 00000000..0f6f6062 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/BackgroundArea.kt @@ -0,0 +1,85 @@ +package com.alarmy.near.presentation.feature.onboarding.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.BlurEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.alarmy.near.presentation.ui.theme.NearTheme + +/** + * 배경 장식 컴포넌트 + * 원형 블러 배경 장식을 제공하는 컴포넌트 + */ +@Composable +fun BackgroundArea() { + Box( + modifier = Modifier + .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF), + ) { + BackgroundEllipse( + modifier = Modifier.offset(x = (-78).dp), + ) + + BackgroundEllipse( + modifier = Modifier.offset(x = 201.dp, y = 155.dp), + opacity = 0.2f, + color = NearTheme.colors.PURPLE01_4E3EC7, + ) + } +} + +@Composable +fun BackgroundEllipse( + modifier: Modifier = Modifier, + color: Color = NearTheme.colors.BLUE03_58ABEC, + blurRadius: Float = 200f, + size: Dp = 251.dp, + opacity: Float = 0.3f, +) { + Box( + modifier = modifier + .size(size) + .graphicsLayer { + renderEffect = BlurEffect( + radiusX = blurRadius, + radiusY = blurRadius, + edgeTreatment = TileMode.Clamp, + ) + } + .alpha(opacity) + .background( + color = color, + shape = CircleShape + ) + ) +} + +@Preview(showBackground = true) +@Composable +fun BackgroundAreaPreview() { + NearTheme { + BackgroundArea() + } +} + +@Preview() +@Composable +fun BackgroundDecorationPreview() { + NearTheme { + BackgroundEllipse() + } +} + diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/OnboardingButton.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/OnboardingButton.kt new file mode 100644 index 00000000..9e33148b --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/OnboardingButton.kt @@ -0,0 +1,62 @@ +package com.alarmy.near.presentation.feature.onboarding.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.alarmy.near.R +import com.alarmy.near.presentation.ui.component.button.NearBasicButton +import com.alarmy.near.presentation.ui.theme.NearTheme + +/** + * 온보딩 화면용 버튼 컴포넌트 + */ +@Composable +fun OnboardingButton( + currentPage: Int, + totalPages: Int, + isLoading: Boolean = false, + onNextClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isLastPage = currentPage == totalPages - 1 + val buttonText = if (isLastPage) stringResource(R.string.onboarding_auth_button_text) else stringResource(R.string.onboarding_next_button_text) + + NearBasicButton( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + onClick = onNextClick, + enabled = !isLoading, + contentPadding = PaddingValues(vertical = 17.dp), + ) { + Text( + text = buttonText, + style = + NearTheme.typography.B1_16_BOLD, + ) + } +} + +@Preview +@Composable +fun OnboardingButtonPreview() { + NearTheme { + OnboardingButton(currentPage = 0, totalPages = 5, onNextClick = {}) + } +} + +@Preview +@Composable +fun OnboardingButtonLastPreview() { + NearTheme { + OnboardingButton(currentPage = 4, totalPages = 5, onNextClick = {}) + } +} + diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/PageIndicator.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/PageIndicator.kt new file mode 100644 index 00000000..63b6a6d4 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/PageIndicator.kt @@ -0,0 +1,51 @@ +package com.alarmy.near.presentation.feature.onboarding.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.presentation.ui.theme.NearTheme + +/** + * 페이지 인디케이터 컴포넌트 + * 현재 페이지를 시각적으로 표시하는 점들 + */ +@Composable +fun PageIndicator( + pageCount: Int, + currentPage: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(pageCount) { index -> + val isSelected = index == currentPage + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background( + if (isSelected) NearTheme.colors.BLUE01_5AA2E9 else NearTheme.colors.GRAY03_EBEBEB, + ), + ) + } + } +} + +@Preview +@Composable +fun PageIndicatorPreview() { + NearTheme { + PageIndicator(pageCount = 3, currentPage = 1) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/model/OnboardingPage.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/model/OnboardingPage.kt new file mode 100644 index 00000000..ab0290eb --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/model/OnboardingPage.kt @@ -0,0 +1,13 @@ +package com.alarmy.near.presentation.feature.onboarding.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes + +/** + * 온보딩 페이지 데이터 모델 + * 각 페이지의 정보를 담는 데이터 클래스 + */ +data class OnboardingPage( + @StringRes val titleResId: Int, + @DrawableRes val image: Int, +) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/navigation/NavigationOnboarding.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/navigation/NavigationOnboarding.kt new file mode 100644 index 00000000..b8d39ef9 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/navigation/NavigationOnboarding.kt @@ -0,0 +1,26 @@ +package com.alarmy.near.presentation.feature.onboarding.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.alarmy.near.presentation.feature.onboarding.OnboardingScreen +import kotlinx.serialization.Serializable + +@Serializable +object RouteOnboarding + +// 온보딩 화면으로 네비게이션 +fun NavController.navigateToOnboarding() { + navigate(RouteOnboarding) { + popUpTo(0) { inclusive = true } + } +} + +// 온보딩 네비게이션 그래프 +fun NavGraphBuilder.onboardingNavGraph(onNavigateToLogin: () -> Unit) { + composable { + OnboardingScreen( + onNavigateToLogin = onNavigateToLogin, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/WebViewFrame.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/WebViewFrame.kt new file mode 100644 index 00000000..e858f076 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/WebViewFrame.kt @@ -0,0 +1,69 @@ +package com.alarmy.near.presentation.ui.component + +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.alarmy.near.presentation.ui.component.appbar.NearCancelTopAppBar + +@Composable +fun WebViewFrame( + onNavigateBack: () -> Unit, + title: String, + url: String, + modifier: Modifier = Modifier, +) { + var canGoBack by remember { mutableStateOf(false) } + var webView: WebView? by remember { mutableStateOf(null) } + + BackHandler(enabled = canGoBack) { + webView?.goBack() + } + + NearFrame { + NearCancelTopAppBar( + modifier = Modifier.padding(horizontal = 24.dp), + title = title, + onCancelClick = onNavigateBack, + ) + + AndroidView( + modifier = modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + webView = this + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + // 페이지 로드 완료 시 뒤로가기 가능 상태 업데이트 + canGoBack = view?.canGoBack() ?: false + } + } + settings.apply { + domStorageEnabled = true + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + loadWithOverviewMode = true + useWideViewPort = true + builtInZoomControls = true + displayZoomControls = false + } + loadUrl(url) + } + }, + update = { view -> + // WebView 업데이트 시 뒤로가기 가능 상태 동기화 + canGoBack = view.canGoBack() + } + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/appbar/NearCancelTopAppBar.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/appbar/NearCancelTopAppBar.kt new file mode 100644 index 00000000..461700e5 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/appbar/NearCancelTopAppBar.kt @@ -0,0 +1,56 @@ +package com.alarmy.near.presentation.ui.component.appbar + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +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.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun NearCancelTopAppBar( + modifier: Modifier = Modifier, + title: String = "", + onCancelClick: () -> Unit = {} +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = NearTheme.typography.B1_16_BOLD, + color = NearTheme.colors.BLACK_1A1A1A + ) + + Image( + modifier = Modifier + .onNoRippleClick(onClick = onCancelClick), + painter = painterResource(id = R.drawable.ic_32_cancel), + contentDescription = "나가기" + ) + } +} + +@Preview(showBackground = true) +@Composable +fun NearCancelTopAppBarPreview() { + NearTheme { + NearCancelTopAppBar( + title = "탈퇴하기", + onCancelClick = { } + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/NearOutlinedTextField.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/NearOutlinedTextField.kt new file mode 100644 index 00000000..4b8e70a2 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/NearOutlinedTextField.kt @@ -0,0 +1,336 @@ +package com.alarmy.near.presentation.ui.component.textfield + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldColors +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.rememberCoroutineScope +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.graphics.Shape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.presentation.ui.component.textfield.internal.NearTextFieldColors +import com.alarmy.near.presentation.ui.theme.NearTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +// 상수 정의 +private val DEFAULT_MIN_HEIGHT = 56.dp +private val CHARACTER_COUNT_END_PADDING = 92.dp +private val ERROR_MESSAGE_START_PADDING = 16.dp +private val ERROR_MESSAGE_VERTICAL_PADDING = 6.dp + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun NearOutlinedTextField( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean = true, + placeholder: String = "", + isError: Boolean = false, + focusRequester: FocusRequester, + maxLines: Int = Int.MAX_VALUE, + maxLength: Int = 200, + shape: Shape = RoundedCornerShape(12.dp), + colors: TextFieldColors = NearTextFieldColors(), + contentPadding: PaddingValues = PaddingValues(16.dp), + focusedBorderThickness: Float = 1.5f, // 포커스 시 border + unfocusedBorderThickness: Float = 1f, // 포커스 해제 시 border + showCharacterCount: Boolean = true, // 굴자수 보이기 +) { + val coroutineScope = rememberCoroutineScope() + val interactionSource = remember { MutableInteractionSource() } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + var hasEverBeenTyped by remember { mutableStateOf(false) } + + LaunchedEffect(value) { + // 한 번이라도 텍스트가 입력되면 hasEverBeenTyped을 true로 설정 + if (value.isNotEmpty() && !hasEverBeenTyped) { + hasEverBeenTyped = true + } + } + + // 에러 상태일 때 스크롤 + LaunchedEffect(isError) { + coroutineScope.launch { + delay(300) + bringIntoViewRequester.bringIntoView() + } + } + + // 글자 수가 표시될 때 텍스트 영역을 위한 패딩 조정 + val adjustedContentPadding = + remember(showCharacterCount, contentPadding) { + if (!showCharacterCount) return@remember contentPadding + + PaddingValues( + start = contentPadding.calculateStartPadding(LayoutDirection.Ltr), + top = contentPadding.calculateTopPadding(), + end = contentPadding.calculateEndPadding(LayoutDirection.Ltr) + CHARACTER_COUNT_END_PADDING, + bottom = contentPadding.calculateBottomPadding(), + ) + } + + Column( + modifier = + modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + BasicTextField( + value = value, + onValueChange = { newValue -> + if (newValue.length > maxLength) { + return@BasicTextField + } + onValueChange(newValue) + }, + enabled = enabled, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = DEFAULT_MIN_HEIGHT) + .focusRequester(focusRequester), + textStyle = NearTheme.typography.B2_14_MEDIUM, + maxLines = maxLines, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = value, + innerTextField = innerTextField, + enabled = enabled, + singleLine = maxLines == 1, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + contentPadding = adjustedContentPadding, + placeholder = { + Text( + text = placeholder, + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY02_B7B7B7, + ) + }, + colors = colors, + container = { + OutlinedTextFieldDefaults.Container( + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + shape = shape, + focusedBorderThickness = focusedBorderThickness.dp, + unfocusedBorderThickness = unfocusedBorderThickness.dp, + ) + }, + ) + }, + ) + + // 글자수는 한 번이라도 입력된 후에는 계속 표시 + if (showCharacterCount && hasEverBeenTyped) { + val characterCountPadding = + remember(contentPadding) { + Modifier.padding( + end = contentPadding.calculateEndPadding(LayoutDirection.Ltr), + bottom = contentPadding.calculateBottomPadding(), + ) + } + + CharacterCountText( + currentLength = value.length, + maxLength = maxLength, + modifier = + Modifier + .align(Alignment.BottomEnd) + .wrapContentSize() + .then(characterCountPadding), + ) + } + } + + // 에러 메시지 표시 + if (isError) { + Text( + text = stringResource(R.string.textfield_error_message), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.NEGATIVE_F04E4E, + modifier = + Modifier + .padding(start = ERROR_MESSAGE_START_PADDING) + .padding(vertical = ERROR_MESSAGE_VERTICAL_PADDING), + ) + } + } +} + +/** + * 글자 수 표시 텍스트 컴포넌트 + */ +@Composable +private fun CharacterCountText( + modifier: Modifier = Modifier, + currentLength: Int, + maxLength: Int, +) { + Text( + modifier = modifier, + textAlign = TextAlign.End, + text = "$currentLength/$maxLength", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY02_B7B7B7, + ) +} + +@Preview(name = "기본 상태", showBackground = true) +@Composable +fun NearOutlinedTextFieldPreview_Default() { + NearTheme { + Surface(modifier = Modifier.padding(16.dp)) { + NearOutlinedTextField( + value = "", + onValueChange = {}, + placeholder = "기본 텍스트필드", + focusRequester = remember { FocusRequester() }, + ) + } + } +} + +@Preview(name = "텍스트 입력됨", showBackground = true) +@Composable +fun NearOutlinedTextFieldPreview_WithText() { + NearTheme { + Surface(modifier = Modifier.padding(16.dp)) { + NearOutlinedTextField( + value = "입력된 텍스트입니다", + onValueChange = {}, + placeholder = "텍스트 입력", + focusRequester = remember { FocusRequester() }, + ) + } + } +} + +@Preview(name = "비활성화 상태", showBackground = true) +@Composable +fun NearOutlinedTextFieldPreview_Disabled() { + NearTheme { + Surface(modifier = Modifier.padding(16.dp)) { + NearOutlinedTextField( + value = "비활성화된 텍스트", + onValueChange = {}, + placeholder = "비활성화", + enabled = false, + focusRequester = remember { FocusRequester() }, + ) + } + } +} + +@Preview(name = "멀티라인", showBackground = true) +@Composable +fun NearOutlinedTextFieldPreview_Multiline() { + NearTheme { + Surface(modifier = Modifier.padding(16.dp)) { + NearOutlinedTextField( + value = "여러 줄에 걸쳐 입력된\n긴 텍스트입니다.\n이렇게 멀티라인으로\n표시됩니다.", + onValueChange = {}, + placeholder = "여러 줄 텍스트 입력", + maxLines = 4, + focusRequester = remember { FocusRequester() }, + ) + } + } +} + +@Preview(name = "포커스 상태 (인터랙티브)", showBackground = true) +@Composable +fun NearOutlinedTextFieldPreview_Interactive() { + var text by remember { mutableStateOf("") } + + NearTheme { + Surface(modifier = Modifier.padding(16.dp)) { + NearOutlinedTextField( + value = text, + onValueChange = { text = it }, + placeholder = "클릭해서 포커스 테스트", + modifier = Modifier.fillMaxWidth(), + focusRequester = remember { FocusRequester() }, + ) + } + } +} + +@Preview(name = "글자 수 표시", showBackground = true) +@Composable +fun NearOutlinedTextFieldPreview_CharacterCount() { + var text by remember { mutableStateOf("글자 수가 표시되는 텍스트필드입니다.") } + + NearTheme { + Surface(modifier = Modifier.padding(16.dp)) { + NearOutlinedTextField( + value = text, + onValueChange = { text = it }, + placeholder = "글자 수 표시", + showCharacterCount = true, + maxLength = 50, + modifier = Modifier.fillMaxWidth(), + focusRequester = remember { FocusRequester() }, + ) + } + } +} + +@Preview(name = "에러 상태", showBackground = true) +@Composable +fun NearOutlinedTextFieldPreview_Error() { + NearTheme { + Surface(modifier = Modifier.padding(16.dp)) { + NearOutlinedTextField( + value = "에러가 있는 텍스트", + onValueChange = {}, + placeholder = "에러 상태 테스트", + isError = true, + modifier = Modifier.fillMaxWidth(), + focusRequester = remember { FocusRequester() }, + ) + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/extension/ImageLoader.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/extension/ImageLoader.kt new file mode 100644 index 00000000..bbbaa9b6 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/extension/ImageLoader.kt @@ -0,0 +1,49 @@ +package com.alarmy.near.presentation.ui.extension + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.alarmy.near.R + +/** + * 이미지 로딩 확장 함수 + * Coil 라이브러리를 사용하여 이미지를 비동기적으로 로드합니다. + */ +@Composable +fun ImageLoader( + uri: String?, + contentScale: ContentScale = ContentScale.Crop, + placeholder: Int = R.drawable.img_80_user1, + error: Int = R.drawable.img_80_user1, + contentDescription: String? = null, + modifier: Modifier = Modifier, +) { + if (!uri.isNullOrEmpty()) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(uri) + .crossfade(true) + .build(), + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + placeholder = painterResource(id = placeholder), + error = painterResource(id = error), + ) + } else { + // URI가 null이거나 비어있을 경우 기본 이미지 표시 + Image( + painter = painterResource(id = placeholder), + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Color.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Color.kt index 61de2917..45d620b4 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Color.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Color.kt @@ -13,10 +13,13 @@ object NearColorPallete { val GRAY04_F7F7F7 = Color(0xFFF7F7F7) val BLUE01_5AA2E9 = Color(0xFF5AA2E9) val BLUE02_8ACCFF = Color(0xFF8ACCFF) + val BLUE03_58ABEC = Color(0xFF58ABEC) val BG01_E3F0F9 = Color(0xFFE3F0F9) val BG02_F4F9FD = Color(0xFFF4F9FD) val NEGATIVE_F04E4E = Color(0xFFF04E4E) val DIM_000000 = Color(0x99000000) + + val PURPLE01_4E3EC7 = Color(0xFF4E3EC7) } @Suppress("PropertyName") @@ -33,6 +36,9 @@ data class NearColor( val BG02_F4F9FD: Color = NearColorPallete.BG02_F4F9FD, val NEGATIVE_F04E4E: Color = NearColorPallete.NEGATIVE_F04E4E, val DIM_000000: Color = NearColorPallete.DIM_000000, + // 온보딩 배경 장식 색상 + val BLUE03_58ABEC: Color = NearColorPallete.BLUE03_58ABEC, + val PURPLE01_4E3EC7: Color = NearColorPallete.PURPLE01_4E3EC7, ) val darkColor = NearColor() diff --git a/Near/app/src/main/java/com/alarmy/near/utils/extensions/FlowExtensions.kt b/Near/app/src/main/java/com/alarmy/near/utils/extensions/FlowExtensions.kt new file mode 100644 index 00000000..3bf0e2d7 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/utils/extensions/FlowExtensions.kt @@ -0,0 +1,9 @@ +package com.alarmy.near.utils.extensions + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +inline fun apiCallFlow(crossinline apiCall: suspend () -> T): Flow = + flow { + emit(apiCall()) + } diff --git a/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_fifth.png b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_fifth.png new file mode 100644 index 00000000..c5429c1b Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_fifth.png differ diff --git a/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_first.png b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_first.png new file mode 100644 index 00000000..e2c31e78 Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_first.png differ diff --git a/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_forth.png b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_forth.png new file mode 100644 index 00000000..84cbf30c Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_forth.png differ diff --git a/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_second.png b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_second.png new file mode 100644 index 00000000..d5a81024 Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_second.png differ diff --git a/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_third.png b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_third.png new file mode 100644 index 00000000..b0895a42 Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_third.png differ diff --git a/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_fifth.png b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_fifth.png new file mode 100644 index 00000000..dc3e4d48 Binary files /dev/null and b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_fifth.png differ diff --git a/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_first.png b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_first.png new file mode 100644 index 00000000..89327daf Binary files /dev/null and b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_first.png differ diff --git a/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_forth.png b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_forth.png new file mode 100644 index 00000000..35375156 Binary files /dev/null and b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_forth.png differ diff --git a/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_second.png b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_second.png new file mode 100644 index 00000000..5f7b59d9 Binary files /dev/null and b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_second.png differ diff --git a/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_third.png b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_third.png new file mode 100644 index 00000000..20bd4402 Binary files /dev/null and b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_third.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_fifth.png b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_fifth.png new file mode 100644 index 00000000..4586afd6 Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_fifth.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_first.png b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_first.png new file mode 100644 index 00000000..2a910597 Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_first.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_forth.png b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_forth.png new file mode 100644 index 00000000..aeccba99 Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_forth.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_second.png b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_second.png new file mode 100644 index 00000000..48b7a611 Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_second.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_third.png b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_third.png new file mode 100644 index 00000000..8892663c Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_third.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_fifth.png b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_fifth.png new file mode 100644 index 00000000..083c53fe Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_fifth.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_first.png b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_first.png new file mode 100644 index 00000000..ad6490c0 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_first.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_forth.png b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_forth.png new file mode 100644 index 00000000..c1f11f3c Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_forth.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_second.png b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_second.png new file mode 100644 index 00000000..e5e07077 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_second.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_third.png b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_third.png new file mode 100644 index 00000000..2a907a2a Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_third.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_fifth.png b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_fifth.png new file mode 100644 index 00000000..df5f9c63 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_fifth.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_first.png b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_first.png new file mode 100644 index 00000000..df693e92 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_first.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_forth.png b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_forth.png new file mode 100644 index 00000000..076d62e9 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_forth.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_second.png b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_second.png new file mode 100644 index 00000000..e5b4d61e Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_second.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_third.png b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_third.png new file mode 100644 index 00000000..e6c9cdf3 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_third.png differ diff --git a/Near/app/src/main/res/drawable/ic_32_cancel.xml b/Near/app/src/main/res/drawable/ic_32_cancel.xml new file mode 100644 index 00000000..ec893332 --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_32_cancel.xml @@ -0,0 +1,9 @@ + + + diff --git a/Near/app/src/main/res/drawable/ic_front_24_gray.xml b/Near/app/src/main/res/drawable/ic_front_24_gray.xml new file mode 100644 index 00000000..c415895c --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_front_24_gray.xml @@ -0,0 +1,13 @@ + + + diff --git a/Near/app/src/main/res/drawable/ic_kakao_badge_32.xml b/Near/app/src/main/res/drawable/ic_kakao_badge_32.xml new file mode 100644 index 00000000..3ecf3979 --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_kakao_badge_32.xml @@ -0,0 +1,15 @@ + + + + diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index 8ec92e67..f277f970 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -11,6 +11,15 @@ 스플래쉬 배경 + + 소중한 사람들과\n더 가까워질 수 있도록 + 연락처와 카카오톡으로\n챙길 사람 등록하기 + 챙기고 싶은 날에\n알림 받기 + 오늘도 잘 챙겼는지\n기록 남기기 + 더 잘 챙길 수 있게\n메시지 추천 받기 + 로그인/회원가입 + 다음 + Near 로고 Near 타이틀 @@ -91,4 +100,41 @@ 토요일 일요일 + + MY + 프로필 이미지 + 일반 + 연결계정 + 알림 설정 + 서비스 정보 + 로그아웃 + 탈퇴하기 + %1$s 상세 + + + 탈퇴하기 + %1$s님,\n떠나는 이유를 알려주시면\n큰 도움이 될 거예요. + 소중한 의견을 받아\n더 나은 서비스를 만들어갈게요. + 자주 이용하지 않아요 + 신규 계정으로 가입할게요 + 개인정보가 우려돼요 + 서비스가 불편해요 + 기타 + 편하게 의견을 남겨주세요. + 그만두기 + 탈퇴하기 + + + 1글자 이상 입력해주세요. + + + 서비스 이용약관 + 개인정보 수집 및 이용동의 + 개인정보 처리방침 + + + 카카오 + - + 소셜 로그인 아이콘 + diff --git a/Near/gradle/libs.versions.toml b/Near/gradle/libs.versions.toml index 0c32d4ea..2ec04d95 100644 --- a/Near/gradle/libs.versions.toml +++ b/Near/gradle/libs.versions.toml @@ -13,8 +13,8 @@ hiltVersion = "2.57" hiltNavigationVersion = "1.2.0" # Retrofit retrofitVersion = "3.0.0" -# Glide -glideVersion = "4.16.0" +# Coil +coilVersion = "2.7.0" # Room roomVersion = "2.6.1" # Ktlint @@ -53,8 +53,7 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hiltVersion" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationVersion" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofitVersion" } -glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glideVersion" } -glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", version.ref = "glideVersion" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilVersion" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "roomVersion" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomVersion" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomVersion" }