Skip to content
Merged
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
4aedce3
chore: 연락처 주기 설정 화면 구성에 필요한 이미지 추가
stopstone Sep 19, 2025
a9e0cfe
feat: 친구 연락 주기 화면 UI 구현
stopstone Sep 19, 2025
8b2bc90
refactor: 연락 주기 친구 목록 배경 여백 수정
stopstone Sep 23, 2025
16a07ab
feat: 친구 연락처 불러오기 UI 구현
stopstone Sep 23, 2025
d9ce4a6
chore: 불필요한 import문 제거
stopstone Sep 23, 2025
b4a1ca6
refactor: ContactCycleButtons 파라미터 추가
stopstone Sep 23, 2025
4e70734
refactor: `FriendListItem`의 친구 제거 버튼 로직 변경
stopstone Sep 23, 2025
3636490
feat: 친구 연락 주기 화면 Content 구현
stopstone Sep 23, 2025
b52b65e
feat: 연락 주기 설정 화면 구현
stopstone Sep 23, 2025
cdd10a2
fix: 친구 연락 주기 화면 UI 수정
stopstone Sep 23, 2025
8d36cbc
refactor: 친구 연락 주기 화면 아이콘 변경 및 색상 적용
stopstone Sep 23, 2025
d91e542
feat: 챙길 친구 화면 TopAppBar 동적 변경
stopstone Sep 23, 2025
33d6d21
refactor: 친구 연락 주기 화면 헤더 분리
stopstone Sep 23, 2025
66ea180
fix: 친구 이름이 길 때 레이아웃 깨짐 수정
stopstone Sep 23, 2025
0394344
Merge branch 'dev' into feat/friends-contact-cycle
stopstone Sep 23, 2025
168239e
refactor: 연락처 로드 화면 아이콘 변경
stopstone Sep 23, 2025
64bf08a
feat: 연락 주기 설정 바텀시트 UI 구현
stopstone Sep 23, 2025
b158dc3
fix: 주기설정 바텀시트 체크박스 해제 안되도록 수정
stopstone Sep 24, 2025
3c93cc0
feat: Date 확장 함수 추가
stopstone Sep 24, 2025
3614e90
feat: 다음 알림 날짜 계산 기능 추가
stopstone Sep 24, 2025
e25befc
refactor: 연락 주기 설정 UI 및 로직 개선
stopstone Sep 24, 2025
e8c0e11
feat: 친구 연락 주기 설정 기능 구현
stopstone Sep 24, 2025
69697ef
feat: 연락 주기 일괄 설정 기능 추가
stopstone Sep 24, 2025
848af59
fix: 주기 설정 바텀 시트 초기값 및 표시 텍스트 수정
stopstone Sep 24, 2025
eb2c982
refactor: 친구 연락 주기 설정 화면 UI 이벤트 및 상태 관리 개선
stopstone Sep 24, 2025
ba28f86
feat: 친구 연락처 주기 설정 화면 추가 및 NavGraph 연결
stopstone Sep 24, 2025
f623125
fix: 친구 선택 없을 시 '다음' 버튼 비활성화
stopstone Sep 24, 2025
66a61c4
feat: "나중에 하기" 버튼 클릭 즉시 홈 화면 이동
stopstone Sep 24, 2025
124e03f
feat: 모든 사람 주기 설정 후 버튼 활성화
stopstone Sep 24, 2025
27a65fa
feat: 친구 연락 주기 설정 화면에서 선택한 연락처 해제 기능 추가
stopstone Sep 24, 2025
6248747
Merge dev branch into feat/friends-contact-cycle
stopstone Sep 30, 2025
f65dccc
chore: 친구 목록 로딩 아이콘 변경
stopstone Oct 1, 2025
3454f58
feat: 연락처 권한 요청 및 화면 이동 로직 추가
stopstone Oct 1, 2025
fdd70dc
feat: 친구 연락 주기 설정 화면에 연락처 선택 기능 추가
stopstone Oct 1, 2025
ca6f100
refactor: 연락처 로딩 성능 개선 및 UI 수정
stopstone Oct 1, 2025
fc5ab12
feat: 챙길사람 화면에 불러온 연락처 반영
stopstone Oct 1, 2025
ed86d0c
refactor: 개별 연락처 주기 설정 기능 구현 및 로직 개선
stopstone Oct 1, 2025
37f144f
feat: 오늘 요일 및 리마인더 주기 변환 함수 추가
stopstone Oct 2, 2025
e41d61d
feat: 친구 연락 주기 설정 완료 시 로딩 처리 추가
stopstone Oct 2, 2025
029f446
feat: 친구 초기 설정 API 모델 및 매퍼 추가
stopstone Oct 2, 2025
fd435e0
feat: 연락처 기반 친구 추가 API 연동
stopstone Oct 2, 2025
6c2f47f
feat: 친구 초기 설정 ViewModel 및 UI 이벤트 구현
stopstone Oct 2, 2025
0407c69
refactor: 로그인 후 친구 연락 주기 설정 화면으로 이동
stopstone Oct 2, 2025
c4b137f
refactor: 친구 연락 주기 설정 화면 문자열 리소스 적용
stopstone Oct 2, 2025
cf30e6c
refactor: 연락처 정렬 스레드 IO 디스패처로 변경
stopstone Oct 2, 2025
90da47f
feat: 친구 추가 및 연락 주기 설정 화면 이동 로직 구현
stopstone Oct 2, 2025
d05063c
feat: 공용 다이얼로그 컴포저블 추가
stopstone Oct 2, 2025
f82cca6
feat: 연락처 권한 거부 시 설정 이동 다이얼로그 표시
stopstone Oct 2, 2025
b45e8e4
refactor: 주소록 권한 상태를 ViewModel에서 관리하도록 변경
stopstone Oct 2, 2025
fdbb7d1
chore: libphonenumber-android 의존성 추가
stopstone Oct 3, 2025
a3fc9ed
refactor: 전화번호 형식 변환 로직 개선
stopstone Oct 3, 2025
ec02439
fix: 전화번호 없는 연락처 처리 시 발생하는 오류 방지
stopstone Oct 3, 2025
ea72f99
refactor: 서버 응답 Enum 변환 로직 안정성 강화
stopstone Oct 3, 2025
3ed57a5
fix: 6개월 후 날짜 계산 로직 수정
stopstone Oct 3, 2025
18ae79c
fix: ContactScreen 무한루프 문제 해결
stopstone Oct 4, 2025
5c7fff2
refactor: 친구 설정 실패 시 스낵바로 에러를 표시하도록 변경
stopstone Oct 4, 2025
09c880a
refactor: 로그인 제공자 타입 조회 로직에 Enum 사용
stopstone Oct 4, 2025
727371c
refactor: 유효하지 않은 요일 값에 대해 예외 발생시키도록 변경
stopstone Oct 4, 2025
611784b
refactor: ViewModel 이벤트 처리 방식을 직접 호출로 변경
stopstone Oct 4, 2025
8a4db8a
refactor: onPermissionDenied 콜백을 non-nullable로 변경
stopstone Oct 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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.extensions.DateExtension

