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 1e85f397..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)
@@ -99,6 +104,9 @@ dependencies {
// Kakao Module
implementation(libs.v2.all)
+
+ // Splash Screen API
+ implementation(libs.androidx.core.splashscreen)
}
fun getProperty(propertyKey: String): String = gradleLocalProperties(rootDir, providers).getProperty(propertyKey)
diff --git a/Near/app/src/main/AndroidManifest.xml b/Near/app/src/main/AndroidManifest.xml
index b0ae0a68..a5539033 100644
--- a/Near/app/src/main/AndroidManifest.xml
+++ b/Near/app/src/main/AndroidManifest.xml
@@ -3,13 +3,14 @@
xmlns:tools="http://schemas.android.com/tools">
+
-
+
+
-
+
+
-
+
+ android:theme="@style/Theme.Near.Splash">
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..63c16d25 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
@@ -2,10 +2,16 @@ package com.alarmy.near.data.di
import com.alarmy.near.data.repository.AuthRepository
import com.alarmy.near.data.repository.AuthRepositoryImpl
+import com.alarmy.near.data.repository.ContactRepository
+import com.alarmy.near.data.repository.DefaultContactRepository
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 +32,16 @@ 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
+
+ @Binds
+ @Singleton
+ abstract fun bindContactRepository(contactRepository: DefaultContactRepository): ContactRepository
}
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/ContactMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/ContactMapper.kt
new file mode 100644
index 00000000..61b32679
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/ContactMapper.kt
@@ -0,0 +1,14 @@
+package com.alarmy.near.data.mapper
+
+import com.alarmy.near.local.entity.ContactEntity
+import com.alarmy.near.model.contact.Contact
+
+fun ContactEntity.toModel(): Contact =
+ Contact(
+ id = id,
+ name = name,
+ phones = phones,
+ photoUri = photoUri,
+ birthDay = birthDay,
+ memo = memo,
+ )
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/ContactRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/ContactRepository.kt
new file mode 100644
index 00000000..8a63bd6b
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/repository/ContactRepository.kt
@@ -0,0 +1,8 @@
+package com.alarmy.near.data.repository
+
+import com.alarmy.near.model.contact.Contact
+import kotlinx.coroutines.flow.Flow
+
+interface ContactRepository {
+ fun fetchAllContacts(): Flow>
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultContactRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultContactRepository.kt
new file mode 100644
index 00000000..9215d53e
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultContactRepository.kt
@@ -0,0 +1,23 @@
+package com.alarmy.near.data.repository
+
+import com.alarmy.near.data.mapper.toModel
+import com.alarmy.near.local.contact.ContactLocalDataSource
+import com.alarmy.near.model.contact.Contact
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+
+class DefaultContactRepository
+ @Inject
+ constructor(
+ private val contactDataSource: ContactLocalDataSource,
+ ) : ContactRepository {
+ override fun fetchAllContacts(): Flow> =
+ flow {
+ emit(
+ contactDataSource.getAllContacts().map {
+ it.toModel()
+ },
+ )
+ }
+ }
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/local/contact/ContactLocalDataSource.kt b/Near/app/src/main/java/com/alarmy/near/local/contact/ContactLocalDataSource.kt
new file mode 100644
index 00000000..2bf723b8
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/local/contact/ContactLocalDataSource.kt
@@ -0,0 +1,198 @@
+package com.alarmy.near.local.contact
+
+import android.content.ContentResolver
+import android.provider.ContactsContract
+import com.alarmy.near.local.entity.ContactEntity
+import com.alarmy.near.local.entity.ImportantDate
+import javax.inject.Inject
+
+class ContactLocalDataSource
+ @Inject
+ constructor(
+ private val contentResolver: ContentResolver,
+ ) {
+ fun getAllContacts(): List {
+ val contacts = mutableListOf()
+
+ val cursor =
+ contentResolver.query(
+ ContactsContract.Contacts.CONTENT_URI,
+ null,
+ null,
+ null,
+ "${ContactsContract.Contacts.DISPLAY_NAME} ASC",
+ )
+
+ cursor?.use {
+ val idIndex = it.getColumnIndex(ContactsContract.Contacts._ID)
+ val nameIndex = it.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
+ val hasPhoneIndex = it.getColumnIndex(ContactsContract.Contacts.HAS_PHONE_NUMBER)
+ val photoIndex = it.getColumnIndex(ContactsContract.Contacts.PHOTO_URI)
+
+ while (it.moveToNext()) {
+ val id = it.getLong(idIndex)
+ val name = it.getString(nameIndex) ?: ""
+ val hasPhone = it.getInt(hasPhoneIndex) > 0
+ val photoUri = it.getString(photoIndex)
+
+ // 전화번호
+ val phones = mutableListOf()
+ if (hasPhone) {
+ val phoneCursor =
+ contentResolver.query(
+ ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
+ null,
+ "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?",
+ arrayOf(id.toString()),
+ null,
+ )
+ phoneCursor?.use { pc ->
+ val phoneIndex =
+ pc.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
+ while (pc.moveToNext()) {
+ phones.add(pc.getString(phoneIndex))
+ }
+ }
+ }
+
+ // 메모
+ var memo: String? = null
+ val noteCursor =
+ contentResolver.query(
+ ContactsContract.Data.CONTENT_URI,
+ arrayOf(ContactsContract.CommonDataKinds.Note.NOTE),
+ "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?",
+ arrayOf(id.toString(), ContactsContract.CommonDataKinds.Note.CONTENT_ITEM_TYPE),
+ null,
+ )
+ noteCursor?.use { nc ->
+ if (nc.moveToFirst()) {
+ memo = nc.getString(0)
+ }
+ }
+
+ // 생일
+ var birthDay: String? = null
+ val birthdayCursor =
+ contentResolver.query(
+ ContactsContract.Data.CONTENT_URI,
+ arrayOf(ContactsContract.CommonDataKinds.Event.START_DATE),
+ "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ? AND ${ContactsContract.CommonDataKinds.Event.TYPE} = ?",
+ arrayOf(
+ id.toString(),
+ ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE,
+ ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY
+ .toString(),
+ ),
+ null,
+ )
+ birthdayCursor?.use { bc ->
+ if (bc.moveToFirst()) {
+ birthDay = bc.getString(0)
+ }
+ }
+
+ // 그룹
+ val groups = mutableListOf()
+ val groupCursor =
+ contentResolver.query(
+ ContactsContract.Data.CONTENT_URI,
+ arrayOf(ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID),
+ "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?",
+ arrayOf(
+ id.toString(),
+ ContactsContract.CommonDataKinds.GroupMembership.CONTENT_ITEM_TYPE,
+ ),
+ null,
+ )
+ groupCursor?.use { gc ->
+ val groupIdIndex =
+ gc.getColumnIndex(
+ ContactsContract.CommonDataKinds.GroupMembership.GROUP_ROW_ID,
+ )
+ while (gc.moveToNext()) {
+ val groupId = gc.getLong(groupIdIndex)
+ val groupNameCursor =
+ contentResolver.query(
+ ContactsContract.Groups.CONTENT_URI,
+ arrayOf(ContactsContract.Groups.TITLE),
+ "${ContactsContract.Groups._ID} = ?",
+ arrayOf(groupId.toString()),
+ null,
+ )
+ groupNameCursor?.use { gnc ->
+ if (gnc.moveToFirst()) {
+ groups.add(gnc.getString(0))
+ }
+ }
+ }
+ }
+
+ // 중요한 날 (기념일)
+ val importantDates = mutableListOf()
+ val eventCursor =
+ contentResolver.query(
+ ContactsContract.Data.CONTENT_URI,
+ arrayOf(
+ ContactsContract.CommonDataKinds.Event.START_DATE,
+ ContactsContract.CommonDataKinds.Event.TYPE,
+ ContactsContract.CommonDataKinds.Event.LABEL,
+ ),
+ "${ContactsContract.Data.CONTACT_ID} = ? AND ${ContactsContract.Data.MIMETYPE} = ?",
+ arrayOf(
+ id.toString(),
+ ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE,
+ ),
+ null,
+ )
+ eventCursor?.use { ec ->
+ val dateIndex =
+ ec.getColumnIndex(ContactsContract.CommonDataKinds.Event.START_DATE)
+ val typeIndex =
+ ec.getColumnIndex(ContactsContract.CommonDataKinds.Event.TYPE)
+ val labelIndex =
+ ec.getColumnIndex(ContactsContract.CommonDataKinds.Event.LABEL)
+
+ while (ec.moveToNext()) {
+ val date = ec.getString(dateIndex)
+ val type = ec.getInt(typeIndex)
+ val customLabel = ec.getString(labelIndex)
+
+ val label =
+ when (type) {
+ ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY -> {
+ continue // 위에서 생일은 포함했으므로 스킵
+ }
+
+ ContactsContract.CommonDataKinds.Event.TYPE_ANNIVERSARY -> "기념일"
+ ContactsContract.CommonDataKinds.Event.TYPE_OTHER ->
+ customLabel
+ ?: "기타"
+
+ else -> "알 수 없음"
+ }
+
+ if (date != null) {
+ importantDates.add(ImportantDate(label, date))
+ }
+ }
+ }
+
+ contacts.add(
+ ContactEntity(
+ id = id,
+ name = name,
+ phones = phones,
+ photoUri = photoUri,
+ birthDay = birthDay,
+ memo = memo,
+ groups = groups,
+ importantDates = importantDates,
+ ),
+ )
+ }
+ }
+
+ return contacts
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/local/contact/di/ContactDataSourceModule.kt b/Near/app/src/main/java/com/alarmy/near/local/contact/di/ContactDataSourceModule.kt
new file mode 100644
index 00000000..ecb16449
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/local/contact/di/ContactDataSourceModule.kt
@@ -0,0 +1,20 @@
+package com.alarmy.near.local.contact.di
+
+import android.content.ContentResolver
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ContactDataSourceModule {
+ @Provides
+ @Singleton
+ fun provideContentResolver(
+ @ApplicationContext context: Context,
+ ): ContentResolver = context.contentResolver
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/local/entity/ContactEntity.kt b/Near/app/src/main/java/com/alarmy/near/local/entity/ContactEntity.kt
new file mode 100644
index 00000000..32ca3c80
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/local/entity/ContactEntity.kt
@@ -0,0 +1,17 @@
+package com.alarmy.near.local.entity
+
+data class ContactEntity(
+ val id: Long,
+ val name: String,
+ val phones: List,
+ val photoUri: String?, // 사진
+ val birthDay: String?, // 생일
+ val memo: String?, // 메모
+ val groups: List, // 그룹 (ex. 가족, 친구, 회사)
+ val importantDates: List,
+)
+
+data class ImportantDate(
+ val label: String,
+ val date: String,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/model/contact/Contact.kt b/Near/app/src/main/java/com/alarmy/near/model/contact/Contact.kt
new file mode 100644
index 00000000..99c6ab29
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/model/contact/Contact.kt
@@ -0,0 +1,14 @@
+package com.alarmy.near.model.contact
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class Contact(
+ val id: Long,
+ val name: String,
+ val phones: List,
+ val photoUri: String?, // 사진
+ val birthDay: String?, // 생일
+ val memo: String?, // 메모
+) : Parcelable
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/contact/ContactScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactScreen.kt
new file mode 100644
index 00000000..7a124315
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactScreen.kt
@@ -0,0 +1,396 @@
+package com.alarmy.near.presentation.feature.contact
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+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.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.LineHeightStyle
+import androidx.compose.ui.text.style.TextAlign
+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.model.contact.Contact
+import com.alarmy.near.presentation.feature.contact.state.ContactUiEvent
+import com.alarmy.near.presentation.feature.contact.state.ContactUiState
+import com.alarmy.near.presentation.feature.contact.state.SelectedContactUiState
+import com.alarmy.near.presentation.ui.component.NearFrame
+import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton
+import com.alarmy.near.presentation.ui.component.checkbox.NearBackgroundCheckbox
+import com.alarmy.near.presentation.ui.component.textfield.NearSearchTextField
+import com.alarmy.near.presentation.ui.extension.onNoRippleClick
+import com.alarmy.near.presentation.ui.theme.NearTheme
+import kotlinx.coroutines.launch
+
+// 선택 완료 및 백 클릭 이벤트 처리
+@Composable
+fun ContactRoute(
+ viewModel: ContactViewModel = hiltViewModel(),
+ onShowErrorSnackBar: (throwable: Throwable?) -> Unit,
+ onBackClick: () -> Unit,
+ onCompletedSelection: (List) -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.uiEvent.collect { event ->
+ when (event) {
+ is ContactUiEvent.Completed -> {
+ onCompletedSelection(event.selectedContacts)
+ }
+ }
+ }
+ }
+
+ when (uiState) {
+ is ContactUiState.Loading -> {
+ Box(modifier = Modifier.fillMaxSize()) {
+ CircularProgressIndicator(
+ color = NearTheme.colors.BLUE01_5AA2E9,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ }
+
+ is ContactUiState.Error -> {
+ Box(modifier = Modifier.fillMaxSize()) {
+ Text(modifier = Modifier.align(Alignment.Center), text = stringResource(R.string.contact_load_error))
+ }
+ }
+
+ is ContactUiState.Success -> {
+ val contacts = (uiState as ContactUiState.Success).contacts
+ ContactScreen(
+ contacts = contacts,
+ searchQuery = searchQuery,
+ onContactCheckedChange = { contactId, isSelected ->
+ viewModel.onContactSelect(isSelected, contactId)
+ },
+ onBackClick = onBackClick,
+ onCompleteClick = viewModel::onCompleteClick,
+ onSearchClick = {},
+ onSearchTextChange = viewModel::onSearchTextChange,
+ )
+ }
+ }
+}
+
+@Composable
+fun ContactScreen(
+ modifier: Modifier = Modifier,
+ contacts: Map> = emptyMap(),
+ searchQuery: String = "",
+ onBackClick: () -> Unit = {},
+ onSearchTextChange: (String) -> Unit = {},
+ onSearchClick: () -> Unit = {},
+ onContactCheckedChange: (Long, Boolean) -> Unit = { _, _ -> },
+ onCompleteClick: () -> Unit = {},
+) {
+ val selectedContactCount = contacts.values.flatten().count { it.isSelected }
+ NearFrame(modifier = modifier) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(
+ start = 24.dp,
+ end = 20.dp,
+ top = 8.dp,
+ bottom = 8.dp,
+ ),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ stringResource(R.string.contact_title_text),
+ style = NearTheme.typography.B1_16_BOLD,
+ color = NearTheme.colors.BLACK_1A1A1A,
+ )
+ Image(
+ modifier = Modifier.onNoRippleClick { onBackClick() },
+ painter = painterResource(R.drawable.ic_close_32_black),
+ contentDescription = stringResource(R.string.contact_close_screen),
+ )
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ NearSearchTextField(
+ placeHolderText = stringResource(R.string.context_search_placeholder),
+ modifier = Modifier.padding(horizontal = 20.dp),
+ value = searchQuery,
+ onValueChange = onSearchTextChange,
+ onSearchClick = onSearchClick,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .fillMaxSize(),
+ ) {
+ ContactList(
+ groupedContacts = contacts,
+ onContactCheckedChange = onContactCheckedChange,
+ )
+ NearSolidTypeButton(
+ enabled = selectedContactCount != 0,
+ modifier =
+ Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ .padding(bottom = 24.dp),
+ contentPadding = PaddingValues(vertical = 17.dp),
+ onClick = onCompleteClick,
+ text = "${selectedContactCount}명 선택 완료",
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ContactList(
+ groupedContacts: Map>,
+ onContactCheckedChange: (Long, Boolean) -> Unit,
+) {
+ val sectionedContacts = groupedContacts.toList()
+ val listState = rememberLazyListState()
+ val coroutineScope = rememberCoroutineScope()
+
+ // 섹션별 첫 번째 아이템 인덱스 계산
+ val sectionIndexMap =
+ remember(sectionedContacts) {
+ val map = mutableMapOf()
+ var index = 0
+ sectionedContacts.forEach { (initial, contacts) ->
+ map[initial] = index // stickyHeader 위치
+ index += 1 + contacts.size + 1 // header + items + spacer
+ }
+ map
+ }
+
+ Box {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = listState,
+ ) {
+ sectionedContacts.forEach { (initial, contacts) ->
+ stickyHeader {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .background(NearTheme.colors.BG02_F4F9FD)
+ .padding(vertical = 12.dp, horizontal = 24.dp),
+ ) {
+ Text(
+ text = initial,
+ style = NearTheme.typography.B1_16_BOLD,
+ color = NearTheme.colors.BLACK_1A1A1A,
+ )
+ }
+ Spacer(modifier = Modifier.height(6.dp))
+ }
+ itemsIndexed(contacts) { index, contact ->
+ Column {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ onContactCheckedChange(
+ contact.contact.id,
+ !contact.isSelected,
+ )
+ }.padding(horizontal = 24.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ NearBackgroundCheckbox(
+ checked = contact.isSelected,
+ onCheckedChange = { checked ->
+ onContactCheckedChange(contact.contact.id, checked)
+ },
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = contact.contact.name,
+ textAlign = TextAlign.Center,
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.BLACK_1A1A1A,
+ )
+ }
+ if (index < contacts.lastIndex) {
+ HorizontalDivider(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp),
+ color = NearTheme.colors.GRAY03_EBEBEB,
+ thickness = 1.dp,
+ )
+ }
+ }
+ }
+ item { Spacer(modifier = Modifier.height(18.dp)) }
+ }
+ item {
+ Spacer(modifier = Modifier.height(90.dp))
+ }
+ }
+
+ // 우측 인덱스 바
+ val allInitials =
+ listOf(
+ "ㄱ",
+ "ㄴ",
+ "ㄷ",
+ "ㄹ",
+ "ㅁ",
+ "ㅂ",
+ "ㅅ",
+ "ㅇ",
+ "ㅈ",
+ "ㅊ",
+ "ㅋ",
+ "ㅌ",
+ "ㅍ",
+ "ㅎ",
+ ) + ('A'..'Z').map { it.toString() } + "#"
+
+ Column(
+ modifier =
+ Modifier
+ .align(Alignment.TopEnd)
+ .padding(end = 12.dp, top = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ allInitials.forEach { initial ->
+ Text(
+ text = initial,
+ style =
+ NearTheme.typography.FC_12_BOLD.copy(
+ fontSize = 10.sp,
+ lineHeight = 13.sp,
+ lineHeightStyle =
+ LineHeightStyle(
+ alignment = LineHeightStyle.Alignment.Center,
+ trim = LineHeightStyle.Trim.None,
+ ),
+ ),
+ color = NearTheme.colors.BLUE01_5AA2E9,
+ modifier =
+ Modifier
+ .onNoRippleClick {
+ // 실제 존재하는 섹션 중 가장 가까운 이전 섹션 찾기
+ val available = sectionIndexMap.keys.sorted()
+ val target = available.lastOrNull { it <= initial }
+ val index = sectionIndexMap[target]
+ if (index != null) {
+ coroutineScope.launch {
+ listState.animateScrollToItem(index)
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun ContactScreenPreview() {
+ NearTheme {
+ ContactScreen(
+ contacts =
+ mapOf(
+ "ㄱ" to
+ listOf(
+ SelectedContactUiState(
+ contact =
+ Contact(
+ id = 1L,
+ name = "김철수",
+ phones = listOf("010-1234-5678"),
+ photoUri = null,
+ birthDay = "1995-03-15",
+ memo = "고등학교 친구",
+ ),
+ isSelected = false,
+ ),
+ SelectedContactUiState(
+ contact =
+ Contact(
+ id = 2L,
+ name = "강민수",
+ phones = listOf("010-2222-3333"),
+ photoUri = null,
+ birthDay = null,
+ memo = "회사 동료",
+ ),
+ isSelected = true,
+ ),
+ ),
+ "ㅂ" to
+ listOf(
+ SelectedContactUiState(
+ contact =
+ Contact(
+ id = 3L,
+ name = "박영희",
+ phones = listOf("010-9876-5432"),
+ photoUri = null,
+ birthDay = null,
+ memo = null,
+ ),
+ isSelected = false,
+ ),
+ ),
+ "ㅊ" to
+ listOf(
+ SelectedContactUiState(
+ contact =
+ Contact(
+ id = 4L,
+ name = "최수정",
+ phones = listOf("010-4444-5555"),
+ photoUri = null,
+ birthDay = "1998-07-22",
+ memo = "대학 동기",
+ ),
+ isSelected = false,
+ ),
+ ),
+ ),
+ )
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactViewModel.kt
new file mode 100644
index 00000000..33c558a4
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactViewModel.kt
@@ -0,0 +1,165 @@
+package com.alarmy.near.presentation.feature.contact
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.alarmy.near.data.repository.ContactRepository
+import com.alarmy.near.presentation.feature.contact.state.ContactUiEvent
+import com.alarmy.near.presentation.feature.contact.state.ContactUiState
+import com.alarmy.near.presentation.feature.contact.state.SelectedContactUiState
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class ContactViewModel
+ @Inject
+ constructor(
+ contactRepository: ContactRepository,
+ ) : ViewModel() {
+ private val _uiEvent = Channel()
+ val uiEvent = _uiEvent.receiveAsFlow()
+
+ private val selectedIds = MutableStateFlow>(emptySet())
+ private val _searchQuery = MutableStateFlow("")
+ val searchQuery: StateFlow = _searchQuery.asStateFlow()
+
+ // 원본 연락처 리스트
+ private val contactsFlow = contactRepository.fetchAllContacts()
+
+ val uiState: StateFlow =
+ combine(
+ contactsFlow,
+ selectedIds,
+ _searchQuery,
+ ) { contacts, selectedIds, query ->
+ val filtered =
+ if (query.isBlank()) {
+ contacts
+ } else {
+ contacts.filter { contact ->
+ contact.name.contains(query, ignoreCase = true)
+ }
+ }
+
+ val uiContacts =
+ filtered.map { contact ->
+ SelectedContactUiState(
+ contact = contact,
+ isSelected = contact.id in selectedIds,
+ )
+ }
+
+ // groupBy → 정렬된 Map 으로 변환
+ val grouped = uiContacts.groupBy { getInitial(it.contact.name) }
+ val sorted = grouped.toSortedMap(initialComparator)
+
+ ContactUiState.Success(
+ contacts = sorted,
+ )
+ }.stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5000L),
+ ContactUiState.Loading,
+ )
+
+ // 한글 - 영어 - 특수문자 순으로 정렬하는 Comparator
+ private val initialComparator =
+ Comparator { a, b ->
+ val orderA = categoryOrder(a)
+ val orderB = categoryOrder(b)
+
+ if (orderA == orderB) {
+ a.compareTo(b) // 같은 카테고리 안에서는 알파벳/자음 순 정렬
+ } else {
+ orderA - orderB
+ }
+ }
+
+ // 한글=0, 영어=1, 그 외=2
+ private fun categoryOrder(initial: String): Int =
+ when {
+ initial.first() in 'ㄱ'..'ㅎ' -> 0
+ initial.first().isLetter() -> 1
+ else -> 2
+ }
+
+ fun onContactSelect(
+ isSelected: Boolean,
+ contactId: Long,
+ ) {
+ selectedIds.update { ids ->
+ if (isSelected) ids + contactId else ids - contactId
+ }
+ }
+
+ fun onSearchTextChange(value: String) {
+ _searchQuery.value = value
+ }
+
+ fun onCompleteClick() {
+ val selected =
+ (uiState.value as? ContactUiState.Success)
+ ?.contacts
+ ?.flatMap { it.value }
+ ?.filter { it.isSelected }
+ ?.map {
+ it.contact
+ } ?: return
+ viewModelScope.launch {
+ _uiEvent.send(ContactUiEvent.Completed(selected))
+ }
+ }
+
+ private fun getInitial(name: String): String {
+ if (name.isEmpty()) return "#"
+
+ val ch = name.first()
+ return if (ch in '가'..'힣') {
+ val base = ch.code - 0xAC00
+ val initialIndex = base / (21 * 28)
+
+ val initials =
+ listOf(
+ "ㄱ",
+ "ㄲ",
+ "ㄴ",
+ "ㄷ",
+ "ㄸ",
+ "ㄹ",
+ "ㅁ",
+ "ㅂ",
+ "ㅃ",
+ "ㅅ",
+ "ㅆ",
+ "ㅇ",
+ "ㅈ",
+ "ㅉ",
+ "ㅊ",
+ "ㅋ",
+ "ㅌ",
+ "ㅍ",
+ "ㅎ",
+ )
+ val initial = initials[initialIndex]
+ when (initial) {
+ "ㄲ" -> "ㄱ"
+ "ㄸ" -> "ㄷ"
+ "ㅃ" -> "ㅂ"
+ "ㅆ" -> "ㅅ"
+ "ㅉ" -> "ㅈ"
+ else -> initial
+ }
+ } else {
+ if (ch.isLetter()) ch.uppercaseChar().toString() else "#"
+ }
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/navigation/ContactNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/navigation/ContactNavigation.kt
new file mode 100644
index 00000000..7b43ffde
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/navigation/ContactNavigation.kt
@@ -0,0 +1,35 @@
+package com.alarmy.near.presentation.feature.contact.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.alarmy.near.model.contact.Contact
+import com.alarmy.near.presentation.feature.contact.ContactRoute
+import kotlinx.serialization.Serializable
+
+const val CONTACT_SELECTION_COMPLETE_KEY = "CONTACT_SELECTION_COMPLETE_KEY"
+
+@Serializable
+object RouteContact
+
+/*
+* 추후 홈으로 화면 이동이 필요할 때 이 함수를 사용합니다.
+* */
+fun NavController.navigateToContact(navOptions: NavOptions) {
+ navigate(RouteContact, navOptions)
+}
+
+fun NavGraphBuilder.contactNavGraph(
+ onShowErrorSnackBar: (throwable: Throwable?) -> Unit,
+ onBackClick: () -> Unit,
+ onCompletedSelection: (List) -> Unit,
+) {
+ composable { backStackEntry ->
+ ContactRoute(
+ onShowErrorSnackBar = onShowErrorSnackBar,
+ onBackClick = onBackClick,
+ onCompletedSelection = onCompletedSelection,
+ )
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/state/ContactUiEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/state/ContactUiEvent.kt
new file mode 100644
index 00000000..880ee192
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/state/ContactUiEvent.kt
@@ -0,0 +1,7 @@
+package com.alarmy.near.presentation.feature.contact.state
+
+import com.alarmy.near.model.contact.Contact
+
+sealed class ContactUiEvent {
+ data class Completed(val selectedContacts: List) : ContactUiEvent()
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/state/ContactUiState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/state/ContactUiState.kt
new file mode 100644
index 00000000..36c934c2
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/state/ContactUiState.kt
@@ -0,0 +1,20 @@
+package com.alarmy.near.presentation.feature.contact.state
+
+import com.alarmy.near.model.contact.Contact
+
+sealed class ContactUiState {
+ object Loading : ContactUiState()
+
+ data class Success(
+ val contacts: Map>,
+ ) : ContactUiState()
+
+ data class Error(
+ val throwable: Throwable,
+ ) : ContactUiState()
+}
+
+data class SelectedContactUiState(
+ val contact: Contact,
+ val isSelected: Boolean = false,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt
index c8a4ccfe..32def2b4 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeViewModel.kt
@@ -1,7 +1,9 @@
package com.alarmy.near.presentation.feature.home
+import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.alarmy.near.data.repository.ContactRepository
import com.alarmy.near.data.repository.FriendRepository
import com.alarmy.near.model.friendsummary.FriendSummary
import com.alarmy.near.model.monthly.MonthlyFriend
@@ -10,6 +12,7 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.forEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@@ -19,6 +22,7 @@ class HomeViewModel
@Inject
constructor(
friendRepository: FriendRepository,
+ contactRepository: ContactRepository,
) : ViewModel() {
private val _errorEvent = Channel()
val errorEvent = _errorEvent.receiveAsFlow()
@@ -28,8 +32,7 @@ class HomeViewModel
.fetchFriends()
.catch {
_errorEvent.send(it)
- }
- .stateIn(
+ }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
@@ -41,8 +44,7 @@ class HomeViewModel
.fetchMonthlyFriends()
.catch {
_errorEvent.send(it)
- }
- .stateIn(
+ }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt
index 10990fba..c231a9c3 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt
@@ -89,7 +89,7 @@ private fun LoginIntroductionSection(modifier: Modifier = Modifier) {
Image(
modifier = modifier.wrapContentSize(Alignment.Center),
alignment = Alignment.Center,
- painter = painterResource(R.drawable.ic_near_logo_title),
+ painter = painterResource(R.drawable.ic_near_logo_title_primary),
contentDescription = stringResource(R.string.near_logo_title),
)
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 35f00fdc..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
@@ -4,17 +4,47 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.compose.runtime.getValue
+import androidx.core.splashscreen.SplashScreen
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
import com.alarmy.near.presentation.ui.theme.NearTheme
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
+ private val mainViewModel: MainViewModel by viewModels()
+
override fun onCreate(savedInstanceState: Bundle?) {
+ val splashScreen = installSplashScreen()
+
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ setupSplashScreen(splashScreen)
+
setContent {
NearTheme {
- NearApp()
+ val uiState by mainViewModel.uiState.collectAsStateWithLifecycle()
+ if (!uiState.isLoading) {
+ NearApp(
+ startDestination = uiState.startDestination,
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * 스플래시 스크린을 설정하고 MainViewModel의 상태를 관찰합니다.
+ * API 스플래시가 표시되는 동안 백그라운드에서 검증을 수행합니다.
+ */
+ private fun setupSplashScreen(splashScreen: SplashScreen) {
+ lifecycleScope.launch {
+ mainViewModel.uiState.collect { uiState ->
+ splashScreen.setKeepOnScreenCondition { uiState.isLoading }
}
}
}
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
new file mode 100644
index 00000000..37a23776
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainViewModel.kt
@@ -0,0 +1,78 @@
+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
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MainViewModel @Inject constructor(
+ private val authRepository: AuthRepository,
+ private val onBoardingRepository: OnBoardingRepository,
+) : ViewModel() {
+
+ // UI 상태 관리
+ private val _uiState = MutableStateFlow(MainUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ checkAppStatus()
+ }
+
+ /**
+ * 앱 상태를 확인하고 스플래시 스크린을 제어합니다.
+ * 온보딩 완료 여부와 로그인 상태를 확인하여 적절한 화면으로 이동합니다.
+ */
+ private fun checkAppStatus() {
+ viewModelScope.launch {
+ 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
+ )
+ }
+ }
+ }
+}
+
+/**
+ * MainActivity의 UI 상태를 관리하는 데이터 클래스
+ */
+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 106f00b5..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
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Scaffold
@@ -26,6 +25,7 @@ import kotlinx.coroutines.launch
internal fun NearApp(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(),
+ startDestination: Any,
) {
val snackBarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
@@ -48,6 +48,7 @@ internal fun NearApp(
NearNavHost(
modifier = Modifier.consumeWindowInsets(innerPadding), // 하위 뷰에 Padding을 소비한 것으로 알립니다.
navController = navController,
+ 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 adf33e39..bef58d32 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
@@ -7,34 +7,105 @@ import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
+import androidx.navigation.navOptions
+import com.alarmy.near.presentation.feature.contact.navigation.CONTACT_SELECTION_COMPLETE_KEY
+import com.alarmy.near.presentation.feature.contact.navigation.RouteContact
+import com.alarmy.near.presentation.feature.contact.navigation.contactNavGraph
import com.alarmy.near.presentation.feature.friendprofile.navigation.friendProfileNavGraph
import com.alarmy.near.presentation.feature.friendprofile.navigation.navigateToFriendProfile
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 java.net.URLEncoder
-import java.nio.charset.StandardCharsets
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
@Composable
internal fun NearNavHost(
modifier: Modifier = Modifier,
navController: NavHostController,
+ startDestination: Any, // 나중에 모든 루트를 sealed로 구성하면 sealed 타입으로 변경
onShowSnackbar: (Throwable?) -> Unit = { _ -> },
) {
val context = LocalContext.current
+
/*
* 화면 이동 및 구성을 위한 컴포저블 함수입니다.
+ * startDestination 파라미터로 받은 시작 화면으로 이동합니다.
* */
NavHost(
modifier = modifier,
navController = navController,
- startDestination = RouteHome,
+ 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 ->
@@ -63,6 +134,8 @@ internal fun NearNavHost(
),
)
})
+
+ // 친구 프로필 편집 화면 NavGraph
friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = {
navController.popBackStack()
}, onSuccessEdit = {
@@ -72,16 +145,18 @@ internal fun NearNavHost(
)
navController.popBackStack()
})
+
// 로그인 화면 NavGraph
loginNavGraph(
onShowErrorSnackBar = onShowSnackbar,
onNavigateToHome = {
navController.navigateToHome(
- navOptions = androidx.navigation.navOptions {
- popUpTo(RouteLogin) { inclusive = true }
- }
+ navOptions =
+ navOptions {
+ popUpTo(RouteLogin) { inclusive = true }
+ },
)
- }
+ },
)
// 홈 화면 NavGraph
@@ -94,5 +169,19 @@ internal fun NearNavHost(
onAlarmClick = {},
onAddContactClick = {},
)
+
+ contactNavGraph(
+ onShowErrorSnackBar = onShowSnackbar,
+ onBackClick = {
+ navController.popBackStack()
+ },
+ onCompletedSelection = {
+ navController.previousBackStackEntry?.savedStateHandle?.set(
+ CONTACT_SELECTION_COMPLETE_KEY,
+ it,
+ )
+ 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/component/textfield/NearSearchTextField.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/NearSearchTextField.kt
new file mode 100644
index 00000000..6864d3df
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/NearSearchTextField.kt
@@ -0,0 +1,93 @@
+package com.alarmy.near.presentation.ui.component.textfield
+
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.TextLayoutResult
+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.textfield.internal.NearSearchTextFieldDecorationBox
+import com.alarmy.near.presentation.ui.component.textfield.internal.NearTextFieldColors
+import com.alarmy.near.presentation.ui.theme.NearTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NearSearchTextField(
+ modifier: Modifier = Modifier,
+ value: String,
+ enabled: Boolean = true,
+ onValueChange: (String) -> Unit,
+ onSearchClick: () -> Unit,
+ placeHolderText: String = "",
+ singleLine: Boolean = true,
+ onTextLayout: (textLayoutResult: TextLayoutResult) -> Unit = {},
+ interactionSource: InteractionSource = remember { MutableInteractionSource() },
+) {
+ val colors = NearTextFieldColors()
+
+ NearTextField(
+ value = value,
+ modifier = modifier.heightIn(min = 0.dp, max = 52.dp),
+ enabled = enabled,
+ onValueChange = onValueChange,
+ placeHolderText = placeHolderText,
+ singleLine = singleLine,
+ onTextLayout = onTextLayout,
+ interactionSource = interactionSource,
+ decorationBox = { innerTextField ->
+ NearSearchTextFieldDecorationBox(
+ value = value,
+ innerTextField = innerTextField,
+ enabled = enabled,
+ singleLine = singleLine,
+ interactionSource = interactionSource,
+ colors = colors,
+ placeHolderText = placeHolderText,
+ onSearchClick = onSearchClick,
+ )
+ }
+ )
+}
+
+@Preview(widthDp = 370, heightDp = 80, showBackground = true)
+@Composable
+fun NearSearchTextFieldPreview() {
+ Surface(modifier = Modifier.padding(horizontal = 20.dp)) {
+ NearSearchTextField(
+ modifier = Modifier.wrapContentHeight(),
+ value = "",
+ onValueChange = {},
+ onSearchClick = {},
+ placeHolderText = "검색어를 입력하세요",
+ )
+ }
+}
+
+@Preview(widthDp = 370, heightDp = 80, showBackground = true)
+@Composable
+fun NearSearchTextFieldWithTextPreview() {
+ Surface(modifier = Modifier.padding(horizontal = 20.dp)) {
+ NearSearchTextField(
+ modifier = Modifier.wrapContentHeight(),
+ value = "검색 텍스트",
+ onValueChange = {},
+ onSearchClick = {},
+ placeHolderText = "검색어를 입력하세요",
+ )
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/internal/NearSearchTextFieldDecorationBox.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/internal/NearSearchTextFieldDecorationBox.kt
new file mode 100644
index 00000000..04b3ac0b
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/textfield/internal/NearSearchTextFieldDecorationBox.kt
@@ -0,0 +1,92 @@
+package com.alarmy.near.presentation.ui.component.textfield.internal
+
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextFieldColors
+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.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import com.alarmy.near.R
+import com.alarmy.near.presentation.ui.theme.NearTheme
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun NearSearchTextFieldDecorationBox(
+ value: String,
+ innerTextField: @Composable () -> Unit,
+ enabled: Boolean,
+ singleLine: Boolean,
+ interactionSource: InteractionSource,
+ colors: TextFieldColors,
+ placeHolderText: String,
+ onSearchClick: () -> Unit,
+ contentPadding: PaddingValues = PaddingValues(
+ start = 16.dp,
+ top = 16.dp,
+ bottom = 16.dp,
+ end = 8.dp
+ ),
+) {
+ OutlinedTextFieldDefaults.DecorationBox(
+ contentPadding = contentPadding,
+ value = value,
+ innerTextField = {
+ Box(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(end = 40.dp)
+ ) {
+ innerTextField()
+ }
+
+ IconButton(
+ onClick = onSearchClick,
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .size(32.dp),
+ enabled = enabled
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_24_search),
+ contentDescription = "검색",
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+ },
+ enabled = enabled,
+ singleLine = singleLine,
+ interactionSource = interactionSource,
+ visualTransformation = VisualTransformation.None,
+ placeholder = {
+ Text(
+ text = placeHolderText,
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.GRAY02_B7B7B7,
+ )
+ },
+ container = {
+ NearTextFieldDecorationContainer(
+ enabled = enabled,
+ interactionSource = interactionSource,
+ colors = colors,
+ )
+ },
+ )
+}
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/permission/ContactPermissionRequester.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/permission/ContactPermissionRequester.kt
new file mode 100644
index 00000000..c36736ed
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/permission/ContactPermissionRequester.kt
@@ -0,0 +1,29 @@
+package com.alarmy.near.presentation.ui.permission
+
+import android.Manifest
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import com.alarmy.near.permission.PermissionState
+import com.alarmy.near.permission.rememberContactPermissionState
+
+@Composable
+fun ContactPermissionRequester(
+ onGranted: @Composable () -> Unit,
+ onDenied: @Composable (onRequestPermission: () -> Unit) -> Unit,
+ onShowRationale: @Composable (onRequestPermission: () -> Unit) -> Unit = onDenied,
+) {
+ val launcher =
+ rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestPermission(),
+ onResult = {},
+ )
+
+ val permissionState = rememberContactPermissionState()
+
+ when (permissionState) {
+ PermissionState.GRANTED -> onGranted()
+ PermissionState.DENIED -> onDenied { launcher.launch(Manifest.permission.READ_CONTACTS) }
+ PermissionState.SHOW_RATIONALE -> onShowRationale { launcher.launch(Manifest.permission.READ_CONTACTS) }
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/permission/PermissionState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/permission/PermissionState.kt
new file mode 100644
index 00000000..2c7343b8
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/permission/PermissionState.kt
@@ -0,0 +1,45 @@
+package com.alarmy.near.permission
+
+import android.Manifest
+import android.content.pm.PackageManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+
+enum class PermissionState {
+ GRANTED,
+ DENIED,
+ SHOW_RATIONALE,
+}
+
+@Composable
+fun rememberContactPermissionState(): PermissionState {
+ val context = LocalContext.current
+ var permissionState by remember { mutableStateOf(PermissionState.DENIED) }
+
+ LaunchedEffect(Unit) {
+ permissionState =
+ when {
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_CONTACTS,
+ ) == PackageManager.PERMISSION_GRANTED -> PermissionState.GRANTED
+
+ (context as? androidx.activity.ComponentActivity)?.shouldShowRequestPermissionRationale(
+ Manifest.permission.READ_CONTACTS,
+ ) == true -> PermissionState.SHOW_RATIONALE
+
+ else -> PermissionState.DENIED
+ }
+ }
+
+ return permissionState
+}
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_bg.png b/Near/app/src/main/res/drawable-hdpi/img_bg.png
new file mode 100644
index 00000000..7ad84255
Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_bg.png differ
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/img_bg.png b/Near/app/src/main/res/drawable-mdpi/img_bg.png
similarity index 100%
rename from Near/app/src/main/res/drawable/img_bg.png
rename to Near/app/src/main/res/drawable-mdpi/img_bg.png
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_bg.png b/Near/app/src/main/res/drawable-xhdpi/img_bg.png
new file mode 100644
index 00000000..eca43ad9
Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_bg.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_bg.png b/Near/app/src/main/res/drawable-xxhdpi/img_bg.png
new file mode 100644
index 00000000..bb6e965a
Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_bg.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_bg.png b/Near/app/src/main/res/drawable-xxxhdpi/img_bg.png
new file mode 100644
index 00000000..bc5e2345
Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_bg.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_24_search.xml b/Near/app/src/main/res/drawable/ic_24_search.xml
new file mode 100644
index 00000000..2af0214e
--- /dev/null
+++ b/Near/app/src/main/res/drawable/ic_24_search.xml
@@ -0,0 +1,20 @@
+
+
+
+
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_close_32_black.xml b/Near/app/src/main/res/drawable/ic_close_32_black.xml
new file mode 100644
index 00000000..ec893332
--- /dev/null
+++ b/Near/app/src/main/res/drawable/ic_close_32_black.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/drawable/ic_launcher_foreground.xml b/Near/app/src/main/res/drawable/ic_launcher_foreground.xml
index 2b068d11..db210bef 100644
--- a/Near/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/Near/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,30 +1,33 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+ android:width="100dp"
+ android:height="101dp"
+ android:viewportWidth="100"
+ android:viewportHeight="101">
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Near/app/src/main/res/drawable/ic_near_logo_title.xml b/Near/app/src/main/res/drawable/ic_near_logo_title_primary.xml
similarity index 100%
rename from Near/app/src/main/res/drawable/ic_near_logo_title.xml
rename to Near/app/src/main/res/drawable/ic_near_logo_title_primary.xml
diff --git a/Near/app/src/main/res/drawable/ic_near_logo_title_white.xml b/Near/app/src/main/res/drawable/ic_near_logo_title_white.xml
new file mode 100644
index 00000000..68cda581
--- /dev/null
+++ b/Near/app/src/main/res/drawable/ic_near_logo_title_white.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/Near/app/src/main/res/drawable/img_splash_logo.xml b/Near/app/src/main/res/drawable/img_splash_logo.xml
new file mode 100644
index 00000000..3ea2c402
--- /dev/null
+++ b/Near/app/src/main/res/drawable/img_splash_logo.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100644
index 6f3b755b..00000000
--- a/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 6f3b755b..e61cccae 100644
--- a/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,6 +1,4 @@
-
-
-
-
\ No newline at end of file
+
+
diff --git a/Near/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Near/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..f1fae059
Binary files /dev/null and b/Near/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/Near/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/Near/app/src/main/res/mipmap-hdpi/ic_launcher.webp
deleted file mode 100644
index c209e78e..00000000
Binary files a/Near/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ
diff --git a/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 00000000..cff35eaa
Binary files /dev/null and b/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
deleted file mode 100644
index b2dfe3d1..00000000
Binary files a/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/Near/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Near/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..1d716579
Binary files /dev/null and b/Near/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/Near/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/Near/app/src/main/res/mipmap-mdpi/ic_launcher.webp
deleted file mode 100644
index 4f0f1d64..00000000
Binary files a/Near/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ
diff --git a/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 00000000..55dad60a
Binary files /dev/null and b/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
deleted file mode 100644
index 62b611da..00000000
Binary files a/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..4bdeb0d0
Binary files /dev/null and b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
deleted file mode 100644
index 948a3070..00000000
Binary files a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ
diff --git a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..c57d22ed
Binary files /dev/null and b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
deleted file mode 100644
index 1b9a6956..00000000
Binary files a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..10430089
Binary files /dev/null and b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
deleted file mode 100644
index 28d4b77f..00000000
Binary files a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..afc978cf
Binary files /dev/null and b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9287f508..00000000
Binary files a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..b24a89ed
Binary files /dev/null and b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
deleted file mode 100644
index aa7d6427..00000000
Binary files a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 00000000..2010a26f
Binary files /dev/null and b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9126ae37..00000000
Binary files a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml
index 173ad5c1..8dfc484d 100644
--- a/Near/app/src/main/res/values/strings.xml
+++ b/Near/app/src/main/res/values/strings.xml
@@ -8,6 +8,18 @@
메뉴
네트워크 에러가 발생했습니다.
+
+ 스플래쉬 배경
+
+
+ 소중한 사람들과\n더 가까워질 수 있도록
+ 연락처와 카카오톡으로\n챙길 사람 등록하기
+ 챙기고 싶은 날에\n알림 받기
+ 오늘도 잘 챙겼는지\n기록 남기기
+ 더 잘 챙길 수 있게\n메시지 추천 받기
+ 로그인/회원가입
+ 다음
+
Near 로고
Near 타이틀
@@ -88,4 +100,47 @@
토요일
일요일
+
+ MY
+ 프로필 이미지
+ 일반
+ 연결계정
+ 알림 설정
+ 서비스 정보
+ 로그아웃
+ 탈퇴하기
+ %1$s 상세
+
+
+ 탈퇴하기
+ %1$s님,\n떠나는 이유를 알려주시면\n큰 도움이 될 거예요.
+ 소중한 의견을 받아\n더 나은 서비스를 만들어갈게요.
+ 자주 이용하지 않아요
+ 신규 계정으로 가입할게요
+ 개인정보가 우려돼요
+ 서비스가 불편해요
+ 기타
+ 편하게 의견을 남겨주세요.
+ 그만두기
+ 탈퇴하기
+
+
+ 1글자 이상 입력해주세요.
+
+
+ 서비스 이용약관
+ 개인정보 수집 및 이용동의
+ 개인정보 처리방침
+
+
+ 카카오
+ -
+ 소셜 로그인 아이콘
+
+
+ 연락처에서 불러오기
+ 화면 닫기
+ 이름 검색
+ 연락처를 불러오지 못했습니다.
+
diff --git a/Near/app/src/main/res/values/themes.xml b/Near/app/src/main/res/values/themes.xml
index dbbeb9df..abc32d05 100644
--- a/Near/app/src/main/res/values/themes.xml
+++ b/Near/app/src/main/res/values/themes.xml
@@ -1,5 +1,14 @@
+
-
\ No newline at end of file
+
+
+
+
diff --git a/Near/gradle/libs.versions.toml b/Near/gradle/libs.versions.toml
index d3aa3673..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
@@ -30,6 +30,8 @@ datastorePreferences = "1.1.7"
datastoreCore = "1.1.7"
#Kakao
v2All = "2.21.7"
+# Splash Screen
+splashScreen = "1.0.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -51,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" }
@@ -63,6 +64,7 @@ logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-intercep
retrofit-kotlin-serialization-converter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofitVersion" }
androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" }
v2-all = { module = "com.kakao.sdk:v2-all", version.ref = "v2All" }
+androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }