diff --git a/Near/app/build.gradle.kts b/Near/app/build.gradle.kts index 702769ce..64289e48 100644 --- a/Near/app/build.gradle.kts +++ b/Near/app/build.gradle.kts @@ -107,6 +107,9 @@ dependencies { // Splash Screen API implementation(libs.androidx.core.splashscreen) + + // libphonenumber-android for phone number formatting + implementation("io.michaelrocks:libphonenumber-android:9.0.15") } fun getProperty(propertyKey: String): String = gradleLocalProperties(rootDir, providers).getProperty(propertyKey) diff --git a/Near/app/src/main/java/com/alarmy/near/NearApplication.kt b/Near/app/src/main/java/com/alarmy/near/NearApplication.kt index 757cb3e9..8c1cba6e 100644 --- a/Near/app/src/main/java/com/alarmy/near/NearApplication.kt +++ b/Near/app/src/main/java/com/alarmy/near/NearApplication.kt @@ -1,6 +1,7 @@ package com.alarmy.near import android.app.Application +import com.alarmy.near.utils.PhoneNumberFormatter import com.kakao.sdk.common.KakaoSdk import dagger.hilt.android.HiltAndroidApp @@ -12,5 +13,8 @@ class NearApplication : Application() { // 카카오 SDK 초기화 KakaoSdk.init(this, BuildConfig.KAKAO_NATIVE_APP_KEY) + + // PhoneNumberFormatter 초기화 + PhoneNumberFormatter.initialize(this) } } diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendInitMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendInitMapper.kt new file mode 100644 index 00000000..0efe1617 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendInitMapper.kt @@ -0,0 +1,104 @@ +package com.alarmy.near.data.mapper + +import com.alarmy.near.model.Anniversary +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.network.request.ContactFrequencyInitRequest +import com.alarmy.near.network.request.FriendInitItemRequest +import com.alarmy.near.network.response.AnniversaryInitEntity +import com.alarmy.near.network.response.ContactFrequencyInitEntity +import com.alarmy.near.network.response.FriendInitItemEntity +import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel +import com.alarmy.near.utils.PhoneNumberFormatter +import com.alarmy.near.utils.extensions.DateExtension +import com.alarmy.near.utils.logger.NearLog + +/** + * PhoneNumberFormatter를 사용하여 전화번호를 포맷팅하는 함수 + * - 다양한 전화번호 형식 지원 (휴대폰, 지역번호 등) + * - 한국 전화번호 형식으로 통일 (010-0000-0000, 02-0000-0000 등) + * - 잘못된 형식의 경우 원본 반환 + */ +private fun String.formatPhoneNumber(): String = PhoneNumberFormatter.formatPhoneNumber(this) + +/** + * UI 모델을 서버 요청 모델로 변환 + */ +fun FriendContactUIModel.toFriendInitItemRequest(providerType: String): FriendInitItemRequest = + FriendInitItemRequest( + name = name, + phone = phones.firstOrNull()?.formatPhoneNumber() ?: "", + memo = memo, + birthDay = birthDay, + source = providerType, + contactFrequency = createContactFrequencyRequest(reminderInterval!!), + imageUploadRequest = null, + anniversary = null, + relation = "FRIEND", // 기본값으로 FRIEND 설정 + ) + +/** + * ContactFrequencyInitEntity를 모델로 변환 + */ +fun ContactFrequencyInitEntity.toModel(): ContactFrequency = + ContactFrequency( + reminderInterval = contactWeek.toReminderInterval(), + dayOfWeek = dayOfWeek.toDayOfWeek(), + ) + +/** + * AnniversaryInitEntity를 모델로 변환 + */ +fun AnniversaryInitEntity.toModel(): Anniversary = + Anniversary( + id = id, + title = title, + date = date, + ) + +/** + * 서버 응답을 모델로 변환 + */ +fun FriendInitItemEntity.toModel(): Friend = + Friend( + friendId = friendId, + name = name, + phone = phone, + memo = memo, + birthday = null, + imageUrl = preSignedImageUrl, + relation = source.toRelation(), + contactFrequency = contactFrequency.toModel(), + anniversaryList = anniversary?.let { listOf(it.toModel()) } ?: emptyList(), + lastContactAt = nextContactAt, + ) + +/** + * ReminderInterval을 ContactFrequencyInitRequest로 변환 + */ +private fun createContactFrequencyRequest(reminderInterval: ReminderInterval): ContactFrequencyInitRequest = + ContactFrequencyInitRequest( + contactWeek = DateExtension.toContactWeekString(reminderInterval), + dayOfWeek = DateExtension.getTodayDayOfWeekInEnglish(), + ) + +private fun String.toReminderInterval(): ReminderInterval = + runCatching { ReminderInterval.valueOf(this) } + .onFailure { exception -> + NearLog.w("잘못된 연락 주기 값: '$this', 기본값(EVERY_WEEK) 사용") + }.getOrDefault(ReminderInterval.EVERY_WEEK) + +private fun String.toDayOfWeek(): DayOfWeek = + runCatching { DayOfWeek.valueOf(this) } + .onFailure { exception -> + NearLog.w("잘못된 요일 값: '$this', 기본값(MONDAY) 사용") + }.getOrDefault(DayOfWeek.MONDAY) + +private fun String.toRelation(): Relation = + runCatching { Relation.valueOf(this) } + .onFailure { exception -> + NearLog.w("잘못된 관계 값: '$this', 기본값(FRIEND) 사용") + }.getOrDefault(Relation.FRIEND) 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 68203c95..e797a6ae 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 @@ -12,12 +12,13 @@ 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 +import com.alarmy.near.utils.logger.NearLog fun FriendEntity.toModel(): Friend = Friend( friendId = friendId, imageUrl = imageUrl, - relation = Relation.valueOf(relation), + relation = relation.toRelation(), name = name, contactFrequency = contactFrequency.toModel(), birthday = birthday, @@ -29,8 +30,8 @@ fun FriendEntity.toModel(): Friend = fun ContactFrequencyEntity.toModel(): ContactFrequency = ContactFrequency( - reminderInterval = ReminderInterval.valueOf(contactWeek), - dayOfWeek = DayOfWeek.valueOf(dayOfWeek), + reminderInterval = contactWeek.toReminderInterval(), + dayOfWeek = dayOfWeek.toDayOfWeek(), ) fun AnniversaryEntity.toModel(): Anniversary = @@ -63,3 +64,30 @@ fun Anniversary.toRequest(): AnniversaryRequest = title = title, date = date, ) + +/** + * 안전한 ReminderInterval 변환 (로깅 포함) + */ +private fun String.toReminderInterval(): ReminderInterval = + runCatching { ReminderInterval.valueOf(this) } + .onFailure { exception -> + NearLog.w("잘못된 연락 주기 값: '$this', 기본값(EVERY_WEEK) 사용") + }.getOrDefault(ReminderInterval.EVERY_WEEK) + +/** + * 안전한 DayOfWeek 변환 (로깅 포함) + */ +private fun String.toDayOfWeek(): DayOfWeek = + runCatching { DayOfWeek.valueOf(this) } + .onFailure { exception -> + NearLog.w("잘못된 요일 값: '$this', 기본값(MONDAY) 사용") + }.getOrDefault(DayOfWeek.MONDAY) + +/** + * 안전한 Relation 변환 (로깅 포함) + */ +private fun String.toRelation(): Relation = + runCatching { Relation.valueOf(this) } + .onFailure { exception -> + NearLog.w("잘못된 관계 값: '$this', 기본값(FRIEND) 사용") + }.getOrDefault(Relation.FRIEND) 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 7651da94..e63d0dd0 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,12 +1,17 @@ package com.alarmy.near.data.repository +import com.alarmy.near.data.mapper.toFriendInitItemRequest import com.alarmy.near.data.mapper.toModel 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.request.FriendInitRequest +import com.alarmy.near.network.response.FriendInitItemEntity import com.alarmy.near.network.service.FriendService +import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel +import com.alarmy.near.utils.extensions.apiCallFlow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import javax.inject.Inject @@ -63,4 +68,22 @@ class DefaultFriendRepository val response = friendService.recordContact(friendId) emit(response.message) // CommonMessageEntity.message 라고 가정 } + + override fun initFriends( + contacts: List, + providerType: String, + ): Flow> = + apiCallFlow { + // UI 모델을 Data 모델로 변환 + val friendInitRequest = + FriendInitRequest( + friendList = + contacts + .filter { it.reminderInterval != null } + .map { it.toFriendInitItemRequest(providerType) }, + ) + + // 서버 요청 및 응답 반환 + friendService.initFriends(friendInitRequest).friendList + } } 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 81e6761b..c437c4e4 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 @@ -2,8 +2,10 @@ package com.alarmy.near.data.repository import com.alarmy.near.model.Friend import com.alarmy.near.model.FriendRecord +import com.alarmy.near.network.response.FriendInitItemEntity import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend +import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel import kotlinx.coroutines.flow.Flow interface FriendRepository { @@ -23,4 +25,6 @@ interface FriendRepository { fun fetchFriendRecord(friendId: String): Flow> fun recordContact(friendId: String): Flow + + fun initFriends(contacts: List, providerType: String): Flow> } diff --git a/Near/app/src/main/java/com/alarmy/near/network/request/FriendInitRequest.kt b/Near/app/src/main/java/com/alarmy/near/network/request/FriendInitRequest.kt new file mode 100644 index 00000000..da18008b --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/request/FriendInitRequest.kt @@ -0,0 +1,56 @@ +package com.alarmy.near.network.request + +import kotlinx.serialization.Serializable + +/** + * 친구 초기 설정 API 요청 데이터 모델 + */ +@Serializable +data class FriendInitRequest( + val friendList: List, +) + +/** + * 친구 초기 설정 개별 아이템 요청 데이터 모델 + */ +@Serializable +data class FriendInitItemRequest( + val name: String, + val phone: String, + val memo: String? = null, + val birthDay: String? = null, + val source: String, + val contactFrequency: ContactFrequencyInitRequest, + val imageUploadRequest: ImageUploadRequest? = null, + val anniversary: AnniversaryInitRequest? = null, + val relation: String? = null, +) + +/** + * 연락처 주기 설정 요청 데이터 모델 + */ +@Serializable +data class ContactFrequencyInitRequest( + val contactWeek: String, // EVERY_WEEK, EVERY_TWO_WEEKS, EVERY_MONTH + val dayOfWeek: String, // MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY +) + +/** + * 이미지 업로드 요청 데이터 모델 + */ +@Serializable +data class ImageUploadRequest( + val fileName: String? = null, + val contentType: String? = null, + val fileSize: Int? = null, + val category: String? = null, +) + +/** + * 기념일 요청 데이터 모델 + */ +@Serializable +data class AnniversaryInitRequest( + val title: String, + val date: String? = null, +) diff --git a/Near/app/src/main/java/com/alarmy/near/network/response/FriendInitEntity.kt b/Near/app/src/main/java/com/alarmy/near/network/response/FriendInitEntity.kt new file mode 100644 index 00000000..43bce43e --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/response/FriendInitEntity.kt @@ -0,0 +1,38 @@ +package com.alarmy.near.network.response + +import kotlinx.serialization.Serializable + +/** + * 친구 초기 설정 API 응답 데이터 + */ +@Serializable +data class FriendInitEntity( + val friendList: List, +) + +@Serializable +data class FriendInitItemEntity( + val friendId: String, + val name: String, + val source: String, + val contactFrequency: ContactFrequencyInitEntity, + val nextContactAt: String, + val phone: String, + val preSignedImageUrl: String? = null, + val fileName: String? = null, + val memo: String? = null, + val anniversary: AnniversaryInitEntity? = null, +) + +@Serializable +data class ContactFrequencyInitEntity( + val contactWeek: String, + val dayOfWeek: String, +) + +@Serializable +data class AnniversaryInitEntity( + val id: Int, + val title: String, + val date: String, +) 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 6aafb65d..c7cf3864 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,8 +1,10 @@ package com.alarmy.near.network.service +import com.alarmy.near.network.request.FriendInitRequest 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.FriendInitEntity import com.alarmy.near.network.response.FriendRecordEntity import com.alarmy.near.network.response.FriendSummaryEntity import com.alarmy.near.network.response.MonthlyFriendEntity @@ -45,4 +47,9 @@ interface FriendService { suspend fun recordContact( @Path("friendId") friendId: String, ): CommonMessageEntity + + @POST("/friend/init") + suspend fun initFriends( + @Body friendInitRequest: FriendInitRequest, + ): FriendInitEntity } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactScreen.kt index 7a124315..8d3aaab3 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactScreen.kt @@ -73,7 +73,12 @@ fun ContactRoute( when (uiState) { is ContactUiState.Loading -> { - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF), + ) { CircularProgressIndicator( color = NearTheme.colors.BLUE01_5AA2E9, modifier = Modifier.align(Alignment.Center), @@ -83,7 +88,10 @@ fun ContactRoute( is ContactUiState.Error -> { Box(modifier = Modifier.fillMaxSize()) { - Text(modifier = Modifier.align(Alignment.Center), text = stringResource(R.string.contact_load_error)) + Text( + modifier = Modifier.align(Alignment.Center), + text = stringResource(R.string.contact_load_error), + ) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactViewModel.kt index 33c558a4..4ebd6308 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactViewModel.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/contact/ContactViewModel.kt @@ -7,12 +7,14 @@ import com.alarmy.near.presentation.feature.contact.state.ContactUiEvent import com.alarmy.near.presentation.feature.contact.state.ContactUiState import com.alarmy.near.presentation.feature.contact.state.SelectedContactUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -41,6 +43,7 @@ class ContactViewModel selectedIds, _searchQuery, ) { contacts, selectedIds, query -> + // 무거운 연산: filter, map, groupBy, sort val filtered = if (query.isBlank()) { contacts @@ -65,11 +68,12 @@ class ContactViewModel ContactUiState.Success( contacts = sorted, ) - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000L), - ContactUiState.Loading, - ) + }.flowOn(Dispatchers.IO) // 무거운 연산을 백그라운드 스레드에서 실행 + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000L), + ContactUiState.Loading, // 초기 상태: Loading + ) // 한글 - 영어 - 특수문자 순으로 정렬하는 Comparator private val initialComparator = diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/FriendContactCycleScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/FriendContactCycleScreen.kt new file mode 100644 index 00000000..2c4de0de --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/FriendContactCycleScreen.kt @@ -0,0 +1,341 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle + +import androidx.compose.foundation.Image +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavBackStackEntry +import com.alarmy.near.R +import com.alarmy.near.model.ReminderInterval +import com.alarmy.near.model.contact.Contact +import com.alarmy.near.presentation.feature.contact.navigation.CONTACT_SELECTION_COMPLETE_KEY +import com.alarmy.near.presentation.feature.friendcontactcycle.components.ContactCycleButtons +import com.alarmy.near.presentation.feature.friendcontactcycle.components.ContactCycleContent +import com.alarmy.near.presentation.feature.friendcontactcycle.components.ContactLoadContent +import com.alarmy.near.presentation.feature.friendcontactcycle.components.ContactPermissionDeniedDialog +import com.alarmy.near.presentation.feature.friendcontactcycle.model.ContactCycleStep +import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel +import com.alarmy.near.presentation.feature.friendcontactcycle.state.FriendContactUIEvent +import com.alarmy.near.presentation.feature.friendcontactcycle.state.FriendContactUIState +import com.alarmy.near.presentation.ui.component.NearFrame +import com.alarmy.near.presentation.ui.permission.ContactPermissionRequester +import com.alarmy.near.presentation.ui.theme.NearTheme +import com.alarmy.near.presentation.ui.util.AppSettingsUtil + +@Composable +internal fun FriendContactCycleRoute( + navBackStackEntry: NavBackStackEntry, + onNavigateToHome: () -> Unit, + onNavigateToContact: () -> Unit = {}, + onShowErrorSnackBar: (throwable: Throwable?) -> Unit, + viewModel: FriendContactViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + // Navigation에서 전달된 연락처 데이터 관찰 + LaunchedEffect(Unit) { + navBackStackEntry.savedStateHandle + .get>( + CONTACT_SELECTION_COMPLETE_KEY, + )?.let { contacts -> + viewModel.addSelectedContacts(contacts) + navBackStackEntry.savedStateHandle.remove>( + CONTACT_SELECTION_COMPLETE_KEY, + ) + } + } + + // UI 이벤트 처리 + LaunchedEffect(viewModel.uiEvent) { + viewModel.uiEvent.collect { event -> + when (event) { + is FriendContactUIEvent.NavigateToHome -> onNavigateToHome() + is FriendContactUIEvent.ShowError -> onShowErrorSnackBar(event.throwable) + } + } + } + + FriendContactCycleScreen( + uiState = uiState, + onMoveToNextStep = { viewModel.moveToNextStep() }, + onMoveToPreviousStep = { viewModel.moveToPreviousStep() }, + onDeselectContact = { contactId -> viewModel.deselectContact(contactId) }, + onToggleBulkSetting = { viewModel.toggleBulkSetting() }, + onOpenBottomSheet = { viewModel.openBottomSheet() }, + onOpenIndividualBottomSheet = { contactId -> viewModel.openIndividualBottomSheet(contactId) }, + onCloseBottomSheet = { viewModel.closeBottomSheet() }, + onCompleteCycleSetting = { reminderInterval -> viewModel.completeCycleSetting(reminderInterval) }, + onCompleteFriendInit = { viewModel.completeFriendInit() }, + onNavigateToHome = onNavigateToHome, + onNavigateToContact = onNavigateToContact, + onSetPermissionRequestFunction = { requestPermission -> + viewModel.setPermissionRequestFunction(requestPermission) + }, + onShowPermissionDeniedDialog = { + viewModel.updatePermissionDeniedDialog(true) + }, + onHidePermissionDeniedDialog = { + viewModel.updatePermissionDeniedDialog(false) + }, + ) +} + +@Composable +fun FriendContactCycleScreen( + uiState: FriendContactUIState, + onMoveToNextStep: () -> Unit, + onMoveToPreviousStep: () -> Unit, + onDeselectContact: (String) -> Unit, + onToggleBulkSetting: () -> Unit, + onOpenBottomSheet: () -> Unit, + onOpenIndividualBottomSheet: (String) -> Unit, + onCloseBottomSheet: () -> Unit, + onCompleteCycleSetting: (ReminderInterval) -> Unit, + onCompleteFriendInit: () -> Unit, + onNavigateToHome: () -> Unit, + onNavigateToContact: () -> Unit = {}, + onSetPermissionRequestFunction: ((() -> Unit) -> Unit) = {}, + onShowPermissionDeniedDialog: () -> Unit = {}, + onHidePermissionDeniedDialog: () -> Unit = {}, +) { + val context = LocalContext.current + + ContactPermissionRequester( + onGranted = { + onNavigateToContact() + }, + onDenied = { requestPermission -> + onSetPermissionRequestFunction(requestPermission) + }, + onShowRationale = { requestPermission -> + onSetPermissionRequestFunction(requestPermission) + }, + onPermissionDenied = { + onShowPermissionDeniedDialog() + }, + ) + + NearFrame( + modifier = + Modifier + .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF) + .padding(horizontal = 24.dp), + ) { + if (uiState.isLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(NearTheme.colors.WHITE_FFFFFF), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = NearTheme.colors.BLUE01_5AA2E9, + ) + } + } + + ContactCycleTopAppBar( + pageIndex = uiState.currentStep.ordinal + 1, + title = stringResource(uiState.currentStep.appbarTitleResId), + ) + + Spacer(modifier = Modifier.size(24.dp)) + + when (uiState.currentStep) { + ContactCycleStep.LOAD_CONTACTS -> { + ContactCycleHeader( + headerTitle = stringResource(R.string.friend_contact_cycle_header_title), + headerSubTitle = stringResource(R.string.friend_contact_cycle_header_subtitle), + ) + + Spacer(modifier = Modifier.size(40.dp)) + + ContactLoadContent( + contacts = uiState.contacts, + onDeselectContact = onDeselectContact, + onContactLoadClick = { + // NearListModuleBackground 클릭 시 권한 요청 + uiState.onRequestPermission?.invoke() + }, + ) + + Spacer(modifier = Modifier.size(16.dp)) + + ContactCycleButtons( + onLeftButtonClick = onNavigateToHome, + onRightButtonClick = onMoveToNextStep, + leftButtonText = stringResource(R.string.friend_contact_cycle_later_button), + rightButtonText = stringResource(R.string.friend_contact_cycle_next_button), + isRightButtonEnabled = uiState.contacts.isNotEmpty(), + ) + } + + ContactCycleStep.SET_CYCLE -> { + ContactCycleHeader( + headerTitle = stringResource(R.string.friend_contact_cycle_setting_header_title), + headerSubTitle = stringResource(R.string.friend_contact_cycle_setting_header_subtitle), + ) + + Spacer(modifier = Modifier.size(40.dp)) + + ContactCycleContent( + contacts = uiState.contacts, + isBulkSettingEnabled = uiState.isBulkSettingEnabled, + isBottomSheetVisible = uiState.isBottomSheetVisible, + selectedCycle = uiState.selectedCycle, + onToggleBulkSetting = onToggleBulkSetting, + onOpenBottomSheet = onOpenBottomSheet, + onOpenIndividualBottomSheet = onOpenIndividualBottomSheet, + onCloseBottomSheet = onCloseBottomSheet, + onCompleteCycleSetting = onCompleteCycleSetting, + ) + + Spacer(modifier = Modifier.size(16.dp)) + + ContactCycleButtons( + onLeftButtonClick = onMoveToPreviousStep, + onRightButtonClick = onCompleteFriendInit, + leftButtonText = stringResource(R.string.friend_contact_cycle_previous_button), + rightButtonText = stringResource(R.string.friend_contact_cycle_complete_button), + isRightButtonEnabled = uiState.isAllContactsCycleSet, + ) + } + } + + Spacer(modifier = Modifier.size(24.dp)) + } + + // 권한 거부 다이얼로그 표시 + if (uiState.showPermissionDeniedDialog) { + ContactPermissionDeniedDialog( + onDismiss = { + onHidePermissionDeniedDialog() + }, + onGoToSettings = { + onHidePermissionDeniedDialog() + AppSettingsUtil.openAppSettings(context) + }, + ) + } +} + +@Composable +fun ContactCycleTopAppBar( + pageIndex: Int, + title: String, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 13.dp), + ) { + Text( + text = title, + style = NearTheme.typography.B1_16_BOLD, + ) + + Text( + text = stringResource(R.string.friend_contact_cycle_page_format, pageIndex), + style = + NearTheme.typography.B2_14_MEDIUM.copy( + color = NearTheme.colors.GRAY01_888888, + ), + ) + } +} + +@Composable +private fun ContactCycleHeader( + headerTitle: String, + headerSubTitle: String, +) { + Image( + painter = painterResource(R.drawable.img_100_character_default), + contentDescription = null, + ) + + Spacer(modifier = Modifier.size(8.dp)) + + Text( + text = headerTitle, + style = NearTheme.typography.H1_24_MEDIUM, + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Text( + text = headerSubTitle, + style = + NearTheme.typography.B1_16_MEDIUM.copy( + color = NearTheme.colors.GRAY01_888888, + ), + ) +} + +@Preview(showBackground = true) +@Composable +fun FriendContactCycleScreenPreview() { + val contacts = + listOf( + FriendContactUIModel( + id = 1, + name = "신짱구", + ), + FriendContactUIModel( + id = 2, + name = "철수", + ), + FriendContactUIModel( + id = 3, + name = "유리", + ), + ) + + NearTheme { + FriendContactCycleScreen( + uiState = + FriendContactUIState( + contacts = contacts, + ), + onMoveToNextStep = {}, + onMoveToPreviousStep = {}, + onDeselectContact = {}, + onToggleBulkSetting = {}, + onOpenBottomSheet = {}, + onOpenIndividualBottomSheet = {}, + onCloseBottomSheet = {}, + onCompleteCycleSetting = {}, + onNavigateToHome = {}, + onNavigateToContact = {}, + onCompleteFriendInit = {}, + onSetPermissionRequestFunction = {}, + onShowPermissionDeniedDialog = {}, + onHidePermissionDeniedDialog = {}, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/FriendContactViewModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/FriendContactViewModel.kt new file mode 100644 index 00000000..778cb2d5 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/FriendContactViewModel.kt @@ -0,0 +1,254 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alarmy.near.data.repository.FriendRepository +import com.alarmy.near.data.repository.MemberRepository +import com.alarmy.near.model.ProviderType +import com.alarmy.near.model.ReminderInterval +import com.alarmy.near.model.contact.Contact +import com.alarmy.near.presentation.feature.contact.navigation.CONTACT_SELECTION_COMPLETE_KEY +import com.alarmy.near.presentation.feature.friendcontactcycle.model.ContactCycleStep +import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel +import com.alarmy.near.presentation.feature.friendcontactcycle.model.toFriendContactUIModel +import com.alarmy.near.presentation.feature.friendcontactcycle.state.FriendContactUIEvent +import com.alarmy.near.presentation.feature.friendcontactcycle.state.FriendContactUIState +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.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class FriendContactViewModel + @Inject + constructor( + private val savedStateHandle: SavedStateHandle, + private val friendRepository: FriendRepository, + private val memberRepository: MemberRepository, + ) : ViewModel() { + private val _uiState = MutableStateFlow(FriendContactUIState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + init { + observeContactSelection() + } + + // 화면 분기 관련 함수 + fun moveToNextStep() { + viewModelScope.launch { + _uiState.value = + _uiState.value.copy( + currentStep = ContactCycleStep.SET_CYCLE, + ) + } + } + + fun moveToPreviousStep() { + viewModelScope.launch { + _uiState.value = + _uiState.value.copy( + currentStep = ContactCycleStep.LOAD_CONTACTS, + ) + } + } + + // 연락처 관련 함수 + private fun observeContactSelection() { + viewModelScope.launch { + savedStateHandle + .getStateFlow?>( + CONTACT_SELECTION_COMPLETE_KEY, + null, + ).collect { selectedContacts -> + selectedContacts?.let { contacts -> + addSelectedContacts(contacts) + savedStateHandle.remove>( + CONTACT_SELECTION_COMPLETE_KEY, + ) + } + } + } + } + + fun addSelectedContacts(contacts: List) { + val friendContacts = contacts.map { it.toFriendContactUIModel() } + _uiState.value = + _uiState.value.copy( + contacts = friendContacts, + ) + } + + fun deselectContact(contactId: String) { + val currentState = _uiState.value + val updatedContacts = + currentState.contacts.filter { contact -> + contact.id.toString() != contactId + } + + _uiState.value = currentState.copy(contacts = updatedContacts) + } + + // 한번에 설정 관련 함수 + fun toggleBulkSetting() { + val currentState = _uiState.value + + if (currentState.isBulkSettingEnabled) { + // 체크 해제 시: 한번에 설정 모드만 해제, 각 연락처의 주기는 유지 + _uiState.value = + currentState.copy( + isBulkSettingEnabled = false, + selectedCycle = null, + ) + } else { + // 체크 시: 바텀시트 표시 + _uiState.value = + currentState.copy( + isBulkSettingEnabled = true, + isBottomSheetVisible = true, + ) + } + } + + // 한번에 설정 바텀시트 열기 + fun openBottomSheet() { + _uiState.value = + _uiState.value.copy( + isBottomSheetVisible = true, + ) + } + + // 개별 설정 바텀시트 열기 (특정 연락처 선택) + fun openIndividualBottomSheet(contactId: String) { + _uiState.value = + _uiState.value.copy( + isBottomSheetVisible = true, + selectedContactId = contactId, + ) + } + + // 바텀시트 닫기 (취소 시 한번에 설정 모드도 해제) + fun closeBottomSheet() { + val currentState = _uiState.value + _uiState.value = + currentState.copy( + isBottomSheetVisible = false, + selectedContactId = null, + ) + + // 바텀시트를 취소로 닫으면 체크박스도 해제 (한번에 설정일 때만) + if (currentState.selectedCycle == null && currentState.isBulkSettingEnabled) { + _uiState.value = + _uiState.value.copy( + isBulkSettingEnabled = false, + ) + } + } + + // 주기 설정 완료 (개별/한번에 설정 분기 처리) + fun completeCycleSetting(reminderInterval: ReminderInterval) { + _uiState.value.run { + selectedContactId?.let { + applyIndividualCycleSetting(this, reminderInterval) + } ?: applyBulkCycleSetting(this, reminderInterval) + } + } + + // 개별 연락처 주기 설정 적용 (한번에 설정 모드가 true라면 해제합니다) + private fun applyIndividualCycleSetting( + currentState: FriendContactUIState, + reminderInterval: ReminderInterval, + ) { + val updatedContacts = updateContactCycle(currentState.selectedContactId!!, reminderInterval) + _uiState.value = + currentState.copy( + isBottomSheetVisible = false, + contacts = updatedContacts, + selectedContactId = null, + isBulkSettingEnabled = false, + selectedCycle = null, + ) + } + + // 한번에 설정 주기 적용 (모든 연락처에 동일 주기 설정) + private fun applyBulkCycleSetting( + currentState: FriendContactUIState, + reminderInterval: ReminderInterval, + ) { + val updatedContacts = updateAllContactsCycle(reminderInterval) + _uiState.value = + currentState.copy( + selectedCycle = reminderInterval, + isBottomSheetVisible = false, + contacts = updatedContacts, + ) + } + + // 개별 연락처의 주기만 업데이트 + private fun updateContactCycle( + contactId: String, + reminderInterval: ReminderInterval, + ): List = + _uiState.value.contacts.map { contact -> + if (contact.id.toString() == contactId) { + contact.copy(reminderInterval = reminderInterval) + } else { + contact + } + } + + // 모든 연락처의 주기를 동일하게 업데이트 + private fun updateAllContactsCycle(reminderInterval: ReminderInterval): List = + _uiState.value.contacts.map { contact -> + contact.copy(reminderInterval = reminderInterval) + } + + // 서버에 친구 목록 전송 + fun completeFriendInit() { + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(isLoading = true) + + friendRepository + .initFriends( + contacts = _uiState.value.contacts, + providerType = getCurrentUserProviderType(), + ).collect { friendInitResponse -> + _uiState.value = _uiState.value.copy(isLoading = false) + _uiEvent.send(FriendContactUIEvent.NavigateToHome) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy(isLoading = false) + _uiEvent.send(FriendContactUIEvent.ShowError(e)) + } + } + } + + // 현재 사용자의 로그인 타입 가져오기 + private suspend fun getCurrentUserProviderType(): String = + runCatching { + memberRepository + .getMyInfo() + .first() + .providerType + }.getOrElse { exception -> + ProviderType.KAKAO.name + } + + // 권한 관련 함수들 + fun setPermissionRequestFunction(requestPermission: () -> Unit) { + _uiState.value = _uiState.value.copy(onRequestPermission = requestPermission) + } + + fun updatePermissionDeniedDialog(show: Boolean) { + _uiState.value = _uiState.value.copy(showPermissionDeniedDialog = show) + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactCycleButtons.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactCycleButtons.kt new file mode 100644 index 00000000..bf86f61a --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactCycleButtons.kt @@ -0,0 +1,55 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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.component.button.NearLineTypeButton +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun ContactCycleButtons( + onLeftButtonClick: () -> Unit = {}, + onRightButtonClick: () -> Unit = {}, + leftButtonText: String = stringResource(R.string.friend_contact_cycle_later_button), + rightButtonText: String = stringResource(R.string.friend_contact_cycle_next_button), + isRightButtonEnabled: Boolean = true, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + NearLineTypeButton( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + enabled = true, + text = leftButtonText, + onClick = onLeftButtonClick, + ) + + Spacer(modifier = Modifier.size(7.dp)) + + NearBasicButton( + modifier = Modifier.weight(1f), + onClick = onRightButtonClick, + enabled = isRightButtonEnabled, + contentPadding = PaddingValues(16.dp), + ) { + Text( + text = rightButtonText, + style = NearTheme.typography.B1_16_BOLD, + ) + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactCycleContent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactCycleContent.kt new file mode 100644 index 00000000..ae5c5116 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactCycleContent.kt @@ -0,0 +1,208 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.model.ReminderInterval +import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel +import com.alarmy.near.presentation.ui.component.checkbox.NearBackgroundCheckbox +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme +import com.alarmy.near.utils.extensions.DateExtension + +@Composable +fun ColumnScope.ContactCycleContent( + contacts: List, + isBulkSettingEnabled: Boolean, + isBottomSheetVisible: Boolean, + selectedCycle: ReminderInterval?, + onToggleBulkSetting: () -> Unit, + onOpenBottomSheet: () -> Unit, + onOpenIndividualBottomSheet: (String) -> Unit, + onCloseBottomSheet: () -> Unit, + onCompleteCycleSetting: (ReminderInterval) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.friend_contact_cycle_bulk_setting_text), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + Spacer(modifier = Modifier.size(12.dp)) + + NearBackgroundCheckbox( + checked = isBulkSettingEnabled, + onCheckedChange = { checked -> + onToggleBulkSetting() + }, + ) + } + + Spacer(modifier = Modifier.size(14.dp)) + + // 한번에 설정이 활성화되었을 때만 표시 + if (isBulkSettingEnabled) { + Row( + modifier = + Modifier + .fillMaxWidth() + .border( + border = BorderStroke(1.dp, NearTheme.colors.GRAY03_EBEBEB), + shape = RoundedCornerShape(12.dp), + ).padding( + horizontal = 16.dp, + vertical = 14.dp, + ).onNoRippleClick { + onOpenBottomSheet() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = selectedCycle?.let { DateExtension.getCycleText(it) } ?: stringResource(R.string.friend_contact_cycle_weekly_format, DateExtension.getTodayDayOfWeekInKorean()), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + ) + + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_24_down), + contentDescription = null, + colorFilter = ColorFilter.tint(NearTheme.colors.GRAY01_888888), + ) + } + } + + // 리스트가 있을 때만 밑에 리스트 표시 + if (contacts.isNotEmpty()) { + Spacer(modifier = Modifier.size(16.dp)) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items( + items = contacts, + key = { contact -> contact.id }, + ) { contact -> + FriendListItem( + contact = contact, + ) { + Row( + modifier = + Modifier.onNoRippleClick { + // 개별 주기 설정 바텀시트 열기 + onOpenIndividualBottomSheet(contact.id.toString()) + }, + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + contact.reminderInterval?.let { DateExtension.getCycleText(it) } + ?: stringResource(R.string.friend_contact_cycle_cycle_setting_text), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + + Spacer(modifier = Modifier.size(2.dp)) + + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_24_down), + contentDescription = null, + colorFilter = ColorFilter.tint(NearTheme.colors.GRAY01_888888), + ) + } + } + } + } + } else { + // 리스트가 비어있을 때도 공간 확보 + Spacer(modifier = Modifier.weight(1f)) + } + + // 바텀시트 표시 + CycleSettingBottomSheet( + isVisible = isBottomSheetVisible, + onDismiss = { + onCloseBottomSheet() + }, + onComplete = { selectedInterval -> + onCompleteCycleSetting(selectedInterval) + }, + currentSelectedInterval = selectedCycle, + ) +} + +@Preview(showBackground = true) +@Composable +fun ContactCycleContentPreview() { + val contacts = + listOf( + FriendContactUIModel( + id = 1, + name = "신짱구", + photoUri = null, + ), + FriendContactUIModel( + id = 2, + name = "철수", + photoUri = null, + ), + FriendContactUIModel( + id = 3, + name = "유리", + photoUri = null, + ), + ) + + NearTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + ContactCycleContent( + contacts = contacts, + isBulkSettingEnabled = true, + isBottomSheetVisible = false, + selectedCycle = ReminderInterval.EVERY_WEEK, + onToggleBulkSetting = {}, + onOpenBottomSheet = {}, + onOpenIndividualBottomSheet = {}, + onCloseBottomSheet = {}, + onCompleteCycleSetting = {}, + ) + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactLoadContent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactLoadContent.kt new file mode 100644 index 00000000..d6eed8a4 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactLoadContent.kt @@ -0,0 +1,178 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +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.presentation.feature.friendcontactcycle.model.FriendContactUIModel +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun ColumnScope.ContactLoadContent( + contacts: List, + onDeselectContact: (String) -> Unit, + onContactLoadClick: () -> Unit = {}, +) { + NearListModuleBackground( + onClick = onContactLoadClick, + ) { + when (contacts.isEmpty()) { + true -> { + Image( + painter = painterResource(R.drawable.ic_front_24_gray), + contentDescription = null, + colorFilter = ColorFilter.tint(NearTheme.colors.GRAY01_888888), + ) + } + + false -> { + // 리스트가 있을 때 "다시 선택" 텍스트 표시 + Text( + text = stringResource(R.string.friend_contact_cycle_reselect_text), + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.BLUE01_5AA2E9, + textAlign = TextAlign.End, + ) + } + } + } + + // 리스트가 있을 때만 밑에 리스트 표시 + if (contacts.isNotEmpty()) { + Spacer(modifier = Modifier.size(16.dp)) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items( + items = contacts, + key = { contact -> contact.id }, + ) { contact -> + FriendListItem( + contact = contact, + ) { + Image( + painter = painterResource(R.drawable.ic_32_cancel), + contentDescription = stringResource(R.string.friend_contact_cycle_remove_friend_description), + colorFilter = ColorFilter.tint(NearTheme.colors.GRAY01_888888), + modifier = + Modifier + .size(24.dp) + .onNoRippleClick { + onDeselectContact(contact.id.toString()) + }, + ) + } + } + } + } else { + // 리스트가 비어있을 때도 공간 확보 + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +fun FriendListItem( + modifier: Modifier = Modifier, + contact: FriendContactUIModel, + content: @Composable () -> Unit = {}, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(NearTheme.colors.BG02_F4F9FD) + .padding(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + Image( + painter = painterResource(R.drawable.img_100_character_default), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Text( + text = contact.name, + style = NearTheme.typography.B2_14_MEDIUM, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.weight(1f), + ) + } + + content() + } +} + +@Preview(showBackground = true) +@Composable +fun ContactLoadContentPreview() { + val contacts = + listOf( + FriendContactUIModel( + id = 1, + name = "신짱구", + photoUri = null, + ), + FriendContactUIModel( + id = 2, + name = "철수", + photoUri = null, + ), + FriendContactUIModel( + id = 3, + name = "유리", + photoUri = null, + ), + ) + + NearTheme { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + ContactLoadContent( + contacts = contacts, + onDeselectContact = {}, + onContactLoadClick = {}, + ) + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactPermissionDeniedDialog.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactPermissionDeniedDialog.kt new file mode 100644 index 00000000..115b8a64 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactPermissionDeniedDialog.kt @@ -0,0 +1,38 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.alarmy.near.R +import com.alarmy.near.presentation.ui.component.dialog.NearBasicDialog +import com.alarmy.near.presentation.ui.theme.NearTheme + +/** + * 연락처 권한 거부 시 표시되는 다이얼로그 + */ +@Composable +fun ContactPermissionDeniedDialog( + onDismiss: () -> Unit, + onGoToSettings: () -> Unit, +) { + NearBasicDialog( + onDismiss = onDismiss, + title = stringResource(R.string.contact_permission_denied_title), + body = stringResource(R.string.contact_permission_denied_message), + dismissButtonText = stringResource(R.string.contact_permission_cancel), + confirmButtonText = stringResource(R.string.contact_permission_go_to_settings), + onDismissButtonClick = onDismiss, + onConfirmButtonClick = onGoToSettings, + ) +} + +@Preview(showBackground = true) +@Composable +fun ContactPermissionDeniedDialogPreview() { + NearTheme { + ContactPermissionDeniedDialog( + onDismiss = {}, + onGoToSettings = {}, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/CycleSettingBottomSheet.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/CycleSettingBottomSheet.kt new file mode 100644 index 00000000..826334a4 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/CycleSettingBottomSheet.kt @@ -0,0 +1,214 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.rememberModalBottomSheetState +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.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.model.ReminderInterval +import com.alarmy.near.presentation.ui.component.checkbox.NearCheckbox +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme +import com.alarmy.near.utils.extensions.DateExtension + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CycleSettingBottomSheet( + isVisible: Boolean, + onDismiss: () -> Unit, + onComplete: (ReminderInterval) -> Unit = {}, + currentSelectedInterval: ReminderInterval? = null, + modifier: Modifier = Modifier, +) { + // 선택된 주기 상태 관리 (기존 선택값이 있으면 그것을 사용, 없으면 매주를 기본값으로) + var selectedInterval by remember(isVisible) { + mutableStateOf(currentSelectedInterval ?: ReminderInterval.EVERY_WEEK) + } + + if (isVisible) { + val bottomSheetState = + rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = bottomSheetState, + dragHandle = { + BottomSheetDefaults.DragHandle( + color = NearTheme.colors.BLACK_1A1A1A.copy(alpha = 0.1f), + width = 36.dp, + height = 5.dp, + ) + }, + modifier = modifier, + containerColor = NearTheme.colors.WHITE_FFFFFF, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) { + Text( + text = stringResource(R.string.friend_contact_cycle_cycle_setting_text), + style = NearTheme.typography.B1_16_BOLD, + ) + + Spacer(modifier = Modifier.size(24.dp)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(NearTheme.colors.BG02_F4F9FD) + .padding(vertical = 18.dp, horizontal = 20.dp), + ) { + Text( + text = + buildAnnotatedString { + withStyle( + style = + SpanStyle( + color = NearTheme.colors.BLACK_1A1A1A, + fontWeight = NearTheme.typography.B2_14_MEDIUM.fontWeight, + ), + ) { + append(stringResource(R.string.friend_contact_cycle_weekly_prefix)) + } + withStyle( + style = + SpanStyle( + color = NearTheme.colors.BLUE01_5AA2E9, + fontWeight = NearTheme.typography.B2_14_BOLD.fontWeight, + ), + ) { + append(DateExtension.getTodayDayOfWeekInKorean()) + } + }, + style = NearTheme.typography.B2_14_MEDIUM, + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.size(20.dp)) + + VerticalDivider( + modifier = + Modifier + .size(width = 1.dp, height = 28.dp), + color = + NearTheme.colors.BLACK_1A1A1A.copy( + alpha = 0.1f, + ), + ) + + Spacer(modifier = Modifier.size(20.dp)) + + Text( + text = "다음 주기 : ${ + selectedInterval?.let { DateExtension.getNextCycleDate(it) } + ?: DateExtension.getNextWeekSameDay() + }", + style = NearTheme.typography.B2_14_MEDIUM, + color = NearTheme.colors.GRAY01_888888, + ) + } + } + + Spacer(modifier = Modifier.size(8.dp)) + + ReminderInterval.entries.forEach { interval -> + val isSelected = selectedInterval == interval + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier + .fillMaxWidth() + .onNoRippleClick { + if (!isSelected) { + selectedInterval = interval + } + }.padding(vertical = 15.dp), + ) { + Text( + text = stringResource(interval.labelRes), + style = + if (isSelected) { + NearTheme.typography.B1_16_BOLD + } else { + NearTheme.typography.B2_14_MEDIUM + }, + ) + + if (isSelected) { + NearCheckbox( + checked = true, + onCheckedChange = { checked -> + // 체크박스 클릭 시 해제되지 않도록 수정 + // 체크된 상태를 유지 + }, + ) + } + } + } + Spacer(modifier = Modifier.size(24.dp)) + + ContactCycleButtons( + onLeftButtonClick = onDismiss, + onRightButtonClick = { + selectedInterval?.let { interval -> + onComplete(interval) + onDismiss() + } + }, + leftButtonText = stringResource(R.string.friend_contact_cycle_cancel_button), + rightButtonText = stringResource(R.string.friend_contact_cycle_complete_button), + ) + Spacer(modifier = Modifier.size(24.dp)) + } + } + } +} + +@Preview +@Composable +fun CycleSettingBottomSheetPreview() { + NearTheme { + CycleSettingBottomSheet( + isVisible = true, + onDismiss = {}, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/NearListModuleBackground.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/NearListModuleBackground.kt new file mode 100644 index 00000000..151d6f8c --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/NearListModuleBackground.kt @@ -0,0 +1,85 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle.components + +import androidx.compose.foundation.Image +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.R +import com.alarmy.near.presentation.ui.extension.onNoRippleClick +import com.alarmy.near.presentation.ui.theme.NearTheme + +/** + * NearListModuleBackground + * + * 리스트 모듈의 배경 컴포넌트 + * - 연락처 아이콘과 "연락처에서 불러오기" 텍스트를 포함 + * - onClick: 클릭 시 실행할 액션 (연락처 권한 요청 등) + */ +@Composable +fun NearListModuleBackground( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + content: @Composable () -> Unit = {}, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .shadow( + elevation = 4.dp, + shape = RoundedCornerShape(12.dp), + ).background( + color = NearTheme.colors.WHITE_FFFFFF, + shape = RoundedCornerShape(12.dp), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.CenterStart) + .onNoRippleClick { onClick() } + .padding(vertical = 18.dp, horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Image( + painter = painterResource(R.drawable.img_32_contact_square), + contentDescription = stringResource(R.string.friend_contact_cycle_contact_icon_description), + ) + + Text( + text = stringResource(R.string.friend_contact_cycle_contact_load_text), + style = NearTheme.typography.B2_14_MEDIUM, + ) + } + + content() + } + } +} + +@Preview +@Composable +fun NearListModuleBackgroundPreview() { + NearTheme { + NearListModuleBackground() + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/model/ContactCycleStep.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/model/ContactCycleStep.kt new file mode 100644 index 00000000..c7bdc8b7 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/model/ContactCycleStep.kt @@ -0,0 +1,10 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle.model + +import com.alarmy.near.R + +enum class ContactCycleStep( + val appbarTitleResId: Int, +) { + LOAD_CONTACTS(R.string.friend_contact_cycle_load_contacts_title), // 연락처 불러오기 단계 + SET_CYCLE(R.string.friend_contact_cycle_set_cycle_title), // 연락 주기 설정 단계 +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/model/FriendContactUIModel.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/model/FriendContactUIModel.kt new file mode 100644 index 00000000..db34a74c --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/model/FriendContactUIModel.kt @@ -0,0 +1,30 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle.model + +import android.os.Parcelable +import com.alarmy.near.model.ReminderInterval +import com.alarmy.near.model.contact.Contact +import kotlinx.parcelize.Parcelize + +@Parcelize +data class FriendContactUIModel( + val id: Long, + val name: String, + val photoUri: String? = null, + val phones: List = emptyList(), + val birthDay: String? = null, + val memo: String? = null, + val reminderInterval: ReminderInterval? = null, +) : Parcelable + +/** + * Contact를 FriendContactUIModel로 변환 + */ +fun Contact.toFriendContactUIModel(): FriendContactUIModel = + FriendContactUIModel( + id = id, + name = name, + photoUri = photoUri, + phones = phones, + birthDay = birthDay, + memo = memo, + ) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/navigation/FriendContactCycleNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/navigation/FriendContactCycleNavigation.kt new file mode 100644 index 00000000..7f14379c --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/navigation/FriendContactCycleNavigation.kt @@ -0,0 +1,36 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.alarmy.near.presentation.feature.friendcontactcycle.FriendContactCycleRoute +import kotlinx.serialization.Serializable + +@Serializable +object RouteFriendContactCycle + +/** + * 친구 연락처 주기 설정 화면으로 이동하는 확장 함수 + */ +fun NavController.navigateToFriendContactCycle(navOptions: NavOptions? = null) { + navigate(RouteFriendContactCycle, navOptions) +} + +/** + * 친구 연락처 주기 설정 화면 NavGraph 정의 + */ +fun NavGraphBuilder.friendContactCycleNavGraph( + onNavigateToHome: () -> Unit, + onNavigateToContact: () -> Unit = {}, + onShowErrorSnackBar: (throwable: Throwable?) -> Unit = { _ -> }, +) { + composable { backStackEntry -> + FriendContactCycleRoute( + navBackStackEntry = backStackEntry, + onNavigateToHome = onNavigateToHome, + onNavigateToContact = onNavigateToContact, + onShowErrorSnackBar = onShowErrorSnackBar, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/state/FriendContactUIEvent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/state/FriendContactUIEvent.kt new file mode 100644 index 00000000..7baa4e4f --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/state/FriendContactUIEvent.kt @@ -0,0 +1,14 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle.state + +/** + * FriendContactCycle 화면의 UI 이벤트 + */ +sealed class FriendContactUIEvent { + // 홈 화면으로 이동 + object NavigateToHome : FriendContactUIEvent() + + // 에러 표시 + data class ShowError( + val throwable: Throwable?, + ) : FriendContactUIEvent() +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/state/FriendContactUIState.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/state/FriendContactUIState.kt new file mode 100644 index 00000000..220a1fbd --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/state/FriendContactUIState.kt @@ -0,0 +1,27 @@ +package com.alarmy.near.presentation.feature.friendcontactcycle.state + +import com.alarmy.near.model.ReminderInterval +import com.alarmy.near.presentation.feature.friendcontactcycle.model.ContactCycleStep +import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel + +/** + * FriendContactCycle 화면의 UI 상태 + */ +data class FriendContactUIState( + val currentStep: ContactCycleStep = ContactCycleStep.LOAD_CONTACTS, + val contacts: List = emptyList(), + val isBulkSettingEnabled: Boolean = false, + val isBottomSheetVisible: Boolean = false, + val selectedCycle: ReminderInterval? = null, + val selectedContactId: String? = null, // 개별 설정 중인 contact ID + val isLoading: Boolean = false, + // 권한 다이얼로그 관련 상태 + val showPermissionDeniedDialog: Boolean = false, + val onRequestPermission: (() -> Unit)? = null, +) { + /** + * 모든 연락처의 주기 설정이 완료되었는지 확인 + */ + val isAllContactsCycleSet: Boolean + get() = contacts.isNotEmpty() && contacts.all { it.reminderInterval != null } +} 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 0a146e16..14569c21 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 @@ -317,7 +317,7 @@ internal fun HomeScreen( ) { NearDropdownMenuItem( onClick = { - // TODO 연락처 화면 이동 + onAddContactClick() dropdownState.value = false }, text = stringResource(R.string.home_menu_text_add_friend), 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 dc9c0c79..62601f5d 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 @@ -36,7 +36,7 @@ import com.alarmy.near.presentation.ui.theme.NearTheme @Composable internal fun LoginRoute( - onNavigateToHome: () -> Unit, + onNavigateToFriendContactCycle: () -> Unit, onNavigateToWebView: (title: String, url: String) -> Unit, onShowErrorSnackBar: (throwable: Throwable?) -> Unit, viewModel: LoginViewModel = hiltViewModel(), @@ -65,7 +65,7 @@ internal fun LoginRoute( viewModel.event.collect { event -> when (event) { is LoginEvent.NavigateToHome -> { - onNavigateToHome() + onNavigateToFriendContactCycle() } is LoginEvent.ShowTermsDetail -> { diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt index 7711de6a..9598c82c 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/login/navigation/LoginNavigation.kt @@ -17,13 +17,13 @@ fun NavController.navigateToLogin(navOptions: NavOptions? = null) { // 로그인 화면 NavGraph 정의 fun NavGraphBuilder.loginNavGraph( - onNavigateToHome: () -> Unit, + onNavigateToFriendContactCycle: () -> Unit, onNavigateToTerms: (title: String, url: String) -> Unit, onShowErrorSnackBar: (throwable: Throwable?) -> Unit, ) { composable { LoginRoute( - onNavigateToHome = onNavigateToHome, + onNavigateToFriendContactCycle = onNavigateToFriendContactCycle, onNavigateToWebView = onNavigateToTerms, onShowErrorSnackBar = onShowErrorSnackBar, ) 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 b3e9ed1a..2a7997fc 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 @@ -10,6 +10,9 @@ import androidx.navigation.compose.NavHost import androidx.navigation.navOptions import com.alarmy.near.presentation.feature.contact.navigation.CONTACT_SELECTION_COMPLETE_KEY import com.alarmy.near.presentation.feature.contact.navigation.contactNavGraph +import com.alarmy.near.presentation.feature.contact.navigation.navigateToContact +import com.alarmy.near.presentation.feature.friendcontactcycle.navigation.friendContactCycleNavGraph +import com.alarmy.near.presentation.feature.friendcontactcycle.navigation.navigateToFriendContactCycle 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 @@ -17,7 +20,6 @@ import com.alarmy.near.presentation.feature.friendprofileedittor.navigation.frie 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.myprofile.navigation.myProfileNavGraph @@ -61,11 +63,11 @@ internal fun NearNavHost( // 로그인 화면 NavGraph loginNavGraph( - onNavigateToHome = { - navController.navigateToHome( + onNavigateToFriendContactCycle = { + navController.navigateToFriendContactCycle( navOptions = navOptions { - popUpTo(RouteLogin) { inclusive = true } + popUpTo(0) { inclusive = false } }, ) }, @@ -83,7 +85,7 @@ internal fun NearNavHost( }, onMyPageClick = { navController.navigateToMyProfile() }, onAlarmClick = {}, - onAddContactClick = {}, + onAddContactClick = { navController.navigateToFriendContactCycle() }, ) myProfileNavGraph( @@ -148,15 +150,33 @@ internal fun NearNavHost( navController.popBackStack() }) + // 친구 연락처 주기 설정 화면 NavGraph + friendContactCycleNavGraph( + onNavigateToHome = { + // FriendContactCycleScreen에서 홈으로 이동 (뒤로가기 스택 초기화) + navController.navigateToHome( + navOptions = + navOptions { + popUpTo(0) { inclusive = true } + }, + ) + }, + onNavigateToContact = { + navController.navigateToContact( + navOptions = navOptions { }, + ) + }, + onShowErrorSnackBar = onShowSnackbar, + ) + + // 연락처 선택 화면 NavGraph contactNavGraph( onShowErrorSnackBar = onShowSnackbar, - onBackClick = { - navController.popBackStack() - }, - onCompletedSelection = { + onBackClick = { navController.popBackStack() }, + onCompletedSelection = { selectedContacts -> navController.previousBackStackEntry?.savedStateHandle?.set( CONTACT_SELECTION_COMPLETE_KEY, - it, + selectedContacts, ) navController.popBackStack() }, diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dialog/NearBasicDialog.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dialog/NearBasicDialog.kt new file mode 100644 index 00000000..b9439cbf --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dialog/NearBasicDialog.kt @@ -0,0 +1,98 @@ +package com.alarmy.near.presentation.ui.component.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.presentation.ui.component.button.NearBasicButton +import com.alarmy.near.presentation.ui.component.button.NearLineTypeButton +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun NearBasicDialog( + onDismiss: () -> Unit, + title: String? = null, + body: String, + dismissButtonText: String, + confirmButtonText: String, + onDismissButtonClick: (() -> Unit), + onConfirmButtonClick: (() -> Unit), +) { + AlertDialog( + onDismissRequest = onDismiss, + title = + title?.let { dialogTitle -> + { + Text( + text = dialogTitle, + style = NearTheme.typography.H2_18_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + text = { + Text( + text = body, + style = NearTheme.typography.B1_16_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + Row( + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + NearLineTypeButton( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + enabled = true, + text = dismissButtonText, + onClick = onDismissButtonClick, + ) + + NearBasicButton( + modifier = Modifier.weight(1f), + onClick = onConfirmButtonClick, + contentPadding = PaddingValues(16.dp), + ) { + Text( + text = confirmButtonText, + style = NearTheme.typography.B1_16_BOLD, + ) + } + } + }, + containerColor = NearTheme.colors.WHITE_FFFFFF, + shape = RoundedCornerShape(16.dp), + ) +} + +@Preview(showBackground = true) +@Composable +fun NearBasicDialogPreview() { + NearTheme { + NearBasicDialog( + onDismiss = {}, + title = "권한이 필요합니다", + body = "연락처 접근 권한이 필요합니다.\n설정에서 권한을 허용해주세요.", + dismissButtonText = "취소", + confirmButtonText = "설정으로 이동", + onDismissButtonClick = {}, + onConfirmButtonClick = {}, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dialog/NearOutlinedDialog.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dialog/NearOutlinedDialog.kt new file mode 100644 index 00000000..8329b20c --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/dialog/NearOutlinedDialog.kt @@ -0,0 +1,98 @@ +package com.alarmy.near.presentation.ui.component.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.alarmy.near.presentation.ui.component.button.NearBasicButton +import com.alarmy.near.presentation.ui.component.button.NearLineTypeButton +import com.alarmy.near.presentation.ui.theme.NearTheme + +@Composable +fun NearOutlinedDialog( + onDismiss: () -> Unit, + title: String? = null, + body: String, + dismissButtonText: String, + confirmButtonText: String, + onDismissButtonClick: (() -> Unit), + onConfirmButtonClick: (() -> Unit), +) { + AlertDialog( + onDismissRequest = onDismiss, + title = + title?.let { dialogTitle -> + { + Text( + text = dialogTitle, + style = NearTheme.typography.H2_18_BOLD, + color = NearTheme.colors.BLACK_1A1A1A, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + text = { + Text( + text = body, + style = NearTheme.typography.B1_16_MEDIUM, + color = NearTheme.colors.BLACK_1A1A1A, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + Row( + horizontalArrangement = Arrangement.spacedBy(7.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + NearBasicButton( + modifier = Modifier.weight(1f), + onClick = onDismissButtonClick, + contentPadding = PaddingValues(16.dp), + ) { + Text( + text = dismissButtonText, + style = NearTheme.typography.B1_16_BOLD, + ) + } + + NearLineTypeButton( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(16.dp), + enabled = true, + text = confirmButtonText, + onClick = onConfirmButtonClick, + ) + } + }, + containerColor = NearTheme.colors.WHITE_FFFFFF, + shape = RoundedCornerShape(16.dp), + ) +} + +@Preview(showBackground = true) +@Composable +fun NearOutlinedDialogPreview() { + NearTheme { + NearOutlinedDialog( + onDismiss = {}, + title = "권한이 필요합니다", + body = "연락처 접근 권한이 필요합니다.\n설정에서 권한을 허용해주세요.", + confirmButtonText = "취소", + dismissButtonText = "설정으로 이동", + onDismissButtonClick = {}, + onConfirmButtonClick = {}, + ) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/permission/ContactPermissionRequester.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/permission/ContactPermissionRequester.kt index c36736ed..52a09222 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/permission/ContactPermissionRequester.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/permission/ContactPermissionRequester.kt @@ -9,20 +9,30 @@ import com.alarmy.near.permission.rememberContactPermissionState @Composable fun ContactPermissionRequester( - onGranted: @Composable () -> Unit, + onGranted: () -> Unit, onDenied: @Composable (onRequestPermission: () -> Unit) -> Unit, onShowRationale: @Composable (onRequestPermission: () -> Unit) -> Unit = onDenied, + onPermissionDenied: () -> Unit = {}, // 권한 거부 시 콜백 ) { val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), - onResult = {}, - ) + ) { isGranted -> + if (isGranted) { + onGranted() + } else { + // 권한이 거부된 경우 콜백 호출 + onPermissionDenied() + } + } val permissionState = rememberContactPermissionState() when (permissionState) { - PermissionState.GRANTED -> onGranted() + PermissionState.GRANTED -> { + // 권한이 이미 허용된 경우, 자동으로 onGranted를 호출하지 않음 + // 사용자가 명시적으로 권한을 요청했을 때만 launcher를 통해 처리 + } PermissionState.DENIED -> onDenied { launcher.launch(Manifest.permission.READ_CONTACTS) } PermissionState.SHOW_RATIONALE -> onShowRationale { launcher.launch(Manifest.permission.READ_CONTACTS) } } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/util/AppSettingsUtil.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/util/AppSettingsUtil.kt new file mode 100644 index 00000000..3a0cb6e3 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/util/AppSettingsUtil.kt @@ -0,0 +1,20 @@ +package com.alarmy.near.presentation.ui.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.Settings + +/** + * 앱 설정 관련 유틸리티 함수들 + */ +object AppSettingsUtil { + + // 앱의 애플리케이션 정보 페이지로 이동 + fun openAppSettings(context: Context) { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + context.startActivity(intent) + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/utils/PhoneNumberFormatter.kt b/Near/app/src/main/java/com/alarmy/near/utils/PhoneNumberFormatter.kt new file mode 100644 index 00000000..bf59e60e --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/utils/PhoneNumberFormatter.kt @@ -0,0 +1,64 @@ +package com.alarmy.near.utils + +import android.content.Context +import io.michaelrocks.libphonenumber.android.NumberParseException +import io.michaelrocks.libphonenumber.android.PhoneNumberUtil + +/** + * 전화번호 포맷팅을 위한 유틸리티 object + * libphonenumber-android를 사용하여 다양한 전화번호 형식을 처리합니다. + */ +object PhoneNumberFormatter { + private var phoneNumberUtil: PhoneNumberUtil? = null + + /** + * Context를 사용하여 PhoneNumberUtil을 초기화합니다. + * Application Context에서 한 번만 초기화하면 됩니다. + * 사용시 초기화 할 수 있지만 현재 Repository에서 사용하고 있기 때문에 Application에서 초기화를 진행합니다 + */ + fun initialize(context: Context) { + if (phoneNumberUtil == null) { + phoneNumberUtil = PhoneNumberUtil.createInstance(context) + } + } + + /** + * 전화번호를 한국 형식으로 포맷팅합니다. + * - 다양한 전화번호 형식 지원 (휴대폰, 지역번호 등) + * - 한국 전화번호 형식으로 통일 (010-0000-0000, 02-0000-0000 등) + * - 잘못된 형식의 경우 원본 반환 + */ + fun formatPhoneNumber(phoneNumber: String): String { + val util = phoneNumberUtil ?: return phoneNumber + + return try { + // 불필요한 문자들 제거 + val cleanedNumber = + phoneNumber + .replace("//", "") // // 제거 + .replace("-", "") // 하이픈 제거 + .replace(" ", "") // 공백 제거 + .replace("(", "") // 괄호 제거 + .replace(")", "") // 괄호 제거 + .trim() + + // 한국 국가 코드로 파싱 시도 + val parsedNumber = util.parse(cleanedNumber, "KR") + + // 유효한 전화번호인지 확인 + if (util.isValidNumber(parsedNumber)) { + // 한국 형식으로 포맷팅 + util.format(parsedNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL) + } else { + // 유효하지 않은 경우 원본 반환 + phoneNumber + } + } catch (e: NumberParseException) { + // 파싱 실패 시 원본 반환 + phoneNumber + } catch (e: Exception) { + // 기타 예외 발생 시 원본 반환 + phoneNumber + } + } +} diff --git a/Near/app/src/main/java/com/alarmy/near/utils/extensions/DateExtension.kt b/Near/app/src/main/java/com/alarmy/near/utils/extensions/DateExtension.kt new file mode 100644 index 00000000..803497c4 --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/utils/extensions/DateExtension.kt @@ -0,0 +1,171 @@ +package com.alarmy.near.utils.extensions + +import com.alarmy.near.model.DayOfWeek +import com.alarmy.near.model.ReminderInterval +import java.util.Calendar + +/** + * Date 관련 유틸리티 확장 함수들 + */ +object DateExtension { + // 요일 상수 정의 + private val DAY_OF_WEEK_KOREAN_FULL = + mapOf( + Calendar.SUNDAY to "일요일", + Calendar.MONDAY to "월요일", + Calendar.TUESDAY to "화요일", + Calendar.WEDNESDAY to "수요일", + Calendar.THURSDAY to "목요일", + Calendar.FRIDAY to "금요일", + Calendar.SATURDAY to "토요일", + ) + + // Calendar.DAY_OF_WEEK를 한글 요일로 변환하는 공통 함수 + // isFull로 "월요일"을 반환할지, '월' 을 반환할지 결정합니다. + private fun convertDayOfWeekToKorean( + dayOfWeek: Int, + isFull: Boolean = true, + ): String { + val fullDay = DAY_OF_WEEK_KOREAN_FULL[dayOfWeek] ?: "알 수 없음" + return if (isFull) fullDay else fullDay.first().toString() + } + + // 오늘 요일을 한글로 반환 + fun getTodayDayOfWeekInKorean(): String { + val calendar = Calendar.getInstance() + val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + return convertDayOfWeekToKorean(dayOfWeek, isFull = true) + } + + // 특정 날짜의 요일을 한글로 반환 + fun getDayOfWeekInKorean( + year: Int, + month: Int, + day: Int, + ): String { + val calendar = Calendar.getInstance() + calendar.set(year, month - 1, day) // Calendar의 월은 0부터 시작 + val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + return convertDayOfWeekToKorean(dayOfWeek, isFull = true) + } + + // 다음 주 같은 요일의 날짜를 반환 + fun getNextWeekSameDay(): String { + val calendar = Calendar.getInstance() + + // 다음 주 같은 요일로 이동 + calendar.add(Calendar.WEEK_OF_YEAR, 1) + + val month = calendar.get(Calendar.MONTH) + 1 // Calendar의 월은 0부터 시작 + val day = calendar.get(Calendar.DAY_OF_MONTH) + val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + + val dayOfWeekKorean = convertDayOfWeekToKorean(dayOfWeek, isFull = false) + + return "$month/$day $dayOfWeekKorean" + } + + // 선택된 주기에 따른 다음 날짜를 반환 + fun getNextCycleDate(reminderInterval: ReminderInterval): String { + val calendar = Calendar.getInstance() + + when (reminderInterval) { + ReminderInterval.EVERY_DAY -> { + // 매일: 다음 날 + calendar.add(Calendar.DAY_OF_MONTH, 1) + } + + ReminderInterval.EVERY_WEEK -> { + // 매주: 다음 주 같은 요일 + calendar.add(Calendar.WEEK_OF_YEAR, 1) + } + + ReminderInterval.EVERY_TWO_WEEK -> { + // 격주: 2주 후 같은 요일 + calendar.add(Calendar.WEEK_OF_YEAR, 2) + } + + ReminderInterval.EVERY_MONTH -> { + // 매월: 다음 달 같은 날 + calendar.add(Calendar.MONTH, 1) + } + + ReminderInterval.EVERY_SIX_MONTH -> { + // 반년: 6개월 후 같은 날 + calendar.add(Calendar.MONTH, 6) + } + } + + val month = calendar.get(Calendar.MONTH) + 1 // Calendar의 월은 0부터 시작 + val day = calendar.get(Calendar.DAY_OF_MONTH) + val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + + val dayOfWeekKorean = convertDayOfWeekToKorean(dayOfWeek, isFull = false) + + return "$month/$day $dayOfWeekKorean" + } + + // 선택된 주기를 사용자 친화적인 텍스트로 변환 + fun getCycleText(reminderInterval: ReminderInterval): String { + val todayDayOfWeek = getTodayDayOfWeekInKorean() + + return when (reminderInterval) { + ReminderInterval.EVERY_DAY -> "매일" + ReminderInterval.EVERY_WEEK -> "매주 $todayDayOfWeek" + ReminderInterval.EVERY_TWO_WEEK -> "2주마다 $todayDayOfWeek" + ReminderInterval.EVERY_MONTH -> { + val calendar = Calendar.getInstance() + val day = calendar.get(Calendar.DAY_OF_MONTH) + "매달 ${day}일" + } + + ReminderInterval.EVERY_SIX_MONTH -> { + val calendar = Calendar.getInstance() + val month = calendar.get(Calendar.MONTH) + 1 // Calendar의 월은 0부터 시작 + val day = calendar.get(Calendar.DAY_OF_MONTH) + // 6개월 후의 월을 계산 + val nextSixMonthCalendar = + (calendar.clone() as Calendar).apply { + add(Calendar.MONTH, 6) + } + val nextSixMonth = nextSixMonthCalendar.get(Calendar.MONTH) + 1 + "매년 $month/$day, $nextSixMonth/$day" + } + } + } + + // ReminderInterval을 contactWeek 문자열로 변환 + fun toContactWeekString(reminderInterval: ReminderInterval): String = reminderInterval.name + + // 오늘 요일을 DayOfWeek enum으로 반환 + fun getTodayDayOfWeek(): DayOfWeek { + val calendar = Calendar.getInstance() + val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + return when (dayOfWeek) { + Calendar.SUNDAY -> DayOfWeek.SUNDAY + Calendar.MONDAY -> DayOfWeek.MONDAY + Calendar.TUESDAY -> DayOfWeek.TUESDAY + Calendar.WEDNESDAY -> DayOfWeek.WEDNESDAY + Calendar.THURSDAY -> DayOfWeek.THURSDAY + Calendar.FRIDAY -> DayOfWeek.FRIDAY + Calendar.SATURDAY -> DayOfWeek.SATURDAY + else -> throw IllegalStateException("Invalid day of week: $dayOfWeek") + } + } + + // 오늘 요일을 API 요청용 영어 문자열로 반환 + fun getTodayDayOfWeekInEnglish(): String { + val calendar = Calendar.getInstance() + val dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) + return when (dayOfWeek) { + Calendar.SUNDAY -> "SUNDAY" + Calendar.MONDAY -> "MONDAY" + Calendar.TUESDAY -> "TUESDAY" + Calendar.WEDNESDAY -> "WEDNESDAY" + Calendar.THURSDAY -> "THURSDAY" + Calendar.FRIDAY -> "FRIDAY" + Calendar.SATURDAY -> "SATURDAY" + else -> throw IllegalStateException("Invalid day of week: $dayOfWeek") + } + } +} diff --git a/Near/app/src/main/res/drawable/img_100_character_default.xml b/Near/app/src/main/res/drawable/img_100_character_default.xml new file mode 100644 index 00000000..0cb94c0d --- /dev/null +++ b/Near/app/src/main/res/drawable/img_100_character_default.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + diff --git a/Near/app/src/main/res/drawable/img_32_contact_square.xml b/Near/app/src/main/res/drawable/img_32_contact_square.xml new file mode 100644 index 00000000..0e3c7e07 --- /dev/null +++ b/Near/app/src/main/res/drawable/img_32_contact_square.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/Near/app/src/main/res/values/strings.xml b/Near/app/src/main/res/values/strings.xml index e2040381..2c87a53a 100644 --- a/Near/app/src/main/res/values/strings.xml +++ b/Near/app/src/main/res/values/strings.xml @@ -149,4 +149,34 @@ 이름 검색 연락처를 불러오지 못했습니다. + + 챙길 사람 불러오기 + 챙김 주기 설정하기 + 가까워지고 싶은 사람\n10명까지 선택해주세요 + 먼저, 더 가까워지고 싶은\n소중한 사람만 선택해보세요. + 얼마나 자주\n챙기고 싶으세요? + 사람별로 챙기고 싶은 주기를 설정해주세요. + 연락처 아이콘 + 연락처에서 불러오기 + 한번에 설정 + 다시 선택 + 친구 제거 + 주기 설정 + 매주 %1$s + 매주 + 취소 + 완료 + 나중에 하기 + 다음 + 이전 + %1$d/2 + 친구 초기 설정에 실패했습니다. + KAKAO + + + 휴대폰 기기의 연락처\n접근 권한을 켜주세요. + 연락처에서 챙길 사람을 가져오려면\n기기 설정에서 연락처를 허용해주세요. + 설정하러 가기 + 취소 +