/**
* 010 전화번호를 010-0000-0000 형태로 포맷팅하는 함수
* - //010-0000-0000 으로 앞에 //가 붙는 경우
* - 01012341234로 하이픈이 없는 경우
* 최종적으로 010-0000-0000 형태로 변환합니다.
*/
private fun String.formatPhoneNumber(): String {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stopstone // 혹시 02, 031같은 010으로 시작하지 않는 번호로 들어올 땐 어떻게 되나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

휴대전화 형식으로만 변환하게 두었습니다..!
지역번호 같은 경우 000-0000-0000, 000-000-0000와 같이 형식이 다양해서 어떻게 수정을 해야할지 좀 더 고민을 해봐야 할 것 같습니다
작업하면서 제 연락처에 있는 케이스 (//010~, 하이픈X, 하이픈O)만 우선 처리하였습니다!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stopstone // libphonenumber을 쓰면 안될까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@slg1119 앗 감사합니다 한번 시도해보겠습니다 될 거 같아요!
android에서 사용하려면 공식 포트는 아니라서 verySlow 라고 되어있긴 한데 큰 문제는 없을 것 같습니다!

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stopstone // 감사합니다 😁

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@slg1119 유용한 라이브러리 추천 감사합니다 :)

// 1. 불필요한 문자들 제거
val cleaned =
this
.replace("//", "") // // 제거
.replace("-", "") // 하이픈 제거 (하이픈이 없는 경우 위치값으로 넣어줘야 하기때문에 우선 제거)
.replace(" ", "") // 공백 제거
.trim()

// 2. 000-0000-0000 형태로 포맷팅
return "${cleaned.substring(0, 3)}-${cleaned.substring(3, 7)}-${cleaned.substring(7)}"
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

cleaned 문자열의 길이가 충분하지 않을 경우 substring 메서드에서 StringIndexOutOfBoundsException이 발생하여 앱이 비정상 종료될 수 있습니다. 함수 주석에 '010-0000-0000' 형태로 포맷팅한다고 명시되어 있으므로, 11자리 휴대폰 번호 형식일 때만 포맷팅을 수행하고 그 외의 경우에는 정리된 문자열을 그대로 반환하도록 하여 안정성을 높이는 것을 권장합니다.

private fun String.formatPhoneNumber(): String {
    // 1. 불필요한 문자들 제거
    val cleaned =
        this
            .replace("//", "") // // 제거
            .replace("-", "") // 하이픈 제거 (하이픈이 없는 경우 위치값으로 넣어줘야 하기때문에 우선 제거)
            .replace(" ", "") // 공백 제거
            .trim()

    // 2. 11자리 휴대폰 번호인 경우에만 010-0000-0000 형태로 포맷팅
    if (cleaned.length == 11) {
        return "${cleaned.substring(0, 3)}-${cleaned.substring(3, 7)}-${cleaned.substring(7)}"
    }

    // 그 외의 경우는 정리된 문자열을 그대로 반환
    return cleaned
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

libphonenumber-android 라이브러리로 전화번호 포매팅 진행하였습니다


/**
* UI 모델을 서버 요청 모델로 변환
*/
fun FriendContactUIModel.toFriendInitItemRequest(providerType: String): FriendInitItemRequest =
FriendInitItemRequest(
name = name,
phone = phones.first().formatPhoneNumber(),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

phones 리스트가 비어있을 경우 first()를 호출하면 NoSuchElementException이 발생하여 앱이 비정상 종료될 수 있습니다. firstOrNull()을 사용하고 null일 경우를 안전하게 처리하는 것이 좋습니다. phone 필드는 nullable이 아니므로, 빈 문자열과 같은 기본값을 제공하는 것을 고려해볼 수 있습니다.

        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 = ReminderInterval.valueOf(contactWeek),
dayOfWeek = DayOfWeek.valueOf(dayOfWeek),
)
Comment on lines 46 to 50

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

서버에서 예상치 못한 contactWeek 또는 dayOfWeek 문자열을 보낼 경우, valueOf() 함수는 IllegalArgumentException을 발생시켜 앱을 비정상 종료시킬 수 있습니다. runCatching을 사용하여 예외를 처리하고 기본값을 제공하는 방식으로 안정성을 높이는 것을 권장합니다.

fun ContactFrequencyInitEntity.toModel(): ContactFrequency =
    ContactFrequency(
        reminderInterval = runCatching { ReminderInterval.valueOf(contactWeek) }.getOrDefault(ReminderInterval.EVERY_WEEK),
        dayOfWeek = runCatching { DayOfWeek.valueOf(dayOfWeek) }.getOrDefault(DayOfWeek.MONDAY),
    )


/**
* 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 = Relation.valueOf(source),
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(),
)
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -63,4 +68,22 @@ class DefaultFriendRepository
val response = friendService.recordContact(friendId)
emit(response.message) // CommonMessageEntity.message 라고 가정
}

override fun initFriends(
contacts: List<FriendContactUIModel>,
providerType: String,
): Flow<List<FriendInitItemEntity>> =
apiCallFlow {
// UI 모델을 Data 모델로 변환
val friendInitRequest =
FriendInitRequest(
friendList =
contacts
.filter { it.reminderInterval != null }
.map { it.toFriendInitItemRequest(providerType) },
)

// 서버 요청 및 응답 반환
friendService.initFriends(friendInitRequest).friendList
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,4 +25,6 @@ interface FriendRepository {
fun fetchFriendRecord(friendId: String): Flow<List<FriendRecord>>

fun recordContact(friendId: String): Flow<String>

fun initFriends(contacts: List<FriendContactUIModel>, providerType: String): Flow<List<FriendInitItemEntity>>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.alarmy.near.network.request

import kotlinx.serialization.Serializable

/**
* 친구 초기 설정 API 요청 데이터 모델
*/
@Serializable
data class FriendInitRequest(
val friendList: List<FriendInitItemRequest>,
)

/**
* 친구 초기 설정 개별 아이템 요청 데이터 모델
*/
@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,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.alarmy.near.network.response

import kotlinx.serialization.Serializable

/**
* 친구 초기 설정 API 응답 데이터
*/
@Serializable
data class FriendInitEntity(
val friendList: List<FriendInitItemEntity>,
)

@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,
)
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -45,4 +47,9 @@ interface FriendService {
suspend fun recordContact(
@Path("friendId") friendId: String,
): CommonMessageEntity

@POST("/friend/init")
suspend fun initFriends(
@Body friendInitRequest: FriendInitRequest,
): FriendInitEntity
}
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,6 +43,7 @@ class ContactViewModel
selectedIds,
_searchQuery,
) { contacts, selectedIds, query ->
// 무거운 연산: filter, map, groupBy, sort
val filtered =
if (query.isBlank()) {
contacts
Expand All @@ -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 =
Expand Down
Loading