diff --git a/.github/workflows/android-pull-request-ci.yml b/.github/workflows/android-pull-request-ci.yml
index 5bb75fef..8854d504 100644
--- a/.github/workflows/android-pull-request-ci.yml
+++ b/.github/workflows/android-pull-request-ci.yml
@@ -43,6 +43,12 @@ jobs:
run: |
echo "TEMP_TOKEN=\"TEMP_TOKEN\"" >> local.properties
+ - name: Access Kakao KAKAO_NATIVE_APP_KEY
+ env:
+ KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }}
+ run: |
+ echo "KAKAO_NATIVE_APP_KEY=\"$KAKAO_NATIVE_APP_KEY\"" >> 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 bd42f1a5..1e85f397 100644
--- a/Near/app/build.gradle.kts
+++ b/Near/app/build.gradle.kts
@@ -7,6 +7,7 @@ plugins {
alias(libs.plugins.hilt.application)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.kotlin.parcelize)
}
android {
@@ -32,10 +33,14 @@ 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"))
+ 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"))
+ manifestPlaceholders["kakaoAppKey"] = getProperty("KAKAO_NATIVE_APP_KEY").replace("\"", "")
}
}
compileOptions {
@@ -88,6 +93,12 @@ dependencies {
implementation(libs.navigation.compose)
// Serialization
implementation(libs.kotlin.serialization.json)
+ // DataStore
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.datastore.core)
+
+ // Kakao Module
+ implementation(libs.v2.all)
}
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 08b99e2f..b0ae0a68 100644
--- a/Near/app/src/main/AndroidManifest.xml
+++ b/Near/app/src/main/AndroidManifest.xml
@@ -15,6 +15,25 @@
android:supportsRtl="true"
android:theme="@style/Theme.Near"
tools:targetApi="31">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
=
+ try {
+ val token =
+ if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) {
+ loginWithKakaoTalk()
+ } else {
+ loginWithKakaoAccount()
+ }
+
+ if (token.isNotEmpty()) {
+ Result.success(token)
+ } else {
+ Result.failure(Exception("사용자가 로그인을 취소했습니다"))
+ }
+ } catch (exception: Exception) {
+ Result.failure(exception)
+ }
+
+ private suspend fun loginWithKakaoTalk(): String =
+ suspendCancellableCoroutine { continuation ->
+ UserApiClient.instance.loginWithKakaoTalk(context) { token, error ->
+ when {
+ error != null -> {
+ if (error is ClientError && error.reason == ClientErrorCause.Cancelled) {
+ continuation.resume("")
+ } else {
+ UserApiClient.instance.loginWithKakaoAccount(context) { retryToken, retryError ->
+ handleLoginResult(retryToken, retryError, continuation)
+ }
+ }
+ }
+ token != null -> continuation.resume(token.accessToken)
+ else -> continuation.resume("")
+ }
+ }
+ }
+
+ private suspend fun loginWithKakaoAccount(): String =
+ suspendCancellableCoroutine { continuation ->
+ UserApiClient.instance.loginWithKakaoAccount(context) { token, error ->
+ handleLoginResult(token, error, continuation)
+ }
+ }
+
+ private fun handleLoginResult(
+ token: OAuthToken?,
+ error: Throwable?,
+ continuation: CancellableContinuation,
+ ) {
+ when {
+ error != null -> {
+ if (error is ClientError && error.reason == ClientErrorCause.Cancelled) {
+ continuation.resume("")
+ } else {
+ continuation.resumeWith(Result.failure(error))
+ }
+ }
+ token != null -> continuation.resume(token.accessToken)
+ else -> continuation.resume("")
+ }
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt
new file mode 100644
index 00000000..9d2bb534
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginDataSource.kt
@@ -0,0 +1,20 @@
+package com.alarmy.near.data.datasource
+
+import com.alarmy.near.model.ProviderType
+
+/**
+ * 소셜 로그인 데이터 소스 인터페이스
+ * Strategy 패턴으로 각 소셜 플랫폼별로 구현
+ */
+interface SocialLoginDataSource {
+ /**
+ * 지원하는 소셜 로그인 타입
+ */
+ val supportedType: ProviderType
+
+ /**
+ * 소셜 로그인 수행
+ * Context는 생성자에서 주입받아 사용
+ */
+ suspend fun login(): Result
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt
new file mode 100644
index 00000000..1944d12a
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/datasource/SocialLoginProcessor.kt
@@ -0,0 +1,32 @@
+package com.alarmy.near.data.datasource
+
+
+import com.alarmy.near.model.ProviderType
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * 소셜 로그인 프로세서
+ * Strategy 패턴으로 동적으로 로그인 방식 선택
+ */
+@Singleton
+class SocialLoginProcessor
+ @Inject
+ constructor(
+ private val socialLoginDataSources: Set<@JvmSuppressWildcards SocialLoginDataSource>,
+ ) {
+ /**
+ * 소셜 로그인 처리
+ * @param providerType 로그인 제공자 타입
+ * @return 로그인 결과
+ */
+ suspend fun processLogin(
+ providerType: ProviderType,
+ ): Result {
+ val dataSource =
+ socialLoginDataSources.find { it.supportedType == providerType }
+ ?: return Result.failure(Exception("지원하지 않는 로그인 타입입니다: ${providerType.name}"))
+
+ return dataSource.login()
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt
new file mode 100644
index 00000000..4a6fb2b8
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/di/DataSourceModule.kt
@@ -0,0 +1,17 @@
+package com.alarmy.near.data.di
+
+import com.alarmy.near.data.datasource.KakaoDataSource
+import com.alarmy.near.data.datasource.SocialLoginDataSource
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface DataSourceModule {
+ @Binds
+ @IntoSet
+ abstract fun bindKakaoDataSource(kakaoDataSource: KakaoDataSource): SocialLoginDataSource
+}
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
new file mode 100644
index 00000000..5f73bfb6
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt
@@ -0,0 +1,25 @@
+package com.alarmy.near.data.di
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.preferencesDataStore
+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
+
+// DataStore 확장 프로퍼티
+private val Context.dataStore: DataStore by preferencesDataStore(name = "auth_preferences")
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DataStoreModule {
+ @Provides
+ @Singleton
+ fun provideDataStore(
+ @ApplicationContext context: Context,
+ ): DataStore = context.dataStore
+}
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 3dff6205..5d73c37f 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
@@ -1,5 +1,7 @@
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.DefaultFriendRepository
import com.alarmy.near.data.repository.ExampleRepository
import com.alarmy.near.data.repository.ExampleRepositoryImpl
@@ -20,4 +22,8 @@ interface RepositoryModule {
@Binds
@Singleton
abstract fun bindFriendRepository(friendRepository: DefaultFriendRepository): FriendRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository
}
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
new file mode 100644
index 00000000..13f5eaa0
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt
@@ -0,0 +1,110 @@
+package com.alarmy.near.data.local.datastore
+
+import androidx.datastore.core.DataStore
+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 kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * 토큰 저장소 DataStore 관리 클래스
+ * 액세스 토큰과 리프레시 토큰의 저장, 조회, 삭제를 담당
+ */
+@Singleton
+class TokenPreferences
+ @Inject
+ constructor(
+ private val dataStore: DataStore,
+ ) {
+ private val accessTokenKey = stringPreferencesKey("access_token")
+ private val refreshTokenKey = stringPreferencesKey("refresh_token")
+ private val expiresAtKey = longPreferencesKey("expires_at")
+
+ /**
+ * 두 토큰 동시 저장
+ */
+ suspend fun saveTokens(
+ accessToken: String,
+ refreshToken: String?,
+ expiresIn: Long? = null,
+ ) {
+ dataStore.edit { preferences ->
+ preferences[accessTokenKey] = accessToken
+ refreshToken?.let {
+ preferences[refreshTokenKey] = it
+ }
+ expiresIn?.let {
+ val expiresAt = System.currentTimeMillis() + (it * 1000)
+ preferences[expiresAtKey] = expiresAt
+ }
+ }
+ }
+
+ /**
+ * 액세스 토큰 조회
+ */
+ suspend fun getAccessToken(): String? = dataStore.data.first()[accessTokenKey]
+
+ /**
+ * 리프레시 토큰 조회
+ */
+ suspend fun getRefreshToken(): String? = dataStore.data.first()[refreshTokenKey]
+
+ /**
+ * 토큰 존재 여부 확인
+ */
+ suspend fun hasValidTokens(): Boolean {
+ val prefs = dataStore.data.first()
+ val accessToken = prefs[accessTokenKey]
+ if (accessToken.isNullOrBlank()) return false
+
+ val expiresAt = prefs[expiresAtKey]
+ val isExpired = expiresAt == null || System.currentTimeMillis() >= expiresAt
+ return !isExpired
+ }
+
+ /**
+ * 토큰 만료 여부 확인
+ */
+ suspend fun isTokenExpired(): Boolean {
+ val expiresAt = dataStore.data.first()[expiresAtKey] ?: return true
+ return System.currentTimeMillis() >= expiresAt
+ }
+
+ /**
+ * 토큰 만료 시간 조회
+ */
+ suspend fun getTokenExpiresAt(): Long? = dataStore.data.first()[expiresAtKey]
+
+ /**
+ * 모든 토큰 삭제
+ */
+ suspend fun clearAllTokens() {
+ dataStore.edit { preferences ->
+ preferences.remove(accessTokenKey)
+ preferences.remove(refreshTokenKey)
+ preferences.remove(expiresAtKey)
+ }
+ }
+
+ /**
+ * 액세스 토큰 관찰
+ */
+ fun observeAccessToken() =
+ dataStore.data.map { preferences ->
+ preferences[accessTokenKey]
+ }
+
+ /**
+ * 로그인 상태 관찰
+ */
+ fun observeLoginStatus() =
+ dataStore.data.map { preferences ->
+ !preferences[accessTokenKey].isNullOrBlank()
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt
index fcfc70c5..68203c95 100644
--- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt
+++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendMapper.kt
@@ -1,21 +1,65 @@
package com.alarmy.near.data.mapper
+import com.alarmy.near.model.Anniversary
import com.alarmy.near.model.ContactFrequency
-import com.alarmy.near.model.FriendSummary
+import com.alarmy.near.model.DayOfWeek
+import com.alarmy.near.model.Friend
+import com.alarmy.near.model.Relation
+import com.alarmy.near.model.ReminderInterval
+import com.alarmy.near.network.request.AnniversaryRequest
+import com.alarmy.near.network.request.ContactFrequencyRequest
+import com.alarmy.near.network.request.FriendRequest
+import com.alarmy.near.network.response.AnniversaryEntity
+import com.alarmy.near.network.response.ContactFrequencyEntity
import com.alarmy.near.network.response.FriendEntity
-fun FriendEntity.toModel(): FriendSummary =
- FriendSummary(
- id = friendId,
+fun FriendEntity.toModel(): Friend =
+ Friend(
+ friendId = friendId,
+ imageUrl = imageUrl,
+ relation = Relation.valueOf(relation),
name = name,
- profileImageUrl = imageUrl,
- lastContactedAt = lastContactAt,
- isContacted = true,
- contactFrequency =
- when (checkRate) {
- in 0..29 -> ContactFrequency.LOW
- in 30..69 -> ContactFrequency.MIDDLE
- in 70..100 -> ContactFrequency.HIGH
- else -> ContactFrequency.LOW
- },
- )
+ contactFrequency = contactFrequency.toModel(),
+ birthday = birthday,
+ anniversaryList = anniversaryList.map { it.toModel() },
+ memo = memo,
+ phone = phone,
+ lastContactAt = lastContactAt,
+ )
+
+fun ContactFrequencyEntity.toModel(): ContactFrequency =
+ ContactFrequency(
+ reminderInterval = ReminderInterval.valueOf(contactWeek),
+ dayOfWeek = DayOfWeek.valueOf(dayOfWeek),
+ )
+
+fun AnniversaryEntity.toModel(): Anniversary =
+ Anniversary(
+ id = id,
+ title = title,
+ date = date,
+ )
+
+fun Friend.toRequest(): FriendRequest =
+ FriendRequest(
+ name = name,
+ relation = relation.toString(),
+ contactFrequency = contactFrequency.toRequest(),
+ birthday = birthday,
+ anniversaryList = anniversaryList.map { it.toRequest() },
+ memo = memo,
+ phone = phone,
+ )
+
+fun ContactFrequency.toRequest(): ContactFrequencyRequest =
+ ContactFrequencyRequest(
+ contactWeek = reminderInterval.toString(),
+ dayOfWeek = dayOfWeek.toString(),
+ )
+
+fun Anniversary.toRequest(): AnniversaryRequest =
+ AnniversaryRequest(
+ id = id,
+ title = title,
+ date = date,
+ )
diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt
new file mode 100644
index 00000000..ce99b806
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendRecordMapper.kt
@@ -0,0 +1,20 @@
+package com.alarmy.near.data.mapper
+
+import com.alarmy.near.model.FriendRecord
+import com.alarmy.near.network.response.FriendRecordEntity
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+fun FriendRecordEntity.toModel(): FriendRecord =
+ FriendRecord(
+ isChecked = isChecked,
+ createdAt = createdAt.toShortDate(),
+ )
+
+private fun String.toShortDate(): String {
+ val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+ val outputFormatter = DateTimeFormatter.ofPattern("yy.MM.dd")
+
+ val dateTime = LocalDateTime.parse(this, inputFormatter)
+ return dateTime.format(outputFormatter)
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt
new file mode 100644
index 00000000..c1811712
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendSummaryMapper.kt
@@ -0,0 +1,25 @@
+package com.alarmy.near.data.mapper
+
+import com.alarmy.near.model.friendsummary.ContactFrequencyLevel
+import com.alarmy.near.model.friendsummary.FriendSummary
+import com.alarmy.near.network.response.FriendSummaryEntity
+
+private val CONTACT_FREQUENCY_LOW_RANGE = 0..29
+private val CONTACT_FREQUENCY_MIDDLE_RANGE = 30..69
+private val CONTACT_FREQUENCY_HIGH_RANGE = 70..100
+
+fun FriendSummaryEntity.toModel(): FriendSummary =
+ FriendSummary(
+ id = friendId,
+ name = name,
+ profileImageUrl = imageUrl,
+ lastContactedAt = lastContactAt,
+ isContacted = true,
+ contactFrequencyLevel =
+ when (checkRate) {
+ in CONTACT_FREQUENCY_LOW_RANGE -> ContactFrequencyLevel.LOW
+ in CONTACT_FREQUENCY_MIDDLE_RANGE -> ContactFrequencyLevel.MIDDLE
+ in CONTACT_FREQUENCY_HIGH_RANGE -> ContactFrequencyLevel.HIGH
+ else -> ContactFrequencyLevel.LOW
+ },
+ )
diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt
new file mode 100644
index 00000000..ded14dc6
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepository.kt
@@ -0,0 +1,32 @@
+package com.alarmy.near.data.repository
+
+import com.alarmy.near.model.ProviderType
+import kotlinx.coroutines.flow.Flow
+
+interface AuthRepository {
+ // 소셜 로그인 수행
+ suspend fun performSocialLogin(providerType: ProviderType): Result
+
+ /**
+ * 소셜 로그인 수행 (토큰 직접 전달)
+ */
+ suspend fun socialLogin(
+ accessToken: String,
+ providerType: ProviderType,
+ ): Result
+
+ // 로그아웃 수행
+ suspend fun logout()
+
+ // 로그인 상태 확인
+ suspend fun isLoggedIn(): Boolean
+
+ // 현재 사용자 토큰 가져오기
+ suspend fun getCurrentUserToken(): String?
+
+ // 로그인 상태 확인
+ fun observeLoginStatus(): Flow
+
+ // 토큰 갱신
+ suspend fun refreshToken(): Boolean
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt
new file mode 100644
index 00000000..43f51f80
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/data/repository/AuthRepositoryImpl.kt
@@ -0,0 +1,115 @@
+package com.alarmy.near.data.repository
+
+import com.alarmy.near.data.datasource.SocialLoginProcessor
+import com.alarmy.near.model.ProviderType
+import com.alarmy.near.network.auth.TokenManager
+import com.alarmy.near.network.request.SocialLoginRequest
+import com.alarmy.near.network.service.AuthService
+import kotlinx.coroutines.flow.Flow
+import retrofit2.HttpException
+import javax.inject.Inject
+
+class AuthRepositoryImpl
+ @Inject
+ constructor(
+ private val authService: AuthService,
+ private val socialLoginProcessor: SocialLoginProcessor,
+ private val tokenManager: TokenManager,
+ ) : AuthRepository {
+ override suspend fun performSocialLogin(providerType: ProviderType): Result =
+ try {
+ val result = socialLoginProcessor.processLogin(providerType)
+
+ if (result.isSuccess) {
+ val accessToken = result.getOrThrow()
+ socialLogin(accessToken, providerType)
+ } else {
+ Result.failure(
+ createLoginException(
+ providerType = providerType,
+ errorMessage = result.exceptionOrNull()?.message,
+ defaultMessage = "로그인에 실패했습니다",
+ ),
+ )
+ }
+ } catch (exception: Exception) {
+ Result.failure(
+ createLoginException(
+ providerType = providerType,
+ errorMessage = exception.message,
+ defaultMessage = "로그인 중 오류가 발생했습니다",
+ ),
+ )
+ }
+
+ override suspend fun socialLogin(
+ accessToken: String,
+ providerType: ProviderType,
+ ): Result =
+ try {
+ val request =
+ SocialLoginRequest(
+ accessToken = accessToken,
+ providerType = providerType.name,
+ )
+
+ val response = authService.socialLogin(request)
+
+ // 토큰 저장
+ val expiresIn = tokenManager.calculateExpiresIn(response.refreshTokenInfo?.expiresAt)
+ tokenManager.saveTokens(
+ accessToken = response.accessToken,
+ refreshToken = response.refreshTokenInfo?.token,
+ expiresIn = expiresIn,
+ )
+
+ Result.success(Unit)
+ } catch (exception: HttpException) {
+ val errorMessage = getHttpErrorMessage(exception.code())
+ Result.failure(Exception(errorMessage))
+ } catch (exception: Exception) {
+ val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다"
+ Result.failure(Exception(errorMessage))
+ }
+
+ override suspend fun logout() {
+ tokenManager.clearAllTokens()
+ }
+
+ override suspend fun isLoggedIn(): Boolean =
+ try {
+ tokenManager.hasValidToken()
+ } catch (exception: Exception) {
+ false
+ }
+
+ override suspend fun getCurrentUserToken(): String? =
+ try {
+ tokenManager.getAccessToken()
+ } catch (exception: Exception) {
+ null
+ }
+
+ override fun observeLoginStatus(): Flow = tokenManager.observeLoginStatus()
+
+ override suspend fun refreshToken(): Boolean = tokenManager.refreshToken()
+
+ private fun createLoginException(
+ providerType: ProviderType,
+ errorMessage: String?,
+ defaultMessage: String,
+ ): Exception {
+ val finalMessage = errorMessage ?: "$providerType $defaultMessage"
+ return Exception(finalMessage)
+ }
+
+ // TODO 추후 에러 메시지 변경
+ private fun getHttpErrorMessage(httpCode: Int): String =
+ when (httpCode) {
+ 400 -> "잘못된 요청입니다"
+ 401 -> "소셜 로그인에 실패했습니다. 다시 시도해주세요."
+ 403 -> "접근이 거부되었습니다"
+ 500 -> "서버에 문제가 발생했습니다"
+ else -> "로그인 중 오류가 발생했습니다"
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt
index 61ae63e7..7651da94 100644
--- a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt
+++ b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt
@@ -1,7 +1,10 @@
package com.alarmy.near.data.repository
import com.alarmy.near.data.mapper.toModel
-import com.alarmy.near.model.FriendSummary
+import com.alarmy.near.data.mapper.toRequest
+import com.alarmy.near.model.Friend
+import com.alarmy.near.model.FriendRecord
+import com.alarmy.near.model.friendsummary.FriendSummary
import com.alarmy.near.model.monthly.MonthlyFriend
import com.alarmy.near.network.service.FriendService
import kotlinx.coroutines.flow.Flow
@@ -30,4 +33,34 @@ class DefaultFriendRepository
},
)
}
+
+ override fun fetchFriendById(friendId: String): Flow =
+ flow {
+ emit(friendService.fetchFriendById(friendId).toModel())
+ }
+
+ override fun updateFriend(
+ friendId: String,
+ friend: Friend,
+ ): Flow =
+ flow {
+ emit(friendService.updateFriend(friendId, friend.toRequest()).toModel())
+ }
+
+ override fun deleteFriend(friendId: String): Flow =
+ flow {
+ friendService.deleteFriend(friendId)
+ emit(Unit)
+ }
+
+ override fun fetchFriendRecord(friendId: String): Flow> =
+ flow {
+ emit(friendService.fetchFriendRecord(friendId).map { it.toModel() })
+ }
+
+ override fun recordContact(friendId: String): Flow =
+ flow {
+ val response = friendService.recordContact(friendId)
+ emit(response.message) // CommonMessageEntity.message 라고 가정
+ }
}
diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt
index 425654fe..81e6761b 100644
--- a/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt
+++ b/Near/app/src/main/java/com/alarmy/near/data/repository/FriendRepository.kt
@@ -1,6 +1,8 @@
package com.alarmy.near.data.repository
-import com.alarmy.near.model.FriendSummary
+import com.alarmy.near.model.Friend
+import com.alarmy.near.model.FriendRecord
+import com.alarmy.near.model.friendsummary.FriendSummary
import com.alarmy.near.model.monthly.MonthlyFriend
import kotlinx.coroutines.flow.Flow
@@ -8,4 +10,17 @@ interface FriendRepository {
fun fetchFriends(): Flow>
fun fetchMonthlyFriends(): Flow>
+
+ fun fetchFriendById(friendId: String): Flow
+
+ fun updateFriend(
+ friendId: String,
+ friend: Friend,
+ ): Flow
+
+ fun deleteFriend(friendId: String): Flow
+
+ fun fetchFriendRecord(friendId: String): Flow>
+
+ fun recordContact(friendId: String): Flow
}
diff --git a/Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt b/Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt
deleted file mode 100644
index dfcea4a5..00000000
--- a/Near/app/src/main/java/com/alarmy/near/model/ContactFrequency.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.alarmy.near.model
-
-enum class ContactFrequency {
- LOW,
- MIDDLE,
- HIGH,
-}
diff --git a/Near/app/src/main/java/com/alarmy/near/model/Friend.kt b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt
new file mode 100644
index 00000000..b37b23a6
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/model/Friend.kt
@@ -0,0 +1,63 @@
+package com.alarmy.near.model
+
+import android.os.Parcelable
+import androidx.annotation.StringRes
+import com.alarmy.near.R
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Serializable
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+
+@Parcelize
+@Serializable
+data class Friend(
+ val friendId: String,
+ val imageUrl: String?,
+ val relation: Relation,
+ val name: String,
+ val contactFrequency: ContactFrequency,
+ val birthday: String?,
+ val anniversaryList: List,
+ val memo: String?,
+ val phone: String?,
+ val lastContactAt: String?, // "2025-07-16"
+) : Parcelable {
+ val isContactedToday: Boolean
+ get() = lastContactAt?.isToday() ?: false
+
+ private fun String.isToday(): Boolean {
+ val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREA)
+ val targetDate = LocalDate.parse(this, formatter)
+ val today = LocalDate.now()
+ return targetDate == today
+ }
+}
+
+@Serializable
+@Parcelize
+data class ContactFrequency(
+ val reminderInterval: ReminderInterval,
+ val dayOfWeek: DayOfWeek,
+) : Parcelable
+
+@Serializable
+@Parcelize
+data class Anniversary(
+ val id: Int? = null,
+ val title: String,
+ val date: String? = null,
+) : Parcelable
+
+@Serializable
+enum class DayOfWeek(
+ @param:StringRes val resId: Int,
+) {
+ MONDAY(R.string.day_of_week_monday),
+ TUESDAY(R.string.day_of_week_tuesday),
+ WEDNESDAY(R.string.day_of_week_wednesday),
+ THURSDAY(R.string.day_of_week_thursday),
+ FRIDAY(R.string.day_of_week_friday),
+ SATURDAY(R.string.day_of_week_saturday),
+ SUNDAY(R.string.day_of_week_sunday),
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt b/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt
new file mode 100644
index 00000000..2d6a7459
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/model/FriendRecord.kt
@@ -0,0 +1,6 @@
+package com.alarmy.near.model
+
+data class FriendRecord(
+ val isChecked: Boolean,
+ val createdAt: String, // ex) 25.11.12
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt b/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt
new file mode 100644
index 00000000..e38df6cd
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/model/LoginResult.kt
@@ -0,0 +1,8 @@
+package com.alarmy.near.model
+
+data class LoginResult(
+ val isSuccess: Boolean,
+ val accessToken: String? = null,
+ val refreshToken: String? = null,
+ val errorMessage: String? = null,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt b/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt
new file mode 100644
index 00000000..9d4f0fbe
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/model/ProviderType.kt
@@ -0,0 +1,6 @@
+package com.alarmy.near.model
+
+enum class ProviderType {
+ KAKAO,
+ ETC,
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/model/Relation.kt b/Near/app/src/main/java/com/alarmy/near/model/Relation.kt
index e01a7a52..1c84a7ba 100644
--- a/Near/app/src/main/java/com/alarmy/near/model/Relation.kt
+++ b/Near/app/src/main/java/com/alarmy/near/model/Relation.kt
@@ -1,7 +1,12 @@
package com.alarmy.near.model
-enum class Relation {
- FRIEND,
- FAMILY,
- ACQUAINTANCE,
+import androidx.annotation.StringRes
+import com.alarmy.near.R
+
+enum class Relation(
+ @param:StringRes val resId: Int,
+) {
+ FRIEND(R.string.relation_friend),
+ FAMILY(R.string.relation_family),
+ ACQUAINTANCE(R.string.relation_acquaintance),
}
diff --git a/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt b/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt
index 0d39369d..5bbd80f8 100644
--- a/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt
+++ b/Near/app/src/main/java/com/alarmy/near/model/ReminderInterval.kt
@@ -6,9 +6,9 @@ import com.alarmy.near.R
enum class ReminderInterval(
@param:StringRes val labelRes: Int,
) {
- DAILY(R.string.reminder_interval_daily), // 매일
- WEEKLY(R.string.reminder_interval_weekly), // 매주
- BIWEEKLY(R.string.reminder_interval_biweekly), // 2주
- MONTHLY(R.string.reminder_interval_monthly), // 매달
- SEMIANNUAL(R.string.reminder_interval_semiannual), // 6개월
+ EVERY_DAY(R.string.reminder_interval_daily),
+ EVERY_WEEK(R.string.reminder_interval_weekly),
+ EVERY_TWO_WEEK(R.string.reminder_interval_biweekly),
+ EVERY_MONTH(R.string.reminder_interval_monthly),
+ EVERY_SIX_MONTH(R.string.reminder_interval_semiannual),
}
diff --git a/Near/app/src/main/java/com/alarmy/near/model/friendsummary/ContactFrequencyLevel.kt b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/ContactFrequencyLevel.kt
new file mode 100644
index 00000000..2da7665a
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/ContactFrequencyLevel.kt
@@ -0,0 +1,7 @@
+package com.alarmy.near.model.friendsummary
+
+enum class ContactFrequencyLevel {
+ LOW,
+ MIDDLE,
+ HIGH,
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/FriendSummary.kt
similarity index 69%
rename from Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt
rename to Near/app/src/main/java/com/alarmy/near/model/friendsummary/FriendSummary.kt
index 49249003..7d5799e3 100644
--- a/Near/app/src/main/java/com/alarmy/near/model/FriendSummary.kt
+++ b/Near/app/src/main/java/com/alarmy/near/model/friendsummary/FriendSummary.kt
@@ -1,4 +1,4 @@
-package com.alarmy.near.model
+package com.alarmy.near.model.friendsummary
import androidx.compose.runtime.Immutable
@@ -9,5 +9,5 @@ data class FriendSummary(
val profileImageUrl: String?,
val lastContactedAt: String?,
val isContacted: Boolean,
- val contactFrequency: ContactFrequency,
+ val contactFrequencyLevel: ContactFrequencyLevel,
)
diff --git a/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt b/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt
index 553d6939..0efc9fb0 100644
--- a/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt
+++ b/Near/app/src/main/java/com/alarmy/near/model/monthly/MonthlyFriendType.kt
@@ -3,6 +3,7 @@ package com.alarmy.near.model.monthly
import androidx.annotation.DrawableRes
import com.alarmy.near.R
+// TODO Drawable Res UI-Layer 이동
enum class MonthlyFriendType(
@param:DrawableRes val imageSrc: Int,
) {
diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TestTokenInterceptor.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TestTokenInterceptor.kt
new file mode 100644
index 00000000..f1b24c8f
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TestTokenInterceptor.kt
@@ -0,0 +1,21 @@
+package com.alarmy.near.network.auth
+
+import com.alarmy.near.BuildConfig
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+
+class TestTokenInterceptor
+ @Inject
+ constructor() : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ return chain.proceed(
+ request =
+ request
+ .newBuilder()
+ .addHeader("Authorization", "Bearer ${BuildConfig.TEMP_TOKEN}")
+ .build(),
+ )
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt
new file mode 100644
index 00000000..fb6d01ee
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenAuthenticator.kt
@@ -0,0 +1,55 @@
+package com.alarmy.near.network.auth
+
+import kotlinx.coroutines.runBlocking
+import okhttp3.Authenticator
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.Route
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * 토큰 인증자
+ * 401 에러 발생 시 토큰 갱신을 시도하고 원래 요청을 재시도
+ */
+@Singleton
+class TokenAuthenticator
+@Inject
+constructor(
+ private val tokenManager: TokenManager,
+) : Authenticator {
+
+ override fun authenticate(route: Route?, response: Response): Request? {
+ // 401 에러가 아니면 null 반환 (인증 시도하지 않음)
+ if (response.code != 401) {
+ return null
+ }
+
+ // runBlocking을 사용하여 suspend 함수 호출
+ return runBlocking {
+ try {
+ // 토큰 갱신 시도
+ val refreshSuccess = tokenManager.refreshToken()
+
+ // 토큰 갱신 실패 시 null 반환 (재로그인 필요)
+ if (!refreshSuccess) {
+ return@runBlocking null
+ }
+
+ // 새로운 토큰으로 원래 요청 재시도
+ val newToken = tokenManager.getAccessToken()
+
+ if (newToken != null) {
+ response.request
+ .newBuilder()
+ .header("Authorization", "Bearer $newToken")
+ .build()
+ } else {
+ null
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt
index e2e99566..225e547e 100644
--- a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt
+++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenInterceptor.kt
@@ -1,21 +1,51 @@
package com.alarmy.near.network.auth
-import com.alarmy.near.BuildConfig
+import com.alarmy.near.network.model.AuthEndpoint
+import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
+import okhttp3.Request
import okhttp3.Response
import javax.inject.Inject
+import javax.inject.Singleton
+/**
+ * 토큰 인터셉터
+ * Authorization 헤더를 자동으로 추가하고 인증이 필요없는 요청은 제외
+ */
+@Singleton
class TokenInterceptor
- @Inject
- constructor() : Interceptor {
- override fun intercept(chain: Interceptor.Chain): Response {
- val request = chain.request()
- return chain.proceed(
- request =
- request
- .newBuilder()
- .addHeader("Authorization", "Bearer ${BuildConfig.TEMP_TOKEN}")
- .build(),
- )
+@Inject
+constructor(
+ private val tokenManager: TokenManager,
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+
+ if (isAuthExcludedRequest(originalRequest)) {
+ return chain.proceed(originalRequest)
+ }
+
+ // 현재 토큰을 헤더에 추가
+ val token = runBlocking { tokenManager.getAccessToken() }
+ val requestWithAuth = if (token != null) {
+ originalRequest
+ .newBuilder()
+ .header("Authorization", "Bearer $token")
+ .build()
+ } else {
+ originalRequest
}
+
+ // 요청 실행 (401 에러는 Authenticator에서 처리)
+ return chain.proceed(requestWithAuth)
+ }
+
+ /**
+ * 인증이 필요없는 요청인지 확인
+ */
+ private fun isAuthExcludedRequest(request: Request): Boolean {
+ val url = request.url.toString()
+ return AuthEndpoint.excludedPaths.any { url.contains(it) }
}
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt
new file mode 100644
index 00000000..29411a4e
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/auth/TokenManager.kt
@@ -0,0 +1,125 @@
+package com.alarmy.near.network.auth
+
+import com.alarmy.near.data.local.datastore.TokenPreferences
+import com.alarmy.near.network.request.TokenRefreshRequest
+import com.alarmy.near.network.service.AuthService
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeParseException
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+
+/**
+ * 토큰 관리자
+ * 토큰 저장, 조회, 갱신을 담당하는 중앙 관리 클래스
+ * Provider 패턴으로 순환 참조 해결
+ */
+@Singleton
+class TokenManager
+@Inject
+constructor(
+ private val tokenPreferences: TokenPreferences,
+ private val authServiceProvider: Provider, // Provider로 지연 주입
+) {
+
+ private val refreshMutex = Mutex()
+
+ /**
+ * 현재 액세스 토큰 가져오기
+ */
+ suspend fun getAccessToken(): String? {
+ return tokenPreferences.getAccessToken()
+ }
+
+ /**
+ * 토큰 갱신 시도
+ * @return 갱신 성공 여부
+ */
+ suspend fun refreshToken(): Boolean {
+ return refreshMutex.withLock {
+ try {
+ val refreshToken = tokenPreferences.getRefreshToken() ?: return false
+
+ // Provider를 통해 AuthService 가져오기 (지연 주입)
+ val authService = authServiceProvider.get()
+
+ // 토큰 갱신 API 호출
+ val request = TokenRefreshRequest(refreshToken = refreshToken)
+ val response = authService.renewToken(request)
+
+ // 새로운 토큰 저장
+ val expiresIn = calculateExpiresIn(response.refreshTokenInfo?.expiresAt)
+
+ tokenPreferences.saveTokens(
+ accessToken = response.accessToken,
+ refreshToken = response.refreshTokenInfo?.token,
+ expiresIn = expiresIn,
+ )
+
+ true
+ } catch (e: Exception) {
+ // 갱신 실패 시 토큰 삭제
+ tokenPreferences.clearAllTokens()
+ false
+ }
+ }
+ }
+
+ /**
+ * 토큰이 유효한지 확인
+ */
+ suspend fun hasValidToken(): Boolean {
+ return tokenPreferences.hasValidTokens()
+ }
+
+ /**
+ * 모든 토큰 삭제
+ */
+ suspend fun clearAllTokens() {
+ tokenPreferences.clearAllTokens()
+ }
+
+ /**
+ * 로그인 상태 관찰
+ */
+ fun observeLoginStatus(): Flow {
+ return tokenPreferences.observeLoginStatus()
+ }
+
+ /**
+ * 토큰 저장
+ */
+ suspend fun saveTokens(
+ accessToken: String,
+ refreshToken: String?,
+ expiresIn: Long?,
+ ) {
+ tokenPreferences.saveTokens(
+ accessToken = accessToken,
+ refreshToken = refreshToken,
+ expiresIn = expiresIn,
+ )
+ }
+
+ /**
+ * 만료 시간 계산 (초 단위)
+ * java.time 패키지 사용으로 스레드 안전성 보장
+ */
+ fun calculateExpiresIn(expiresAtString: String?): Long? {
+ return expiresAtString?.let { expiresAt ->
+ try {
+ val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
+ val expiresAtTime = LocalDateTime.parse(expiresAt, formatter)
+ val expiresAtMillis = expiresAtTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
+ (expiresAtMillis - System.currentTimeMillis()) / 1000 // 초 단위로 변환
+ } catch (e: DateTimeParseException) {
+ null
+ }
+ }
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt b/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt
index de4e1e8d..2b3938d7 100644
--- a/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt
+++ b/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt
@@ -1,6 +1,8 @@
package com.alarmy.near.network.di
import com.alarmy.near.BuildConfig
+import com.alarmy.near.network.auth.TestTokenInterceptor
+import com.alarmy.near.network.auth.TokenAuthenticator
import com.alarmy.near.network.auth.TokenInterceptor
import dagger.Module
import dagger.Provides
@@ -32,11 +34,15 @@ object NetworkModule {
fun provideOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
tokenInterceptor: TokenInterceptor,
+ tokenAuthenticator: TokenAuthenticator,
+ testTokenInterceptor: TestTokenInterceptor,
): OkHttpClient =
OkHttpClient
.Builder()
.addInterceptor(loggingInterceptor)
- .addInterceptor(tokenInterceptor)
+ .addInterceptor(testTokenInterceptor)
+// .addInterceptor(tokenInterceptor)
+// .authenticator(tokenAuthenticator)
.build()
@Provides
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 a6ef4802..6324fd18 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
@@ -1,5 +1,6 @@
package com.alarmy.near.network.di
+import com.alarmy.near.network.service.AuthService
import com.alarmy.near.network.service.FriendService
import dagger.Module
import dagger.Provides
@@ -11,6 +12,10 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ServiceModule {
+ @Provides
+ @Singleton
+ fun provideAuthService(retrofit: Retrofit): AuthService = retrofit.create(AuthService::class.java)
+
@Provides
@Singleton
fun provideFriendService(retrofit: Retrofit): FriendService = retrofit.create(FriendService::class.java)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt b/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt
new file mode 100644
index 00000000..a19479ed
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/model/AuthEndpoint.kt
@@ -0,0 +1,21 @@
+package com.alarmy.near.network.model
+
+/**
+ * 인증 관련 API 엔드포인트
+ *
+ * 인증이 필요없는 경로들을 관리하여 TokenInterceptor에서
+ * Authorization 헤더 추가를 제외할 수 있습니다.
+ */
+enum class AuthEndpoint(val path: String) {
+ SOCIAL_LOGIN("/auth/social"),
+ TOKEN_RENEW("/auth/renew"),
+ ;
+
+ companion object {
+ /**
+ * 인증이 필요없는 모든 경로 목록
+ * TokenInterceptor에서 이 경로들은 Authorization 헤더를 추가하지 않습니다
+ */
+ val excludedPaths: List = entries.map { it.path }
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt
new file mode 100644
index 00000000..fc9878e2
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/request/FriendRequest.kt
@@ -0,0 +1,27 @@
+package com.alarmy.near.network.request
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class FriendRequest(
+ val name: String,
+ val relation: String,
+ val contactFrequency: ContactFrequencyRequest,
+ val birthday: String?,
+ val anniversaryList: List,
+ val memo: String?,
+ val phone: String?,
+)
+
+@Serializable
+data class ContactFrequencyRequest(
+ val contactWeek: String,
+ val dayOfWeek: String,
+)
+
+@Serializable
+data class AnniversaryRequest(
+ val id: Int? = null,
+ val title: String,
+ val date: String? = null,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt
new file mode 100644
index 00000000..56a78fee
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/request/SocialLoginRequest.kt
@@ -0,0 +1,18 @@
+package com.alarmy.near.network.request
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * 소셜 로그인 요청 데이터 클래스
+ *
+ * @param accessToken 소셜 로그인 AccessToken
+ * @param providerType 소셜 로그인 Provider 타입 (KAKAO, APPLE 등)
+ */
+@Serializable
+data class SocialLoginRequest(
+ @SerialName("accessToken")
+ val accessToken: String,
+ @SerialName("providerType")
+ val providerType: String,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt
new file mode 100644
index 00000000..2edc96ef
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/request/TokenRefreshRequest.kt
@@ -0,0 +1,11 @@
+package com.alarmy.near.network.request
+
+import kotlinx.serialization.Serializable
+
+/**
+ * 토큰 갱신 요청 모델
+ */
+@Serializable
+data class TokenRefreshRequest(
+ val refreshToken: String
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/CommonMessageEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/CommonMessageEntity.kt
new file mode 100644
index 00000000..6566e2bf
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/response/CommonMessageEntity.kt
@@ -0,0 +1,8 @@
+package com.alarmy.near.network.response
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CommonMessageEntity(
+ val message: String,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt
index 7ec3ab3d..dcea2b5c 100644
--- a/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt
+++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendEntity.kt
@@ -1,15 +1,31 @@
package com.alarmy.near.network.response
+import com.alarmy.near.model.DayOfWeek
import kotlinx.serialization.Serializable
@Serializable
data class FriendEntity(
val friendId: String,
- val position: Int,
- val source: String,
+ val imageUrl: String?,
+ val relation: String,
val name: String,
- val imageUrl: String? = null,
- val fileName: String? = null,
- val checkRate: Int,
- val lastContactAt: String? = null,
+ val contactFrequency: ContactFrequencyEntity,
+ val birthday: String?,
+ val anniversaryList: List,
+ val memo: String?,
+ val phone: String?,
+ val lastContactAt: String?,
+)
+
+@Serializable
+data class ContactFrequencyEntity(
+ val contactWeek: String,
+ val dayOfWeek: String,
+)
+
+@Serializable
+data class AnniversaryEntity(
+ val id: Int,
+ val title: String,
+ val date: String,
)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt
new file mode 100644
index 00000000..577349c4
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendRecordEntity.kt
@@ -0,0 +1,9 @@
+package com.alarmy.near.network.response
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class FriendRecordEntity(
+ val isChecked: Boolean,
+ val createdAt: String,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendSummaryEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendSummaryEntity.kt
new file mode 100644
index 00000000..f6a6c55b
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendSummaryEntity.kt
@@ -0,0 +1,15 @@
+package com.alarmy.near.network.response
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class FriendSummaryEntity(
+ val friendId: String,
+ val position: Int,
+ val source: String,
+ val name: String,
+ val imageUrl: String? = null,
+ val fileName: String? = null,
+ val checkRate: Int,
+ val lastContactAt: String? = null,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt
new file mode 100644
index 00000000..c8ff5fd4
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/response/LoginResponse.kt
@@ -0,0 +1,21 @@
+package com.alarmy.near.network.response
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+// 소셜 로그인 응답 데이터 클래스
+@Serializable
+data class LoginResponse(
+ @SerialName("accessToken")
+ val accessToken: String,
+ @SerialName("refreshTokenInfo")
+ val refreshTokenInfo: RefreshTokenInfo? = null,
+)
+
+@Serializable
+data class RefreshTokenInfo(
+ @SerialName("token")
+ val token: String,
+ @SerialName("expiresAt")
+ val expiresAt: String, // "2025-08-20 03:02:07" 형식
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt b/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt
new file mode 100644
index 00000000..b4b0dc73
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/response/TokenRefreshResponse.kt
@@ -0,0 +1,15 @@
+package com.alarmy.near.network.response
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * 토큰 갱신 응답 모델
+ */
+@Serializable
+data class TokenRefreshResponse(
+ @SerialName("accessToken")
+ val accessToken: String,
+ @SerialName("refreshTokenInfo")
+ val refreshTokenInfo: RefreshTokenInfo? = null,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt b/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt
new file mode 100644
index 00000000..587008ea
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/network/service/AuthService.kt
@@ -0,0 +1,25 @@
+package com.alarmy.near.network.service
+
+import com.alarmy.near.network.request.SocialLoginRequest
+import com.alarmy.near.network.request.TokenRefreshRequest
+import com.alarmy.near.network.response.LoginResponse
+import com.alarmy.near.network.response.TokenRefreshResponse
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+/**
+ * 인증 관련 API 서비스
+ */
+interface AuthService {
+ // 소셜 로그인 API 호출
+ @POST("/auth/social")
+ suspend fun socialLogin(
+ @Body request: SocialLoginRequest,
+ ): LoginResponse
+
+ // 토큰 갱신 API 호출
+ @POST("/auth/renew")
+ suspend fun renewToken(
+ @Body request: TokenRefreshRequest,
+ ): TokenRefreshResponse
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt b/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt
index 0beca5dc..6aafb65d 100644
--- a/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt
+++ b/Near/app/src/main/java/com/alarmy/near/network/service/FriendService.kt
@@ -1,13 +1,48 @@
package com.alarmy.near.network.service
+import com.alarmy.near.network.request.FriendRequest
+import com.alarmy.near.network.response.CommonMessageEntity
import com.alarmy.near.network.response.FriendEntity
+import com.alarmy.near.network.response.FriendRecordEntity
+import com.alarmy.near.network.response.FriendSummaryEntity
import com.alarmy.near.network.response.MonthlyFriendEntity
+import retrofit2.http.Body
+import retrofit2.http.DELETE
import retrofit2.http.GET
+import retrofit2.http.POST
+import retrofit2.http.PUT
+import retrofit2.http.Path
interface FriendService {
@GET("/friend/list")
- suspend fun fetchFriends(): List
+ suspend fun fetchFriends(): List
@GET("/friend/monthly")
suspend fun fetchMonthlyFriends(): List
+
+ @GET("/friend/{friendId}")
+ suspend fun fetchFriendById(
+ @Path("friendId") friendId: String,
+ ): FriendEntity
+
+ @PUT("/friend/{friendId}")
+ suspend fun updateFriend(
+ @Path("friendId") friendId: String,
+ @Body friendRequest: FriendRequest,
+ ): FriendEntity
+
+ @DELETE("/friend/{friendId}")
+ suspend fun deleteFriend(
+ @Path("friendId") friendId: String,
+ )
+
+ @GET("/friend/record/{friendId}")
+ suspend fun fetchFriendRecord(
+ @Path("friendId") friendId: String,
+ ): List
+
+ @POST("/friend/record/{friendId}")
+ suspend fun recordContact(
+ @Path("friendId") friendId: String,
+ ): CommonMessageEntity
}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt
index 95e1a2d5..b9376bb5 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt
@@ -12,17 +12,23 @@ 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
@@ -31,12 +37,16 @@ import androidx.compose.material3.TabRow
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -45,252 +55,452 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.alarmy.near.R
+import com.alarmy.near.model.ContactFrequency
+import com.alarmy.near.model.DayOfWeek
+import com.alarmy.near.model.Friend
+import com.alarmy.near.model.FriendRecord
+import com.alarmy.near.model.Relation
+import com.alarmy.near.model.ReminderInterval
import com.alarmy.near.presentation.feature.friendprofile.component.CallButton
import com.alarmy.near.presentation.feature.friendprofile.component.MessageButton
+import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendProfileUIEvent
+import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendShipRecordState
+import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState
+import com.alarmy.near.presentation.ui.component.NearFrame
import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar
import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton
+import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenu
+import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenuItem
import com.alarmy.near.presentation.ui.extension.onNoRippleClick
import com.alarmy.near.presentation.ui.theme.NearTheme
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
@Composable
fun FriendProfileRoute(
+ viewModel: FriendProfileViewModel = hiltViewModel(),
onShowErrorSnackBar: (throwable: Throwable?) -> Unit,
onClickBackButton: () -> Unit = {},
+ onEditFriendInfo: (Friend) -> Unit = {},
+ onClickCallButton: (phoneNumber: String) -> Unit = {},
+ onClickMessageButton: (phoneNumber: String) -> Unit = {},
+ onDeleteFriendSuccess: (friendId: String) -> Unit = {},
) {
+ val friendState = viewModel.friendFlow.collectAsStateWithLifecycle()
+ val friendShipRecordState = viewModel.friendShipRecordStateFlow.collectAsStateWithLifecycle()
+ val recordSuccessDialogState = remember { mutableStateOf(false) }
+ LaunchedEffect(viewModel.uiEvent) {
+ launch {
+ viewModel.uiEvent.collect { event ->
+ when (event) {
+ is FriendProfileUIEvent.NetworkError -> {
+ onShowErrorSnackBar(IllegalStateException("네트워크 에러가 발생했습니다."))
+ }
+
+ is FriendProfileUIEvent.DeleteFriendSuccess -> {
+ onDeleteFriendSuccess(event.friendId)
+ }
+
+ is FriendProfileUIEvent.RecordFriendShipSuccess -> {
+ recordSuccessDialogState.value = true
+ }
+ }
+ }
+ }
+ }
FriendProfileScreen(
+ friendState = friendState.value,
+ friendShipRecordState = friendShipRecordState.value,
+ recordSuccessDialogState = recordSuccessDialogState.value,
onClickBackButton = onClickBackButton,
+ onEditFriendInfo = onEditFriendInfo,
+ onClickCallButton = onClickCallButton,
+ onClickMessageButton = onClickMessageButton,
+ onRecordFriendShip = viewModel::onRecordFriendShip,
+ onDeleteFriend = viewModel::onDeleteFriend,
+ onDismissRecordSuccessDialog = {
+ recordSuccessDialogState.value = false
+ },
)
}
@Composable
fun FriendProfileScreen(
modifier: Modifier = Modifier,
+ friendState: FriendState,
+ friendShipRecordState: FriendShipRecordState,
+ recordSuccessDialogState: Boolean = false,
onClickBackButton: () -> Unit = {},
+ onEditFriendInfo: (Friend) -> Unit = {},
+ onClickCallButton: (phoneNumber: String) -> Unit = {},
+ onClickMessageButton: (phoneNumber: String) -> Unit = {},
+ onRecordFriendShip: (friendId: String) -> Unit = {},
+ onDeleteFriend: (friendId: String) -> Unit = {},
+ onDismissRecordSuccessDialog: () -> Unit = {},
) {
- // TODO Home 머지시 상단 패딩 + status 색상 변경
val currentTabPosition = remember { mutableIntStateOf(0) }
- Box(modifier = modifier.padding(bottom = 24.dp)) {
- Column(
- modifier =
- Modifier
- .align(Alignment.TopStart)
- .fillMaxSize()
- .background(NearTheme.colors.WHITE_FFFFFF),
- ) {
- NearTopAppbar(
- title = "프로필 상세",
- onClickBackButton = onClickBackButton,
- menuButton = {
- Image(
- modifier = Modifier.onNoRippleClick(onClick = {}).padding(end = 20.dp),
- painter = painterResource(R.drawable.ic_32_menu),
- contentDescription = stringResource(R.string.common_menu_button_description),
- )
- },
- )
- Spacer(modifier = Modifier.height(18.dp))
- Row(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 32.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Box(
- modifier =
- Modifier,
- ) {
- Image(
- modifier = Modifier.align(Alignment.Center),
- painter = painterResource(R.drawable.img_80_user1),
- contentDescription = null,
- )
- Image(
+ val dropdownState = remember { mutableStateOf(false) }
+
+ NearFrame(modifier = modifier) {
+ Box {
+ when (friendState) {
+ is FriendState.Success -> {
+ val friend = friendState.friend
+ Column(
modifier =
Modifier
- .align(Alignment.TopEnd)
- .offset(x = 2.dp, y = (-2).dp),
- painter = painterResource(R.drawable.ic_visual_24_emoji_100),
- contentDescription = null,
- )
- }
- Spacer(modifier = Modifier.width(24.dp))
- Column {
- Text(
- modifier = Modifier.widthIn(max = 145.dp),
- text = "일이삼사오육칠",
- style = NearTheme.typography.B1_16_BOLD,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = "3월 22일 더 가까워졌어요",
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.BLUE01_5AA2E9,
- )
- }
- }
- Spacer(modifier = Modifier.height(24.dp))
- Row(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 20.dp),
- ) {
- CallButton(modifier = Modifier.weight(1f), onClick = {})
- Spacer(modifier = Modifier.width(7.dp))
- MessageButton(Modifier.weight(1f), onClick = {})
- }
- Spacer(modifier = Modifier.height(24.dp))
- TabRow(
- modifier =
- Modifier
- .padding(horizontal = 25.dp)
- .width(170.dp),
- containerColor = NearTheme.colors.WHITE_FFFFFF,
- selectedTabIndex = 0,
- divider = {},
- indicator = {
- TabRowDefaults.SecondaryIndicator(
+ .align(Alignment.TopStart)
+ .fillMaxSize()
+ .background(NearTheme.colors.WHITE_FFFFFF),
+ ) {
+ if (recordSuccessDialogState) {
+ LaunchedEffect(true) {
+ if (recordSuccessDialogState) {
+ delay(2000L)
+ onDismissRecordSuccessDialog()
+ }
+ }
+ Dialog(onDismissRequest = onDismissRecordSuccessDialog) {
+ Column(
+ modifier =
+ Modifier
+ .width(255.dp)
+ .height(186.dp)
+ .background(
+ color = NearTheme.colors.WHITE_FFFFFF,
+ shape = RoundedCornerShape(16.dp),
+ ),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painterResource(R.drawable.img_100_character_success),
+ contentDescription = "",
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ stringResource(R.string.friend_profile_info_contact_success_text),
+ style = NearTheme.typography.B1_16_BOLD,
+ color = Color(0xff222222),
+ )
+ }
+ }
+ }
+ NearTopAppbar(
+ title = stringResource(R.string.friend_profile_title),
+ onClickBackButton = onClickBackButton,
+ menuButton = {
+ Column(modifier = Modifier.padding(end = 20.dp)) {
+ Image(
+ modifier =
+ Modifier
+ .onNoRippleClick(onClick = {
+ dropdownState.value = true
+ }),
+ painter = painterResource(R.drawable.ic_32_menu),
+ contentDescription = stringResource(R.string.common_menu_button_description),
+ )
+ NearDropdownMenu(
+ expanded = dropdownState.value,
+ onDismissRequest = { dropdownState.value = false },
+ ) {
+ NearDropdownMenuItem(
+ onClick = {
+ onEditFriendInfo(friend)
+ dropdownState.value = false
+ },
+ text = stringResource(R.string.friend_profile_info_edit)
+ )
+ NearDropdownMenuItem(
+ onClick = {
+ onDeleteFriend(friend.friendId)
+ dropdownState.value = false
+ },
+ text = stringResource(R.string.friend_profile_info_delete)
+ )
+ }
+ }
+ },
+ )
+
+ Spacer(modifier = Modifier.height(18.dp))
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier =
+ Modifier,
+ ) {
+ Image(
+ modifier = Modifier.align(Alignment.Center),
+ painter = painterResource(R.drawable.img_80_user1),
+ contentDescription = null,
+ )
+ Image(
+ modifier =
+ Modifier
+ .align(Alignment.TopEnd)
+ .offset(x = 2.dp, y = (-2).dp),
+ painter = painterResource(R.drawable.ic_visual_24_emoji_0),
+ contentDescription = null,
+ )
+ }
+ Spacer(modifier = Modifier.width(24.dp))
+ Column {
+ Text(
+ modifier = Modifier.widthIn(max = 145.dp),
+ text = friend.name,
+ style = NearTheme.typography.B1_16_BOLD,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ if (friend.lastContactAt != null) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text =
+ stringResource(
+ R.string.friend_profile_last_contact_date_format,
+ friend.lastContactAt.lastContactFormat(),
+ ),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.BLUE01_5AA2E9,
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp),
+ ) {
+ CallButton(
+ modifier = Modifier.weight(1f),
+ enabled = !friend.phone.isNullOrBlank(),
+ onClick = {
+ friend.phone?.let {
+ onClickCallButton(friend.phone)
+ }
+ },
+ )
+ Spacer(modifier = Modifier.width(7.dp))
+ MessageButton(
+ Modifier.weight(1f),
+ enabled = !friend.phone.isNullOrBlank(),
+ onClick = {
+ friend.phone?.let {
+ onClickMessageButton(friend.phone)
+ }
+ },
+ )
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ TabRow(
+ modifier =
+ Modifier
+ .padding(horizontal = 25.dp)
+ .width(170.dp),
+ containerColor = NearTheme.colors.WHITE_FFFFFF,
+ selectedTabIndex = 0,
+ divider = {},
+ indicator = {
+ TabRowDefaults.SecondaryIndicator(
+ modifier =
+ Modifier
+ .customTabIndicatorOffset(
+ it[currentTabPosition.intValue],
+ 80.dp,
+ ), // 넓이, 애니메이션 지정
+ // 모양 지정
+ height = 3.dp,
+ color = NearTheme.colors.BLUE01_5AA2E9,
+ )
+ },
+ ) {
+ Tab(
+ modifier =
+ Modifier
+ .width(85.dp)
+ .height(50.dp),
+ selected = true,
+ onClick = {
+ currentTabPosition.intValue = 0
+ },
+ ) {
+ if (currentTabPosition.intValue == 0) {
+ Text(
+ text = stringResource(R.string.friend_profile_tab_text_profile),
+ style = NearTheme.typography.B2_14_BOLD,
+ color = NearTheme.colors.BLACK_1A1A1A,
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.friend_profile_tab_text_profile),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.GRAY02_B7B7B7,
+ )
+ }
+ }
+ Tab(
+ modifier =
+ Modifier
+ .width(85.dp)
+ .height(50.dp),
+ selected = true,
+ onClick = {
+ currentTabPosition.intValue = 1
+ },
+ ) {
+ if (currentTabPosition.intValue == 1) {
+ Text(
+ text = stringResource(R.string.friend_profile_tab_text_record),
+ style = NearTheme.typography.B2_14_BOLD,
+ color = NearTheme.colors.BLACK_1A1A1A,
+ )
+ } else {
+ Text(
+ text = stringResource(R.string.friend_profile_tab_text_record),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.GRAY02_B7B7B7,
+ )
+ }
+ }
+ }
+ HorizontalDivider(thickness = 1.dp, color = NearTheme.colors.GRAY03_EBEBEB)
+ if (currentTabPosition.intValue == 0) {
+ ProfileTab(friend = friend)
+ } else {
+ RecordTab(friendShipRecordState = friendShipRecordState)
+ }
+ Spacer(modifier = Modifier.height(60.dp))
+ }
+ NearSolidTypeButton(
modifier =
Modifier
- .customTabIndicatorOffset(
- it[currentTabPosition.intValue],
- 80.dp,
- ), // 넓이, 애니메이션 지정
- // 모양 지정
- height = 3.dp,
- color = NearTheme.colors.BLUE01_5AA2E9,
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ .align(Alignment.BottomCenter),
+ contentPadding = PaddingValues(vertical = 17.dp),
+ enabled = friend.isContactedToday.not(),
+ onClick = { onRecordFriendShip(friend.friendId) },
+ text = stringResource(R.string.friend_profile_record_button_text),
)
- },
- ) {
- Tab(
- modifier =
- Modifier
- .width(85.dp)
- .height(50.dp),
- selected = true,
- onClick = {
- currentTabPosition.intValue = 0
- },
- ) {
- if (currentTabPosition.intValue == 0) {
- Text(
- text = stringResource(R.string.friend_profile_tab_text_profile),
- style = NearTheme.typography.B2_14_BOLD,
- color = NearTheme.colors.BLACK_1A1A1A,
- )
- } else {
- Text(
- text = stringResource(R.string.friend_profile_tab_text_profile),
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.GRAY02_B7B7B7,
- )
+ }
+
+ is FriendState.Loading -> {
+ Box(modifier = Modifier.fillMaxSize()) {
+ CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
- Tab(
- modifier =
- Modifier
- .width(85.dp)
- .height(50.dp),
- selected = true,
- onClick = {
- currentTabPosition.intValue = 1
- },
- ) {
- if (currentTabPosition.intValue == 1) {
- Text(
- text = stringResource(R.string.friend_profile_tab_text_record),
- style = NearTheme.typography.B2_14_BOLD,
- color = NearTheme.colors.BLACK_1A1A1A,
- )
- } else {
+
+ is FriendState.Error -> {
+ Box(modifier = Modifier.fillMaxSize()) {
Text(
- text = stringResource(R.string.friend_profile_tab_text_record),
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.GRAY02_B7B7B7,
+ modifier = Modifier.align(Alignment.Center),
+ text = "프로필 정보를 불러오는데 실패했습니다.",
)
}
}
}
- HorizontalDivider(thickness = 1.dp, color = NearTheme.colors.GRAY03_EBEBEB)
- if (currentTabPosition.intValue == 0) {
- ProfileTab()
- } else {
- RecordTab()
- }
}
- NearSolidTypeButton(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 20.dp)
- .align(Alignment.BottomCenter),
- contentPadding = PaddingValues(vertical = 17.dp),
- enabled = true,
- onClick = {},
- text = stringResource(R.string.friend_profile_record_button_text),
- )
}
}
@Composable
-private fun ProfileTab(modifier: Modifier = Modifier) {
+private fun ProfileTab(
+ modifier: Modifier = Modifier,
+ friend: Friend,
+) {
Column(modifier = modifier) {
Spacer(modifier = Modifier.height(32.dp))
ProfileDetailInfo(
category = stringResource(R.string.friend_profile_info_category_relation),
- content = "친구",
+ content = stringResource(friend.relation.resId),
)
Spacer(modifier = Modifier.height(16.dp))
ProfileDetailInfo(
category = stringResource(R.string.friend_profile_info_category_term_of_contact),
- content = "2주",
+ content = stringResource(friend.contactFrequency.reminderInterval.labelRes),
)
Spacer(modifier = Modifier.height(16.dp))
ProfileDetailInfo(
category = stringResource(R.string.friend_profile_info_category_birthday),
- content = "1996.03.21",
+ content = friend.birthday?.replace("-", ".") ?: "-",
)
Spacer(modifier = Modifier.height(16.dp))
ProfileDetailInfo(
category = stringResource(R.string.friend_profile_info_category_anniversary),
- content = "결혼기념일 (2020.06.24)",
+ content =
+ friend.anniversaryList.joinToString(" ") {
+ "${it.title} (${it.date})"
+ },
)
Spacer(modifier = Modifier.height(16.dp))
ProfileMemoInfo(
- content = null,
+ content = friend.memo,
)
}
}
@Composable
-private fun RecordTab(modifier: Modifier = Modifier) {
+private fun RecordTab(
+ modifier: Modifier = Modifier,
+ friendShipRecordState: FriendShipRecordState,
+) {
Column(modifier = modifier.padding(horizontal = 24.dp)) {
Spacer(modifier = Modifier.height(24.dp))
Text(
- "챙김 기록",
+ stringResource(R.string.friend_profile_info_record_title_text),
style = NearTheme.typography.B2_14_BOLD,
color = NearTheme.colors.BLACK_1A1A1A,
)
- Spacer(modifier = Modifier.height(13.dp))
-
- LazyVerticalGrid(
- GridCells.Fixed(3),
- verticalArrangement = Arrangement.spacedBy(24.dp),
- contentPadding = PaddingValues(bottom = 60.dp),
- ) {
- items(15) {
- RecordItem()
+ if (friendShipRecordState.isEmpty) {
+ Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
+ Spacer(modifier = Modifier.height(60.dp))
+ Image(painterResource(R.drawable.img_100_character_empty), contentDescription = null)
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ stringResource(R.string.friend_profile_info_empty_contact_friend),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.GRAY01_888888,
+ )
+ }
+ } else {
+ Spacer(modifier = Modifier.height(13.dp))
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(3),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ contentPadding = PaddingValues(bottom = 60.dp),
+ ) {
+ items(friendShipRecordState.records.size) {
+ RecordItem(
+ friendRecord = friendShipRecordState.records[friendShipRecordState.records.size - 1 - it],
+ index =
+ friendShipRecordState.records.size - it,
+ )
+ }
}
}
}
}
@Composable
-private fun RecordItem(modifier: Modifier = Modifier) {
+private fun RecordItem(
+ modifier: Modifier = Modifier,
+ index: Int,
+ friendRecord: FriendRecord,
+) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -320,7 +530,7 @@ private fun RecordItem(modifier: Modifier = Modifier) {
contentDescription = null,
)
Text(
- "11번째 챙김",
+ stringResource(R.string.friend_profile_info_contact_record_text, index),
style = NearTheme.typography.B2_14_MEDIUM,
color = NearTheme.colors.BLUE01_5AA2E9,
)
@@ -328,7 +538,7 @@ private fun RecordItem(modifier: Modifier = Modifier) {
}
Spacer(modifier = Modifier.height(8.dp))
Text(
- "25.03.20",
+ friendRecord.createdAt,
style = NearTheme.typography.B2_14_MEDIUM,
color = NearTheme.colors.GRAY01_888888,
)
@@ -455,10 +665,48 @@ fun Modifier.customTabIndicatorOffset(
.width(currentTabWidth)
}
+private fun String.lastContactFormat(): String {
+ val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
+ val outputFormatter = DateTimeFormatter.ofPattern("M월 d일")
+
+ val date = LocalDate.parse(this, inputFormatter)
+ return date.format(outputFormatter)
+}
+
@Preview(showBackground = true)
@Composable
fun FriendProfileScreenPreview() {
NearTheme {
- FriendProfileScreen()
+ FriendProfileScreen(
+ friendState =
+ FriendState.Success(
+ Friend(
+ friendId = "adfaggasf",
+ imageUrl = "",
+ relation = Relation.FRIEND,
+ name = "",
+ contactFrequency =
+ ContactFrequency(
+ reminderInterval = ReminderInterval.EVERY_TWO_WEEK,
+ dayOfWeek = DayOfWeek.THURSDAY,
+ ),
+ birthday = "1998-11-13",
+ anniversaryList = listOf(),
+ memo = "",
+ phone = "",
+ lastContactAt = "",
+ ),
+ ),
+ friendShipRecordState =
+ FriendShipRecordState(
+ records =
+ List(5) {
+ FriendRecord(
+ isChecked = true,
+ createdAt = "2023-11-1$it",
+ )
+ },
+ ),
+ )
}
}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt
new file mode 100644
index 00000000..141e4594
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileViewModel.kt
@@ -0,0 +1,148 @@
+package com.alarmy.near.presentation.feature.friendprofile
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.navigation.toRoute
+import com.alarmy.near.data.repository.FriendRepository
+import com.alarmy.near.model.Friend
+import com.alarmy.near.model.FriendRecord
+import com.alarmy.near.presentation.feature.friendprofile.navigation.RouteFriendProfile
+import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendProfileUIEvent
+import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendShipRecordState
+import com.alarmy.near.presentation.feature.friendprofile.uistate.FriendState
+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.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+import javax.inject.Inject
+
+@HiltViewModel
+class FriendProfileViewModel
+ @Inject
+ constructor(
+ savedStateHandle: SavedStateHandle,
+ private val friendRepository: FriendRepository,
+ ) : ViewModel() {
+ private val friendId: String =
+ savedStateHandle.toRoute().friendId
+ private val _uiEvent = Channel()
+ val uiEvent = _uiEvent.receiveAsFlow()
+ private val _friendFlow: MutableStateFlow = MutableStateFlow(FriendState.Loading)
+ val friendFlow: StateFlow = _friendFlow.asStateFlow()
+
+ private val _friendShipRecordStateFlow: MutableStateFlow =
+ MutableStateFlow(FriendShipRecordState(isLoading = true))
+ val friendShipRecordStateFlow: StateFlow =
+ _friendShipRecordStateFlow.asStateFlow()
+
+ init {
+ fetchFriend()
+ fetchFriendShipRecord()
+ }
+
+ fun fetchFriend() {
+ friendRepository
+ .fetchFriendById(friendId)
+ .onEach { friend ->
+ _friendFlow.value = FriendState.Success(friend)
+ }.catch { error ->
+ _friendFlow.value = FriendState.Error("데이터를 가져오는데 실패했습니다.")
+ _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음
+ }.launchIn(viewModelScope)
+ }
+
+ fun fetchFriendShipRecord() {
+ friendRepository
+ .fetchFriendRecord(friendId)
+ .onEach { records ->
+ _friendShipRecordStateFlow.update {
+ it.copy(
+ records = records.filter { record -> record.isChecked },
+ isEmpty = records.isEmpty(),
+ isLoading = false,
+ )
+ }
+ }.catch { error ->
+ _friendShipRecordStateFlow.update {
+ it.copy(
+ isEmpty = true,
+ isLoading = false,
+ )
+ }
+ _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음
+ }.launchIn(viewModelScope)
+ }
+
+ fun onDeleteFriend(friendId: String) {
+ friendRepository
+ .deleteFriend(friendId)
+ .onEach {
+ _uiEvent.send(FriendProfileUIEvent.DeleteFriendSuccess(friendId))
+ // event
+ }.catch { error ->
+ _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음
+ }.launchIn(viewModelScope)
+ }
+
+ fun onRecordFriendShip(friendId: String) {
+ friendRepository
+ .recordContact(friendId) // 내 현재 시간 가져와서
+ .onEach { _ ->
+ _uiEvent.send(FriendProfileUIEvent.RecordFriendShipSuccess)
+ _friendShipRecordStateFlow.update { recordState ->
+ recordState.copy(
+ records =
+ listOf(
+ FriendRecord(
+ isChecked = true,
+ createdAt = getTodayShortFormat(),
+ ),
+ ) + (recordState.records),
+ )
+ }
+ if (friendFlow.value is FriendState.Success) {
+ _friendFlow.update {
+ (it as FriendState.Success).copy(
+ friend =
+ it.friend.copy(
+ lastContactAt = getTodayDashFormat(),
+ ),
+ )
+ }
+ }
+
+ // event
+ }.catch { error ->
+ _uiEvent.send(FriendProfileUIEvent.NetworkError) // UI에서 단발성 이벤트로도 쓸 수 있음
+ }.launchIn(viewModelScope)
+ }
+
+ private fun getTodayShortFormat(): String {
+ val today = LocalDate.now()
+ val formatter = DateTimeFormatter.ofPattern("yy.MM.dd", Locale.KOREA)
+ return today.format(formatter)
+ }
+
+ private fun getTodayDashFormat(): String {
+ val today = LocalDate.now()
+ val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.KOREA)
+ return today.format(formatter)
+ }
+
+ fun updateFriend(friend: Friend) {
+ if (_friendFlow.value is FriendState.Success) {
+ _friendFlow.update { (it as FriendState.Success).copy(friend = friend) }
+ }
+ }
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt
index c3b4608a..d22a48d3 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/navigation/FriendProfileNavigation.kt
@@ -1,16 +1,32 @@
package com.alarmy.near.presentation.feature.friendprofile.navigation
+import android.os.Build
+import android.os.Parcelable
+import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
+import androidx.navigation.NavType
import androidx.navigation.compose.composable
+import androidx.navigation.toRoute
+import androidx.savedstate.SavedState
+import com.alarmy.near.model.Friend
import com.alarmy.near.presentation.feature.friendprofile.FriendProfileRoute
+import com.alarmy.near.presentation.feature.friendprofile.FriendProfileViewModel
+import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.FRIEND_PROFILE_EDIT_COMPLETE_KEY
+import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.FriendType
+import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor
+import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+@Parcelize
@Serializable
data class RouteFriendProfile(
val friendId: String,
-)
+) : Parcelable
fun NavController.navigateToFriendProfile(
friendId: String,
@@ -22,11 +38,23 @@ fun NavController.navigateToFriendProfile(
fun NavGraphBuilder.friendProfileNavGraph(
onShowErrorSnackBar: (throwable: Throwable?) -> Unit,
onClickBackButton: () -> Unit,
+ onEditFriendInfo: (Friend) -> Unit = {},
+ onClickCallButton: (phoneNumber: String) -> Unit = {},
+ onClickMessageButton: (phoneNumber: String) -> Unit = {},
) {
composable { backStackEntry ->
+ val viewModel: FriendProfileViewModel = hiltViewModel()
+ val friend = backStackEntry.savedStateHandle.get(FRIEND_PROFILE_EDIT_COMPLETE_KEY)
+ friend?.let {
+ viewModel.updateFriend(it)
+ }
FriendProfileRoute(
+ viewModel = viewModel,
onShowErrorSnackBar = onShowErrorSnackBar,
onClickBackButton = onClickBackButton,
+ onEditFriendInfo = onEditFriendInfo,
+ onClickCallButton = onClickCallButton,
+ onClickMessageButton = onClickMessageButton,
)
}
}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt
new file mode 100644
index 00000000..87133fef
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendProfileUIEvent.kt
@@ -0,0 +1,11 @@
+package com.alarmy.near.presentation.feature.friendprofile.uistate
+
+sealed interface FriendProfileUIEvent {
+ data object NetworkError : FriendProfileUIEvent
+
+ data class DeleteFriendSuccess(
+ val friendId: String,
+ ) : FriendProfileUIEvent
+
+ data object RecordFriendShipSuccess : FriendProfileUIEvent
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt
new file mode 100644
index 00000000..8fa62e68
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendShipRecordState.kt
@@ -0,0 +1,9 @@
+package com.alarmy.near.presentation.feature.friendprofile.uistate
+
+import com.alarmy.near.model.FriendRecord
+
+data class FriendShipRecordState(
+ val records: List = emptyList(),
+ val isEmpty: Boolean = true,
+ val isLoading: Boolean = false,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendState.kt
new file mode 100644
index 00000000..b9b089a7
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/uistate/FriendState.kt
@@ -0,0 +1,15 @@
+package com.alarmy.near.presentation.feature.friendprofile.uistate
+
+import com.alarmy.near.model.Friend
+
+sealed interface FriendState {
+ object Loading : FriendState
+
+ data class Success(
+ val friend: Friend,
+ ) : FriendState
+
+ data class Error(
+ val errorMessage: String,
+ ) : FriendState
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt
index fab2cd0f..cdcf3714 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorScreen.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
@@ -22,10 +23,12 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -36,23 +39,83 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
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.model.ContactFrequency
+import com.alarmy.near.model.DayOfWeek
+import com.alarmy.near.model.Friend
+import com.alarmy.near.model.Relation
+import com.alarmy.near.model.ReminderInterval
import com.alarmy.near.presentation.feature.friendprofileedittor.component.NearDatePicker
import com.alarmy.near.presentation.feature.friendprofileedittor.component.ReminderIntervalBottomSheet
+import com.alarmy.near.presentation.feature.friendprofileedittor.dialog.EditorExitDialog
+import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIEvent
+import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState
+import com.alarmy.near.presentation.ui.component.NearFrame
import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar
import com.alarmy.near.presentation.ui.component.radiobutton.NearSmallRadioButton
import com.alarmy.near.presentation.ui.component.textfield.NearLimitedTextField
import com.alarmy.near.presentation.ui.component.textfield.NearTextField
import com.alarmy.near.presentation.ui.extension.onNoRippleClick
import com.alarmy.near.presentation.ui.theme.NearTheme
+import kotlinx.coroutines.launch
@Composable
fun FriendProfileEditorRoute(
+ viewModel: FriendProfileEditorViewModel = hiltViewModel(),
onShowErrorSnackBar: (throwable: Throwable?) -> Unit,
onClickBackButton: () -> Unit = {},
+ onSuccessEdit: (Friend) -> Unit = {},
) {
+ val friendProfileEditorUIState = viewModel.uiState.collectAsStateWithLifecycle()
+ val warningDialogState = remember { mutableStateOf(false) }
+ val context = LocalContext.current
+
+ LaunchedEffect(viewModel.uiEvent) {
+ launch {
+ viewModel.uiEvent.collect { event ->
+ when (event) {
+ FriendProfileEditorUIEvent.WarningExit -> {
+ warningDialogState.value = true
+ }
+
+ FriendProfileEditorUIEvent.Exit -> {
+ warningDialogState.value = false
+ onClickBackButton()
+ }
+
+ is FriendProfileEditorUIEvent.FriendProfileEditFailure -> {
+ onShowErrorSnackBar(event.throwable)
+ }
+
+ FriendProfileEditorUIEvent.FriendProfileEditNetworkError -> {
+ onShowErrorSnackBar(IllegalStateException(context.getString(R.string.network_error_message)))
+ }
+
+ is FriendProfileEditorUIEvent.FriendProfileEditSuccess -> {
+ onSuccessEdit(event.friend)
+ }
+ }
+ }
+ }
+ }
FriendProfileEditorScreen(
- onClickBackButton = onClickBackButton,
+ friendProfileEditorUIState = friendProfileEditorUIState.value,
+ dialogState = warningDialogState.value,
+ onClickBackButton = viewModel::onExit,
+ onNameChanged = viewModel::onNameChanged,
+ onRelationChanged = viewModel::onRelationChanged,
+ onReminderIntervalChanged = viewModel::onRemindIntervalChanged,
+ onBirthdayChanged = viewModel::onBirthdayChanged,
+ onAnniversaryNameChange = viewModel::onAnniversaryTitleChanged,
+ onAnniversaryDateSelected = viewModel::onAnniversaryDateChanged,
+ onRemoveAnniversary = viewModel::onRemoveAnniversary,
+ onAddAnniversary = viewModel::onAddAnniversary,
+ onMemoChanged = viewModel::onMemoChanged,
+ onSubmit = viewModel::onSubmit,
+ onEditorExit = onClickBackButton,
+ onCloseDialog = { warningDialogState.value = false },
)
}
@@ -60,317 +123,108 @@ fun FriendProfileEditorRoute(
@Composable
fun FriendProfileEditorScreen(
modifier: Modifier = Modifier,
+ dialogState: Boolean = false,
+ friendProfileEditorUIState: FriendProfileEditorUIState,
onClickBackButton: () -> Unit = {},
+ onNameChanged: (String) -> Unit = {},
+ onRelationChanged: (Relation) -> Unit = {},
+ onReminderIntervalChanged: (ReminderInterval) -> Unit = {},
+ onBirthdayChanged: (Long) -> Unit = {},
+ onAnniversaryNameChange: (index: Int, name: String) -> Unit = { _, _ -> },
+ onAnniversaryDateSelected: (index: Int, dataTimeMillis: Long) -> Unit = { _, _ -> },
+ onRemoveAnniversary: (index: Int) -> Unit = { _ -> },
+ onAddAnniversary: () -> Unit = {},
+ onMemoChanged: (String) -> Unit = {},
+ onSubmit: () -> Unit = {},
+ onEditorExit: () -> Unit = {},
+ onCloseDialog: () -> Unit = {},
) {
- val isErrorMessageVisible = remember { mutableStateOf(false) }
- val density = LocalDensity.current
- val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() }
val showBottomSheet = remember { mutableStateOf(false) }
if (showBottomSheet.value) {
ReminderIntervalBottomSheet(onDismissRequest = {
showBottomSheet.value = false
+ }, onSelectReminderInterval = {
+ onReminderIntervalChanged(it)
+ showBottomSheet.value = false
})
}
- Column(
- modifier =
- modifier
- .fillMaxSize()
- .background(NearTheme.colors.WHITE_FFFFFF),
- ) {
- Spacer(modifier = Modifier.padding(top = statusBarHeightDp))
- NearTopAppbar(
- modifier = Modifier.padding(end = 24.dp),
- title = "",
- onClickBackButton = onClickBackButton,
- menuButton = {
- Text(
- text = "완료",
- style = NearTheme.typography.B1_16_BOLD,
- color = NearTheme.colors.BLACK_1A1A1A,
- )
+ if (dialogState) {
+ EditorExitDialog(
+ onDismissRequest = {
+ onCloseDialog()
+ },
+ onConfirm = {
+ onEditorExit()
},
)
- Spacer(modifier = Modifier.height(16.dp))
- Column(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(start = 24.dp, end = 20.dp),
- ) {
- Row(
- modifier =
- Modifier
- .fillMaxWidth(),
- ) {
- Text(
- modifier = Modifier.padding(top = 16.dp),
- text =
- buildAnnotatedString {
- append("이름")
- withStyle(
- style =
- SpanStyle(
- color = NearTheme.colors.BLUE01_5AA2E9,
- ),
- ) {
- append("*")
- }
- },
- textAlign = TextAlign.Center,
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.GRAY01_888888,
- )
- Spacer(modifier = Modifier.width(55.dp))
- NearTextField(
- modifier = Modifier.weight(1f),
- value = "가나다",
- onValueChange = {
- },
- )
- }
- if (isErrorMessageVisible.value) {
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- "이름을 입력해주세요.",
- style = NearTheme.typography.FC_12_MEDIUM,
- color = NearTheme.colors.NEGATIVE_F04E4E,
- )
- }
- Spacer(modifier = Modifier.height(32.dp))
- Row(
- modifier =
- Modifier
- .fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- "관계",
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.GRAY01_888888,
- )
- Spacer(modifier = Modifier.width(72.dp))
- Row(
- modifier =
- Modifier
- .weight(1f)
- .padding(end = 35.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- NearSmallRadioButton(
- selected = false,
- onClick = {},
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = stringResource(R.string.friend_profile_editor_relation_freind),
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.BLACK_1A1A1A,
- )
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- NearSmallRadioButton(
- selected = false,
- onClick = {},
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = stringResource(R.string.friend_profile_editor_relation_family),
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.BLACK_1A1A1A,
- )
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- NearSmallRadioButton(
- selected = false,
- onClick = {},
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = stringResource(R.string.friend_profile_editor_relation_acquaintance),
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.BLACK_1A1A1A,
- )
- }
- }
- }
- Spacer(modifier = Modifier.height(33.dp))
- Row(
- modifier =
- Modifier
- .fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- "연락 주기",
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.GRAY01_888888,
- )
- Spacer(modifier = Modifier.width(35.dp))
- Surface(
- modifier =
- modifier
- .weight(1f)
- .onNoRippleClick({
- showBottomSheet.value = true
- }),
- shape = RoundedCornerShape(12.dp),
- border =
- BorderStroke(
- width = 1.dp,
- color = NearTheme.colors.GRAY03_EBEBEB,
- ),
- color = NearTheme.colors.WHITE_FFFFFF,
- ) {
- Row(
- modifier =
- Modifier.padding(
- start = 16.dp,
- end = 12.dp,
- top = 14.dp,
- bottom = 14.dp,
- ),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
+ }
+ NearFrame(modifier = modifier) {
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ item {
+ NearTopAppbar(
+ modifier = Modifier.padding(end = 24.dp),
+ title = "",
+ onClickBackButton = onClickBackButton,
+ menuButton = {
Text(
- "2주 (수요일 마다)",
- style = NearTheme.typography.B2_14_MEDIUM,
+ modifier =
+ Modifier.onNoRippleClick(onClick = {
+ onSubmit()
+ }),
+ text = stringResource(R.string.friend_profile_editor_edit_complete_text),
+ style = NearTheme.typography.B1_16_BOLD,
color = NearTheme.colors.BLACK_1A1A1A,
)
- Image(
- painter = painterResource(id = R.drawable.ic_24_down),
- contentDescription = null,
- )
- }
- }
- }
- Spacer(modifier = Modifier.height(16.dp))
- Row(
- modifier =
- Modifier
- .fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- val birthdayDatePickerState = remember { mutableStateOf(false) }
- val datePickerState =
- rememberDatePickerState()
- if (birthdayDatePickerState.value) {
- NearDatePicker(
- datePickerState = datePickerState,
- onDismiss = { birthdayDatePickerState.value = false },
- onDateSelected = {},
- )
- }
- Text(
- "생일",
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.GRAY01_888888,
+ },
)
- Spacer(modifier = Modifier.width(62.dp))
- Surface(
+ Spacer(modifier = Modifier.height(16.dp))
+ Column(
modifier =
- modifier
- .weight(1f),
- shape = RoundedCornerShape(12.dp),
- border =
- BorderStroke(
- width = 1.dp,
- color = NearTheme.colors.GRAY03_EBEBEB,
- ),
- color = NearTheme.colors.WHITE_FFFFFF,
+ Modifier
+ .fillMaxWidth()
+ .padding(start = 24.dp, end = 20.dp),
) {
Row(
modifier =
Modifier
- .padding(
- start = 16.dp,
- end = 12.dp,
- top = 14.dp,
- bottom = 14.dp,
- ).onNoRippleClick({
- birthdayDatePickerState.value = true
- }),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
+ .fillMaxWidth(),
) {
Text(
- "2020.06.24",
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.BLACK_1A1A1A,
- )
- Image(
- painter = painterResource(id = R.drawable.ic_24_down),
- contentDescription = null,
- )
- }
- }
- }
- Spacer(modifier = Modifier.height(32.dp))
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = "기념일",
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.GRAY01_888888,
- )
- Text(
- modifier = Modifier.onNoRippleClick(onClick = {}),
- text = "추가하기",
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.BLUE01_5AA2E9,
- )
- }
- Spacer(modifier = Modifier.height(16.dp))
- }
- LazyColumn(
- modifier = Modifier.background(color = NearTheme.colors.BG02_F4F9FD),
- contentPadding =
- PaddingValues(
- top = 20.dp,
- bottom = 32.dp,
- start = 24.dp,
- end = 20.dp,
- ),
- verticalArrangement = Arrangement.spacedBy(32.dp),
- ) {
- item {
- Column {
- val anniversaryDatePickerState = remember { mutableStateOf(false) }
- val datePickerState =
- rememberDatePickerState()
- if (anniversaryDatePickerState.value) {
- NearDatePicker(
- datePickerState = datePickerState,
- onDismiss = { anniversaryDatePickerState.value = false },
- onDateSelected = {},
- )
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- text = "기념일 이름",
+ modifier = Modifier.padding(top = 16.dp),
+ text =
+ buildAnnotatedString {
+ append(stringResource(R.string.friend_profile_editor_name))
+ withStyle(
+ style =
+ SpanStyle(
+ color = NearTheme.colors.BLUE01_5AA2E9,
+ ),
+ ) {
+ append("*")
+ }
+ },
+ textAlign = TextAlign.Center,
style = NearTheme.typography.B2_14_MEDIUM,
color = NearTheme.colors.GRAY01_888888,
)
- Spacer(modifier = Modifier.width(23.dp))
+ Spacer(modifier = Modifier.width(55.dp))
NearTextField(
modifier = Modifier.weight(1f),
- value = "가나다",
+ value = friendProfileEditorUIState.name.value,
onValueChange = {
+ onNameChanged(it)
},
)
}
- if (isErrorMessageVisible.value) {
+ if (friendProfileEditorUIState.name.error) {
Spacer(modifier = Modifier.height(8.dp))
Text(
- "이름을 입력해주세요.",
+ stringResource(R.string.friend_profile_editor_enter_name),
style = NearTheme.typography.FC_12_MEDIUM,
color = NearTheme.colors.NEGATIVE_F04E4E,
)
}
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(32.dp))
Row(
modifier =
Modifier
@@ -378,17 +232,83 @@ fun FriendProfileEditorScreen(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
- "날짜",
+ stringResource(R.string.friend_profile_editor_relation),
style = NearTheme.typography.B2_14_MEDIUM,
color = NearTheme.colors.GRAY01_888888,
)
- Spacer(modifier = Modifier.width(62.dp))
+ Spacer(modifier = Modifier.width(72.dp))
+ Row(
+ modifier =
+ Modifier
+ .weight(1f)
+ .padding(end = 35.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ NearSmallRadioButton(
+ selected = friendProfileEditorUIState.relation == Relation.FRIEND,
+ onClick = {
+ onRelationChanged(Relation.FRIEND)
+ },
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.friend_profile_editor_relation_friend),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.BLACK_1A1A1A,
+ )
+ }
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ NearSmallRadioButton(
+ selected = friendProfileEditorUIState.relation == Relation.FAMILY,
+ onClick = {
+ onRelationChanged(Relation.FAMILY)
+ },
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.friend_profile_editor_relation_family),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.BLACK_1A1A1A,
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ NearSmallRadioButton(
+ selected = friendProfileEditorUIState.relation == Relation.ACQUAINTANCE,
+ onClick = { onRelationChanged(Relation.ACQUAINTANCE) },
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(R.string.friend_profile_editor_relation_acquaintance),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.BLACK_1A1A1A,
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(33.dp))
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ stringResource(R.string.friend_profile_editor_contact_period),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.GRAY01_888888,
+ )
+ Spacer(modifier = Modifier.width(35.dp))
Surface(
modifier =
- modifier
+ Modifier
.weight(1f)
- .onNoRippleClick(onClick = {
- anniversaryDatePickerState.value = true
+ .onNoRippleClick({
+ showBottomSheet.value = true
}),
shape = RoundedCornerShape(12.dp),
border =
@@ -410,7 +330,78 @@ fun FriendProfileEditorScreen(
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
- "2020.06.24",
+ text =
+ stringResource(friendProfileEditorUIState.contactFrequency.reminderInterval.labelRes) +
+ stringResource(
+ R.string.friend_profile_editor_contact_period_format,
+ stringResource(friendProfileEditorUIState.contactFrequency.dayOfWeek.resId),
+ ),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.BLACK_1A1A1A,
+ )
+ Image(
+ painter = painterResource(id = R.drawable.ic_24_down),
+ contentDescription = null,
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ val birthdayDatePickerState = remember { mutableStateOf(false) }
+ val datePickerState =
+ rememberDatePickerState()
+ if (birthdayDatePickerState.value) {
+ NearDatePicker(
+ datePickerState = datePickerState,
+ onDismiss = { birthdayDatePickerState.value = false },
+ onDateSelected = {
+ it?.let {
+ onBirthdayChanged(it)
+ }
+ },
+ )
+ }
+ Text(
+ stringResource(R.string.friend_profile_editor_birthday),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.GRAY01_888888,
+ )
+ Spacer(modifier = Modifier.width(62.dp))
+ Surface(
+ modifier =
+ Modifier
+ .weight(1f),
+ shape = RoundedCornerShape(12.dp),
+ border =
+ BorderStroke(
+ width = 1.dp,
+ color = NearTheme.colors.GRAY03_EBEBEB,
+ ),
+ color = NearTheme.colors.WHITE_FFFFFF,
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .padding(
+ start = 16.dp,
+ end = 12.dp,
+ top = 14.dp,
+ bottom = 14.dp,
+ ).onNoRippleClick({
+ birthdayDatePickerState.value = true
+ }),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ friendProfileEditorUIState.birthday.value
+ ?: stringResource(R.string.friend_profile_editor_select_date),
style = NearTheme.typography.B2_14_MEDIUM,
color = NearTheme.colors.BLACK_1A1A1A,
)
@@ -422,46 +413,184 @@ fun FriendProfileEditorScreen(
}
}
Spacer(modifier = Modifier.height(32.dp))
- Text(
+ Row(
modifier = Modifier.fillMaxWidth(),
- textAlign = TextAlign.End,
- text = "삭제하기",
- textDecoration = TextDecoration.Underline,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = stringResource(R.string.friend_profile_editor_anniversary),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.GRAY01_888888,
+ )
+ Text(
+ modifier =
+ Modifier.onNoRippleClick(onClick = {
+ onAddAnniversary()
+ }),
+ text = stringResource(R.string.friend_profile_editor_anniversary_add),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.BLUE01_5AA2E9,
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+ if (friendProfileEditorUIState.anniversaries.isNotEmpty()) {
+ items(
+ count = friendProfileEditorUIState.anniversaries.size,
+ ) { index ->
+ Column(
+ modifier =
+ Modifier
+ .background(color = NearTheme.colors.BG02_F4F9FD)
+ .padding(
+ PaddingValues(
+ top = 20.dp,
+ bottom = 32.dp,
+ start = 24.dp,
+ end = 20.dp,
+ ),
+ ),
+ ) {
+ val anniversaryDatePickerState = remember { mutableStateOf(false) }
+ val datePickerState =
+ rememberDatePickerState()
+ if (anniversaryDatePickerState.value) {
+ NearDatePicker(
+ datePickerState = datePickerState,
+ onDismiss = { anniversaryDatePickerState.value = false },
+ onDateSelected = {
+ it?.let {
+ onAnniversaryDateSelected(index, it)
+ }
+ },
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = stringResource(R.string.friend_profile_editor_anniversary_name),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.GRAY01_888888,
+ )
+ Spacer(modifier = Modifier.width(23.dp))
+ NearTextField(
+ modifier = Modifier.weight(1f),
+ value = friendProfileEditorUIState.anniversaries[index].title.value,
+ onValueChange = {
+ onAnniversaryNameChange(index, it)
+ },
+ )
+ }
+ if (friendProfileEditorUIState.anniversaries[index].title.error) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ stringResource(R.string.friend_profile_editor_name_hint_text),
+ style = NearTheme.typography.FC_12_MEDIUM,
+ color = NearTheme.colors.NEGATIVE_F04E4E,
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ stringResource(R.string.friend_profile_editor_date),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.GRAY01_888888,
+ )
+ Spacer(modifier = Modifier.width(62.dp))
+ Surface(
+ modifier =
+ Modifier
+ .weight(1f)
+ .onNoRippleClick(onClick = {
+ anniversaryDatePickerState.value = true
+ }),
+ shape = RoundedCornerShape(12.dp),
+ border =
+ BorderStroke(
+ width = 1.dp,
+ color = NearTheme.colors.GRAY03_EBEBEB,
+ ),
+ color = NearTheme.colors.WHITE_FFFFFF,
+ ) {
+ Row(
+ modifier =
+ Modifier.padding(
+ start = 16.dp,
+ end = 12.dp,
+ top = 14.dp,
+ bottom = 14.dp,
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ friendProfileEditorUIState.anniversaries[index].date.value
+ ?: stringResource(R.string.friend_profile_editor_select_date),
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.BLACK_1A1A1A,
+ )
+ Image(
+ painter = painterResource(id = R.drawable.ic_24_down),
+ contentDescription = null,
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(32.dp))
+ Text(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .onNoRippleClick(
+ onClick = {
+ onRemoveAnniversary(index)
+ },
+ ),
+ textAlign = TextAlign.End,
+ text = stringResource(R.string.friend_profile_editor_delete),
+ textDecoration = TextDecoration.Underline,
+ color = NearTheme.colors.GRAY01_888888,
+ )
+ }
+ }
+ }
+ item {
+ Spacer(modifier = Modifier.height(24.dp))
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ ) {
+ Text(
+ modifier = Modifier.padding(top = 16.dp),
+ text = stringResource(R.string.friend_profile_editor_memo),
+ style = NearTheme.typography.B2_14_MEDIUM,
color = NearTheme.colors.GRAY01_888888,
)
+ Spacer(modifier = Modifier.width(23.dp))
+ NearLimitedTextField(
+ modifier =
+ Modifier
+ .weight(1f)
+ .height(180.dp),
+ value = friendProfileEditorUIState.memo.value ?: "",
+ onValueChange = {
+ onMemoChanged(it)
+ },
+ placeHolderText =
+ stringResource(R.string.friend_profile_editor_memo_default_text),
+ maxTextCount = 100,
+ )
+ Spacer(modifier = Modifier.height(80.dp))
}
}
}
- Spacer(modifier = Modifier.height(24.dp))
- Row(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp),
- ) {
- Text(
- modifier = Modifier.padding(top = 16.dp),
- text = "메모",
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.GRAY01_888888,
- )
- Spacer(modifier = Modifier.width(23.dp))
- NearLimitedTextField(
- modifier =
- Modifier
- .weight(1f)
- .height(180.dp),
- value = "",
- onValueChange = {
- },
- placeHolderText =
- "꼭 기억해야 할 내용을 기록해보세요.\n" +
- "예) 날생선 X, 작년 생일에\n" +
- "키링 선물함 등",
- maxTextCount = 100,
- )
- }
- Spacer(modifier = Modifier.height(80.dp))
}
}
@@ -469,6 +598,15 @@ fun FriendProfileEditorScreen(
@Composable
fun FriendProfileEditorScreenPreview() {
NearTheme {
- FriendProfileEditorScreen()
+ FriendProfileEditorScreen(
+ friendProfileEditorUIState =
+ FriendProfileEditorUIState(
+ contactFrequency =
+ ContactFrequency(
+ reminderInterval = ReminderInterval.EVERY_DAY,
+ dayOfWeek = DayOfWeek.SATURDAY,
+ ),
+ ),
+ )
}
}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt
index d3cbb6ce..8892075e 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/FriendProfileEditorViewModel.kt
@@ -1,21 +1,207 @@
package com.alarmy.near.presentation.feature.friendprofileedittor
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.navigation.toRoute
+import com.alarmy.near.data.repository.FriendRepository
+import com.alarmy.near.model.Friend
import com.alarmy.near.model.Relation
+import com.alarmy.near.model.ReminderInterval
+import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor
+import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.AnniversaryUIState
+import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIEvent
+import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.FriendProfileEditorUIState
+import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toModel
+import com.alarmy.near.presentation.feature.friendprofileedittor.uistate.toUiModel
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class FriendProfileEditorViewModel
@Inject
- constructor() : ViewModel() {
- private val _relation: MutableStateFlow = MutableStateFlow(null)
- val relation = _relation.asStateFlow()
+ constructor(
+ savedStateHandle: SavedStateHandle,
+ private val friendRepository: FriendRepository,
+ ) : ViewModel() {
+ private val friend: Friend =
+ savedStateHandle.toRoute(RouteFriendProfileEditor.routeTypeMap).friend
- fun setRelation(relation: Relation) {
- _relation.update { relation }
+ private val _uiState: MutableStateFlow =
+ MutableStateFlow(friend.toUiModel())
+ val uiState = _uiState.asStateFlow()
+
+ private val _uiEvent = Channel()
+ val uiEvent = _uiEvent.receiveAsFlow()
+
+ fun onNameChanged(value: String) {
+ if (value.length > MAX_NAME_LENGTH) {
+ return
+ }
+ _uiState.update {
+ it.copy(
+ name =
+ it.name.copy(
+ value = value,
+ isDirty = true,
+ error = value.isEmpty(),
+ ),
+ )
+ }
+ }
+
+ fun onRelationChanged(value: Relation) {
+ _uiState.update { it.copy(relation = value) }
+ }
+
+ fun onRemindIntervalChanged(value: ReminderInterval) {
+ _uiState.update {
+ it.copy(
+ contactFrequency =
+ it.contactFrequency.copy(
+ reminderInterval = value,
+ ),
+ )
+ }
+ }
+
+ fun onBirthdayChanged(value: Long) {
+ _uiState.update {
+ it.copy(
+ birthday =
+ it.birthday.copy(
+ value = convertMillisToDate(value),
+ isDirty = true,
+ ),
+ )
+ }
+ }
+
+ fun onAnniversaryTitleChanged(
+ index: Int,
+ value: String,
+ ) {
+ _uiState.update {
+ it.copy(
+ anniversaries =
+ it.anniversaries.toMutableList().apply {
+ this[index] =
+ this[index].copy(
+ title =
+ this[index].title.copy(
+ value = value,
+ isDirty = true,
+ error = value.isEmpty(),
+ ),
+ )
+ },
+ )
+ }
+ }
+
+ fun onAnniversaryDateChanged(
+ index: Int,
+ value: Long,
+ ) {
+ _uiState.update {
+ it.copy(
+ anniversaries =
+ it.anniversaries.toMutableList().apply {
+ this[index] =
+ this[index].copy(
+ date =
+ this[index].date.copy(
+ value = convertMillisToDate(value),
+ isDirty = true,
+ ),
+ )
+ },
+ )
+ }
+ }
+
+ fun onAddAnniversary() {
+ _uiState.update { it.copy(anniversaries = it.anniversaries + AnniversaryUIState()) }
+ }
+
+ fun onRemoveAnniversary(index: Int) {
+ _uiState.update {
+ it.copy(anniversaries = it.anniversaries.toMutableList().apply { removeAt(index) })
+ }
+ }
+
+ fun onMemoChanged(value: String?) {
+ value?.length?.let {
+ if (it > MAX_MEMO_LENGTH) {
+ return
+ }
+ }
+ _uiState.update {
+ it.copy(
+ memo =
+ it.memo.copy(
+ value = value,
+ isDirty = true,
+ ),
+ )
+ }
+ }
+
+ private fun convertMillisToDate(millis: Long): String {
+ val formatter = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault())
+ return formatter.format(Date(millis))
+ }
+
+ fun onExit() {
+ viewModelScope.launch {
+ if (uiState.value.anniversaries.any { it.title.isDirty || it.date.isDirty } ||
+ uiState.value.name.isDirty || uiState.value.memo.isDirty ||
+ uiState.value.birthday.isDirty
+ ) {
+ _uiEvent.send(FriendProfileEditorUIEvent.WarningExit)
+ } else {
+ _uiEvent.send(FriendProfileEditorUIEvent.Exit)
+ }
+ }
+ }
+
+ fun onSubmit() {
+ val updatedFriend = _uiState.value
+ if ((updatedFriend.name.error || updatedFriend.anniversaries.any { it.title.error || it.title.value.isBlank() })) {
+ // error
+ return
+ }
+
+ viewModelScope.launch {
+ friendRepository
+ .updateFriend(
+ friendId = friend.friendId,
+ friend =
+ updatedFriend.toModel(
+ friendId = friend.friendId,
+ imageUrl = friend.imageUrl ?: "",
+ phone = friend.phone ?: "",
+ lastContactAt = friend.lastContactAt ?: "",
+ ),
+ ).catch {
+ }.collect {
+ _uiEvent.send(FriendProfileEditorUIEvent.FriendProfileEditSuccess(it))
+ }
+ }
+ }
+
+ companion object {
+ private const val MAX_NAME_LENGTH = 20
+ private const val MAX_MEMO_LENGTH = 200
}
}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt
index a3bfca5b..2f23a951 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/NearDatePicker.kt
@@ -27,9 +27,10 @@ fun NearDatePicker(
onDismiss: () -> Unit,
) {
DatePickerDialog(
- colors = DatePickerDefaults.colors().copy(
- containerColor = NearTheme.colors.WHITE_FFFFFF,
- ),
+ colors =
+ DatePickerDefaults.colors().copy(
+ containerColor = NearTheme.colors.WHITE_FFFFFF,
+ ),
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
@@ -45,21 +46,22 @@ fun NearDatePicker(
}
},
) {
- DatePicker(state = datePickerState, title = null, headline = null, showModeToggle = false,
+ DatePicker(
+ state = datePickerState,
+ title = null,
+ headline = null,
+ showModeToggle = false,
dateFormatter =
remember
{ DatePickerDefaults.dateFormatter() },
- colors = DatePickerDefaults.colors().copy(
- containerColor = NearTheme.colors.WHITE_FFFFFF,
- ))
+ colors =
+ DatePickerDefaults.colors().copy(
+ containerColor = NearTheme.colors.WHITE_FFFFFF,
+ ),
+ )
}
}
-private fun convertMillisToDate(millis: Long): String {
- val formatter = SimpleDateFormat("MM/dd/yyyy", Locale.getDefault())
- return formatter.format(Date(millis))
-}
-
@OptIn(ExperimentalMaterial3Api::class)
@Preview(showBackground = true)
@Composable
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt
index b4f43b77..ce7902f1 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/component/ReminderIntervalBottomSheet.kt
@@ -44,7 +44,7 @@ import com.alarmy.near.presentation.ui.theme.NearTheme
@Composable
fun ReminderIntervalBottomSheet(
modifier: Modifier = Modifier,
- selectedReminderInterval: ReminderInterval = ReminderInterval.WEEKLY,
+ selectedReminderInterval: ReminderInterval = ReminderInterval.EVERY_WEEK,
onSelectReminderInterval: (ReminderInterval) -> Unit = {},
sheetState: SheetState =
rememberModalBottomSheetState(
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt
index 5aa9c093..df55ac6d 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/dialog/EditorExitDialog.kt
@@ -6,8 +6,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.theme.NearTheme
@Composable
@@ -20,27 +22,26 @@ internal fun EditorExitDialog(
modifier = modifier,
onDismissRequest = onDismissRequest,
title = {
- Text(text = "수정을 그만두시나요?")
+ Text(text = stringResource(R.string.editor_exit_title))
},
text = {
Text(
text =
- "화면을 나가면 \n" +
- "수정 내용은 저장되지 않아요.",
+ stringResource(R.string.editor_exit_content),
)
},
confirmButton = {
TextButton(
onClick = onConfirm,
) {
- Text("확인")
+ Text(stringResource(R.string.editor_exit_confirm))
}
},
dismissButton = {
TextButton(
onClick = onDismissRequest,
) {
- Text("취소")
+ Text(stringResource(R.string.editor_exit_dismiss))
}
},
shape = RoundedCornerShape(24.dp),
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt
index d7521c67..1fd39a14 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/navigation/FriendProfileNavigation.kt
@@ -1,27 +1,90 @@
package com.alarmy.near.presentation.feature.friendprofileedittor.navigation
+import android.os.Build
+import android.os.Parcelable
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
+import androidx.navigation.NavType
import androidx.navigation.compose.composable
+import androidx.savedstate.SavedState
+import com.alarmy.near.model.Friend
import com.alarmy.near.presentation.feature.friendprofileedittor.FriendProfileEditorRoute
+import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.RouteFriendProfileEditor.Companion.routeTypeMap
+import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+
+const val FRIEND_PROFILE_EDIT_COMPLETE_KEY = "FRIEND_PROFILE_EDIT_COMPLETE_KEY"
@Serializable
-object RouteFriendProfileEditor
+@Parcelize
+data class RouteFriendProfileEditor(
+ val friend: Friend,
+) : Parcelable {
+ companion object {
+ val routeTypeMap =
+ mapOf>(
+ typeOf() to FriendType,
+ )
+ }
+}
-fun NavController.navigateToFriendProfileEditor(navOptions: NavOptions) {
- navigate(RouteFriendProfileEditor, navOptions)
+fun NavController.navigateToFriendProfileEditor(
+ friend: Friend,
+ navOptions: NavOptions? = null,
+) {
+ navigate(
+ RouteFriendProfileEditor(
+ friend = friend,
+ ),
+ navOptions,
+ )
}
fun NavGraphBuilder.friendProfileEditorNavGraph(
onShowErrorSnackBar: (throwable: Throwable?) -> Unit,
onClickBackButton: () -> Unit = {},
+ onSuccessEdit: (Friend) -> Unit = {},
) {
- composable { backStackEntry ->
+ composable(
+ typeMap =
+ routeTypeMap,
+ ) { backStackEntry ->
FriendProfileEditorRoute(
onShowErrorSnackBar = onShowErrorSnackBar,
onClickBackButton = onClickBackButton,
+ onSuccessEdit = onSuccessEdit,
)
}
}
+
+internal val FriendType =
+ object : NavType(
+ isNullableAllowed = false,
+ ) {
+ override fun put(
+ bundle: SavedState,
+ key: String,
+ value: Friend,
+ ) {
+ bundle.putParcelable(key, value)
+ }
+
+ override fun get(
+ bundle: SavedState,
+ key: String,
+ ): Friend? =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ bundle.getParcelable(key, Friend::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ bundle.getParcelable(key)
+ }
+
+ override fun parseValue(value: String): Friend = Json.decodeFromString(value)
+
+ override fun serializeAsValue(value: Friend): String = Json.encodeToString(value)
+ }
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt
new file mode 100644
index 00000000..bf84abfa
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIEvent.kt
@@ -0,0 +1,19 @@
+package com.alarmy.near.presentation.feature.friendprofileedittor.uistate
+
+import com.alarmy.near.model.Friend
+
+sealed interface FriendProfileEditorUIEvent {
+ data class FriendProfileEditSuccess(
+ val friend: Friend,
+ ) : FriendProfileEditorUIEvent
+
+ data class FriendProfileEditFailure(
+ val throwable: Throwable,
+ ) : FriendProfileEditorUIEvent
+
+ data object FriendProfileEditNetworkError : FriendProfileEditorUIEvent
+
+ data object WarningExit : FriendProfileEditorUIEvent
+
+ data object Exit : FriendProfileEditorUIEvent
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt
new file mode 100644
index 00000000..4e92060a
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofileedittor/uistate/FriendProfileEditorUIState.kt
@@ -0,0 +1,68 @@
+package com.alarmy.near.presentation.feature.friendprofileedittor.uistate
+
+import com.alarmy.near.model.Anniversary
+import com.alarmy.near.model.ContactFrequency
+import com.alarmy.near.model.Friend
+import com.alarmy.near.model.Relation
+
+data class FriendProfileEditorUIState(
+ val name: InputField = InputField(""),
+ val relation: Relation = Relation.FRIEND,
+ val contactFrequency: ContactFrequency,
+ val birthday: InputField = InputField(null),
+ val anniversaries: List = emptyList(),
+ val memo: InputField = InputField(null),
+)
+
+data class AnniversaryUIState(
+ val title: InputField = InputField(""),
+ val date: InputField = InputField(null),
+)
+
+data class InputField(
+ val value: T,
+ val error: Boolean = false, // null이면 유효한 상태
+ val isDirty: Boolean = false, // 유저가 입력을 시도했는지
+)
+
+fun Friend.toUiModel(): FriendProfileEditorUIState =
+ FriendProfileEditorUIState(
+ name = InputField(name),
+ relation = relation,
+ contactFrequency = contactFrequency,
+ birthday = InputField(birthday),
+ anniversaries = anniversaryList.map { it.toUiModel() },
+ memo = InputField(memo),
+ )
+
+fun Anniversary.toUiModel(): AnniversaryUIState =
+ AnniversaryUIState(
+ title = InputField(title),
+ date = InputField(date),
+ )
+
+fun FriendProfileEditorUIState.toModel(
+ friendId: String,
+ imageUrl: String,
+ phone: String,
+ lastContactAt: String,
+): Friend =
+ Friend(
+ name = name.value,
+ relation = relation,
+ contactFrequency =
+ contactFrequency,
+ birthday = birthday.value?.replace(".", "-"),
+ anniversaryList =
+ anniversaries.map {
+ Anniversary(
+ title = it.title.value,
+ date = it.date.value?.replace(".", "-"),
+ )
+ },
+ friendId = friendId,
+ imageUrl = imageUrl,
+ memo = memo.value,
+ phone = phone,
+ lastContactAt = lastContactAt,
+ )
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt
index 61612787..0a146e16 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/HomeScreen.kt
@@ -22,8 +22,6 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -52,11 +50,13 @@ 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.ContactFrequency
-import com.alarmy.near.model.FriendSummary
+import com.alarmy.near.model.friendsummary.ContactFrequencyLevel
+import com.alarmy.near.model.friendsummary.FriendSummary
import com.alarmy.near.model.monthly.MonthlyFriend
import com.alarmy.near.model.monthly.MonthlyFriendType
import com.alarmy.near.presentation.feature.home.component.MyContacts
+import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenu
+import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenuItem
import com.alarmy.near.presentation.ui.extension.dropShadow
import com.alarmy.near.presentation.ui.extension.onNoRippleClick
import com.alarmy.near.presentation.ui.theme.NearTheme
@@ -311,24 +311,16 @@ internal fun HomeScreen(
painter = painterResource(R.drawable.ic_32_menu),
contentDescription = stringResource(R.string.home_my_people_setting),
)
- DropdownMenu(
- modifier = Modifier.background(color = NearTheme.colors.WHITE_FFFFFF),
+ NearDropdownMenu(
expanded = dropdownState.value,
- shape = RoundedCornerShape(12.dp),
onDismissRequest = { dropdownState.value = false },
) {
- DropdownMenuItem(
+ NearDropdownMenuItem(
onClick = {
// TODO 연락처 화면 이동
dropdownState.value = false
},
- text = {
- Text(
- stringResource(R.string.home_menu_text_add_friend),
- style = NearTheme.typography.B2_14_MEDIUM,
- color = NearTheme.colors.BLACK_1A1A1A,
- )
- },
+ text = stringResource(R.string.home_menu_text_add_friend),
)
}
}
@@ -388,7 +380,7 @@ internal fun HomeScreenPreview() {
profileImageUrl = "https://search.yahoo.com/search?p=partiendo",
lastContactedAt = "2025-07-16",
isContacted = false,
- contactFrequency = ContactFrequency.HIGH,
+ contactFrequencyLevel = ContactFrequencyLevel.HIGH,
)
},
monthlyFriends =
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 899c1503..c8a4ccfe 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
@@ -3,7 +3,7 @@ package com.alarmy.near.presentation.feature.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.alarmy.near.data.repository.FriendRepository
-import com.alarmy.near.model.FriendSummary
+import com.alarmy.near.model.friendsummary.FriendSummary
import com.alarmy.near.model.monthly.MonthlyFriend
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt
index 15fd3d28..892176ff 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt
@@ -19,8 +19,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.alarmy.near.R
-import com.alarmy.near.model.ContactFrequency
-import com.alarmy.near.model.FriendSummary
+import com.alarmy.near.model.friendsummary.ContactFrequencyLevel
+import com.alarmy.near.model.friendsummary.FriendSummary
import com.alarmy.near.presentation.ui.extension.onNoRippleClick
import com.alarmy.near.presentation.ui.theme.NearTheme
@@ -50,10 +50,10 @@ fun ContactItem(
.align(Alignment.TopEnd)
.offset(x = 4.dp, y = (-4).dp),
painter =
- when (friendSummary.contactFrequency) {
- ContactFrequency.LOW -> painterResource(R.drawable.ic_visual_24_emoji_0)
- ContactFrequency.MIDDLE -> painterResource(R.drawable.ic_visual_24_emoji_50)
- ContactFrequency.HIGH -> painterResource(R.drawable.ic_visual_24_emoji_100)
+ when (friendSummary.contactFrequencyLevel) {
+ ContactFrequencyLevel.LOW -> painterResource(R.drawable.ic_visual_24_emoji_0)
+ ContactFrequencyLevel.MIDDLE -> painterResource(R.drawable.ic_visual_24_emoji_50)
+ ContactFrequencyLevel.HIGH -> painterResource(R.drawable.ic_visual_24_emoji_100)
},
contentDescription = "",
)
@@ -99,7 +99,7 @@ fun ContactItemPreview() {
profileImageUrl = "",
lastContactedAt = "2025-04-21",
isContacted = true,
- contactFrequency = ContactFrequency.HIGH,
+ contactFrequencyLevel = ContactFrequencyLevel.HIGH,
),
)
}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt
index c09f3796..3e1ac181 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/MyContacts.kt
@@ -17,10 +17,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import com.alarmy.near.model.ContactFrequency
-import com.alarmy.near.model.FriendSummary
+import com.alarmy.near.model.friendsummary.ContactFrequencyLevel
+import com.alarmy.near.model.friendsummary.FriendSummary
import com.alarmy.near.presentation.ui.theme.NearTheme
-import java.time.LocalDate
private const val OVERFLOW_WIDTH_OF_CONTACT_ITEM_BY_NAME_TEXT = 34
@@ -240,7 +239,7 @@ fun MyContactsPreview() {
profileImageUrl = "https://search.yahoo.com/search?p=partiendo",
lastContactedAt = "2025-04-21",
isContacted = false,
- contactFrequency = ContactFrequency.LOW,
+ contactFrequencyLevel = ContactFrequencyLevel.LOW,
)
}.chunked(5),
)
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
new file mode 100644
index 00000000..10990fba
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt
@@ -0,0 +1,164 @@
+package com.alarmy.near.presentation.feature.login
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+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.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.layout.wrapContentSize
+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.Alignment
+import androidx.compose.ui.Modifier
+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 androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.alarmy.near.R
+import com.alarmy.near.model.ProviderType
+import com.alarmy.near.presentation.ui.theme.NearTheme
+
+@Composable
+internal fun LoginRoute(
+ onNavigateToHome: () -> Unit,
+ viewModel: LoginViewModel = hiltViewModel(),
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(Unit) {
+ viewModel.loginSuccessEvent.collect {
+ onNavigateToHome()
+ }
+ }
+
+ LoginScreen(
+ uiState = uiState,
+ onLoginClick = { providerType ->
+ viewModel.performLogin(providerType)
+ },
+ )
+}
+
+@Composable
+fun LoginScreen(
+ uiState: LoginUiState,
+ onLoginClick: (ProviderType) -> Unit,
+) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .background(NearTheme.colors.WHITE_FFFFFF)
+ .systemBarsPadding(),
+ ) {
+ LoginIntroductionSection(modifier = Modifier.fillMaxWidth())
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // 소셜 로그인 버튼
+ SocialLoginButtons(
+ isLoading = uiState.isLoading,
+ onLoginClick = onLoginClick,
+ )
+ }
+}
+
+@Composable
+private fun LoginIntroductionSection(modifier: Modifier = Modifier) {
+ Spacer(modifier = Modifier.height(LoginScreenConstants.TOP_SPACING.dp))
+
+ Image(
+ modifier = modifier.size(LoginScreenConstants.LOGO_SIZE.dp),
+ alignment = Alignment.Center,
+ painter = painterResource(R.drawable.img_40_character),
+ contentDescription = stringResource(R.string.near_logo),
+ )
+
+ Image(
+ modifier = modifier.wrapContentSize(Alignment.Center),
+ alignment = Alignment.Center,
+ painter = painterResource(R.drawable.ic_near_logo_title),
+ contentDescription = stringResource(R.string.near_logo_title),
+ )
+
+ Spacer(modifier = Modifier.size(LoginScreenConstants.DESCRIPTION_SPACING.dp))
+
+ Text(
+ modifier = modifier.wrapContentSize(Alignment.Center),
+ text = stringResource(R.string.login_near_description),
+ style = NearTheme.typography.B1_16_MEDIUM,
+ color = NearTheme.colors.GRAY01_888888,
+ )
+}
+
+@Composable
+private fun ColumnScope.SocialLoginButtons(
+ isLoading: Boolean,
+ onLoginClick: (ProviderType) -> Unit,
+) {
+ // 카카오 로그인 버튼
+ SocialLoginButton(
+ isEnabled = !isLoading,
+ providerType = ProviderType.KAKAO,
+ buttonResource = R.drawable.btn_kakao_login,
+ contentDescription = stringResource(R.string.login_kakao_login_button_text),
+ onLoginClick = onLoginClick,
+ )
+
+ Spacer(modifier = Modifier.size(LoginScreenConstants.BOTTOM_SPACING.dp))
+}
+
+@Composable
+private fun ColumnScope.SocialLoginButton(
+ isEnabled: Boolean,
+ providerType: ProviderType,
+ buttonResource: Int,
+ contentDescription: String,
+ onLoginClick: (ProviderType) -> Unit,
+) {
+ Image(
+ modifier =
+ Modifier
+ .wrapContentSize()
+ .align(Alignment.CenterHorizontally)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ enabled = isEnabled,
+ ) {
+ onLoginClick(providerType)
+ },
+ painter = painterResource(buttonResource),
+ contentDescription = contentDescription,
+ )
+}
+
+private object LoginScreenConstants {
+ const val TOP_SPACING = 170
+ const val LOGO_SIZE = 160
+ const val DESCRIPTION_SPACING = 12
+ const val BOTTOM_SPACING = 96
+}
+
+@Preview(showBackground = true)
+@Composable
+fun LoginScreenPreview() {
+ NearTheme {
+ LoginScreen(
+ uiState = LoginUiState(),
+ onLoginClick = { },
+ )
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt
new file mode 100644
index 00000000..5f9a9c68
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginViewModel.kt
@@ -0,0 +1,67 @@
+package com.alarmy.near.presentation.feature.login
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.alarmy.near.data.repository.AuthRepository
+import com.alarmy.near.model.ProviderType
+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.receiveAsFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class LoginViewModel
+ @Inject
+ constructor(
+ private val authRepository: AuthRepository,
+ ) : ViewModel() {
+ // UI 상태 관리
+ private val _uiState = MutableStateFlow(LoginUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ // 에러 이벤트 관리
+ private val _errorEvent = Channel()
+ val errorEvent = _errorEvent.receiveAsFlow()
+
+ // 로그인 성공 이벤트 관리
+ private val _loginSuccessEvent = Channel()
+ val loginSuccessEvent = _loginSuccessEvent.receiveAsFlow()
+
+ /**
+ * 소셜 로그인 수행
+ */
+ fun performLogin(providerType: ProviderType) {
+ viewModelScope.launch {
+ updateLoadingState(isLoading = true)
+
+ authRepository.performSocialLogin(providerType)
+ .onSuccess {
+ updateLoadingState(isLoading = false)
+ _loginSuccessEvent.send(Unit)
+ }
+ .onFailure { exception ->
+ updateLoadingState(isLoading = false)
+ _errorEvent.send(exception)
+ }
+ }
+ }
+
+ /**
+ * 로딩 상태 업데이트
+ */
+ private fun updateLoadingState(isLoading: Boolean) {
+ _uiState.value = _uiState.value.copy(isLoading = isLoading)
+ }
+ }
+
+/**
+ * 로그인 화면 UI 상태
+ */
+data class LoginUiState(
+ val isLoading: Boolean = false,
+ val hasError: Boolean = false,
+)
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt
new file mode 100644
index 00000000..f2d70ce7
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt
@@ -0,0 +1,28 @@
+package com.alarmy.near.presentation.feature.login.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.compose.composable
+import com.alarmy.near.presentation.feature.login.LoginRoute
+import kotlinx.serialization.Serializable
+
+@Serializable
+object RouteLogin
+
+// 로그인 화면으로 이동하는 확장 함수
+fun NavController.navigateToLogin(navOptions: NavOptions? = null) {
+ navigate(RouteLogin, navOptions)
+}
+
+// 로그인 화면 NavGraph 정의
+fun NavGraphBuilder.loginNavGraph(
+ onNavigateToHome: () -> Unit,
+ onShowErrorSnackBar: (throwable: Throwable?) -> Unit,
+) {
+ composable {
+ LoginRoute(
+ onNavigateToHome = onNavigateToHome,
+ )
+ }
+}
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 94f9e995..adf33e39 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
@@ -1,14 +1,24 @@
package com.alarmy.near.presentation.feature.main
+import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.net.toUri
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
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
@Composable
internal fun NearNavHost(
@@ -16,6 +26,7 @@ internal fun NearNavHost(
navController: NavHostController,
onShowSnackbar: (Throwable?) -> Unit = { _ -> },
) {
+ val context = LocalContext.current
/*
* 화면 이동 및 구성을 위한 컴포저블 함수입니다.
* */
@@ -26,10 +37,54 @@ internal fun NearNavHost(
) {
friendProfileNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = {
navController.popBackStack()
+ }, onClickCallButton = { phoneNumber ->
+ val intent =
+ Intent(Intent.ACTION_DIAL).apply {
+ data = "tel:$phoneNumber".toUri()
+ }
+ context.startActivity(intent)
+ }, onClickMessageButton = { phoneNumber ->
+ val intent =
+ Intent(Intent.ACTION_VIEW).apply {
+ data = "sms:$phoneNumber".toUri()
+ }
+ context.startActivity(intent)
+ }, onEditFriendInfo = {
+ navController.navigateToFriendProfileEditor(
+ friend =
+ it.copy(
+ imageUrl =
+ it.imageUrl?.let { imageUrl ->
+ URLEncoder.encode(
+ imageUrl,
+ StandardCharsets.UTF_8.toString(),
+ )
+ },
+ ),
+ )
})
friendProfileEditorNavGraph(onShowErrorSnackBar = onShowSnackbar, onClickBackButton = {
navController.popBackStack()
+ }, onSuccessEdit = {
+ navController.previousBackStackEntry?.savedStateHandle?.set(
+ FRIEND_PROFILE_EDIT_COMPLETE_KEY,
+ it,
+ )
+ navController.popBackStack()
})
+ // 로그인 화면 NavGraph
+ loginNavGraph(
+ onShowErrorSnackBar = onShowSnackbar,
+ onNavigateToHome = {
+ navController.navigateToHome(
+ navOptions = androidx.navigation.navOptions {
+ popUpTo(RouteLogin) { inclusive = true }
+ }
+ )
+ }
+ )
+
+ // 홈 화면 NavGraph
homeNavGraph(
onShowErrorSnackBar = onShowSnackbar,
onContactClick = { contactId ->
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/NearFrame.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/NearFrame.kt
new file mode 100644
index 00000000..08821874
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/NearFrame.kt
@@ -0,0 +1,35 @@
+package com.alarmy.near.presentation.ui.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+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.statusBars
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import com.alarmy.near.presentation.ui.theme.NearTheme
+
+@Composable
+fun NearFrame(
+ modifier: Modifier = Modifier,
+ backgroundColor: Color = NearTheme.colors.WHITE_FFFFFF,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val density = LocalDensity.current
+ val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() }
+ val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() }
+ Column(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .background(
+ color = backgroundColor,
+ ).padding(top = statusBarHeightDp, bottom = navigationBarHeightDp),
+ content = content,
+ )
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dropdown/NearDropDown.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dropdown/NearDropDown.kt
new file mode 100644
index 00000000..f92309b8
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dropdown/NearDropDown.kt
@@ -0,0 +1,84 @@
+package com.alarmy.near.presentation.ui.component.dropdown
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Text
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.alarmy.near.presentation.ui.theme.NearTheme
+
+@Composable
+fun NearDropdownMenu(
+ modifier: Modifier = Modifier,
+ expanded: Boolean,
+ onDismissRequest: () -> Unit,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ DropdownMenu(
+ modifier = modifier.background(NearTheme.colors.WHITE_FFFFFF),
+ expanded = expanded,
+ shape = RoundedCornerShape(12.dp),
+ onDismissRequest = onDismissRequest,
+ content = content,
+ )
+}
+
+@Composable
+fun NearDropdownMenuItem(
+ text: String,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+) {
+ DropdownMenuItem(
+ modifier = modifier,
+ onClick = onClick,
+ text = {
+ Text(
+ text = text,
+ style = NearTheme.typography.B2_14_MEDIUM,
+ color = NearTheme.colors.BLACK_1A1A1A,
+ )
+ },
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+fun NearDropdownMenuPreview() {
+ var expanded by remember { mutableStateOf(true) }
+
+ NearTheme {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentSize(Alignment.Center)
+ ) {
+ NearDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false },
+ ) {
+ NearDropdownMenuItem(
+ text = "친구 정보 수정",
+ onClick = { expanded = false }
+ )
+ NearDropdownMenuItem(
+ text = "친구 삭제",
+ onClick = { expanded = false }
+ )
+ }
+ }
+ }
+}
diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt
index b6167e82..5737bacc 100644
--- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt
+++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Theme.kt
@@ -1,9 +1,13 @@
package com.alarmy.near.presentation.ui.theme
+import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
val LocalCustomColors =
staticCompositionLocalOf {
@@ -30,6 +34,20 @@ fun NearTheme(
LocalCustomTypography provides Typography,
content = content,
)
+
+/* 스크린에서 상태바 아이콘 색상
+* */
+ val view = LocalView.current
+// val isDarkTheme = isSystemInDarkTheme() 시스템 다크 모드 여부를 Boolean으로 반환
+ val isDarkTheme = false
+
+ SideEffect {
+ if (!view.isInEditMode) {
+ val window = (view.context as Activity).window
+ val insetsController = WindowCompat.getInsetsController(window, view)
+ insetsController.isAppearanceLightStatusBars = !isDarkTheme
+ }
+ }
}
object NearTheme {
diff --git a/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt b/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt
new file mode 100644
index 00000000..2d13c051
--- /dev/null
+++ b/Near/app/src/main/java/com/alarmy/near/utils/logger/NearLog.kt
@@ -0,0 +1,99 @@
+package com.alarmy.near.utils.logger
+
+import android.util.Log
+import com.alarmy.near.BuildConfig
+
+object NearLog {
+ private const val TAG = "Near"
+ private const val LOGGER_FILE_NAME = "NearLog.kt"
+
+ private fun buildLogMessage(message: String): String =
+ runCatching {
+ val stackTrace = Thread.currentThread().stackTrace
+
+ // 스택 트레이스를 순회하며 로거 파일이 아닌 첫 번째 호출자를 찾습니다
+ // 인덱스 0: Thread.getStackTrace()
+ // 인덱스 1: 현재 함수 (buildLogMessage)
+ // 인덱스 2~: 로그 함수들 (d, e 등)
+ // 그 이후: 실제 호출자
+ val callerElement =
+ stackTrace.drop(2).firstOrNull { element ->
+ element.fileName != LOGGER_FILE_NAME
+ } ?: throw IllegalStateException("Caller not found")
+
+ val fileName =
+ callerElement.fileName
+ ?.substringBeforeLast('.')
+ ?: "Unknown"
+
+ val methodName = callerElement.methodName ?: "unknownMethod"
+ val lineNumber = callerElement.lineNumber
+ val originalFileName = callerElement.fileName ?: "Unknown"
+
+ "[$fileName::$methodName ($originalFileName:$lineNumber)] $message"
+ }.getOrElse { exception ->
+ // 스택 트레이스 분석 실패 시 간단한 포맷으로 대체하여 로깅 기능 유지
+ "[LogError:${exception.javaClass.simpleName}] $message"
+ }
+
+ // 디버그 모드 확인
+ private fun isLoggingEnabled(): Boolean = BuildConfig.DEBUG
+
+ /**
+ * 공통 로그 출력 함수
+ * 모든 로그 레벨에서 공통으로 사용되는 로직을 통합합니다
+ */
+ private fun writeLog(
+ level: Int,
+ tag: String,
+ message: String,
+ throwable: Throwable? = null,
+ ) {
+ if (!isLoggingEnabled()) return
+
+ val formattedMessage = buildLogMessage(message)
+
+ when (level) {
+ Log.VERBOSE -> Log.v(tag, formattedMessage)
+ Log.DEBUG -> Log.d(tag, formattedMessage)
+ Log.INFO -> Log.i(tag, formattedMessage)
+ Log.WARN -> Log.w(tag, formattedMessage)
+ Log.ERROR -> Log.e(tag, formattedMessage, throwable)
+ }
+ }
+
+ // Verbose 로그
+ fun v(
+ message: String,
+ tag: String = TAG,
+ ) = writeLog(Log.VERBOSE, tag, message)
+
+ // Debug 로그
+ fun d(
+ message: String,
+ tag: String = TAG,
+ ) = writeLog(Log.DEBUG, tag, message)
+
+ // Info 로그
+ fun i(
+ message: String,
+ tag: String = TAG,
+ ) = writeLog(Log.INFO, tag, message)
+
+ // Warning 로그
+ fun w(
+ message: String,
+ tag: String = TAG,
+ ) = writeLog(Log.WARN, tag, message)
+
+ // Error 로그
+ fun e(
+ message: String,
+ throwable: Throwable? = null,
+ name: String? = null,
+ tag: String = TAG,
+ ) {
+ val finalMessage = if (name != null) "$name: $message" else message
+ writeLog(Log.ERROR, tag, finalMessage, throwable)
+ }
+}
diff --git a/Near/app/src/main/res/drawable/btn_kakao_login.xml b/Near/app/src/main/res/drawable/btn_kakao_login.xml
new file mode 100644
index 00000000..c477cf1f
--- /dev/null
+++ b/Near/app/src/main/res/drawable/btn_kakao_login.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
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.xml
new file mode 100644
index 00000000..4a457eac
--- /dev/null
+++ b/Near/app/src/main/res/drawable/ic_near_logo_title.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/Near/app/src/main/res/drawable/img_100_character_success.xml b/Near/app/src/main/res/drawable/img_100_character_success.xml
new file mode 100644
index 00000000..db210bef
--- /dev/null
+++ b/Near/app/src/main/res/drawable/img_100_character_success.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml
index 3606877d..173ad5c1 100644
--- a/Near/app/src/main/res/values/strings.xml
+++ b/Near/app/src/main/res/values/strings.xml
@@ -6,6 +6,13 @@
뒤로 가기
메뉴
+ 네트워크 에러가 발생했습니다.
+
+
+ Near 로고
+ Near 타이틀
+ 소중한 사람들과 더 가까워지는 시간
+ 카카오 로그인 버튼
MY
@@ -16,6 +23,7 @@
연락처 추가
가까워 지고 싶은 사람을\n추가해보세요.
사람 추가
+ 사람 추가
전화걸기
@@ -29,13 +37,55 @@
기념일
메모
꼭 기억해야 할 내용을 기록해보세요.\n예) 날생선 X, 작년 생일에 키링 선물함 등
- 친구
+ %1$s 더 가까워졌어요
+ 친구
가족
지인
+ 프로필 상세
+ 친구
+ 가족
+ 지인
+ 연락 주기
+ 생일
+ 날짜 선택
+ 삭제하기
+ 꼭 기억해야 할 내용을 기록해보세요.\n예) 날생선 X, 작년 생일에 키링 선물함 등
+ 이름을 입력해주세요.
+ 기념일 이름
+ 추가하기
+ 기념일
+ (%1$s 마다)
+ 더 가까워졌어요!
+ 수정
+ 삭제
+ 이번달은 챙길 사람이 없네요.
+ %1$d번째 챙김
+ 챙김 기록
+ 완료
+ 이름
+ 이름을 입력해주세요.
+ 관계
+ 날짜
+ 메모
+
+
매일
매주
2주
매달
6개월
- 사람 추가
+
+
+ 수정을 그만두시나요?
+ 화면을 나가면 \n수정 내용은 저장되지 않아요.
+ 확인
+ 취소
+ 월요일
+ 화요일
+ 수요일
+ 목요일
+ 금요일
+ 토요일
+ 일요일
+
diff --git a/Near/gradle/libs.versions.toml b/Near/gradle/libs.versions.toml
index 9c13f8fc..d3aa3673 100644
--- a/Near/gradle/libs.versions.toml
+++ b/Near/gradle/libs.versions.toml
@@ -25,9 +25,15 @@ navigationVersion = "2.9.2"
kotlinSerializationVersion = "1.9.0"
# OkHttp
okHttp = "5.1.0"
+# DataStore
+datastorePreferences = "1.1.7"
+datastoreCore = "1.1.7"
+#Kakao
+v2All = "2.21.7"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -55,6 +61,8 @@ navigation-compose = { group = "androidx.navigation", name = "navigation-compose
kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerializationVersion" }
logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okHttp" }
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" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@@ -64,4 +72,5 @@ hilt-application = { id = "com.google.dagger.hilt.android", version.ref = "hiltV
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintVersion" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+kotlin-parcelize = {id = "kotlin-parcelize"}
diff --git a/Near/settings.gradle.kts b/Near/settings.gradle.kts
index 2d06f9e7..de36a1fc 100644
--- a/Near/settings.gradle.kts
+++ b/Near/settings.gradle.kts
@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven { url = java.net.URI("https://devrepo.kakao.com/nexus/content/groups/public/") }
}
}