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 @@ + + 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" }