diff --git a/.github/workflows/android-pull-request-ci.yml b/.github/workflows/android-pull-request-ci.yml index 8854d504..af693824 100644 --- a/.github/workflows/android-pull-request-ci.yml +++ b/.github/workflows/android-pull-request-ci.yml @@ -44,10 +44,10 @@ jobs: 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 + 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 6ab76de2..0284dce0 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 { @@ -98,6 +99,9 @@ dependencies { // Kakao Module implementation(libs.v2.all) + + // Splash Screen API + implementation(libs.androidx.core.splashscreen) } fun getProperty(propertyKey: String): String = gradleLocalProperties(rootDir, providers).getProperty(propertyKey) diff --git a/Near/app/src/main/AndroidManifest.xml b/Near/app/src/main/AndroidManifest.xml index b0ae0a68..09d49b30 100644 --- a/Near/app/src/main/AndroidManifest.xml +++ b/Near/app/src/main/AndroidManifest.xml @@ -9,7 +9,7 @@ android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" - android:icon="@mipmap/ic_launcher" + android:icon="@mipmap/ic_launcher_round" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" @@ -20,25 +20,28 @@ - + + - + + - + + android:theme="@style/Theme.Near.Splash"> diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt index 5f73bfb6..12c320b2 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/di/DataStoreModule.kt @@ -9,17 +9,35 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Qualifier import javax.inject.Singleton +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthDataStore + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class OnboardingDataStore + // DataStore 확장 프로퍼티 -private val Context.dataStore: DataStore by preferencesDataStore(name = "auth_preferences") +private val Context.authDataStore: DataStore by preferencesDataStore(name = "auth_preferences") +private val Context.onboardingDataStore: DataStore by preferencesDataStore(name = "onboarding_preferences") @Module @InstallIn(SingletonComponent::class) object DataStoreModule { @Provides @Singleton - fun provideDataStore( + @AuthDataStore + fun provideAuthDataStore( + @ApplicationContext context: Context, + ): DataStore = context.authDataStore + + @Provides + @Singleton + @OnboardingDataStore + fun provideOnboardingDataStore( @ApplicationContext context: Context, - ): DataStore = context.dataStore + ): DataStore = context.onboardingDataStore } diff --git a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt index 5d73c37f..50d1b230 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/di/RepositoryModule.kt @@ -6,6 +6,8 @@ import com.alarmy.near.data.repository.DefaultFriendRepository import com.alarmy.near.data.repository.ExampleRepository import com.alarmy.near.data.repository.ExampleRepositoryImpl import com.alarmy.near.data.repository.FriendRepository +import com.alarmy.near.data.repository.OnBoardingRepository +import com.alarmy.near.data.repository.OnBoardingRepositoryImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -26,4 +28,8 @@ interface RepositoryModule { @Binds @Singleton abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository + + @Binds + @Singleton + abstract fun bindOnBoardingRepository(onBoardingRepositoryImpl: OnBoardingRepositoryImpl): OnBoardingRepository } diff --git a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/OnboardingPreferences.kt b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/OnboardingPreferences.kt new file mode 100644 index 00000000..fe7f5841 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/OnboardingPreferences.kt @@ -0,0 +1,48 @@ +package com.alarmy.near.data.local.datastore + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import com.alarmy.near.data.di.OnboardingDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 온보딩 완료 상태를 관리하는 DataStore + * 최초 접속 여부를 확인하여 온보딩 화면 표시 여부를 결정 + */ +@Singleton +class OnboardingPreferences @Inject constructor( + @OnboardingDataStore private val dataStore: DataStore, +) { + companion object { + private val ONBOARDING_COMPLETED_KEY = booleanPreferencesKey("onboarding_completed") + } + + /** + * 온보딩 완료 여부를 확인하는 Flow + * true: 온보딩 완료됨, false: 온보딩 미완료 + */ + val isOnboardingCompleted: Flow = dataStore.data.map { preferences -> + preferences[ONBOARDING_COMPLETED_KEY] ?: false + } + + /** + * 온보딩 완료 상태를 저장 + */ + suspend fun setOnboardingCompleted(completed: Boolean) { + dataStore.edit { preferences -> + preferences[ONBOARDING_COMPLETED_KEY] = completed + } + } + + /** + * 온보딩 완료 상태를 true로 설정 + */ + suspend fun markOnboardingAsCompleted() { + setOnboardingCompleted(true) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt index 13f5eaa0..81c5362b 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/local/datastore/TokenPreferences.kt @@ -5,9 +5,9 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import com.alarmy.near.data.di.AuthDataStore import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map - import javax.inject.Inject import javax.inject.Singleton @@ -16,11 +16,9 @@ import javax.inject.Singleton * 액세스 토큰과 리프레시 토큰의 저장, 조회, 삭제를 담당 */ @Singleton -class TokenPreferences - @Inject - constructor( - private val dataStore: DataStore, - ) { +class TokenPreferences @Inject constructor( + @AuthDataStore private val dataStore: DataStore, +) { private val accessTokenKey = stringPreferencesKey("access_token") private val refreshTokenKey = stringPreferencesKey("refresh_token") private val expiresAtKey = longPreferencesKey("expires_at") diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/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/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/data/repository/OnBoardingRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/OnBoardingRepository.kt new file mode 100644 index 00000000..c71f8c37 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/OnBoardingRepository.kt @@ -0,0 +1,37 @@ +package com.alarmy.near.data.repository + +import kotlinx.coroutines.flow.Flow + +/** + * 온보딩 관련 데이터를 관리하는 Repository 인터페이스 + * 온보딩 완료 상태의 저장, 조회, 확인 기능을 제공 + */ +interface OnBoardingRepository { + + /** + * 온보딩 완료 여부를 확인하는 Flow + * true: 온보딩 완료됨, false: 온보딩 미완료 + */ + fun observeOnboardingStatus(): Flow + + /** + * 온보딩 완료 상태를 저장 + * completed: 온보딩 완료 여부 + */ + suspend fun setOnboardingCompleted(completed: Boolean) + + /** + * 온보딩을 완료로 표시 + */ + suspend fun markOnboardingAsCompleted() + + /** + * 온보딩 완료 상태를 확인 + */ + suspend fun isOnboardingCompleted(): Boolean + + /** + * 온보딩 상태를 초기화 (테스트용 또는 재온보딩용) + */ + suspend fun resetOnboardingStatus() +} diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/OnBoardingRepositoryImpl.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/OnBoardingRepositoryImpl.kt new file mode 100644 index 00000000..02ae619e --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/OnBoardingRepositoryImpl.kt @@ -0,0 +1,37 @@ +package com.alarmy.near.data.repository + +import com.alarmy.near.data.local.datastore.OnboardingPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +/** + * OnBoardingRepository의 구현체 + * OnboardingPreferences를 사용하여 온보딩 상태를 관리 + */ +@Singleton +class OnBoardingRepositoryImpl @Inject constructor( + private val onboardingPreferences: OnboardingPreferences, +) : OnBoardingRepository { + + override fun observeOnboardingStatus(): Flow { + return onboardingPreferences.isOnboardingCompleted + } + + override suspend fun setOnboardingCompleted(completed: Boolean) { + onboardingPreferences.setOnboardingCompleted(completed) + } + + override suspend fun markOnboardingAsCompleted() { + onboardingPreferences.markOnboardingAsCompleted() + } + + override suspend fun isOnboardingCompleted(): Boolean { + return onboardingPreferences.isOnboardingCompleted.first() + } + + override suspend fun resetOnboardingStatus() { + onboardingPreferences.setOnboardingCompleted(false) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/model/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/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/di/NetworkModule.kt b/Near/app/src/main/java/com/alarmy/near/network/di/NetworkModule.kt index e6663ac9..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,7 @@ 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 @@ -34,12 +35,14 @@ object NetworkModule { loggingInterceptor: HttpLoggingInterceptor, tokenInterceptor: TokenInterceptor, tokenAuthenticator: TokenAuthenticator, + testTokenInterceptor: TestTokenInterceptor, ): OkHttpClient = OkHttpClient .Builder() .addInterceptor(loggingInterceptor) - .addInterceptor(tokenInterceptor) - .authenticator(tokenAuthenticator) + .addInterceptor(testTokenInterceptor) +// .addInterceptor(tokenInterceptor) +// .authenticator(tokenAuthenticator) .build() @Provides 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/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/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 index 10990fba..c231a9c3 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/LoginScreen.kt @@ -89,7 +89,7 @@ private fun LoginIntroductionSection(modifier: Modifier = Modifier) { Image( modifier = modifier.wrapContentSize(Alignment.Center), alignment = Alignment.Center, - painter = painterResource(R.drawable.ic_near_logo_title), + painter = painterResource(R.drawable.ic_near_logo_title_primary), contentDescription = stringResource(R.string.near_logo_title), ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt index 35f00fdc..c79559a9 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainActivity.kt @@ -4,17 +4,47 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.core.splashscreen.SplashScreen +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope import com.alarmy.near.presentation.ui.theme.NearTheme import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : ComponentActivity() { + private val mainViewModel: MainViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + super.onCreate(savedInstanceState) enableEdgeToEdge() + setupSplashScreen(splashScreen) + setContent { NearTheme { - NearApp() + val uiState by mainViewModel.uiState.collectAsStateWithLifecycle() + if (!uiState.isLoading) { + NearApp( + startDestination = uiState.startDestination, + ) + } + } + } + } + + /** + * 스플래시 스크린을 설정하고 MainViewModel의 상태를 관찰합니다. + * API 스플래시가 표시되는 동안 백그라운드에서 검증을 수행합니다. + */ + private fun setupSplashScreen(splashScreen: SplashScreen) { + lifecycleScope.launch { + mainViewModel.uiState.collect { uiState -> + splashScreen.setKeepOnScreenCondition { uiState.isLoading } } } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainViewModel.kt new file mode 100644 index 00000000..37a23776 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/MainViewModel.kt @@ -0,0 +1,78 @@ +package com.alarmy.near.presentation.feature.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alarmy.near.data.repository.AuthRepository +import com.alarmy.near.data.repository.OnBoardingRepository +import com.alarmy.near.presentation.feature.home.navigation.RouteHome +import com.alarmy.near.presentation.feature.login.navigation.RouteLogin +import com.alarmy.near.presentation.feature.onboarding.navigation.RouteOnboarding +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val onBoardingRepository: OnBoardingRepository, +) : ViewModel() { + + // UI 상태 관리 + private val _uiState = MutableStateFlow(MainUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + checkAppStatus() + } + + /** + * 앱 상태를 확인하고 스플래시 스크린을 제어합니다. + * 온보딩 완료 여부와 로그인 상태를 확인하여 적절한 화면으로 이동합니다. + */ + private fun checkAppStatus() { + viewModelScope.launch { + runCatching { + // 온보딩 완료 여부 확인 + val isOnboardingCompleted = onBoardingRepository.isOnboardingCompleted() + // 로그인 상태 검증 + val isLoggedIn = authRepository.isLoggedIn() + + // 시작 화면 결정 + val startDestination = when { + !isOnboardingCompleted -> RouteOnboarding + isLoggedIn -> RouteHome + else -> RouteLogin + } + + // UI 상태 업데이트 + _uiState.value = _uiState.value.copy( + isLoading = false, + isOnboardingCompleted = isOnboardingCompleted, + isLoggedIn = isLoggedIn, + startDestination = startDestination + ) + }.onFailure { + // 에러 발생 시 기본값으로 설정 + _uiState.value = _uiState.value.copy( + isLoading = false, + isOnboardingCompleted = false, + isLoggedIn = false, + startDestination = RouteOnboarding + ) + } + } + } +} + +/** + * MainActivity의 UI 상태를 관리하는 데이터 클래스 + */ +data class MainUiState( + val isLoading: Boolean = true, + val isOnboardingCompleted: Boolean = false, + val isLoggedIn: Boolean = false, + val startDestination: Any = RouteOnboarding, +) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt index 106f00b5..1b7be78c 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearApp.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Scaffold @@ -26,6 +25,7 @@ import kotlinx.coroutines.launch internal fun NearApp( modifier: Modifier = Modifier, navController: NavHostController = rememberNavController(), + startDestination: Any, ) { val snackBarState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -48,6 +48,7 @@ internal fun NearApp( NearNavHost( modifier = Modifier.consumeWindowInsets(innerPadding), // 하위 뷰에 Padding을 소비한 것으로 알립니다. navController = navController, + startDestination = startDestination, onShowSnackbar = { scope.launch { snackBarState.showSnackbar( diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/main/NearNavHost.kt index 3b7ad4b9..d7ca83b2 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,44 +1,110 @@ 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 androidx.navigation.navOptions 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.home.navigation.RouteHome +import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.navigateToFriendProfileEditor import com.alarmy.near.presentation.feature.home.navigation.homeNavGraph import com.alarmy.near.presentation.feature.home.navigation.navigateToHome import com.alarmy.near.presentation.feature.login.navigation.RouteLogin import com.alarmy.near.presentation.feature.login.navigation.loginNavGraph +import com.alarmy.near.presentation.feature.login.navigation.navigateToLogin +import com.alarmy.near.presentation.feature.onboarding.navigation.RouteOnboarding +import com.alarmy.near.presentation.feature.onboarding.navigation.onboardingNavGraph +import java.net.URLEncoder +import java.nio.charset.StandardCharsets @Composable internal fun NearNavHost( modifier: Modifier = Modifier, navController: NavHostController, + startDestination: Any, // 나중에 모든 루트를 sealed로 구성하면 sealed 타입으로 변경 onShowSnackbar: (Throwable?) -> Unit = { _ -> }, ) { + val context = LocalContext.current + /* * 화면 이동 및 구성을 위한 컴포저블 함수입니다. + * startDestination 파라미터로 받은 시작 화면으로 이동합니다. * */ NavHost( modifier = modifier, navController = navController, - startDestination = RouteLogin, + startDestination = startDestination, ) { + // 온보딩 화면 NavGraph + onboardingNavGraph( + onNavigateToLogin = { + navController.navigateToLogin( + navOptions = + navOptions { + popUpTo(RouteOnboarding) { inclusive = true } + }, + ) + }, + ) + + 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 } - } + navOptions = + navOptions { + popUpTo(RouteLogin) { inclusive = true } + }, ) - } + }, ) - + // 홈 화면 NavGraph homeNavGraph( onShowErrorSnackBar = onShowSnackbar, @@ -49,21 +115,21 @@ internal fun NearNavHost( onAlarmClick = {}, onAddContactClick = {}, ) - + // 친구 프로필 화면 NavGraph friendProfileNavGraph( onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { navController.popBackStack() - } + }, ) - + // 친구 프로필 편집 화면 NavGraph friendProfileEditorNavGraph( onShowErrorSnackBar = onShowSnackbar, onClickBackButton = { navController.popBackStack() - } + }, ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt new file mode 100644 index 00000000..48928afc --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingScreen.kt @@ -0,0 +1,234 @@ +package com.alarmy.near.presentation.feature.onboarding + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.alarmy.near.R +import com.alarmy.near.presentation.feature.onboarding.components.BackgroundArea +import com.alarmy.near.presentation.feature.onboarding.components.OnboardingButton +import com.alarmy.near.presentation.feature.onboarding.components.PageIndicator +import com.alarmy.near.presentation.feature.onboarding.model.OnboardingPage +import com.alarmy.near.presentation.ui.theme.NearTheme +import kotlinx.coroutines.launch + +/** + * 온보딩 화면 메인 컴포넌트 + * 5페이지로 구성된 뷰페이저 형태의 온보딩 화면 + */ +@Composable +fun OnboardingScreen( + onNavigateToLogin: () -> Unit, + viewModel: OnboardingViewModel = hiltViewModel(), +) { + // UI 상태 관찰 + val uiState by viewModel.uiState.collectAsState() + + // 사이드 이펙트 처리 + LaunchedEffect(Unit) { + viewModel.effect.collect { effect -> + when (effect) { + is OnboardingEffect.NavigateToLogin -> { + onNavigateToLogin() + } + } + } + } + + // 온보딩 페이지 데이터 - remember로 성능 최적화 + val pages = remember { + listOf( + OnboardingPage( + titleResId = R.string.first_onboarding_title, + image = R.drawable.img_onboarding_page_first, + ), + OnboardingPage( + titleResId = R.string.second_onboarding_title, + image = R.drawable.img_onboarding_page_second, + ), + OnboardingPage( + titleResId = R.string.third_onboarding_title, + image = R.drawable.img_onboarding_page_third, + ), + OnboardingPage( + titleResId = R.string.fourth_onboarding_title, + image = R.drawable.img_onboarding_page_forth, + ), + OnboardingPage( + titleResId = R.string.fifth_onboarding_title, + image = R.drawable.img_onboarding_page_fifth, + ), + ) + } + + // 상태바와 네비게이션 바 높이 계산 + val density = LocalDensity.current + val statusBarHeightDp = with(density) { WindowInsets.statusBars.getTop(density).toDp() } + val navigationBarHeightDp = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + + // 페이저 상태 관리 + val pagerState = rememberPagerState(pageCount = { pages.size }) + val scope = rememberCoroutineScope() + + Box( + modifier = Modifier.fillMaxSize(), + ) { + BackgroundArea() + Column( + modifier = Modifier + .padding(top = statusBarHeightDp, bottom = navigationBarHeightDp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 뷰페이저 + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f), + ) { page -> + OnboardingPageContent( + page = pages[page], + modifier = Modifier.fillMaxSize(), + ) + } + + Spacer(modifier = Modifier.size(25.dp)) + + // 페이지 인디케이터 + PageIndicator( + pageCount = pages.size, + currentPage = pagerState.currentPage, + ) + + Spacer(modifier = Modifier.size(32.dp)) + + // 다음/완료 버튼 + OnboardingButton( + currentPage = pagerState.currentPage, + totalPages = pages.size, + isLoading = uiState.isLoading, + onNextClick = { + if (pagerState.currentPage < pages.size - 1) { + scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } else { + // 온보딩 완료 시 DataStore에 저장 + viewModel.completeOnboarding() + } + }, + ) + Spacer(modifier = Modifier.size(24.dp)) + } + } +} + +/** + * 온보딩 페이지 콘텐츠 컴포넌트 + * 각 페이지의 제목과 설명을 표시 + */ +@Composable +private fun OnboardingPageContent( + page: OnboardingPage, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.size(45.dp)) + + // 각 온보딩 페이지 타이틀 + Text( + text = createAnnotatedText(stringResource(page.titleResId)), + textAlign = TextAlign.Center, + style = + NearTheme.typography.H1_24_BOLD.copy( + fontSize = 20.sp, + lineHeight = 30.sp, + ), + ) + + Spacer(modifier = Modifier.size(24.dp)) + + Image( + modifier = Modifier.weight(1f), + painter = painterResource(page.image), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + + } +} + +/** + * \n 이후 텍스트에 다른 색상을 적용하는 AnnotatedString 생성 + * 현 페이지에서 \n 이후 텍스트 색상이 다른 규칙이 있습니다. + */ +@Composable +private fun createAnnotatedText( + text: String, + defaultColor: Color = NearTheme.colors.BLACK_1A1A1A, + highlightColor: Color = NearTheme.colors.BLUE01_5AA2E9, +): AnnotatedString = + buildAnnotatedString { + val newLineIndex = text.indexOf("\n") + + if (newLineIndex != -1) { + // \n 이전 텍스트 (기본 색상) + appendStyledText(text.substring(0, newLineIndex), defaultColor) + // \n 이후 텍스트 (강조 색상) + appendStyledText(text.substring(newLineIndex), highlightColor) + } else { + // 텍스트에 \n가 없는 경우 + appendStyledText(text, defaultColor) + } + } + +/** + * 지정된 색상으로 텍스트를 추가하는 함수 + */ +private fun AnnotatedString.Builder.appendStyledText(text: String, color: Color) { + withStyle(style = SpanStyle(color = color)) { + append(text) + } +} + +@Preview(showBackground = true) +@Composable +fun OnboardingScreenPreview() { + NearTheme { + OnboardingScreen( + onNavigateToLogin = {}, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingViewModel.kt new file mode 100644 index 00000000..d5c65e72 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/OnboardingViewModel.kt @@ -0,0 +1,63 @@ +package com.alarmy.near.presentation.feature.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alarmy.near.data.repository.OnBoardingRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * 온보딩 화면의 상태와 로직을 관리하는 ViewModel + */ +@HiltViewModel +class OnboardingViewModel @Inject constructor( + private val onBoardingRepository: OnBoardingRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(OnboardingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effect = MutableSharedFlow() + val effect = _effect.asSharedFlow() + + /** + * 온보딩 완료 처리 + */ + fun completeOnboarding() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + + runCatching { + onBoardingRepository.markOnboardingAsCompleted() + }.onSuccess { + _effect.emit(OnboardingEffect.NavigateToLogin) + }.onFailure { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = exception.message + ) + } + } + } + +} + +/** + * 온보딩 UI 상태 + */ +data class OnboardingUiState( + val isLoading: Boolean = false, + val error: String? = null, +) + +/** + * 온보딩 사이드 이펙트 + */ +sealed class OnboardingEffect { + object NavigateToLogin : OnboardingEffect() +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/BackgroundArea.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/BackgroundArea.kt new file mode 100644 index 00000000..0f6f6062 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/BackgroundArea.kt @@ -0,0 +1,85 @@ +package com.alarmy.near.presentation.feature.onboarding.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.BlurEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.alarmy.near.presentation.ui.theme.NearTheme + +/** + * 배경 장식 컴포넌트 + * 원형 블러 배경 장식을 제공하는 컴포넌트 + */ +@Composable +fun BackgroundArea() { + Box( + modifier = Modifier + .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF), + ) { + BackgroundEllipse( + modifier = Modifier.offset(x = (-78).dp), + ) + + BackgroundEllipse( + modifier = Modifier.offset(x = 201.dp, y = 155.dp), + opacity = 0.2f, + color = NearTheme.colors.PURPLE01_4E3EC7, + ) + } +} + +@Composable +fun BackgroundEllipse( + modifier: Modifier = Modifier, + color: Color = NearTheme.colors.BLUE03_58ABEC, + blurRadius: Float = 200f, + size: Dp = 251.dp, + opacity: Float = 0.3f, +) { + Box( + modifier = modifier + .size(size) + .graphicsLayer { + renderEffect = BlurEffect( + radiusX = blurRadius, + radiusY = blurRadius, + edgeTreatment = TileMode.Clamp, + ) + } + .alpha(opacity) + .background( + color = color, + shape = CircleShape + ) + ) +} + +@Preview(showBackground = true) +@Composable +fun BackgroundAreaPreview() { + NearTheme { + BackgroundArea() + } +} + +@Preview() +@Composable +fun BackgroundDecorationPreview() { + NearTheme { + BackgroundEllipse() + } +} + diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/OnboardingButton.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/OnboardingButton.kt new file mode 100644 index 00000000..9e33148b --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/OnboardingButton.kt @@ -0,0 +1,62 @@ +package com.alarmy.near.presentation.feature.onboarding.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.presentation.ui.component.button.NearBasicButton +import com.alarmy.near.presentation.ui.theme.NearTheme + +/** + * 온보딩 화면용 버튼 컴포넌트 + */ +@Composable +fun OnboardingButton( + currentPage: Int, + totalPages: Int, + isLoading: Boolean = false, + onNextClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isLastPage = currentPage == totalPages - 1 + val buttonText = if (isLastPage) stringResource(R.string.onboarding_auth_button_text) else stringResource(R.string.onboarding_next_button_text) + + NearBasicButton( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + onClick = onNextClick, + enabled = !isLoading, + contentPadding = PaddingValues(vertical = 17.dp), + ) { + Text( + text = buttonText, + style = + NearTheme.typography.B1_16_BOLD, + ) + } +} + +@Preview +@Composable +fun OnboardingButtonPreview() { + NearTheme { + OnboardingButton(currentPage = 0, totalPages = 5, onNextClick = {}) + } +} + +@Preview +@Composable +fun OnboardingButtonLastPreview() { + NearTheme { + OnboardingButton(currentPage = 4, totalPages = 5, onNextClick = {}) + } +} + diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/PageIndicator.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/PageIndicator.kt new file mode 100644 index 00000000..63b6a6d4 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/components/PageIndicator.kt @@ -0,0 +1,51 @@ +package com.alarmy.near.presentation.feature.onboarding.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.presentation.ui.theme.NearTheme + +/** + * 페이지 인디케이터 컴포넌트 + * 현재 페이지를 시각적으로 표시하는 점들 + */ +@Composable +fun PageIndicator( + pageCount: Int, + currentPage: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(pageCount) { index -> + val isSelected = index == currentPage + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background( + if (isSelected) NearTheme.colors.BLUE01_5AA2E9 else NearTheme.colors.GRAY03_EBEBEB, + ), + ) + } + } +} + +@Preview +@Composable +fun PageIndicatorPreview() { + NearTheme { + PageIndicator(pageCount = 3, currentPage = 1) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/model/OnboardingPage.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/model/OnboardingPage.kt new file mode 100644 index 00000000..ab0290eb --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/model/OnboardingPage.kt @@ -0,0 +1,13 @@ +package com.alarmy.near.presentation.feature.onboarding.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes + +/** + * 온보딩 페이지 데이터 모델 + * 각 페이지의 정보를 담는 데이터 클래스 + */ +data class OnboardingPage( + @StringRes val titleResId: Int, + @DrawableRes val image: Int, +) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/navigation/NavigationOnboarding.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/navigation/NavigationOnboarding.kt new file mode 100644 index 00000000..b8d39ef9 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/onboarding/navigation/NavigationOnboarding.kt @@ -0,0 +1,26 @@ +package com.alarmy.near.presentation.feature.onboarding.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.alarmy.near.presentation.feature.onboarding.OnboardingScreen +import kotlinx.serialization.Serializable + +@Serializable +object RouteOnboarding + +// 온보딩 화면으로 네비게이션 +fun NavController.navigateToOnboarding() { + navigate(RouteOnboarding) { + popUpTo(0) { inclusive = true } + } +} + +// 온보딩 네비게이션 그래프 +fun NavGraphBuilder.onboardingNavGraph(onNavigateToLogin: () -> Unit) { + composable { + OnboardingScreen( + onNavigateToLogin = onNavigateToLogin, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/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/Color.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Color.kt index 61de2917..45d620b4 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Color.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/theme/Color.kt @@ -13,10 +13,13 @@ object NearColorPallete { val GRAY04_F7F7F7 = Color(0xFFF7F7F7) val BLUE01_5AA2E9 = Color(0xFF5AA2E9) val BLUE02_8ACCFF = Color(0xFF8ACCFF) + val BLUE03_58ABEC = Color(0xFF58ABEC) val BG01_E3F0F9 = Color(0xFFE3F0F9) val BG02_F4F9FD = Color(0xFFF4F9FD) val NEGATIVE_F04E4E = Color(0xFFF04E4E) val DIM_000000 = Color(0x99000000) + + val PURPLE01_4E3EC7 = Color(0xFF4E3EC7) } @Suppress("PropertyName") @@ -33,6 +36,9 @@ data class NearColor( val BG02_F4F9FD: Color = NearColorPallete.BG02_F4F9FD, val NEGATIVE_F04E4E: Color = NearColorPallete.NEGATIVE_F04E4E, val DIM_000000: Color = NearColorPallete.DIM_000000, + // 온보딩 배경 장식 색상 + val BLUE03_58ABEC: Color = NearColorPallete.BLUE03_58ABEC, + val PURPLE01_4E3EC7: Color = NearColorPallete.PURPLE01_4E3EC7, ) val darkColor = NearColor() diff --git a/Near/app/src/main/res/drawable-hdpi/img_bg.png b/Near/app/src/main/res/drawable-hdpi/img_bg.png new file mode 100644 index 00000000..7ad84255 Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_bg.png differ diff --git a/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_fifth.png b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_fifth.png new file mode 100644 index 00000000..c5429c1b Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_fifth.png differ diff --git a/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_first.png b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_first.png new file mode 100644 index 00000000..e2c31e78 Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_first.png differ diff --git a/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_forth.png b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_forth.png new file mode 100644 index 00000000..84cbf30c Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_forth.png differ diff --git a/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_second.png b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_second.png new file mode 100644 index 00000000..d5a81024 Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_second.png differ diff --git a/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_third.png b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_third.png new file mode 100644 index 00000000..b0895a42 Binary files /dev/null and b/Near/app/src/main/res/drawable-hdpi/img_onboarding_page_third.png differ diff --git a/Near/app/src/main/res/drawable/img_bg.png b/Near/app/src/main/res/drawable-mdpi/img_bg.png similarity index 100% rename from Near/app/src/main/res/drawable/img_bg.png rename to Near/app/src/main/res/drawable-mdpi/img_bg.png diff --git a/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_fifth.png b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_fifth.png new file mode 100644 index 00000000..dc3e4d48 Binary files /dev/null and b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_fifth.png differ diff --git a/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_first.png b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_first.png new file mode 100644 index 00000000..89327daf Binary files /dev/null and b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_first.png differ diff --git a/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_forth.png b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_forth.png new file mode 100644 index 00000000..35375156 Binary files /dev/null and b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_forth.png differ diff --git a/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_second.png b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_second.png new file mode 100644 index 00000000..5f7b59d9 Binary files /dev/null and b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_second.png differ diff --git a/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_third.png b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_third.png new file mode 100644 index 00000000..20bd4402 Binary files /dev/null and b/Near/app/src/main/res/drawable-mdpi/img_onboarding_page_third.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/img_bg.png b/Near/app/src/main/res/drawable-xhdpi/img_bg.png new file mode 100644 index 00000000..eca43ad9 Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_bg.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_fifth.png b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_fifth.png new file mode 100644 index 00000000..4586afd6 Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_fifth.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_first.png b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_first.png new file mode 100644 index 00000000..2a910597 Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_first.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_forth.png b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_forth.png new file mode 100644 index 00000000..aeccba99 Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_forth.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_second.png b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_second.png new file mode 100644 index 00000000..48b7a611 Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_second.png differ diff --git a/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_third.png b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_third.png new file mode 100644 index 00000000..8892663c Binary files /dev/null and b/Near/app/src/main/res/drawable-xhdpi/img_onboarding_page_third.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/img_bg.png b/Near/app/src/main/res/drawable-xxhdpi/img_bg.png new file mode 100644 index 00000000..bb6e965a Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_bg.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_fifth.png b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_fifth.png new file mode 100644 index 00000000..083c53fe Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_fifth.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_first.png b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_first.png new file mode 100644 index 00000000..ad6490c0 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_first.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_forth.png b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_forth.png new file mode 100644 index 00000000..c1f11f3c Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_forth.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_second.png b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_second.png new file mode 100644 index 00000000..e5e07077 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_second.png differ diff --git a/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_third.png b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_third.png new file mode 100644 index 00000000..2a907a2a Binary files /dev/null and b/Near/app/src/main/res/drawable-xxhdpi/img_onboarding_page_third.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/img_bg.png b/Near/app/src/main/res/drawable-xxxhdpi/img_bg.png new file mode 100644 index 00000000..bc5e2345 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_bg.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_fifth.png b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_fifth.png new file mode 100644 index 00000000..df5f9c63 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_fifth.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_first.png b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_first.png new file mode 100644 index 00000000..df693e92 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_first.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_forth.png b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_forth.png new file mode 100644 index 00000000..076d62e9 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_forth.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_second.png b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_second.png new file mode 100644 index 00000000..e5b4d61e Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_second.png differ diff --git a/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_third.png b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_third.png new file mode 100644 index 00000000..e6c9cdf3 Binary files /dev/null and b/Near/app/src/main/res/drawable-xxxhdpi/img_onboarding_page_third.png differ diff --git a/Near/app/src/main/res/drawable/ic_launcher_foreground.xml b/Near/app/src/main/res/drawable/ic_launcher_foreground.xml index 2b068d11..db210bef 100644 --- a/Near/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/Near/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,30 +1,33 @@ - - - - - - - - - - \ No newline at end of file + android:width="100dp" + android:height="101dp" + android:viewportWidth="100" + android:viewportHeight="101"> + + + + + + + + + + + + + diff --git a/Near/app/src/main/res/drawable/ic_near_logo_title.xml b/Near/app/src/main/res/drawable/ic_near_logo_title_primary.xml similarity index 100% rename from Near/app/src/main/res/drawable/ic_near_logo_title.xml rename to Near/app/src/main/res/drawable/ic_near_logo_title_primary.xml diff --git a/Near/app/src/main/res/drawable/ic_near_logo_title_white.xml b/Near/app/src/main/res/drawable/ic_near_logo_title_white.xml new file mode 100644 index 00000000..68cda581 --- /dev/null +++ b/Near/app/src/main/res/drawable/ic_near_logo_title_white.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/Near/app/src/main/res/drawable/img_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/drawable/img_splash_logo.xml b/Near/app/src/main/res/drawable/img_splash_logo.xml new file mode 100644 index 00000000..3ea2c402 --- /dev/null +++ b/Near/app/src/main/res/drawable/img_splash_logo.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 6f3b755b..00000000 --- a/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6f3b755b..e61cccae 100644 --- a/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/Near/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,4 @@ - - - - \ No newline at end of file + + diff --git a/Near/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Near/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..f1fae059 Binary files /dev/null and b/Near/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Near/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/Near/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78e..00000000 Binary files a/Near/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..cff35eaa Binary files /dev/null and b/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1..00000000 Binary files a/Near/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/Near/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Near/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..1d716579 Binary files /dev/null and b/Near/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Near/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/Near/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64..00000000 Binary files a/Near/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..55dad60a Binary files /dev/null and b/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611da..00000000 Binary files a/Near/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..4bdeb0d0 Binary files /dev/null and b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070..00000000 Binary files a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..c57d22ed Binary files /dev/null and b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956..00000000 Binary files a/Near/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..10430089 Binary files /dev/null and b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77f..00000000 Binary files a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..afc978cf Binary files /dev/null and b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f508..00000000 Binary files a/Near/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..b24a89ed Binary files /dev/null and b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d6427..00000000 Binary files a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..2010a26f Binary files /dev/null and b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae37..00000000 Binary files a/Near/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index f6daf58a..a67f8928 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -6,6 +6,19 @@ 뒤로 가기 메뉴 + 네트워크 에러가 발생했습니다. + + + 스플래쉬 배경 + + + 소중한 사람들과\n더 가까워질 수 있도록 + 연락처와 카카오톡으로\n챙길 사람 등록하기 + 챙기고 싶은 날에\n알림 받기 + 오늘도 잘 챙겼는지\n기록 남기기 + 더 잘 챙길 수 있게\n메시지 추천 받기 + 로그인/회원가입 + 다음 Near 로고 @@ -22,6 +35,7 @@ 연락처 추가 가까워 지고 싶은 사람을\n추가해보세요. 사람 추가 + 사람 추가 전화걸기 @@ -35,13 +49,55 @@ 기념일 메모 꼭 기억해야 할 내용을 기록해보세요.\n예) 날생선 X, 작년 생일에 키링 선물함 등 - 친구 + %1$s 더 가까워졌어요 + 친구 가족 지인 + 프로필 상세 + 친구 + 가족 + 지인 + 연락 주기 + 생일 + 날짜 선택 + 삭제하기 + 꼭 기억해야 할 내용을 기록해보세요.\n예) 날생선 X, 작년 생일에 키링 선물함 등 + 이름을 입력해주세요. + 기념일 이름 + 추가하기 + 기념일 + (%1$s 마다) + 더 가까워졌어요! + 수정 + 삭제 + 이번달은 챙길 사람이 없네요. + %1$d번째 챙김 + 챙김 기록 + 완료 + 이름 + 이름을 입력해주세요. + 관계 + 날짜 + 메모 + + 매일 매주 2주 매달 6개월 - 사람 추가 + + + 수정을 그만두시나요? + 화면을 나가면 \n수정 내용은 저장되지 않아요. + 확인 + 취소 + 월요일 + 화요일 + 수요일 + 목요일 + 금요일 + 토요일 + 일요일 + diff --git a/Near/app/src/main/res/values/themes.xml b/Near/app/src/main/res/values/themes.xml index dbbeb9df..abc32d05 100644 --- a/Near/app/src/main/res/values/themes.xml +++ b/Near/app/src/main/res/values/themes.xml @@ -1,5 +1,14 @@ + + diff --git a/Near/gradle/libs.versions.toml b/Near/gradle/libs.versions.toml index 21b73514..0c32d4ea 100644 --- a/Near/gradle/libs.versions.toml +++ b/Near/gradle/libs.versions.toml @@ -30,6 +30,8 @@ datastorePreferences = "1.1.7" datastoreCore = "1.1.7" #Kakao v2All = "2.21.7" +# Splash Screen +splashScreen = "1.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -63,6 +65,7 @@ logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-intercep retrofit-kotlin-serialization-converter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofitVersion" } androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" } v2-all = { module = "com.kakao.sdk:v2-all", version.ref = "v2All" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } @@ -72,4 +75,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"}