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/") } } }